mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-23 21:37:17 +00:00
Compare commits
67 Commits
bill-rejec
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e0ec5af31 | ||
|
|
f7d16fbc16 | ||
|
|
2a7c7f5508 | ||
|
|
0e322e457e | ||
|
|
775b019c30 | ||
|
|
df4a9f8d9f | ||
|
|
6367ff9f8d | ||
|
|
43913d5c2a | ||
|
|
328ec92662 | ||
|
|
a434defbb7 | ||
|
|
62b69d6dcf | ||
|
|
33d29274ba | ||
|
|
5426a228c5 | ||
|
|
df428d6cc7 | ||
|
|
574f345e91 | ||
|
|
9a79253ee1 | ||
|
|
a05229b988 | ||
|
|
a0b7db19d9 | ||
|
|
bc6e5e2060 | ||
|
|
5de2ac1f26 | ||
|
|
a46930e10a | ||
|
|
18093079d9 | ||
|
|
f4979e6650 | ||
|
|
63413f8bfe | ||
|
|
02c04ae167 | ||
|
|
2dcf82a2ab | ||
|
|
ca1a88b53b | ||
|
|
d75b957ce0 | ||
|
|
e9480bd0e1 | ||
|
|
8793ad8264 | ||
|
|
3691ca5b5b | ||
|
|
56f24a6adf | ||
|
|
60f0a736ab | ||
|
|
1ba071d657 | ||
|
|
b3d55db893 | ||
|
|
876bf0b7f7 | ||
|
|
16b1be814c | ||
|
|
ecc643fde0 | ||
|
|
df3f952fac | ||
|
|
726c92f8ae | ||
|
|
c0f13b01de | ||
|
|
23afc786bb | ||
|
|
3bf3bc709f | ||
|
|
102842253b | ||
|
|
e814d13126 | ||
|
|
7f608f31eb | ||
|
|
f990df9deb | ||
|
|
62cb38d36b | ||
|
|
eca99d3df4 | ||
|
|
8d6ee86ba3 | ||
|
|
e3fc1790d4 | ||
|
|
db6dad7ddd | ||
|
|
719b53f2ad | ||
|
|
79fd06a1ba | ||
|
|
5ebee655b7 | ||
|
|
96db1071da | ||
|
|
9f2b05c103 | ||
|
|
9e01992217 | ||
|
|
5ebc52c95c | ||
|
|
86eb9c6204 | ||
|
|
893fc75df4 | ||
|
|
f2279a139f | ||
|
|
30e0382aa9 | ||
|
|
d2b1965cc8 | ||
|
|
a931c7d2f4 | ||
|
|
221baabf9e | ||
|
|
8b59d66f94 |
13
.github/workflows/linters.yml
vendored
13
.github/workflows/linters.yml
vendored
@@ -23,6 +23,19 @@ jobs:
|
||||
- name: Install and Run Pre-commit
|
||||
uses: pre-commit/action@v3.0.1
|
||||
|
||||
js-unit-tests:
|
||||
name: js unit tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Run JS unit tests
|
||||
run: yarn test:js
|
||||
|
||||
semgrep:
|
||||
name: semgrep
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -47,10 +47,13 @@ class ERPNextAddress(Address):
|
||||
super().on_update()
|
||||
|
||||
address_display = get_address_display(self.as_dict())
|
||||
filters = {"customer_primary_address": self.name}
|
||||
customers = frappe.db.get_all("Customer", filters=filters, as_list=True)
|
||||
for customer_name in customers:
|
||||
frappe.db.set_value("Customer", customer_name[0], "primary_address", address_display)
|
||||
customers = frappe.db.get_all(
|
||||
"Customer", filters={"customer_primary_address": self.name}, pluck="name"
|
||||
)
|
||||
for customer in customers:
|
||||
frappe.db.set_value(
|
||||
"Customer", customer, "primary_address", address_display, update_modified=False
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
||||
@@ -12,7 +12,7 @@ frappe.ui.form.on("Accounting Period", {
|
||||
doc: frm.doc,
|
||||
callback: function (r) {
|
||||
if (r.message) {
|
||||
cur_frm.clear_table("closed_documents");
|
||||
frm.clear_table("closed_documents");
|
||||
r.message.forEach(function (element) {
|
||||
var c = frm.add_child("closed_documents");
|
||||
c.document_type = element.document_type;
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
cur_frm.add_fetch("bank_account", "account", "account");
|
||||
cur_frm.add_fetch("bank_account", "bank_account_no", "bank_account_no");
|
||||
cur_frm.add_fetch("bank_account", "iban", "iban");
|
||||
cur_frm.add_fetch("bank_account", "branch_code", "branch_code");
|
||||
cur_frm.add_fetch("bank", "swift_number", "swift_number");
|
||||
|
||||
frappe.ui.form.on("Bank Guarantee", {
|
||||
setup: function (frm) {
|
||||
frm.add_fetch("bank_account", "account", "account");
|
||||
frm.add_fetch("bank_account", "bank_account_no", "bank_account_no");
|
||||
frm.add_fetch("bank_account", "iban", "iban");
|
||||
frm.add_fetch("bank_account", "branch_code", "branch_code");
|
||||
frm.add_fetch("bank", "swift_number", "swift_number");
|
||||
|
||||
frm.set_query("reference_doctype", function () {
|
||||
return {
|
||||
filters: {
|
||||
@@ -63,11 +63,15 @@ frappe.ui.form.on("Bank Guarantee", {
|
||||
},
|
||||
|
||||
start_date: function (frm) {
|
||||
var end_date = frappe.datetime.add_days(cur_frm.doc.start_date, cur_frm.doc.validity - 1);
|
||||
cur_frm.set_value("end_date", end_date);
|
||||
frm.events.set_end_date(frm);
|
||||
},
|
||||
|
||||
validity: function (frm) {
|
||||
var end_date = frappe.datetime.add_days(cur_frm.doc.start_date, cur_frm.doc.validity - 1);
|
||||
cur_frm.set_value("end_date", end_date);
|
||||
frm.events.set_end_date(frm);
|
||||
},
|
||||
|
||||
set_end_date: function (frm) {
|
||||
let end_date = frappe.datetime.add_days(frm.doc.start_date, frm.doc.validity - 1);
|
||||
frm.set_value("end_date", end_date);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -32,7 +32,7 @@ frappe.ui.form.on("Journal Entry", {
|
||||
erpnext.journal_entry.lock_reversal_entry(frm);
|
||||
}
|
||||
|
||||
erpnext.toggle_naming_series();
|
||||
erpnext.toggle_naming_series(frm);
|
||||
erpnext.journal_entry.add_custom_buttons(frm);
|
||||
erpnext.journal_entry.toggle_fields_based_on_currency(frm);
|
||||
erpnext.accounts.unreconcile_payment.add_unreconcile_btn(frm);
|
||||
|
||||
@@ -253,6 +253,10 @@ def make_reverse_journal_entry(source_name: str, target_doc: str | dict | Docume
|
||||
|
||||
def post_process(source, target) -> None:
|
||||
target.reversal_of = source.name
|
||||
target.naming_series = source.naming_series
|
||||
if source.voucher_type == "Bank Entry":
|
||||
target.cheque_no = source.cheque_no
|
||||
target.cheque_date = source.cheque_date
|
||||
|
||||
doclist = get_mapped_doc(
|
||||
"Journal Entry",
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
// For license information, please see license.txt
|
||||
frappe.provide("erpnext.accounts.dimensions");
|
||||
|
||||
cur_frm.cscript.tax_table = "Advance Taxes and Charges";
|
||||
|
||||
erpnext.accounts.taxes.setup_tax_validations("Payment Entry");
|
||||
erpnext.accounts.taxes.setup_tax_filters("Advance Taxes and Charges");
|
||||
|
||||
@@ -46,6 +44,8 @@ frappe.ui.form.on("Payment Entry", {
|
||||
},
|
||||
|
||||
setup: function (frm) {
|
||||
frm.cscript.tax_table = "Advance Taxes and Charges";
|
||||
|
||||
frm.set_query("paid_from", function (doc) {
|
||||
frm.events.validate_company(frm);
|
||||
|
||||
|
||||
@@ -431,4 +431,4 @@ frappe.ui.form.on("Payment Reconciliation Allocation", {
|
||||
},
|
||||
});
|
||||
|
||||
extend_cscript(cur_frm.cscript, new erpnext.accounts.PaymentReconciliationController({ frm: cur_frm }));
|
||||
frappe.ui.form.set_controller("Payment Reconciliation", erpnext.accounts.PaymentReconciliationController);
|
||||
|
||||
@@ -486,6 +486,13 @@ class PaymentReconciliation(Document):
|
||||
"Accounts Settings", "exchange_gain_loss_posting_date", cache=True
|
||||
)
|
||||
invoice_exchange_map = self.get_invoice_exchange_map(args.get("invoices"), args.get("payments"))
|
||||
account_currency = frappe.get_cached_value(
|
||||
"Account", self.receivable_payable_account, "account_currency"
|
||||
)
|
||||
allocated_amount_precision = get_field_precision(
|
||||
frappe.get_meta("Payment Reconciliation Allocation").get_field("allocated_amount"),
|
||||
currency=account_currency,
|
||||
)
|
||||
|
||||
entries = []
|
||||
for pay in args.get("payments"):
|
||||
@@ -493,11 +500,17 @@ class PaymentReconciliation(Document):
|
||||
for inv in args.get("invoices"):
|
||||
if pay.get("amount") >= inv.get("outstanding_amount"):
|
||||
res = self.get_allocated_entry(pay, inv, inv["outstanding_amount"])
|
||||
pay["amount"] = flt(pay.get("amount")) - flt(inv.get("outstanding_amount"))
|
||||
pay["amount"] = flt(
|
||||
flt(pay.get("amount")) - flt(inv.get("outstanding_amount")),
|
||||
allocated_amount_precision,
|
||||
)
|
||||
inv["outstanding_amount"] = 0
|
||||
else:
|
||||
res = self.get_allocated_entry(pay, inv, pay["amount"])
|
||||
inv["outstanding_amount"] = flt(inv.get("outstanding_amount")) - flt(pay.get("amount"))
|
||||
inv["outstanding_amount"] = flt(
|
||||
flt(inv.get("outstanding_amount")) - flt(pay.get("amount")),
|
||||
allocated_amount_precision,
|
||||
)
|
||||
pay["amount"] = 0
|
||||
|
||||
inv["exchange_rate"] = invoice_exchange_map.get(inv.get("invoice_number"))
|
||||
|
||||
@@ -1659,6 +1659,77 @@ class TestPaymentReconciliation(ERPNextTestSuite):
|
||||
# Should not raise frappe.exceptions.ValidationError: Payment Entry has been modified after you pulled it. Please pull it again.
|
||||
pr.reconcile()
|
||||
|
||||
@ERPNextTestSuite.change_settings("System Settings", {"currency_precision": 2})
|
||||
def test_allocate_entries_rounds_running_balance_to_currency_precision(self):
|
||||
pr = frappe.new_doc("Payment Reconciliation")
|
||||
pr.company = self.company
|
||||
pr.party_type = "Customer"
|
||||
pr.party = self.customer
|
||||
pr.receivable_payable_account = self.debit_to
|
||||
pr.set("invoices", [{"invoice_number": "INV-1"}])
|
||||
pr.set("payments", [{"reference_name": "PAY-1"}])
|
||||
|
||||
invoices = [
|
||||
{
|
||||
"invoice_type": "Sales Invoice",
|
||||
"invoice_number": "INV-1",
|
||||
"outstanding_amount": 17592.415,
|
||||
"currency": "INR",
|
||||
},
|
||||
]
|
||||
payments = [
|
||||
{
|
||||
"reference_type": "Payment Entry",
|
||||
"reference_name": "PAY-1",
|
||||
"amount": 18230,
|
||||
"currency": "INR",
|
||||
}
|
||||
]
|
||||
|
||||
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
|
||||
|
||||
self.assertEqual(payments[0]["amount"], flt(637.585, 2))
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"System Settings", {"currency_precision": "", "use_number_format_from_currency": 1}
|
||||
)
|
||||
def test_allocate_entries_rounds_running_balance_to_account_currency_precision(self):
|
||||
account_currency = frappe.get_cached_value("Account", self.debit_to, "account_currency")
|
||||
original_number_format = frappe.db.get_value("Currency", account_currency, "number_format")
|
||||
frappe.db.set_value("Currency", account_currency, "number_format", "#,###.###")
|
||||
self.addCleanup(
|
||||
frappe.db.set_value, "Currency", account_currency, "number_format", original_number_format
|
||||
)
|
||||
|
||||
pr = frappe.new_doc("Payment Reconciliation")
|
||||
pr.company = self.company
|
||||
pr.party_type = "Customer"
|
||||
pr.party = self.customer
|
||||
pr.receivable_payable_account = self.debit_to
|
||||
pr.set("invoices", [{"invoice_number": "INV-1"}])
|
||||
pr.set("payments", [{"reference_name": "PAY-1"}])
|
||||
|
||||
invoices = [
|
||||
{
|
||||
"invoice_type": "Sales Invoice",
|
||||
"invoice_number": "INV-1",
|
||||
"outstanding_amount": 17592.415,
|
||||
"currency": account_currency,
|
||||
},
|
||||
]
|
||||
payments = [
|
||||
{
|
||||
"reference_type": "Payment Entry",
|
||||
"reference_name": "PAY-1",
|
||||
"amount": 18230,
|
||||
"currency": account_currency,
|
||||
}
|
||||
]
|
||||
|
||||
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
|
||||
|
||||
self.assertEqual(payments[0]["amount"], flt(637.585, 3))
|
||||
|
||||
def test_reverse_payment_against_payment_for_supplier(self):
|
||||
"""
|
||||
Reconcile a payment against a reverse payment, for a supplier.
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
cur_frm.add_fetch("payment_gateway_account", "payment_account", "payment_account");
|
||||
cur_frm.add_fetch("payment_gateway_account", "payment_gateway", "payment_gateway");
|
||||
cur_frm.add_fetch("payment_gateway_account", "message", "message");
|
||||
|
||||
frappe.ui.form.on("Payment Request", {
|
||||
setup: function (frm) {
|
||||
frm.add_fetch("payment_gateway_account", "message", "message");
|
||||
|
||||
frm.set_query("party_type", function () {
|
||||
return {
|
||||
query: "erpnext.setup.doctype.party_type.party_type.get_party_type",
|
||||
|
||||
@@ -280,12 +280,13 @@ class PeriodClosingVoucher(AccountsController):
|
||||
|
||||
data = self.get_data_for_mapreduce()
|
||||
mapreduce(
|
||||
"erpnext.accounts.doctype.period_closing_voucher.period_closing_voucher.mapper",
|
||||
"erpnext.accounts.doctype.period_closing_voucher.period_closing_voucher.reducer",
|
||||
"erpnext.accounts.doctype.period_closing_voucher.period_closing_voucher.process_date_range",
|
||||
"erpnext.accounts.doctype.period_closing_voucher.period_closing_voucher.aggregate_partial_result",
|
||||
"erpnext.accounts.doctype.period_closing_voucher.period_closing_voucher.summarize_and_post_ledger",
|
||||
data,
|
||||
self.doctype,
|
||||
self.name,
|
||||
f"Closing FY {self.fiscal_year}",
|
||||
)
|
||||
|
||||
def on_cancel(self):
|
||||
@@ -330,8 +331,8 @@ class PeriodClosingVoucher(AccountsController):
|
||||
|
||||
def make_gl_entries(self):
|
||||
if frappe.db.estimate_count("GL Entry") > 100_000:
|
||||
frappe.enqueue(
|
||||
process_gl_and_closing_entries,
|
||||
frappe.enqueue_task(
|
||||
method=process_gl_and_closing_entries,
|
||||
doc=self,
|
||||
timeout=1800,
|
||||
)
|
||||
@@ -834,7 +835,7 @@ def get_previous_closed_period_in_current_year(fiscal_year, company):
|
||||
return prev_closed_period_end_date
|
||||
|
||||
|
||||
def mapper(val):
|
||||
def process_date_range(val):
|
||||
start_date = val.from_date
|
||||
end_date = val.to_date
|
||||
pcv = val.pcv
|
||||
@@ -881,7 +882,7 @@ def mapper(val):
|
||||
return res
|
||||
|
||||
|
||||
def reducer(final, partial_res):
|
||||
def aggregate_partial_result(final, partial_res):
|
||||
if final is None:
|
||||
final = []
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
// License: GNU General Public License v3. See license.txt
|
||||
|
||||
frappe.listview_settings["Period Closing Voucher"] = {
|
||||
add_fields: ["gle_processing_status"],
|
||||
get_indicator: function (doc) {
|
||||
const status_colors = {
|
||||
Draft: "red",
|
||||
Submitted: "blue",
|
||||
Cancelled: "red",
|
||||
};
|
||||
|
||||
const gle_processing_status = {
|
||||
"In Progress": [__("Processing GL Entries"), "blue"],
|
||||
Completed: [__("Period Closed"), "green"],
|
||||
Failed: [__("Period Closing Failed"), "red"],
|
||||
};
|
||||
if (doc.docstatus != 0) {
|
||||
return [
|
||||
gle_processing_status[doc.gle_processing_status][0],
|
||||
gle_processing_status[doc.gle_processing_status][1],
|
||||
"gle_processing_status,=," + doc.gle_processing_status,
|
||||
];
|
||||
}
|
||||
|
||||
return [__(doc.docstatus), status_colors[doc.docstatus], "docstatus,=," + doc.docstatus];
|
||||
},
|
||||
};
|
||||
@@ -45,7 +45,7 @@ frappe.ui.form.on("POS Closing Entry", {
|
||||
|
||||
$("#jump_to_error").on("click", (e) => {
|
||||
e.preventDefault();
|
||||
frappe.utils.scroll_to(cur_frm.get_field("error_message").$wrapper, true, 30);
|
||||
frappe.utils.scroll_to(frm.get_field("error_message").$wrapper, true, 30);
|
||||
});
|
||||
|
||||
frm.add_custom_button(__("Retry"), function () {
|
||||
|
||||
@@ -363,7 +363,7 @@ erpnext.selling.POSInvoiceController = class POSInvoiceController extends erpnex
|
||||
}
|
||||
};
|
||||
|
||||
extend_cscript(cur_frm.cscript, new erpnext.selling.POSInvoiceController({ frm: cur_frm }));
|
||||
frappe.ui.form.set_controller("POS Invoice", erpnext.selling.POSInvoiceController);
|
||||
|
||||
frappe.ui.form.on("POS Invoice", {
|
||||
redeem_loyalty_points: function (frm) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.model.mapper import map_child_doc, map_doc
|
||||
from frappe.query_builder import DocType
|
||||
from frappe.utils import cint, flt, get_time, getdate, nowdate, nowtime
|
||||
from frappe.utils.background_jobs import enqueue, is_job_enqueued
|
||||
from frappe.utils.scheduler import is_scheduler_inactive
|
||||
@@ -16,7 +17,6 @@ from frappe.utils.scheduler import is_scheduler_inactive
|
||||
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
|
||||
get_checks_for_pl_and_bs_accounts,
|
||||
)
|
||||
from erpnext.controllers.sales_and_purchase_return import get_sales_invoice_item_from_consolidated_invoice
|
||||
|
||||
|
||||
class POSInvoiceMergeLog(Document):
|
||||
@@ -214,6 +214,8 @@ class POSInvoiceMergeLog(Document):
|
||||
|
||||
loyalty_amount_sum, loyalty_points_sum, idx = 0, 0, 1
|
||||
|
||||
reversed_rows = get_reversed_rows([doc.return_against for doc in data if doc.is_return])
|
||||
|
||||
for doc in data:
|
||||
old_new_item_map = frappe._dict()
|
||||
old_new_tax_map = frappe._dict()
|
||||
@@ -238,9 +240,13 @@ class POSInvoiceMergeLog(Document):
|
||||
si_item.pos_invoice = doc.name
|
||||
si_item.pos_invoice_item = item.name
|
||||
if doc.is_return:
|
||||
si_item.sales_invoice_item = get_sales_invoice_item_from_consolidated_invoice(
|
||||
doc.return_against, item.pos_invoice_item
|
||||
)
|
||||
reversed_row = reversed_rows.get(item.pos_invoice_item) or frappe._dict()
|
||||
si_item.sales_invoice_item = reversed_row.get("name")
|
||||
# quote the rate of the row being reversed: rounding an invoice-level discount
|
||||
# can leave a return's net rate a minor unit above the sale's, and
|
||||
# validate_returned_items refuses a return priced above its original
|
||||
if si_item.sales_invoice_item:
|
||||
si_item.rate = reversed_row.rate
|
||||
if item.serial_and_batch_bundle:
|
||||
si_item.serial_and_batch_bundle = item.serial_and_batch_bundle
|
||||
items.append(si_item)
|
||||
@@ -432,6 +438,28 @@ class POSInvoiceMergeLog(Document):
|
||||
si.cancel()
|
||||
|
||||
|
||||
def get_reversed_rows(return_against):
|
||||
"""Rows of the consolidated sales these returns reverse, keyed by the POS invoice row."""
|
||||
if not return_against:
|
||||
return {}
|
||||
|
||||
sales_invoice = DocType("Sales Invoice")
|
||||
sales_invoice_item = DocType("Sales Invoice Item")
|
||||
|
||||
rows = (
|
||||
frappe.qb.from_(sales_invoice)
|
||||
.from_(sales_invoice_item)
|
||||
.select(sales_invoice_item.name, sales_invoice_item.rate, sales_invoice_item.pos_invoice_item)
|
||||
.where(
|
||||
(sales_invoice.name == sales_invoice_item.parent)
|
||||
& (sales_invoice.is_return == 0)
|
||||
& (sales_invoice_item.pos_invoice.isin(return_against))
|
||||
)
|
||||
).run(as_dict=True)
|
||||
|
||||
return {row.pos_invoice_item: row for row in rows}
|
||||
|
||||
|
||||
def get_all_unconsolidated_invoices():
|
||||
filters = {
|
||||
"consolidated_invoice": ["in", ["", None]],
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
from contextlib import contextmanager
|
||||
|
||||
import frappe
|
||||
from frappe.utils import flt
|
||||
|
||||
from erpnext.accounts.doctype.mode_of_payment.test_mode_of_payment import (
|
||||
set_default_account_for_mode_of_payment,
|
||||
@@ -20,6 +23,67 @@ from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
@contextmanager
|
||||
def rounding_method(method):
|
||||
"""System Settings is also cached on frappe.local, so that copy has to go as well."""
|
||||
previous = frappe.db.get_single_value("System Settings", "rounding_method")
|
||||
try:
|
||||
frappe.db.set_single_value("System Settings", "rounding_method", method)
|
||||
frappe.local.system_settings = None
|
||||
yield
|
||||
finally:
|
||||
frappe.db.set_single_value("System Settings", "rounding_method", previous)
|
||||
frappe.local.system_settings = None
|
||||
|
||||
|
||||
def sell_over_the_counter(lines, discount_percentage=0):
|
||||
item_code, qty, rate = lines[0]
|
||||
sale = create_pos_invoice(item_code=item_code, qty=qty, rate=rate, do_not_save=True)
|
||||
for item_code, qty, rate in lines[1:]:
|
||||
sale.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": item_code,
|
||||
"qty": qty,
|
||||
"rate": rate,
|
||||
"price_list_rate": rate,
|
||||
"warehouse": "_Test Warehouse - _TC",
|
||||
"income_account": "Sales - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
},
|
||||
)
|
||||
|
||||
if discount_percentage:
|
||||
sale.apply_discount_on = "Net Total"
|
||||
sale.additional_discount_percentage = discount_percentage
|
||||
|
||||
sale.run_method("calculate_taxes_and_totals")
|
||||
payable = sale.rounded_total or sale.grand_total
|
||||
sale.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": payable})
|
||||
sale.paid_amount = sale.base_paid_amount = payable
|
||||
sale.insert()
|
||||
sale.submit()
|
||||
return sale
|
||||
|
||||
|
||||
def refund_over_the_counter(sale, qty=None):
|
||||
"""Hand back every line of `sale`, `qty` of each when fewer units come back."""
|
||||
note = make_sales_return(sale.name)
|
||||
if qty is not None:
|
||||
for item in note.items:
|
||||
item.qty = qty
|
||||
|
||||
note.run_method("calculate_taxes_and_totals")
|
||||
refundable = note.rounded_total or note.grand_total
|
||||
note.payments[0].amount = refundable
|
||||
for spare in note.payments[1:]:
|
||||
spare.amount = 0
|
||||
note.paid_amount = note.base_paid_amount = refundable
|
||||
note.insert()
|
||||
note.submit()
|
||||
return note
|
||||
|
||||
|
||||
class TestPOSInvoiceMergeLog(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
mode_of_payment = frappe.get_doc("Mode of Payment", "Bank Draft")
|
||||
@@ -478,3 +542,81 @@ class TestPOSInvoiceMergeLog(ERPNextTestSuite):
|
||||
"POS Invoice Merge Log", {"pos_closing_entry": closing_entry.name}, "company"
|
||||
)
|
||||
self.assertEqual(pos_merge_log_company, closing_entry.company)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Selling Settings", {"allow_multiple_items": 1})
|
||||
def test_consolidating_returns_priced_off_a_rounded_invoice_discount(self):
|
||||
"""A return works out its own share of an invoice-level discount, so rounding can leave
|
||||
it a minor unit above the sale's, and validate_returned_items then refuses it.
|
||||
|
||||
Every shape that reaches a consolidated credit note goes through one closing entry:
|
||||
a split landing on a half minor unit, the same item on two rows so the rows can only
|
||||
be paired through sales_invoice_item, fewer units coming back than went out, and — as
|
||||
a control — a sale with no invoice-level discount to split at all.
|
||||
"""
|
||||
for item_code in ("_Test Item", "_Test Item 2"):
|
||||
make_stock_entry(to_warehouse="_Test Warehouse - _TC", item_code=item_code, rate=100, qty=40)
|
||||
|
||||
with rounding_method("Banker's Rounding (legacy)"):
|
||||
tied = sell_over_the_counter(
|
||||
[("_Test Item", 1, 42.86), ("_Test Item 2", 1, 57.14)], discount_percentage=25
|
||||
)
|
||||
repeated = sell_over_the_counter(
|
||||
[("_Test Item", 1, 42.86), ("_Test Item", 1, 57.14)], discount_percentage=25
|
||||
)
|
||||
oversold = sell_over_the_counter(
|
||||
[("_Test Item", 3, 42.86), ("_Test Item 2", 3, 57.14)], discount_percentage=25
|
||||
)
|
||||
undiscounted = sell_over_the_counter([("_Test Item", 1, 42.86), ("_Test Item 2", 1, 57.14)])
|
||||
|
||||
# the sale and the return really do round the split apart
|
||||
self.assertEqual(
|
||||
{item.item_code: item.net_rate for item in tied.items},
|
||||
{"_Test Item": 32.15, "_Test Item 2": 42.85},
|
||||
)
|
||||
returns = [
|
||||
refund_over_the_counter(tied),
|
||||
refund_over_the_counter(repeated),
|
||||
refund_over_the_counter(oversold, qty=-1),
|
||||
refund_over_the_counter(undiscounted),
|
||||
]
|
||||
self.assertEqual(
|
||||
{item.item_code: item.net_rate for item in returns[0].items},
|
||||
{"_Test Item": 32.14, "_Test Item 2": 42.86},
|
||||
)
|
||||
|
||||
self.make_closing_entry()
|
||||
|
||||
for pos_invoice in [tied, repeated, oversold, undiscounted, *returns]:
|
||||
pos_invoice.load_from_db()
|
||||
self.assertTrue(
|
||||
frappe.db.exists("Sales Invoice", pos_invoice.consolidated_invoice),
|
||||
f"{pos_invoice.name} was not consolidated",
|
||||
)
|
||||
self.assertEqual(
|
||||
frappe.db.get_value("Sales Invoice", pos_invoice.consolidated_invoice, "outstanding_amount"),
|
||||
0,
|
||||
)
|
||||
|
||||
for note in returns:
|
||||
# no returned row may be priced above the row it reverses
|
||||
for row in frappe.get_all(
|
||||
"Sales Invoice Item",
|
||||
filters={"parent": note.consolidated_invoice},
|
||||
fields=["item_code", "rate", "sales_invoice_item"],
|
||||
):
|
||||
self.assertTrue(row.sales_invoice_item, f"{row.item_code} lost its link to the sale")
|
||||
sold_rate = frappe.db.get_value("Sales Invoice Item", row.sales_invoice_item, "rate")
|
||||
self.assertLessEqual(row.rate, sold_rate)
|
||||
|
||||
# returns for one customer land on a single credit note, which still adds up to
|
||||
# everything handed back over the counter
|
||||
refunded = {}
|
||||
for note in returns:
|
||||
refunded[note.consolidated_invoice] = refunded.get(note.consolidated_invoice, 0) + flt(
|
||||
note.grand_total
|
||||
)
|
||||
for consolidated_name, handed_back in refunded.items():
|
||||
self.assertEqual(
|
||||
flt(frappe.db.get_value("Sales Invoice", consolidated_name, "grand_total"), 2),
|
||||
flt(handed_back, 2),
|
||||
)
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"more_info_tab",
|
||||
"section_break_16",
|
||||
"print_format",
|
||||
"receipt_email_template",
|
||||
"letter_head",
|
||||
"column_break0",
|
||||
"tc_name",
|
||||
@@ -316,6 +317,13 @@
|
||||
"label": "Print Format",
|
||||
"options": "Print Format"
|
||||
},
|
||||
{
|
||||
"description": "Subject and message for receipts emailed from the POS. The template gets the invoice as doc. A message typed by the cashier replaces the template message. Leave empty to send the default email.",
|
||||
"fieldname": "receipt_email_template",
|
||||
"fieldtype": "Link",
|
||||
"label": "Receipt Email Template",
|
||||
"options": "Email Template"
|
||||
},
|
||||
{
|
||||
"depends_on": "update_stock",
|
||||
"fieldname": "warehouse",
|
||||
@@ -583,7 +591,7 @@
|
||||
"link_fieldname": "pos_profile"
|
||||
}
|
||||
],
|
||||
"modified": "2026-08-21 23:11:45.419667",
|
||||
"modified": "2026-09-23 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "POS Profile",
|
||||
|
||||
@@ -58,6 +58,7 @@ class POSProfile(Document):
|
||||
print_format: DF.Link | None
|
||||
print_receipt_on_order_complete: DF.Check
|
||||
project: DF.Link | None
|
||||
receipt_email_template: DF.Link | None
|
||||
select_print_heading: DF.Link | None
|
||||
selling_price_list: DF.Link | None
|
||||
set_grand_total_to_default_mop: DF.Check
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
|
||||
frappe.provide("erpnext.accounts");
|
||||
|
||||
cur_frm.cscript.tax_table = "Purchase Taxes and Charges";
|
||||
|
||||
erpnext.accounts.payment_triggers.setup("Purchase Invoice");
|
||||
erpnext.accounts.taxes.setup_tax_filters("Purchase Taxes and Charges");
|
||||
erpnext.accounts.taxes.setup_tax_validations("Purchase Invoice");
|
||||
@@ -478,7 +476,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
|
||||
}
|
||||
};
|
||||
|
||||
cur_frm.script_manager.make(erpnext.accounts.PurchaseInvoice);
|
||||
frappe.ui.form.set_controller("Purchase Invoice", erpnext.accounts.PurchaseInvoice);
|
||||
|
||||
// Hide Fields
|
||||
// ------------
|
||||
@@ -504,70 +502,23 @@ function hide_fields(frm) {
|
||||
frm.refresh_fields();
|
||||
}
|
||||
|
||||
cur_frm.fields_dict.cash_bank_account.get_query = function (doc) {
|
||||
return {
|
||||
filters: [
|
||||
["Account", "account_type", "in", ["Cash", "Bank"]],
|
||||
["Account", "is_group", "=", 0],
|
||||
["Account", "company", "=", doc.company],
|
||||
["Account", "report_type", "=", "Balance Sheet"],
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
cur_frm.fields_dict["items"].grid.get_field("item_code").get_query = function (doc, cdt, cdn) {
|
||||
return {
|
||||
query: "erpnext.controllers.queries.item_query",
|
||||
filters: { is_purchase_item: 1 },
|
||||
};
|
||||
};
|
||||
|
||||
cur_frm.fields_dict["credit_to"].get_query = function (doc) {
|
||||
// filter on Account
|
||||
return {
|
||||
filters: {
|
||||
account_type: "Payable",
|
||||
is_group: 0,
|
||||
company: doc.company,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Get Print Heading
|
||||
cur_frm.fields_dict["select_print_heading"].get_query = function (doc, cdt, cdn) {
|
||||
return {
|
||||
filters: [["Print Heading", "docstatus", "!=", 2]],
|
||||
};
|
||||
};
|
||||
|
||||
cur_frm.set_query("wip_composite_asset", "items", function () {
|
||||
return {
|
||||
filters: { asset_type: "Composite Asset", docstatus: 0 },
|
||||
};
|
||||
});
|
||||
|
||||
cur_frm.cscript.expense_account = function (doc, cdt, cdn) {
|
||||
var d = locals[cdt][cdn];
|
||||
if (d.idx == 1 && d.expense_account) {
|
||||
var cl = doc.items || [];
|
||||
for (var i = 0; i < cl.length; i++) {
|
||||
if (!cl[i].expense_account) cl[i].expense_account = d.expense_account;
|
||||
frappe.ui.form.on("Purchase Invoice Item", {
|
||||
expense_account: function (frm, cdt, cdn) {
|
||||
let d = locals[cdt][cdn];
|
||||
if (d.idx == 1 && d.expense_account) {
|
||||
for (const item of frm.doc.items || []) {
|
||||
if (!item.expense_account) item.expense_account = d.expense_account;
|
||||
}
|
||||
}
|
||||
}
|
||||
refresh_field("items");
|
||||
};
|
||||
|
||||
cur_frm.fields_dict["items"].grid.get_field("cost_center").get_query = function (doc) {
|
||||
return {
|
||||
filters: {
|
||||
company: doc.company,
|
||||
is_group: 0,
|
||||
},
|
||||
};
|
||||
};
|
||||
frm.refresh_field("items");
|
||||
},
|
||||
});
|
||||
|
||||
frappe.ui.form.on("Purchase Invoice", {
|
||||
setup: function (frm) {
|
||||
frm.cscript.tax_table = "Purchase Taxes and Charges";
|
||||
frm.events.set_queries(frm);
|
||||
|
||||
frm.custom_make_buttons = {
|
||||
"Purchase Invoice": "Return / Debit Note",
|
||||
"Payment Entry": "Payment",
|
||||
@@ -627,6 +578,50 @@ frappe.ui.form.on("Purchase Invoice", {
|
||||
};
|
||||
},
|
||||
|
||||
set_queries: function (frm) {
|
||||
frm.set_query("cash_bank_account", function (doc) {
|
||||
return {
|
||||
filters: [
|
||||
["Account", "account_type", "in", ["Cash", "Bank"]],
|
||||
["Account", "is_group", "=", 0],
|
||||
["Account", "company", "=", doc.company],
|
||||
["Account", "report_type", "=", "Balance Sheet"],
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
frm.set_query("credit_to", function (doc) {
|
||||
return {
|
||||
filters: {
|
||||
account_type: "Payable",
|
||||
is_group: 0,
|
||||
company: doc.company,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
frm.set_query("select_print_heading", function () {
|
||||
return {
|
||||
filters: [["Print Heading", "docstatus", "!=", 2]],
|
||||
};
|
||||
});
|
||||
|
||||
frm.set_query("wip_composite_asset", "items", function () {
|
||||
return {
|
||||
filters: { asset_type: "Composite Asset", docstatus: 0 },
|
||||
};
|
||||
});
|
||||
|
||||
frm.set_query("cost_center", "items", function (doc) {
|
||||
return {
|
||||
filters: {
|
||||
company: doc.company,
|
||||
is_group: 0,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
refresh: function (frm) {
|
||||
frm.events.add_custom_buttons(frm);
|
||||
},
|
||||
|
||||
@@ -216,6 +216,11 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
|
||||
if doc.is_internal_supplier and item.valuation_rate:
|
||||
credit_amount = flt(item.valuation_rate * item.stock_qty)
|
||||
|
||||
rejected_amount = self.make_rejected_warehouse_gl_entry(
|
||||
gl_entries, item, voucher_wise_stock_value, inventory_account_map
|
||||
)
|
||||
credit_amount += rejected_amount
|
||||
|
||||
# Intentionally passed negative debit amount to avoid incorrect GL Entry validation
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
@@ -251,6 +256,10 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
|
||||
)
|
||||
|
||||
else:
|
||||
self.make_rejected_warehouse_gl_entry(
|
||||
gl_entries, item, voucher_wise_stock_value, inventory_account_map
|
||||
)
|
||||
|
||||
if not doc.is_internal_transfer():
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
@@ -564,6 +573,49 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
|
||||
|
||||
return stock_asset_rbnb or item.expense_account
|
||||
|
||||
def make_rejected_warehouse_gl_entry(
|
||||
self, gl_entries, item, voucher_wise_stock_value, inventory_account_map
|
||||
) -> float:
|
||||
"""Book the material the invoice moved into the rejected warehouse.
|
||||
|
||||
An internal transfer carries the value credited out of the in-transit warehouse along with
|
||||
the accepted material, so the entry against it is that warehouse, and the caller credits it
|
||||
for both. On an ordinary invoice the supplier entry already holds the cost.
|
||||
"""
|
||||
doc = self.doc
|
||||
if not (item.rejected_warehouse and flt(item.rejected_qty)):
|
||||
return 0.0
|
||||
|
||||
transfers_rejected_material = doc.is_internal_transfer()
|
||||
|
||||
rejected_amount = flt(
|
||||
voucher_wise_stock_value.get((item.name, item.rejected_warehouse)),
|
||||
item.precision("base_net_amount"),
|
||||
)
|
||||
if not rejected_amount:
|
||||
return 0.0
|
||||
|
||||
rejected_account = doc.get_inventory_account_dict(item, inventory_account_map, "rejected_warehouse")
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": rejected_account["account"],
|
||||
"against": item.expense_account if transfers_rejected_material else doc.supplier,
|
||||
"cost_center": item.cost_center,
|
||||
"project": item.project or doc.project,
|
||||
"remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
|
||||
"debit": rejected_amount,
|
||||
"debit_in_transaction_currency": flt(
|
||||
rejected_amount / doc.conversion_rate, item.precision("net_amount")
|
||||
),
|
||||
},
|
||||
rejected_account["account_currency"],
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
|
||||
return rejected_amount if transfers_rejected_material else 0.0
|
||||
|
||||
def make_stock_adjustment_entry(self, gl_entries, item, voucher_wise_stock_value, account_currency):
|
||||
doc = self.doc
|
||||
net_amt_precision = item.precision("base_net_amount")
|
||||
@@ -577,16 +629,24 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
|
||||
if doc.is_return and doc.update_stock and (doc.is_internal_supplier or not doc.return_against):
|
||||
net_rate = item.base_net_amount
|
||||
if item.sales_incoming_rate:
|
||||
net_rate = item.qty * item.sales_incoming_rate
|
||||
# Material of a transfer goes back at the rate it came in with, the rejected
|
||||
# material along with the accepted.
|
||||
net_rate = (flt(item.qty) + flt(item.rejected_qty)) * item.sales_incoming_rate
|
||||
|
||||
stock_amount = net_rate + item.item_tax_amount + flt(item.landed_cost_voucher_amount)
|
||||
warehouse_debit_amount = flt(
|
||||
voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision
|
||||
)
|
||||
|
||||
if flt(stock_amount, net_amt_precision) != flt(warehouse_debit_amount, net_amt_precision):
|
||||
# The rejected warehouse carries the rest of what the invoice paid for, and is booked
|
||||
# by its own entry, so it is not a variance.
|
||||
returned_stock_value = warehouse_debit_amount + flt(
|
||||
voucher_wise_stock_value.get((item.name, item.rejected_warehouse)), net_amt_precision
|
||||
)
|
||||
|
||||
if flt(stock_amount, net_amt_precision) != flt(returned_stock_value, net_amt_precision):
|
||||
cost_of_goods_sold_account = self.get_stock_variance_account(item)
|
||||
stock_adjustment_amt = stock_amount - warehouse_debit_amount
|
||||
stock_adjustment_amt = stock_amount - returned_stock_value
|
||||
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
|
||||
@@ -844,7 +844,9 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
)
|
||||
existing_purchase_cost = existing_purchase_cost and existing_purchase_cost[0].base_net_amount or 0
|
||||
|
||||
pi = make_purchase_invoice(currency="USD", conversion_rate=60, project=project.name)
|
||||
pi = make_purchase_invoice(currency="USD", conversion_rate=60, project=project.name, do_not_save=True)
|
||||
pi.credit_to = "_Test Payable USD - _TC"
|
||||
pi.submit()
|
||||
self.assertEqual(
|
||||
frappe.db.get_value("Project", project.name, "total_purchase_cost"),
|
||||
existing_purchase_cost + 15000,
|
||||
@@ -856,12 +858,14 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
existing_purchase_cost + 15500,
|
||||
)
|
||||
|
||||
pi1.reload()
|
||||
pi1.cancel()
|
||||
self.assertEqual(
|
||||
frappe.db.get_value("Project", project.name, "total_purchase_cost"),
|
||||
existing_purchase_cost + 15000,
|
||||
)
|
||||
|
||||
pi.reload()
|
||||
pi.cancel()
|
||||
self.assertEqual(
|
||||
frappe.db.get_value("Project", project.name, "total_purchase_cost"), existing_purchase_cost
|
||||
@@ -2624,6 +2628,582 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
return_pi.submit()
|
||||
self.assertEqual(return_pi.docstatus, 1)
|
||||
|
||||
def test_internal_transfer_invoice_with_rejected_qty(self):
|
||||
"""An invoice that updates stock moves rejected material out of the in-transit warehouse and
|
||||
books it, like a receipt does."""
|
||||
from erpnext.accounts.doctype.sales_invoice.mapper import make_inter_company_purchase_invoice
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.stock.doctype.item.test_item import create_item
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import (
|
||||
get_gl_entries,
|
||||
make_purchase_receipt,
|
||||
prepare_data_for_internal_transfer,
|
||||
)
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
|
||||
prepare_data_for_internal_transfer()
|
||||
company = "_Test Company with perpetual inventory"
|
||||
|
||||
from_warehouse = create_warehouse("_Test Invoice Transfer From", company=company)
|
||||
transit_warehouse = create_warehouse("_Test Invoice Transfer Transit", company=company)
|
||||
to_warehouse = create_warehouse("_Test Invoice Transfer To", company=company)
|
||||
rejected_warehouse = create_warehouse("_Test Invoice Transfer Rejected", company=company)
|
||||
|
||||
item_doc = create_item("Test Invoice Internal Transfer Item")
|
||||
|
||||
make_purchase_receipt(
|
||||
item_code=item_doc.name, company=company, warehouse=from_warehouse, qty=10, rate=100
|
||||
)
|
||||
|
||||
si = create_sales_invoice(
|
||||
company=company,
|
||||
customer="_Test Internal Customer 2",
|
||||
item_code=item_doc.name,
|
||||
qty=10,
|
||||
rate=100,
|
||||
warehouse=from_warehouse,
|
||||
update_stock=1,
|
||||
cost_center="Main - TCP1",
|
||||
debit_to="Debtors - TCP1",
|
||||
income_account="Sales - TCP1",
|
||||
do_not_save=1,
|
||||
)
|
||||
si.items[0].target_warehouse = transit_warehouse
|
||||
si.insert()
|
||||
si.submit()
|
||||
|
||||
pi = make_inter_company_purchase_invoice(si.name)
|
||||
pi.update_stock = 1
|
||||
pi.items[0].warehouse = to_warehouse
|
||||
pi.items[0].qty = 7
|
||||
pi.items[0].rejected_qty = 3
|
||||
pi.items[0].received_qty = 10
|
||||
pi.items[0].rejected_warehouse = rejected_warehouse
|
||||
pi.items[0].expense_account = "Cost of Goods Sold - TCP1"
|
||||
pi.submit()
|
||||
|
||||
sl_entries = frappe.get_all(
|
||||
"Stock Ledger Entry",
|
||||
filters={"voucher_no": pi.name, "is_cancelled": 0},
|
||||
fields=["warehouse", "actual_qty", "stock_value_difference"],
|
||||
)
|
||||
|
||||
stock_qty = {d.warehouse: d.actual_qty for d in sl_entries}
|
||||
self.assertEqual(stock_qty[transit_warehouse], -10)
|
||||
self.assertEqual(stock_qty[to_warehouse], 7)
|
||||
self.assertEqual(stock_qty[rejected_warehouse], 3)
|
||||
|
||||
booked_value = {}
|
||||
for entry in get_gl_entries("Purchase Invoice", pi.name, skip_cancelled=True):
|
||||
booked_value.setdefault(entry.account, 0)
|
||||
booked_value[entry.account] += flt(entry.debit) - flt(entry.credit)
|
||||
|
||||
self.assertEqual(flt(sum(booked_value.values()), 2), 0)
|
||||
self.assertEqual(booked_value[get_inventory_account(company, transit_warehouse)], -1000)
|
||||
self.assertEqual(booked_value[get_inventory_account(company, to_warehouse)], 700)
|
||||
self.assertEqual(booked_value[get_inventory_account(company, rejected_warehouse)], 300)
|
||||
|
||||
def test_internal_transfer_invoice_with_rejected_batch_qty(self):
|
||||
"""Batch material rejected on a stock updating internal transfer invoice gets its own package."""
|
||||
from erpnext.accounts.doctype.sales_invoice.mapper import make_inter_company_purchase_invoice
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import (
|
||||
make_purchase_receipt,
|
||||
prepare_data_for_internal_transfer,
|
||||
)
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
|
||||
prepare_data_for_internal_transfer()
|
||||
company = "_Test Company with perpetual inventory"
|
||||
|
||||
from_warehouse = create_warehouse("_Test Batch Invoice Transfer From", company=company)
|
||||
transit_warehouse = create_warehouse("_Test Batch Invoice Transfer Transit", company=company)
|
||||
to_warehouse = create_warehouse("_Test Batch Invoice Transfer To", company=company)
|
||||
rejected_warehouse = create_warehouse("_Test Batch Invoice Transfer Rejected", company=company)
|
||||
|
||||
item = make_item(
|
||||
"Test Invoice Internal Transfer Batch Item",
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"has_batch_no": 1,
|
||||
"create_new_batch": 1,
|
||||
"batch_number_series": "TIITB-.####",
|
||||
},
|
||||
)
|
||||
|
||||
make_purchase_receipt(
|
||||
item_code=item.name, company=company, warehouse=from_warehouse, qty=10, rate=100
|
||||
)
|
||||
|
||||
si = create_sales_invoice(
|
||||
company=company,
|
||||
customer="_Test Internal Customer 2",
|
||||
item_code=item.name,
|
||||
qty=10,
|
||||
rate=100,
|
||||
warehouse=from_warehouse,
|
||||
update_stock=1,
|
||||
cost_center="Main - TCP1",
|
||||
debit_to="Debtors - TCP1",
|
||||
income_account="Sales - TCP1",
|
||||
do_not_save=1,
|
||||
)
|
||||
si.items[0].target_warehouse = transit_warehouse
|
||||
si.insert()
|
||||
si.submit()
|
||||
|
||||
pi = make_inter_company_purchase_invoice(si.name)
|
||||
pi.update_stock = 1
|
||||
pi.items[0].warehouse = to_warehouse
|
||||
pi.items[0].qty = 7
|
||||
pi.items[0].rejected_qty = 3
|
||||
pi.items[0].received_qty = 10
|
||||
pi.items[0].rejected_warehouse = rejected_warehouse
|
||||
pi.items[0].expense_account = "Cost of Goods Sold - TCP1"
|
||||
pi.submit()
|
||||
|
||||
row = pi.items[0]
|
||||
self.assertEqual(
|
||||
frappe.db.get_value("Serial and Batch Bundle", row.serial_and_batch_bundle, "total_qty"), 7
|
||||
)
|
||||
self.assertEqual(
|
||||
frappe.db.get_value("Serial and Batch Bundle", row.rejected_serial_and_batch_bundle, "total_qty"),
|
||||
3,
|
||||
)
|
||||
|
||||
sl_entries = frappe.get_all(
|
||||
"Stock Ledger Entry",
|
||||
filters={"voucher_no": pi.name, "is_cancelled": 0},
|
||||
fields=["warehouse", "actual_qty", "stock_value_difference"],
|
||||
)
|
||||
moved_qty = {d.warehouse: d.actual_qty for d in sl_entries}
|
||||
moved_value = {d.warehouse: d.stock_value_difference for d in sl_entries}
|
||||
|
||||
self.assertEqual(moved_qty[transit_warehouse], -10)
|
||||
self.assertEqual(moved_qty[to_warehouse], 7)
|
||||
self.assertEqual(moved_qty[rejected_warehouse], 3)
|
||||
self.assertEqual(flt(moved_value[transit_warehouse]), -1000)
|
||||
self.assertEqual(flt(moved_value[to_warehouse]), 700)
|
||||
self.assertEqual(flt(moved_value[rejected_warehouse]), 300)
|
||||
|
||||
def test_internal_transfer_invoice_with_every_unit_rejected(self):
|
||||
"""An invoice may reject a whole row, and the in-transit warehouse is emptied all the same."""
|
||||
from erpnext.accounts.doctype.sales_invoice.mapper import make_inter_company_purchase_invoice
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.stock.doctype.item.test_item import create_item
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import (
|
||||
make_purchase_receipt,
|
||||
prepare_data_for_internal_transfer,
|
||||
)
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
|
||||
prepare_data_for_internal_transfer()
|
||||
company = "_Test Company with perpetual inventory"
|
||||
|
||||
from_warehouse = create_warehouse("_Test Rejected Invoice From", company=company)
|
||||
transit_warehouse = create_warehouse("_Test Rejected Invoice Transit", company=company)
|
||||
to_warehouse = create_warehouse("_Test Rejected Invoice To", company=company)
|
||||
rejected_warehouse = create_warehouse("_Test Rejected Invoice Rejected", company=company)
|
||||
|
||||
item_doc = create_item("Test Fully Rejected Transfer Item")
|
||||
|
||||
make_purchase_receipt(
|
||||
item_code=item_doc.name, company=company, warehouse=from_warehouse, qty=10, rate=100
|
||||
)
|
||||
|
||||
si = create_sales_invoice(
|
||||
company=company,
|
||||
customer="_Test Internal Customer 2",
|
||||
item_code=item_doc.name,
|
||||
qty=10,
|
||||
rate=100,
|
||||
warehouse=from_warehouse,
|
||||
update_stock=1,
|
||||
cost_center="Main - TCP1",
|
||||
debit_to="Debtors - TCP1",
|
||||
income_account="Sales - TCP1",
|
||||
do_not_save=1,
|
||||
)
|
||||
si.items[0].target_warehouse = transit_warehouse
|
||||
si.insert()
|
||||
si.submit()
|
||||
|
||||
pi = make_inter_company_purchase_invoice(si.name)
|
||||
pi.update_stock = 1
|
||||
pi.items[0].warehouse = to_warehouse
|
||||
pi.items[0].qty = 0
|
||||
pi.items[0].rejected_qty = 10
|
||||
pi.items[0].received_qty = 10
|
||||
pi.items[0].rejected_warehouse = rejected_warehouse
|
||||
pi.items[0].expense_account = "Cost of Goods Sold - TCP1"
|
||||
pi.submit()
|
||||
|
||||
sl_entries = frappe.get_all(
|
||||
"Stock Ledger Entry",
|
||||
filters={"voucher_no": pi.name, "is_cancelled": 0},
|
||||
fields=["warehouse", "actual_qty", "stock_value_difference"],
|
||||
)
|
||||
moved_qty = {d.warehouse: d.actual_qty for d in sl_entries}
|
||||
moved_value = {d.warehouse: d.stock_value_difference for d in sl_entries}
|
||||
|
||||
self.assertNotIn(to_warehouse, moved_qty)
|
||||
self.assertEqual(moved_qty[transit_warehouse], -10)
|
||||
self.assertEqual(moved_qty[rejected_warehouse], 10)
|
||||
self.assertEqual(flt(moved_value[transit_warehouse]), -1000)
|
||||
self.assertEqual(flt(moved_value[rejected_warehouse]), 1000)
|
||||
|
||||
def test_return_of_an_internal_transfer_invoice_that_rejected_everything(self):
|
||||
"""Material rejected in full goes back to the in-transit warehouse at the rate it came in
|
||||
with, and the entries say the same as the stock."""
|
||||
from erpnext.accounts.doctype.sales_invoice.mapper import make_inter_company_purchase_invoice
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.controllers.sales_and_purchase_return import make_return_doc
|
||||
from erpnext.stock.doctype.item.test_item import create_item
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import (
|
||||
make_purchase_receipt,
|
||||
prepare_data_for_internal_transfer,
|
||||
)
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
|
||||
prepare_data_for_internal_transfer()
|
||||
company = "_Test Company with perpetual inventory"
|
||||
|
||||
from_warehouse = create_warehouse("_Test Returned Transfer From", company=company)
|
||||
transit_warehouse = create_warehouse("_Test Returned Transfer Transit", company=company)
|
||||
to_warehouse = create_warehouse("_Test Returned Transfer To", company=company)
|
||||
rejected_warehouse = create_warehouse("_Test Returned Transfer Rejected", company=company)
|
||||
|
||||
item_doc = create_item("Test Returned Fully Rejected Transfer Item")
|
||||
|
||||
make_purchase_receipt(
|
||||
item_code=item_doc.name, company=company, warehouse=from_warehouse, qty=10, rate=100
|
||||
)
|
||||
|
||||
si = create_sales_invoice(
|
||||
company=company,
|
||||
customer="_Test Internal Customer 2",
|
||||
item_code=item_doc.name,
|
||||
qty=10,
|
||||
rate=100,
|
||||
warehouse=from_warehouse,
|
||||
update_stock=1,
|
||||
cost_center="Main - TCP1",
|
||||
debit_to="Debtors - TCP1",
|
||||
income_account="Sales - TCP1",
|
||||
do_not_save=1,
|
||||
)
|
||||
si.items[0].target_warehouse = transit_warehouse
|
||||
si.insert()
|
||||
si.submit()
|
||||
|
||||
pi = make_inter_company_purchase_invoice(si.name)
|
||||
pi.update_stock = 1
|
||||
pi.items[0].warehouse = to_warehouse
|
||||
pi.items[0].qty = 0
|
||||
pi.items[0].rejected_qty = 10
|
||||
pi.items[0].received_qty = 10
|
||||
pi.items[0].rejected_warehouse = rejected_warehouse
|
||||
pi.items[0].expense_account = "Cost of Goods Sold - TCP1"
|
||||
pi.submit()
|
||||
|
||||
returned = make_return_doc("Purchase Invoice", pi.name)
|
||||
returned.update_stock = 1
|
||||
returned.submit()
|
||||
|
||||
moved = {
|
||||
d.warehouse: flt(d.stock_value_difference)
|
||||
for d in frappe.get_all(
|
||||
"Stock Ledger Entry",
|
||||
filters={"voucher_no": returned.name, "is_cancelled": 0},
|
||||
fields=["warehouse", "stock_value_difference"],
|
||||
)
|
||||
}
|
||||
self.assertEqual(moved[transit_warehouse], 1000)
|
||||
self.assertEqual(moved[rejected_warehouse], -1000)
|
||||
|
||||
booked = {}
|
||||
for entry in frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_no": returned.name, "is_cancelled": 0},
|
||||
fields=["account", "debit", "credit"],
|
||||
):
|
||||
booked.setdefault(entry.account, 0)
|
||||
booked[entry.account] += flt(entry.debit) - flt(entry.credit)
|
||||
|
||||
self.assertEqual(flt(sum(booked.values()), 2), 0)
|
||||
self.assertEqual(booked[get_inventory_account(company, transit_warehouse)], 1000)
|
||||
self.assertEqual(booked[get_inventory_account(company, rejected_warehouse)], -1000)
|
||||
|
||||
def test_stock_updating_invoice_rejects_every_unit_of_a_row(self):
|
||||
"""A row of a stock updating invoice may be rejected in full."""
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
|
||||
company = "_Test Company with perpetual inventory"
|
||||
item = make_item(properties={"is_stock_item": 1}).name
|
||||
rejected_warehouse = create_warehouse("_Test Fully Rejected Invoice Warehouse", company=company)
|
||||
|
||||
pi = make_purchase_invoice(
|
||||
company=company,
|
||||
item_code=item,
|
||||
warehouse="Stores - TCP1",
|
||||
qty=0,
|
||||
rejected_qty=10,
|
||||
received_qty=10,
|
||||
rate=100,
|
||||
rejected_warehouse=rejected_warehouse,
|
||||
update_stock=1,
|
||||
expense_account="Cost of Goods Sold - TCP1",
|
||||
cost_center="Main - TCP1",
|
||||
do_not_save=True,
|
||||
)
|
||||
pi.submit()
|
||||
|
||||
moved_qty = {
|
||||
d.warehouse: d.actual_qty
|
||||
for d in frappe.get_all(
|
||||
"Stock Ledger Entry",
|
||||
filters={"voucher_no": pi.name, "is_cancelled": 0},
|
||||
fields=["warehouse", "actual_qty"],
|
||||
)
|
||||
}
|
||||
self.assertEqual(moved_qty, {rejected_warehouse: 10})
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Buying Settings",
|
||||
{
|
||||
"bill_for_rejected_quantity_in_purchase_invoice": 1,
|
||||
"set_valuation_rate_for_rejected_materials": 1,
|
||||
},
|
||||
)
|
||||
def test_stock_updating_invoice_bills_the_rejected_quantity(self):
|
||||
"""With the rejected quantity billed and valued, the invoice pays for every unit received and
|
||||
the stock it moves matches the entries it books."""
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
|
||||
company = "_Test Company with perpetual inventory"
|
||||
item = make_item(properties={"is_stock_item": 1, "valuation_method": "FIFO"}).name
|
||||
rejected_warehouse = create_warehouse("_Test Invoice Billed Rejected Warehouse", company=company)
|
||||
|
||||
pi = make_purchase_invoice(
|
||||
item_code=item,
|
||||
company=company,
|
||||
warehouse="Stores - TCP1",
|
||||
rejected_warehouse=rejected_warehouse,
|
||||
cost_center="Main - TCP1",
|
||||
supplier_warehouse="Work In Progress - TCP1",
|
||||
expense_account="_Test Account Cost for Goods Sold - TCP1",
|
||||
update_stock=1,
|
||||
received_qty=10,
|
||||
qty=6,
|
||||
rejected_qty=4,
|
||||
rate=100,
|
||||
)
|
||||
|
||||
self.assertEqual(pi.items[0].amount, 1000)
|
||||
self.assertEqual(pi.items[0].valuation_rate, 100)
|
||||
|
||||
stock_value = frappe.get_all(
|
||||
"Stock Ledger Entry",
|
||||
filters={"voucher_no": pi.name, "is_cancelled": 0},
|
||||
fields=["warehouse", "stock_value_difference"],
|
||||
)
|
||||
by_warehouse = {d.warehouse: d.stock_value_difference for d in stock_value}
|
||||
|
||||
self.assertEqual(by_warehouse["Stores - TCP1"], 600)
|
||||
self.assertEqual(by_warehouse[rejected_warehouse], 400)
|
||||
|
||||
booked = frappe.get_all(
|
||||
"GL Entry", filters={"voucher_no": pi.name, "is_cancelled": 0}, fields=["debit"]
|
||||
)
|
||||
self.assertEqual(sum(flt(d.debit) for d in booked), 1000)
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Buying Settings",
|
||||
{
|
||||
"bill_for_rejected_quantity_in_purchase_invoice": 1,
|
||||
"set_valuation_rate_for_rejected_materials": 1,
|
||||
},
|
||||
)
|
||||
def test_rejected_material_is_reposted_after_the_setting_changes(self):
|
||||
"""The entries an invoice books follow the stock it moved, so they can be built again once
|
||||
the settings have moved on."""
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
|
||||
company = "_Test Company with perpetual inventory"
|
||||
item = make_item(properties={"is_stock_item": 1, "valuation_method": "FIFO"}).name
|
||||
rejected_warehouse = create_warehouse("_Test Invoice Repost Rejected", company=company)
|
||||
|
||||
pi = make_purchase_invoice(
|
||||
company=company,
|
||||
item_code=item,
|
||||
warehouse="Stores - TCP1",
|
||||
qty=6,
|
||||
rejected_qty=4,
|
||||
received_qty=10,
|
||||
rate=100,
|
||||
rejected_warehouse=rejected_warehouse,
|
||||
update_stock=1,
|
||||
expense_account="Cost of Goods Sold - TCP1",
|
||||
cost_center="Main - TCP1",
|
||||
)
|
||||
|
||||
frappe.db.set_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice", 0)
|
||||
frappe.db.set_single_value("Buying Settings", "set_valuation_rate_for_rejected_materials", 0)
|
||||
|
||||
rebuilt = pi.get_gl_entries()
|
||||
rejected_account = get_inventory_account(company, rejected_warehouse)
|
||||
|
||||
self.assertEqual(
|
||||
flt(sum(flt(entry.get("debit")) - flt(entry.get("credit")) for entry in rebuilt), 2), 0
|
||||
)
|
||||
self.assertEqual(
|
||||
flt(
|
||||
sum(flt(entry.get("debit")) for entry in rebuilt if entry.get("account") == rejected_account)
|
||||
),
|
||||
400,
|
||||
)
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Buying Settings",
|
||||
{
|
||||
"bill_for_rejected_quantity_in_purchase_invoice": 1,
|
||||
"set_valuation_rate_for_rejected_materials": 1,
|
||||
},
|
||||
)
|
||||
def test_return_without_a_reference_books_both_warehouses(self):
|
||||
"""A return that stands on its own gives back the rejected material too, and books it once."""
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
|
||||
company = "_Test Company with perpetual inventory"
|
||||
item = make_item(properties={"is_stock_item": 1, "valuation_method": "FIFO"}).name
|
||||
accepted_warehouse = create_warehouse("_Test Invoice Return Accepted", company=company)
|
||||
rejected_warehouse = create_warehouse("_Test Invoice Return Rejected", company=company)
|
||||
|
||||
def make_invoice(sign):
|
||||
return make_purchase_invoice(
|
||||
company=company,
|
||||
item_code=item,
|
||||
warehouse=accepted_warehouse,
|
||||
qty=6 * sign,
|
||||
rejected_qty=4 * sign,
|
||||
received_qty=10 * sign,
|
||||
rate=100,
|
||||
rejected_warehouse=rejected_warehouse,
|
||||
update_stock=1,
|
||||
is_return=1 if sign < 0 else 0,
|
||||
expense_account="Cost of Goods Sold - TCP1",
|
||||
cost_center="Main - TCP1",
|
||||
)
|
||||
|
||||
make_invoice(1)
|
||||
returned = make_invoice(-1)
|
||||
|
||||
booked = {}
|
||||
for entry in frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_no": returned.name, "is_cancelled": 0},
|
||||
fields=["account", "debit", "credit"],
|
||||
):
|
||||
booked.setdefault(entry.account, 0)
|
||||
booked[entry.account] += flt(entry.debit) - flt(entry.credit)
|
||||
|
||||
self.assertEqual(flt(sum(booked.values()), 2), 0)
|
||||
self.assertEqual(booked[get_inventory_account(company, accepted_warehouse)], -600)
|
||||
self.assertEqual(booked[get_inventory_account(company, rejected_warehouse)], -400)
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Buying Settings",
|
||||
{
|
||||
"bill_for_rejected_quantity_in_purchase_invoice": 1,
|
||||
"set_valuation_rate_for_rejected_materials": 1,
|
||||
},
|
||||
)
|
||||
def test_discount_on_an_invoice_that_bills_the_rejected_quantity(self):
|
||||
"""A discount is spread over every unit the invoice pays for, not the accepted ones alone."""
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
|
||||
company = "_Test Company with perpetual inventory"
|
||||
item = make_item(properties={"is_stock_item": 1, "valuation_method": "FIFO"}).name
|
||||
rejected_warehouse = create_warehouse("_Test Invoice Discount Rejected", company=company)
|
||||
|
||||
pi = make_purchase_invoice(
|
||||
company=company,
|
||||
item_code=item,
|
||||
warehouse="Stores - TCP1",
|
||||
qty=6,
|
||||
rejected_qty=4,
|
||||
received_qty=10,
|
||||
rate=100,
|
||||
rejected_warehouse=rejected_warehouse,
|
||||
update_stock=1,
|
||||
expense_account="Cost of Goods Sold - TCP1",
|
||||
cost_center="Main - TCP1",
|
||||
do_not_save=True,
|
||||
)
|
||||
pi.apply_discount_on = "Net Total"
|
||||
pi.additional_discount_percentage = 10
|
||||
pi.submit()
|
||||
|
||||
self.assertEqual(pi.items[0].amount, 1000)
|
||||
self.assertEqual(pi.items[0].net_rate, 90)
|
||||
self.assertEqual(pi.grand_total, 900)
|
||||
self.assertEqual(frappe.db.get_value("Item", item, "last_purchase_rate"), 90)
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Buying Settings",
|
||||
{
|
||||
"set_valuation_rate_for_rejected_materials": 1,
|
||||
"bill_for_rejected_quantity_in_purchase_invoice": 0,
|
||||
},
|
||||
)
|
||||
def test_rejected_material_is_not_valued_on_a_stock_updating_invoice(self):
|
||||
"""An invoice that does not bill the rejected quantity has nothing to pay for that material,
|
||||
so it carries no cost and the stock the invoice moves matches the entries it books."""
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
|
||||
company = "_Test Company with perpetual inventory"
|
||||
item = make_item(properties={"is_stock_item": 1, "valuation_method": "FIFO"}).name
|
||||
rejected_warehouse = create_warehouse("_Test Invoice Rejected Warehouse", company=company)
|
||||
|
||||
pi = make_purchase_invoice(
|
||||
item_code=item,
|
||||
company=company,
|
||||
warehouse="Stores - TCP1",
|
||||
rejected_warehouse=rejected_warehouse,
|
||||
cost_center="Main - TCP1",
|
||||
supplier_warehouse="Work In Progress - TCP1",
|
||||
expense_account="_Test Account Cost for Goods Sold - TCP1",
|
||||
update_stock=1,
|
||||
received_qty=10,
|
||||
qty=6,
|
||||
rejected_qty=4,
|
||||
rate=100,
|
||||
)
|
||||
|
||||
self.assertEqual(pi.items[0].amount, 600)
|
||||
|
||||
stock_value = frappe.get_all(
|
||||
"Stock Ledger Entry",
|
||||
filters={"voucher_no": pi.name, "is_cancelled": 0},
|
||||
fields=["warehouse", "stock_value_difference"],
|
||||
)
|
||||
by_warehouse = {d.warehouse: d.stock_value_difference for d in stock_value}
|
||||
|
||||
self.assertEqual(by_warehouse["Stores - TCP1"], 600)
|
||||
self.assertEqual(by_warehouse[rejected_warehouse], 0)
|
||||
|
||||
booked = frappe.get_all(
|
||||
"GL Entry", filters={"voucher_no": pi.name, "is_cancelled": 0}, fields=["debit"]
|
||||
)
|
||||
self.assertEqual(sum(flt(d.debit) for d in booked), sum(by_warehouse.values()))
|
||||
|
||||
def test_purchase_invoice_with_use_serial_batch_field_for_rejected_qty(self):
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
// License: GNU General Public License v3. See license.txt
|
||||
|
||||
cur_frm.cscript.tax_table = "Purchase Taxes and Charges";
|
||||
erpnext.accounts.taxes.setup_tax_validations("Purchase Taxes and Charges Template");
|
||||
erpnext.accounts.taxes.setup_tax_filters("Purchase Taxes and Charges");
|
||||
|
||||
frappe.ui.form.on("Purchase Taxes and Charges Template", {
|
||||
setup: function (frm) {
|
||||
frm.cscript.tax_table = "Purchase Taxes and Charges";
|
||||
},
|
||||
});
|
||||
|
||||
frappe.ui.form.on("Purchase Taxes and Charges", {
|
||||
add_deduct_tax(doc, cdt, cdn) {
|
||||
let d = locals[cdt][cdn];
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
|
||||
frappe.provide("erpnext.accounts");
|
||||
|
||||
cur_frm.cscript.tax_table = "Sales Taxes and Charges";
|
||||
|
||||
erpnext.accounts.taxes.setup_tax_validations("Sales Invoice");
|
||||
erpnext.accounts.payment_triggers.setup("Sales Invoice");
|
||||
erpnext.accounts.pos.setup("Sales Invoice");
|
||||
@@ -127,7 +125,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
|
||||
if (should_create_delivery_note) {
|
||||
this.frm.add_custom_button(
|
||||
__("Delivery Note"),
|
||||
this.frm.cscript["Make Delivery Note"],
|
||||
() => this.make_delivery_note(),
|
||||
__("Create")
|
||||
);
|
||||
}
|
||||
@@ -732,28 +730,31 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
|
||||
is_return() {
|
||||
this.toggle_get_items();
|
||||
}
|
||||
|
||||
make_delivery_note() {
|
||||
frappe.model.open_mapped_doc({
|
||||
method: "erpnext.accounts.doctype.sales_invoice.mapper.make_delivery_note",
|
||||
frm: this.frm,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// for backward compatibility: combine new and previous states
|
||||
extend_cscript(cur_frm.cscript, new erpnext.accounts.SalesInvoiceController({ frm: cur_frm }));
|
||||
frappe.ui.form.set_controller("Sales Invoice", erpnext.accounts.SalesInvoiceController);
|
||||
|
||||
cur_frm.cscript["Make Delivery Note"] = function () {
|
||||
frappe.model.open_mapped_doc({
|
||||
method: "erpnext.accounts.doctype.sales_invoice.mapper.make_delivery_note",
|
||||
frm: cur_frm,
|
||||
});
|
||||
};
|
||||
frappe.ui.form.on("Sales Invoice Item", {
|
||||
income_account: function (frm, cdt, cdn) {
|
||||
erpnext.utils.copy_value_in_all_rows(frm.doc, cdt, cdn, "items", "income_account");
|
||||
},
|
||||
|
||||
cur_frm.cscript.income_account = function (doc, cdt, cdn) {
|
||||
erpnext.utils.copy_value_in_all_rows(doc, cdt, cdn, "items", "income_account");
|
||||
};
|
||||
|
||||
cur_frm.cscript.expense_account = function (doc, cdt, cdn) {
|
||||
erpnext.utils.copy_value_in_all_rows(doc, cdt, cdn, "items", "expense_account");
|
||||
};
|
||||
expense_account: function (frm, cdt, cdn) {
|
||||
erpnext.utils.copy_value_in_all_rows(frm.doc, cdt, cdn, "items", "expense_account");
|
||||
},
|
||||
});
|
||||
|
||||
frappe.ui.form.on("Sales Invoice", {
|
||||
setup: function (frm) {
|
||||
frm.cscript.tax_table = "Sales Taxes and Charges";
|
||||
|
||||
frm.add_fetch("customer", "tax_id", "tax_id");
|
||||
frm.add_fetch("payment_term", "invoice_portion", "invoice_portion");
|
||||
frm.add_fetch("payment_term", "description", "description");
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
// License: GNU General Public License v3. See license.txt
|
||||
|
||||
cur_frm.cscript.tax_table = "Sales Taxes and Charges";
|
||||
erpnext.accounts.taxes.setup_tax_validations("Sales Taxes and Charges Template");
|
||||
erpnext.accounts.taxes.setup_tax_filters("Sales Taxes and Charges");
|
||||
|
||||
frappe.ui.form.on("Sales Taxes and Charges Template", {
|
||||
setup: function (frm) {
|
||||
frm.cscript.tax_table = "Sales Taxes and Charges";
|
||||
},
|
||||
});
|
||||
|
||||
@@ -47,6 +47,10 @@
|
||||
"setting_field": "bill_for_rejected_quantity_in_purchase_invoice",
|
||||
"settings_doctype": "Buying Settings"
|
||||
},
|
||||
{
|
||||
"setting_field": "set_valuation_rate_for_rejected_materials",
|
||||
"settings_doctype": "Buying Settings"
|
||||
},
|
||||
{
|
||||
"setting_field": "unlink_payment_on_cancellation_of_invoice",
|
||||
"settings_doctype": "Accounts Settings"
|
||||
|
||||
@@ -6,8 +6,9 @@ from datetime import date
|
||||
import frappe
|
||||
from frappe import _, msgprint, qb, scrub
|
||||
from frappe.contacts.doctype.address.address import get_company_address, get_default_address
|
||||
from frappe.core.doctype.user_permission.user_permission import get_permitted_documents
|
||||
from frappe.core.doctype.user_permission.user_permission import get_user_permissions
|
||||
from frappe.model.utils import get_fetch_values
|
||||
from frappe.permissions import get_allowed_docs_for_doctype
|
||||
from frappe.query_builder.functions import Abs, Date, Sum
|
||||
from frappe.utils import (
|
||||
add_days,
|
||||
@@ -159,7 +160,7 @@ def _get_party_details(
|
||||
)
|
||||
set_contact_details(party_details, party, party_type, doctype)
|
||||
set_other_values(party_details, party, party_type)
|
||||
set_price_list(party_details, party, party_type, price_list, pos_profile)
|
||||
set_price_list(party_details, party, party_type, price_list, pos_profile, doctype)
|
||||
|
||||
tax_template = set_taxes(
|
||||
party.name,
|
||||
@@ -408,13 +409,33 @@ def get_default_price_list(party):
|
||||
return price_list
|
||||
|
||||
|
||||
def set_price_list(party_details, party, party_type, given_price_list, pos=None):
|
||||
def get_permitted_price_lists(doctype=None):
|
||||
permissions = sorted(
|
||||
get_user_permissions().get("Price List", []), key=lambda p: p.get("is_default"), reverse=True
|
||||
)
|
||||
|
||||
# a permission applicable for another doctype doesn't restrict this transaction
|
||||
return get_allowed_docs_for_doctype(permissions, doctype)
|
||||
|
||||
|
||||
def get_usable_price_list(price_lists, party_doctype):
|
||||
transaction_side = "selling" if party_doctype == "Customer" else "buying"
|
||||
|
||||
for price_list in price_lists:
|
||||
details = frappe.get_cached_value(
|
||||
"Price List", price_list, ["enabled", transaction_side], as_dict=True
|
||||
)
|
||||
if details.enabled and details[transaction_side]:
|
||||
return price_list
|
||||
|
||||
|
||||
def set_price_list(party_details, party, party_type, given_price_list, pos=None, doctype=None):
|
||||
# price list
|
||||
price_list = get_permitted_documents("Price List")
|
||||
permitted_price_lists = get_permitted_price_lists(doctype)
|
||||
|
||||
# if there is only one permitted document based on user permissions, set it
|
||||
if price_list and len(price_list) == 1:
|
||||
price_list = price_list[0]
|
||||
if len(permitted_price_lists) == 1:
|
||||
price_list = get_usable_price_list(permitted_price_lists, party.doctype)
|
||||
elif pos and party_type == "Customer":
|
||||
customer_price_list = frappe.get_value("Customer", party.name, "default_price_list")
|
||||
|
||||
@@ -426,6 +447,10 @@ def set_price_list(party_details, party, party_type, given_price_list, pos=None)
|
||||
else:
|
||||
price_list = get_default_price_list(party) or given_price_list
|
||||
|
||||
# don't set a price list the user has no permission for, the transaction can't be saved with it
|
||||
if price_list and permitted_price_lists and price_list not in permitted_price_lists:
|
||||
price_list = get_usable_price_list(permitted_price_lists, party.doctype)
|
||||
|
||||
if price_list and not is_price_list_enabled(price_list):
|
||||
price_list = None
|
||||
|
||||
|
||||
@@ -1178,7 +1178,8 @@ class ReceivablePayableReport:
|
||||
self.add_column(
|
||||
label=_("Party Type"),
|
||||
fieldname="party_type",
|
||||
fieldtype="Data",
|
||||
fieldtype="Link",
|
||||
options="DocType",
|
||||
width=100,
|
||||
)
|
||||
self.add_column(
|
||||
@@ -1229,7 +1230,9 @@ class ReceivablePayableReport:
|
||||
|
||||
self.add_column(label=_("Cost Center"), fieldname="cost_center", fieldtype="Data")
|
||||
self.add_column(label=_("Project"), fieldname="project", fieldtype="Link", options="Project")
|
||||
self.add_column(label=_("Voucher Type"), fieldname="voucher_type", fieldtype="Data")
|
||||
self.add_column(
|
||||
label=_("Voucher Type"), fieldname="voucher_type", fieldtype="Link", options="DocType"
|
||||
)
|
||||
self.add_column(
|
||||
label=_("Voucher No"),
|
||||
fieldname="voucher_no",
|
||||
|
||||
@@ -140,7 +140,8 @@ class AccountsReceivableSummary(ReceivablePayableReport):
|
||||
self.add_column(
|
||||
label=_("Party Type"),
|
||||
fieldname="party_type",
|
||||
fieldtype="Data",
|
||||
fieldtype="Link",
|
||||
options="DocType",
|
||||
width=100,
|
||||
)
|
||||
self.add_column(
|
||||
|
||||
@@ -25,7 +25,8 @@ def get_columns():
|
||||
{
|
||||
"label": _("Payment Document Type"),
|
||||
"fieldname": "payment_document_type",
|
||||
"fieldtype": "Data",
|
||||
"fieldtype": "Link",
|
||||
"options": "DocType",
|
||||
"width": 130,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -66,7 +66,8 @@ def get_columns():
|
||||
{
|
||||
"fieldname": "payment_document",
|
||||
"label": _("Payment Document Type"),
|
||||
"fieldtype": "Data",
|
||||
"fieldtype": "Link",
|
||||
"options": "DocType",
|
||||
"width": 220,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -126,7 +126,8 @@ def get_columns():
|
||||
{
|
||||
"fieldname": "payment_document",
|
||||
"label": _("Payment Document Type"),
|
||||
"fieldtype": "Data",
|
||||
"fieldtype": "Link",
|
||||
"options": "DocType",
|
||||
"width": 220,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -211,7 +211,8 @@ class General_Payment_Ledger_Comparison:
|
||||
dict(
|
||||
label=_("Voucher Type"),
|
||||
fieldname="voucher_type",
|
||||
fieldtype="Data",
|
||||
fieldtype="Link",
|
||||
options="DocType",
|
||||
width="100",
|
||||
)
|
||||
)
|
||||
@@ -230,7 +231,8 @@ class General_Payment_Ledger_Comparison:
|
||||
dict(
|
||||
label=_("Party Type"),
|
||||
fieldname="party_type",
|
||||
fieldtype="Data",
|
||||
fieldtype="Link",
|
||||
options="DocType",
|
||||
width="100",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -558,7 +558,6 @@ def get_accountwise_gle(filters, accounting_dimensions, gl_entries, gle_map):
|
||||
for gle in gl_entries:
|
||||
group_by_value = gle.get(group_by)
|
||||
gle.voucher_subtype = _(gle.voucher_subtype)
|
||||
gle.against_voucher_type = _(gle.against_voucher_type)
|
||||
gle.remarks = _(gle.remarks)
|
||||
gle.party_type = _(gle.party_type)
|
||||
|
||||
@@ -757,7 +756,13 @@ def get_columns(filters):
|
||||
]
|
||||
|
||||
columns += [
|
||||
{"label": _("Voucher Type"), "fieldname": "voucher_type", "width": 120},
|
||||
{
|
||||
"label": _("Voucher Type"),
|
||||
"fieldname": "voucher_type",
|
||||
"fieldtype": "Link",
|
||||
"options": "DocType",
|
||||
"width": 120,
|
||||
},
|
||||
{
|
||||
"label": _("Voucher Subtype"),
|
||||
"fieldname": "voucher_subtype",
|
||||
@@ -802,7 +807,13 @@ def get_columns(filters):
|
||||
|
||||
columns.extend(
|
||||
[
|
||||
{"label": _("Against Voucher Type"), "fieldname": "against_voucher_type", "width": 100},
|
||||
{
|
||||
"label": _("Against Voucher Type"),
|
||||
"fieldname": "against_voucher_type",
|
||||
"fieldtype": "Link",
|
||||
"options": "DocType",
|
||||
"width": 100,
|
||||
},
|
||||
{
|
||||
"label": _("Against Voucher"),
|
||||
"fieldname": "against_voucher",
|
||||
|
||||
@@ -154,7 +154,11 @@ class PaymentLedger:
|
||||
|
||||
self.columns.append(
|
||||
dict(
|
||||
label=_("Party Type"), fieldname="party_type", fieldtype="data", options=options, width="100"
|
||||
label=_("Party Type"),
|
||||
fieldname="party_type",
|
||||
fieldtype="Link",
|
||||
options="DocType",
|
||||
width="100",
|
||||
)
|
||||
)
|
||||
self.columns.append(
|
||||
@@ -164,8 +168,8 @@ class PaymentLedger:
|
||||
dict(
|
||||
label=_("Voucher Type"),
|
||||
fieldname="voucher_type",
|
||||
fieldtype="data",
|
||||
options=options,
|
||||
fieldtype="Link",
|
||||
options="DocType",
|
||||
width="100",
|
||||
)
|
||||
)
|
||||
@@ -182,8 +186,8 @@ class PaymentLedger:
|
||||
dict(
|
||||
label=_("Against Voucher Type"),
|
||||
fieldname="against_voucher_type",
|
||||
fieldtype="data",
|
||||
options=options,
|
||||
fieldtype="Link",
|
||||
options="DocType",
|
||||
width="100",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -82,7 +82,8 @@ def get_columns(filters):
|
||||
{
|
||||
"fieldname": "payment_document",
|
||||
"label": _("Payment Document Type"),
|
||||
"fieldtype": "Data",
|
||||
"fieldtype": "Link",
|
||||
"options": "DocType",
|
||||
"width": 100,
|
||||
},
|
||||
{
|
||||
@@ -92,7 +93,13 @@ def get_columns(filters):
|
||||
"options": "payment_document",
|
||||
"width": 160,
|
||||
},
|
||||
{"fieldname": "party_type", "label": _("Party Type"), "fieldtype": "Data", "width": 100},
|
||||
{
|
||||
"fieldname": "party_type",
|
||||
"label": _("Party Type"),
|
||||
"fieldtype": "Link",
|
||||
"options": "DocType",
|
||||
"width": 100,
|
||||
},
|
||||
{
|
||||
"fieldname": "party",
|
||||
"label": _("Party"),
|
||||
|
||||
@@ -175,6 +175,8 @@ def get_columns(invoice_list, additional_table_columns, include_payments=False):
|
||||
{
|
||||
"label": _("Voucher Type"),
|
||||
"fieldname": "voucher_type",
|
||||
"fieldtype": "Link",
|
||||
"options": "DocType",
|
||||
"width": 120,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -196,6 +196,8 @@ def get_columns(invoice_list, additional_table_columns, include_payments=False):
|
||||
{
|
||||
"label": _("Voucher Type"),
|
||||
"fieldname": "voucher_type",
|
||||
"fieldtype": "Link",
|
||||
"options": "DocType",
|
||||
"width": 120,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -256,7 +256,8 @@ class TaxWithholdingDetailsReport:
|
||||
{
|
||||
"label": _("Transaction Type"),
|
||||
"fieldname": "transaction_type",
|
||||
"fieldtype": "Data",
|
||||
"fieldtype": "Link",
|
||||
"options": "DocType",
|
||||
"width": 130,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -14,7 +14,13 @@ def execute(filters=None):
|
||||
|
||||
def get_columns():
|
||||
return [
|
||||
{"label": _("Voucher Type"), "fieldname": "voucher_type", "width": 300},
|
||||
{
|
||||
"label": _("Voucher Type"),
|
||||
"fieldname": "voucher_type",
|
||||
"fieldtype": "Link",
|
||||
"options": "DocType",
|
||||
"width": 300,
|
||||
},
|
||||
{
|
||||
"label": _("Voucher No"),
|
||||
"fieldname": "voucher_no",
|
||||
|
||||
@@ -91,6 +91,8 @@ class ChildItemUpdater:
|
||||
).format(child_item.idx, child_item.item_code)
|
||||
)
|
||||
|
||||
self._validate_blanket_order_is_open(child_item, d)
|
||||
|
||||
self._validate_quantity_and_rate(child_item, d, rate_unchanged)
|
||||
|
||||
if flt(child_item.get("qty")) != flt(d.get("qty")):
|
||||
@@ -280,6 +282,19 @@ class ChildItemUpdater:
|
||||
|
||||
return flt(get_conversion_factor(child_item.item_code, uom).get("conversion_factor")) or 1
|
||||
|
||||
def _validate_blanket_order_is_open(self, child_item, new_data: dict) -> None:
|
||||
if not child_item.get("blanket_order"):
|
||||
return
|
||||
|
||||
new_stock_qty = flt(
|
||||
flt(new_data.get("qty")) * flt(new_data.get("conversion_factor")),
|
||||
child_item.precision("stock_qty"),
|
||||
)
|
||||
if new_stock_qty > flt(child_item.stock_qty):
|
||||
blanket_order = frappe.get_doc("Blanket Order", child_item.blanket_order, for_update=True)
|
||||
blanket_order.validate_can_be_ordered(self.parent.transaction_date)
|
||||
blanket_order.validate_items_are_open([child_item.item_code])
|
||||
|
||||
def _validate_quantity_and_rate(self, child_item, new_data: dict, rate_unchanged: bool | None) -> None:
|
||||
if not flt(new_data.get("qty")) and not self.allow_zero_qty:
|
||||
frappe.throw(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile
|
||||
from erpnext.accounts.party import get_default_price_list, set_price_list
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
@@ -34,19 +35,159 @@ class PartyTestCase(ERPNextTestSuite):
|
||||
|
||||
self.assertIsNone(party_details.selling_price_list)
|
||||
|
||||
def create_price_list(self, enabled):
|
||||
def test_fallback_should_not_pick_an_unpermitted_price_list(self):
|
||||
permitted_default = self.create_price_list(enabled=1)
|
||||
permitted_other = self.create_price_list(enabled=1)
|
||||
user = self.create_user_with_price_list_permissions([permitted_default, permitted_other])
|
||||
customer = self.create_customer()
|
||||
|
||||
party_details = frappe._dict()
|
||||
with self.set_user(user):
|
||||
set_price_list(
|
||||
party_details, customer, "Customer", self.create_price_list(enabled=1), doctype="Sales Order"
|
||||
)
|
||||
|
||||
self.assertEqual(party_details.selling_price_list, permitted_default)
|
||||
|
||||
def test_permitted_given_price_list_should_be_kept(self):
|
||||
permitted_default = self.create_price_list(enabled=1)
|
||||
permitted_other = self.create_price_list(enabled=1)
|
||||
user = self.create_user_with_price_list_permissions([permitted_default, permitted_other])
|
||||
customer = self.create_customer()
|
||||
|
||||
party_details = frappe._dict()
|
||||
with self.set_user(user):
|
||||
set_price_list(party_details, customer, "Customer", permitted_other, doctype="Sales Order")
|
||||
|
||||
self.assertEqual(party_details.selling_price_list, permitted_other)
|
||||
|
||||
def test_permission_for_another_doctype_should_not_apply(self):
|
||||
permitted = [self.create_price_list(enabled=1), self.create_price_list(enabled=1)]
|
||||
user = self.create_user_with_price_list_permissions(permitted, applicable_for="Quotation")
|
||||
customer = self.create_customer()
|
||||
given_price_list = self.create_price_list(enabled=1)
|
||||
|
||||
party_details = frappe._dict()
|
||||
with self.set_user(user):
|
||||
set_price_list(party_details, customer, "Customer", given_price_list, doctype="Sales Order")
|
||||
|
||||
self.assertEqual(party_details.selling_price_list, given_price_list)
|
||||
|
||||
def test_a_single_permitted_price_list_should_fit_the_transaction(self):
|
||||
buying_price_list = self.create_price_list(enabled=1, selling=0, buying=1)
|
||||
user = self.create_user_with_price_list_permissions([buying_price_list])
|
||||
customer = self.create_customer()
|
||||
given_price_list = self.create_price_list(enabled=1)
|
||||
|
||||
party_details = frappe._dict()
|
||||
with self.set_user(user):
|
||||
set_price_list(party_details, customer, "Customer", given_price_list, doctype="Sales Order")
|
||||
|
||||
self.assertIsNone(party_details.selling_price_list)
|
||||
|
||||
def test_buying_transaction_should_not_take_a_selling_price_list(self):
|
||||
permitted = [self.create_price_list(enabled=1) for _ in range(2)]
|
||||
user = self.create_user_with_price_list_permissions(permitted)
|
||||
supplier_price_list = self.create_price_list(enabled=1, selling=0, buying=1)
|
||||
supplier = self.create_supplier(default_price_list=supplier_price_list)
|
||||
|
||||
party_details = frappe._dict()
|
||||
with self.set_user(user):
|
||||
set_price_list(party_details, supplier, "Supplier", None, doctype="Purchase Order")
|
||||
|
||||
self.assertIsNone(party_details.buying_price_list)
|
||||
|
||||
def test_permission_for_another_doctype_should_not_apply_without_a_doctype(self):
|
||||
permitted = [self.create_price_list(enabled=1), self.create_price_list(enabled=1)]
|
||||
user = self.create_user_with_price_list_permissions(permitted, applicable_for="Quotation")
|
||||
customer = self.create_customer()
|
||||
given_price_list = self.create_price_list(enabled=1)
|
||||
|
||||
party_details = frappe._dict()
|
||||
with self.set_user(user):
|
||||
set_price_list(party_details, customer, "Customer", given_price_list)
|
||||
|
||||
self.assertEqual(party_details.selling_price_list, given_price_list)
|
||||
|
||||
def test_pos_price_list_should_be_kept(self):
|
||||
permitted = [self.create_price_list(enabled=1), self.create_price_list(enabled=1)]
|
||||
user = self.create_user_with_price_list_permissions(permitted)
|
||||
pos_price_list = self.create_price_list(enabled=1)
|
||||
pos_profile = make_pos_profile(selling_price_list=pos_price_list)
|
||||
customer = self.create_customer()
|
||||
|
||||
party_details = frappe._dict()
|
||||
with self.set_user(user):
|
||||
set_price_list(
|
||||
party_details, customer, "Customer", None, pos=pos_profile.name, doctype="POS Invoice"
|
||||
)
|
||||
|
||||
self.assertEqual(party_details.selling_price_list, pos_price_list)
|
||||
|
||||
def test_disabled_permitted_price_lists_should_clear_the_price_list(self):
|
||||
permitted = [self.create_price_list(enabled=0), self.create_price_list(enabled=0)]
|
||||
user = self.create_user_with_price_list_permissions(permitted)
|
||||
customer = self.create_customer()
|
||||
given_price_list = self.create_price_list(enabled=1)
|
||||
|
||||
party_details = frappe._dict()
|
||||
with self.set_user(user):
|
||||
set_price_list(party_details, customer, "Customer", given_price_list, doctype="Sales Order")
|
||||
|
||||
self.assertIsNone(party_details.selling_price_list)
|
||||
|
||||
def create_user_with_price_list_permissions(self, price_lists, applicable_for=None):
|
||||
user = frappe.get_doc(
|
||||
{
|
||||
"doctype": "User",
|
||||
"email": f"{frappe.generate_hash(length=10)}@example.com",
|
||||
"first_name": "Price List Test",
|
||||
"send_welcome_email": 0,
|
||||
"roles": [{"role": "Sales User"}],
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
for idx, price_list in enumerate(price_lists):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "User Permission",
|
||||
"user": user.name,
|
||||
"allow": "Price List",
|
||||
"for_value": price_list,
|
||||
"is_default": int(idx == 0),
|
||||
"apply_to_all_doctypes": int(not applicable_for),
|
||||
"applicable_for": applicable_for,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
frappe.clear_cache(user=user.name)
|
||||
self.addCleanup(frappe.clear_cache, user=user.name)
|
||||
|
||||
return user.name
|
||||
|
||||
def create_price_list(self, enabled, selling=1, buying=0):
|
||||
price_list = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Price List",
|
||||
"price_list_name": frappe.generate_hash(length=10),
|
||||
"currency": "INR",
|
||||
"selling": 1,
|
||||
"selling": selling,
|
||||
"buying": buying,
|
||||
"enabled": enabled,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
return price_list.name
|
||||
|
||||
def create_supplier(self, **values):
|
||||
return frappe.get_doc(
|
||||
{
|
||||
"doctype": "Supplier",
|
||||
"supplier_name": frappe.generate_hash(length=10),
|
||||
**values,
|
||||
}
|
||||
).insert(ignore_permissions=True, ignore_mandatory=True)
|
||||
|
||||
def create_customer(self, **values):
|
||||
customer = frappe.get_doc(
|
||||
{
|
||||
|
||||
@@ -485,4 +485,4 @@ erpnext.assets.AssetCapitalization = class AssetCapitalization extends erpnext.s
|
||||
}
|
||||
};
|
||||
|
||||
cur_frm.cscript = new erpnext.assets.AssetCapitalization({ frm: cur_frm });
|
||||
frappe.ui.form.set_controller("Asset Capitalization", erpnext.assets.AssetCapitalization);
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Asset",
|
||||
"link_filters": "[[\"Asset\",\"docstatus\",\"<\",\"2\"],[\"Asset\",\"company\",\"=\",\"eval:doc.company\"]]",
|
||||
"link_filters": "[[\"Asset\",\"docstatus\",\"<\",\"2\"]]",
|
||||
"options": "Asset",
|
||||
"reqd": 1
|
||||
},
|
||||
|
||||
@@ -231,9 +231,17 @@ class DepreciationScheduleController(StraightLineMethod, WDVMethod):
|
||||
last_depr_date = add_months(
|
||||
self.fb_row.depreciation_start_date, -1 * self.fb_row.frequency_of_depreciation
|
||||
)
|
||||
if self.has_complete_opening_periods() and self.should_get_last_day:
|
||||
last_depr_date = get_last_day(last_depr_date)
|
||||
return last_depr_date
|
||||
|
||||
def get_booked_depr_for_months_count(self, last_depr_date):
|
||||
if last_depr_date and self.has_complete_opening_periods():
|
||||
first_period_end = self.get_next_schedule_date(
|
||||
-cint(self.asset_doc.opening_number_of_booked_depreciations)
|
||||
)
|
||||
return month_diff(last_depr_date, first_period_end) - 1 + self.fb_row.frequency_of_depreciation
|
||||
|
||||
depr_booked_for_months = 0
|
||||
if last_depr_date:
|
||||
asset_used_for_months = self.fb_row.frequency_of_depreciation * (
|
||||
@@ -249,6 +257,20 @@ class DepreciationScheduleController(StraightLineMethod, WDVMethod):
|
||||
)
|
||||
return depr_booked_for_months
|
||||
|
||||
def has_complete_opening_periods(self):
|
||||
if (
|
||||
self.fb_row.depreciation_method not in ("Straight Line", "Manual")
|
||||
or self.fb_row.daily_prorata_based
|
||||
or self.fb_row.shift_based
|
||||
or not self.asset_doc.opening_number_of_booked_depreciations
|
||||
):
|
||||
return False
|
||||
|
||||
first_period_start = add_days(
|
||||
self.get_next_schedule_date(-cint(self.asset_doc.opening_number_of_booked_depreciations) - 1), 1
|
||||
)
|
||||
return first_period_start == getdate(self.asset_doc.available_for_use_date)
|
||||
|
||||
def get_total_pending_days_or_years(self):
|
||||
if cint(frappe.get_single_value("Accounts Settings", "calculate_depr_using_total_days")):
|
||||
last_depr_date = self.get_last_booked_depreciation_date()
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import cstr, flt, getdate
|
||||
from frappe.utils import add_months, cstr, flt, get_last_day, getdate
|
||||
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.assets.doctype.asset.depreciation import (
|
||||
@@ -21,6 +21,30 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestAssetDepreciationSchedule(ERPNextTestSuite):
|
||||
def test_complete_opening_periods(self):
|
||||
for available in ("2026-08-01", "2024-02-01"):
|
||||
for frequency in (1, 3, 6, 12):
|
||||
for opening in (1, 4):
|
||||
with self.subTest(available=available, frequency=frequency, opening=opening):
|
||||
start = get_last_day(add_months(available, (opening + 1) * frequency - 1))
|
||||
asset = create_asset(
|
||||
calculate_depreciation=1,
|
||||
available_for_use_date=available,
|
||||
depreciation_start_date=start,
|
||||
frequency_of_depreciation=frequency,
|
||||
total_number_of_depreciations=12,
|
||||
net_purchase_amount=13200,
|
||||
expected_value_after_useful_life=1200,
|
||||
opening_number_of_booked_depreciations=opening,
|
||||
opening_accumulated_depreciation=1000 * opening,
|
||||
)
|
||||
schedule = get_depr_schedule(asset.name, "Draft")
|
||||
self.assertEqual(
|
||||
[row.depreciation_amount for row in schedule], [1000] * (12 - opening)
|
||||
)
|
||||
self.assertEqual(schedule[-1].accumulated_depreciation_amount, 12000)
|
||||
self.assertEqual(getdate(schedule[0].schedule_date), start)
|
||||
|
||||
def test_throw_error_if_another_asset_depr_schedule_exist(self):
|
||||
asset = create_asset(item_code="Macbook Pro", calculate_depreciation=1, submit=1)
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"description": "If checked, Rejected Quantity will be included while making Purchase Invoice from Purchase Receipt.",
|
||||
"description": "If checked, the rejected quantity is billed anywhere in the purchase cycle.",
|
||||
"fieldname": "bill_for_rejected_quantity_in_purchase_invoice",
|
||||
"fieldtype": "Check",
|
||||
"label": "Bill for rejected quantity in Purchase Invoice"
|
||||
@@ -247,7 +247,7 @@
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "bill_for_rejected_quantity_in_purchase_invoice",
|
||||
"description": "If enabled, the system will generate an accounting entry for materials rejected in the Purchase Receipt.",
|
||||
"description": "If enabled, the system will generate an accounting entry for material rejected anywhere in the purchase cycle.",
|
||||
"fieldname": "set_valuation_rate_for_rejected_materials",
|
||||
"fieldtype": "Check",
|
||||
"label": "Set valuation rate for rejected Materials"
|
||||
|
||||
@@ -77,3 +77,45 @@ class BuyingSettings(Document):
|
||||
def check_maintain_same_rate(self):
|
||||
if self.maintain_same_rate:
|
||||
self.set_landed_cost_based_on_purchase_invoice_rate = 0
|
||||
|
||||
|
||||
def is_rejected_material_valued(voucher_type: str, voucher_detail_no: str | None = None) -> bool:
|
||||
"""Rejected material carries stock value only when something has paid for it.
|
||||
|
||||
Material of an internal transfer always has: its value was credited out of the in-transit
|
||||
warehouse. A Purchase Receipt books rejected material against Stock Received But Not Billed, so
|
||||
the supplier still owes an invoice for it, and Buying Settings decides. A stock updating Purchase
|
||||
Invoice pays for it only when it bills the received qty, which is what the settings ask for.
|
||||
"""
|
||||
if is_material_from_in_transit_warehouse(voucher_type, voucher_detail_no):
|
||||
return True
|
||||
|
||||
if not frappe.db.get_single_value("Buying Settings", "set_valuation_rate_for_rejected_materials"):
|
||||
return False
|
||||
|
||||
return voucher_type != "Purchase Invoice" or bool(
|
||||
frappe.db.get_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice")
|
||||
)
|
||||
|
||||
|
||||
def is_material_from_in_transit_warehouse(voucher_type: str, voucher_detail_no: str | None) -> bool:
|
||||
if voucher_type not in ("Purchase Receipt", "Purchase Invoice") or not voucher_detail_no:
|
||||
return False
|
||||
|
||||
return bool(frappe.get_cached_value(voucher_type + " Item", voucher_detail_no, "from_warehouse"))
|
||||
|
||||
|
||||
def bills_rejected_quantity(doc) -> bool:
|
||||
"""An invoice that moves stock itself has no receipt to bill the rejected material for it, so it
|
||||
bills the received qty when the settings ask for the material to be valued.
|
||||
|
||||
An internal transfer bills nothing of the sort: its material is paid for by the warehouse it came
|
||||
out of.
|
||||
"""
|
||||
if doc.doctype != "Purchase Invoice" or not doc.get("update_stock"):
|
||||
return False
|
||||
|
||||
if doc.get("is_internal_supplier") and doc.get("represents_company") == doc.get("company"):
|
||||
return False
|
||||
|
||||
return is_rejected_material_valued(doc.doctype)
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
frappe.provide("erpnext.buying");
|
||||
frappe.provide("erpnext.accounts.dimensions");
|
||||
|
||||
cur_frm.cscript.tax_table = "Purchase Taxes and Charges";
|
||||
|
||||
erpnext.accounts.taxes.setup_tax_filters("Purchase Taxes and Charges");
|
||||
erpnext.accounts.taxes.setup_tax_validations("Purchase Order");
|
||||
erpnext.buying.setup_buying_controller();
|
||||
|
||||
frappe.ui.form.on("Purchase Order", {
|
||||
setup: function (frm) {
|
||||
frm.cscript.tax_table = "Purchase Taxes and Charges";
|
||||
|
||||
frm.set_indicator_formatter("item_code", function (doc) {
|
||||
let color;
|
||||
if (doc.closed) {
|
||||
@@ -826,21 +826,20 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends (
|
||||
items_on_form_rendered() {
|
||||
set_schedule_date(this.frm);
|
||||
}
|
||||
|
||||
update_status(label, status) {
|
||||
frappe.call({
|
||||
method: "erpnext.buying.doctype.purchase_order.purchase_order.update_status",
|
||||
args: { status: status, name: this.frm.doc.name },
|
||||
callback: () => {
|
||||
this.frm.set_value("status", status);
|
||||
this.frm.reload_doc();
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// for backward compatibility: combine new and previous states
|
||||
extend_cscript(cur_frm.cscript, new erpnext.buying.PurchaseOrderController({ frm: cur_frm }));
|
||||
|
||||
cur_frm.cscript.update_status = function (label, status) {
|
||||
frappe.call({
|
||||
method: "erpnext.buying.doctype.purchase_order.purchase_order.update_status",
|
||||
args: { status: status, name: cur_frm.doc.name },
|
||||
callback: function (r) {
|
||||
cur_frm.set_value("status", status);
|
||||
cur_frm.reload_doc();
|
||||
},
|
||||
});
|
||||
};
|
||||
frappe.ui.form.set_controller("Purchase Order", erpnext.buying.PurchaseOrderController);
|
||||
|
||||
function set_schedule_date(frm) {
|
||||
if (frm.doc.schedule_date) {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
// License: GNU General Public License v3. See license.txt
|
||||
|
||||
cur_frm.add_fetch("contact", "email_id", "email_id");
|
||||
|
||||
erpnext.buying.setup_buying_controller();
|
||||
|
||||
frappe.ui.form.on("Request for Quotation", {
|
||||
@@ -585,5 +583,4 @@ erpnext.buying.RequestforQuotationController = class RequestforQuotationControll
|
||||
}
|
||||
};
|
||||
|
||||
// for backward compatibility: combine new and previous states
|
||||
extend_cscript(cur_frm.cscript, new erpnext.buying.RequestforQuotationController({ frm: cur_frm }));
|
||||
frappe.ui.form.set_controller("Request for Quotation", erpnext.buying.RequestforQuotationController);
|
||||
|
||||
@@ -117,7 +117,7 @@ frappe.ui.form.on("Supplier", {
|
||||
if (frappe.defaults.get_default("supp_master_name") != "Naming Series") {
|
||||
frm.toggle_display("naming_series", false);
|
||||
} else {
|
||||
erpnext.toggle_naming_series();
|
||||
erpnext.toggle_naming_series(frm);
|
||||
}
|
||||
|
||||
if (frm.doc.__islocal) {
|
||||
|
||||
@@ -127,5 +127,4 @@ erpnext.buying.SupplierQuotationController = class SupplierQuotationController e
|
||||
}
|
||||
};
|
||||
|
||||
// for backward compatibility: combine new and previous states
|
||||
extend_cscript(cur_frm.cscript, new erpnext.buying.SupplierQuotationController({ frm: cur_frm }));
|
||||
frappe.ui.form.set_controller("Supplier Quotation", erpnext.buying.SupplierQuotationController);
|
||||
|
||||
@@ -349,12 +349,26 @@ class AccountsController(TransactionBase):
|
||||
self.validate_company_in_accounting_dimension()
|
||||
|
||||
def validate_price_list(self):
|
||||
price_list_field = "selling_price_list" if self.get("selling_price_list") else "buying_price_list"
|
||||
if self.get("selling_price_list"):
|
||||
price_list_field, transaction_side = "selling_price_list", "selling"
|
||||
else:
|
||||
price_list_field, transaction_side = "buying_price_list", "buying"
|
||||
|
||||
price_list = self.get(price_list_field)
|
||||
if not price_list or frappe.db.get_value("Price List", price_list, "enabled"):
|
||||
if not price_list:
|
||||
return
|
||||
|
||||
# Returns retain a submitted voucher's pricing even if its price list is now disabled.
|
||||
details = (
|
||||
frappe.db.get_value("Price List", price_list, ["enabled", transaction_side], as_dict=True)
|
||||
or frappe._dict()
|
||||
)
|
||||
|
||||
# An internal transfer carries the price list of the outward document into the inward one.
|
||||
fits_transaction = details.get(transaction_side) or self.is_internal_transfer()
|
||||
if details.enabled and fits_transaction:
|
||||
return
|
||||
|
||||
# Returns retain a submitted voucher's pricing even if its price list no longer fits.
|
||||
if (
|
||||
self.get("is_return")
|
||||
and self.get("return_against")
|
||||
@@ -365,9 +379,20 @@ class AccountsController(TransactionBase):
|
||||
):
|
||||
return
|
||||
|
||||
if not details.enabled:
|
||||
frappe.throw(
|
||||
_("Price List {0} is disabled").format(get_link_to_form("Price List", price_list)),
|
||||
title=_("Disabled Price List"),
|
||||
)
|
||||
|
||||
if transaction_side == "selling":
|
||||
message = _("Price List {0} cannot be used on a selling transaction")
|
||||
else:
|
||||
message = _("Price List {0} cannot be used on a buying transaction")
|
||||
|
||||
frappe.throw(
|
||||
_("Price List {0} is disabled").format(get_link_to_form("Price List", price_list)),
|
||||
title=_("Disabled Price List"),
|
||||
message.format(get_link_to_form("Price List", price_list)),
|
||||
title=_("Invalid Price List"),
|
||||
)
|
||||
|
||||
def set_default_letter_head(self):
|
||||
@@ -724,12 +749,15 @@ class AccountsController(TransactionBase):
|
||||
args = "for_buying"
|
||||
|
||||
if self.meta.get_field(fieldname) and self.get(fieldname):
|
||||
previous_price_list_currency = self.price_list_currency
|
||||
self.price_list_currency = frappe.db.get_value("Price List", self.get(fieldname), "currency")
|
||||
|
||||
if self.price_list_currency == self.company_currency:
|
||||
self.plc_conversion_rate = 1.0
|
||||
|
||||
elif not self.plc_conversion_rate:
|
||||
elif not self.plc_conversion_rate or (
|
||||
previous_price_list_currency and previous_price_list_currency != self.price_list_currency
|
||||
):
|
||||
self.plc_conversion_rate = get_exchange_rate(
|
||||
self.price_list_currency, self.company_currency, transaction_date, args
|
||||
)
|
||||
@@ -974,7 +1002,7 @@ class AccountsController(TransactionBase):
|
||||
def validate_zero_qty_for_return_invoices_with_stock(self):
|
||||
rows = []
|
||||
for item in self.items:
|
||||
if not flt(item.qty):
|
||||
if not (flt(item.qty) or flt(item.get("rejected_qty"))):
|
||||
rows.append(item)
|
||||
if rows:
|
||||
frappe.throw(
|
||||
@@ -983,12 +1011,18 @@ class AccountsController(TransactionBase):
|
||||
).format(frappe.bold(comma_and(["#" + str(x.idx) for x in rows])))
|
||||
)
|
||||
|
||||
def is_stock_receipt(self) -> bool:
|
||||
"""Whether this document receives material into a warehouse."""
|
||||
return self.doctype == "Purchase Receipt" or (
|
||||
self.doctype == "Purchase Invoice" and self.update_stock
|
||||
)
|
||||
|
||||
def validate_qty_is_not_zero(self):
|
||||
if self.flags.allow_zero_qty:
|
||||
return
|
||||
|
||||
for item in self.items:
|
||||
if self.doctype == "Purchase Receipt" and item.rejected_qty:
|
||||
if self.is_stock_receipt() and item.get("rejected_qty"):
|
||||
continue
|
||||
|
||||
if not flt(item.qty):
|
||||
|
||||
@@ -14,16 +14,21 @@ import erpnext
|
||||
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_dimensions
|
||||
from erpnext.accounts.doctype.budget.budget import validate_expense_against_budget
|
||||
from erpnext.accounts.party import _get_party_details
|
||||
from erpnext.buying.doctype.buying_settings.buying_settings import (
|
||||
bills_rejected_quantity,
|
||||
is_rejected_material_valued,
|
||||
)
|
||||
from erpnext.buying.utils import update_last_purchase_rate, validate_for_items
|
||||
from erpnext.controllers.accounts_controller import get_taxes_and_charges
|
||||
from erpnext.controllers.sales_and_purchase_return import get_rate_for_return
|
||||
from erpnext.controllers.subcontracting_controller import SubcontractingController
|
||||
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
|
||||
from erpnext.stock.get_item_details import (
|
||||
NOT_APPLICABLE_TAX,
|
||||
get_conversion_factor,
|
||||
get_item_defaults,
|
||||
)
|
||||
from erpnext.stock.utils import _get_incoming_rate
|
||||
from erpnext.stock.utils import _get_incoming_rate, is_serial_no_wise_valuation_disabled
|
||||
|
||||
|
||||
class QtyMismatchError(ValidationError):
|
||||
@@ -67,6 +72,7 @@ class BuyingController(SubcontractingController):
|
||||
|
||||
if self.doctype in ("Purchase Receipt", "Purchase Invoice"):
|
||||
self.update_valuation_rate()
|
||||
self.sync_accepted_packages()
|
||||
self.set_serial_and_batch_bundle()
|
||||
|
||||
def onload(self):
|
||||
@@ -146,13 +152,10 @@ class BuyingController(SubcontractingController):
|
||||
|
||||
for item in self.get("items"):
|
||||
if item.get(field) and not item.serial_and_batch_bundle and bundle_ids.get(item.get(field)):
|
||||
item.serial_and_batch_bundle = self.make_package_for_transfer(
|
||||
bundle_ids.get(item.get(field)),
|
||||
item.from_warehouse,
|
||||
type_of_transaction="Outward",
|
||||
do_not_submit=True,
|
||||
qty=item.qty,
|
||||
item.serial_and_batch_bundle = self.make_accepted_package(
|
||||
item, bundle_ids.get(item.get(field))
|
||||
)
|
||||
|
||||
elif (
|
||||
not self.is_new()
|
||||
and item.serial_and_batch_bundle
|
||||
@@ -175,6 +178,138 @@ class BuyingController(SubcontractingController):
|
||||
):
|
||||
frappe.set_value("Serial and Batch Entry", sabe[0], "qty", item.qty)
|
||||
|
||||
if item.get(field) and bundle_ids.get(item.get(field)):
|
||||
self.set_rejected_package(item, bundle_ids.get(item.get(field)))
|
||||
|
||||
def make_accepted_package(self, row, package) -> str:
|
||||
"""Package of the material the row accepts.
|
||||
|
||||
A row that rejects nothing keeps the package of the in-transit warehouse it came out of. A
|
||||
row that rejects material needs a package of the accepted warehouse instead, since that is
|
||||
the entry it belongs to; the material leaving the in-transit warehouse gets a package of its
|
||||
own when the receipt is submitted.
|
||||
"""
|
||||
if not (self.is_internal_receipt() and flt(row.rejected_qty)):
|
||||
return self.make_package_for_transfer(
|
||||
package,
|
||||
row.from_warehouse,
|
||||
type_of_transaction="Outward",
|
||||
do_not_submit=True,
|
||||
qty=flt(row.stock_qty),
|
||||
)
|
||||
|
||||
if not flt(row.stock_qty):
|
||||
return ""
|
||||
|
||||
return self.make_package_for_transfer(
|
||||
package,
|
||||
row.warehouse,
|
||||
type_of_transaction="Inward",
|
||||
do_not_submit=True,
|
||||
qty=flt(row.stock_qty),
|
||||
exclude_serial_nos=self.get_rejected_serial_nos(row),
|
||||
)
|
||||
|
||||
def get_delivered_package(self, row) -> str | None:
|
||||
"""Package of the material the delivery note put in the in-transit warehouse."""
|
||||
field = "delivery_note_item" if self.doctype == "Purchase Receipt" else "sales_invoice_item"
|
||||
doctype = "Delivery Note Item" if self.doctype == "Purchase Receipt" else "Sales Invoice Item"
|
||||
if not row.get(field):
|
||||
return None
|
||||
|
||||
return frappe.db.get_value(doctype, row.get(field), "serial_and_batch_bundle")
|
||||
|
||||
def set_rejected_package(self, row, package) -> None:
|
||||
"""Package of the material the row rejects.
|
||||
|
||||
A receipt of an internal transfer builds no package for it on its own, so rejected material
|
||||
of a tracked item would have nothing to say where it came from.
|
||||
"""
|
||||
if not (self.is_internal_receipt() and flt(row.rejected_qty)) or self.is_return:
|
||||
return
|
||||
|
||||
if row.get("rejected_serial_and_batch_bundle") or not row.rejected_warehouse:
|
||||
return
|
||||
|
||||
rejected_qty = flt(flt(row.rejected_qty) * flt(row.conversion_factor), row.precision("stock_qty"))
|
||||
|
||||
row.rejected_serial_and_batch_bundle = self.make_package_for_transfer(
|
||||
package,
|
||||
row.rejected_warehouse,
|
||||
type_of_transaction="Inward",
|
||||
do_not_submit=True,
|
||||
qty=rejected_qty,
|
||||
exclude_serial_nos=self.get_accepted_serial_nos(row),
|
||||
)
|
||||
|
||||
frappe.db.set_value("Serial and Batch Bundle", row.rejected_serial_and_batch_bundle, "is_rejected", 1)
|
||||
|
||||
def get_accepted_serial_nos(self, row) -> list:
|
||||
if not row.get("serial_and_batch_bundle"):
|
||||
return []
|
||||
|
||||
return frappe.get_all(
|
||||
"Serial and Batch Entry",
|
||||
filters={"parent": row.serial_and_batch_bundle, "serial_no": ("is", "set")},
|
||||
pluck="serial_no",
|
||||
)
|
||||
|
||||
def sync_accepted_packages(self) -> None:
|
||||
"""Keep the package of a row in the shape its own entry needs.
|
||||
|
||||
A row that rejects material carries the package of its accepted warehouse; a row that
|
||||
rejects nothing carries the package of the in-transit warehouse it came out of. Editing the
|
||||
split moves the package from one to the other.
|
||||
"""
|
||||
if not self.is_internal_receipt() or self.is_return:
|
||||
return
|
||||
|
||||
for row in self.get("items"):
|
||||
package = row.get("serial_and_batch_bundle")
|
||||
if not package:
|
||||
continue
|
||||
|
||||
details = frappe.db.get_value(
|
||||
"Serial and Batch Bundle",
|
||||
package,
|
||||
["warehouse", "type_of_transaction", "docstatus"],
|
||||
as_dict=True,
|
||||
)
|
||||
if not details or details.docstatus != 0:
|
||||
continue
|
||||
|
||||
if flt(row.rejected_qty):
|
||||
wanted = (row.warehouse, "Inward")
|
||||
else:
|
||||
wanted = (row.from_warehouse, "Outward")
|
||||
|
||||
if (details.warehouse, details.type_of_transaction) == wanted:
|
||||
continue
|
||||
|
||||
row.serial_and_batch_bundle = self.make_accepted_package(
|
||||
row, self.get_delivered_package(row) or package
|
||||
)
|
||||
frappe.delete_doc("Serial and Batch Bundle", package, force=True, ignore_permissions=True)
|
||||
|
||||
def get_internal_transfer_qty(self, row) -> float:
|
||||
if flt(row.qty) or not self.is_internal_receipt():
|
||||
return flt(row.qty)
|
||||
|
||||
return flt(row.rejected_qty)
|
||||
|
||||
def get_rejected_serial_nos(self, row) -> list:
|
||||
if not flt(row.get("rejected_qty")):
|
||||
return []
|
||||
|
||||
if row.get("rejected_serial_and_batch_bundle"):
|
||||
return frappe.get_all(
|
||||
"Serial and Batch Entry",
|
||||
filters={"parent": row.rejected_serial_and_batch_bundle, "serial_no": ("is", "set")},
|
||||
pluck="serial_no",
|
||||
)
|
||||
|
||||
return get_serial_nos(row.get("rejected_serial_no"))
|
||||
|
||||
def set_rate_for_standalone_debit_note(self):
|
||||
if self.get("is_return") and self.get("update_stock") and not self.return_against:
|
||||
for row in self.items:
|
||||
@@ -466,7 +601,7 @@ class BuyingController(SubcontractingController):
|
||||
|
||||
net_rate = item.base_net_amount
|
||||
if item.sales_incoming_rate: # for internal transfer
|
||||
net_rate = item.qty * item.sales_incoming_rate
|
||||
net_rate = self.get_internal_transfer_qty(item) * item.sales_incoming_rate
|
||||
|
||||
if (
|
||||
not net_rate
|
||||
@@ -477,7 +612,7 @@ class BuyingController(SubcontractingController):
|
||||
):
|
||||
net_rate = item.rejected_qty * item.net_rate
|
||||
|
||||
qty_in_stock_uom = flt(item.qty * item.conversion_factor)
|
||||
qty_in_stock_uom = flt(self.get_valued_qty(item) * item.conversion_factor)
|
||||
if not qty_in_stock_uom and item.get("rejected_qty"):
|
||||
qty_in_stock_uom = flt(item.rejected_qty * item.conversion_factor)
|
||||
|
||||
@@ -492,6 +627,14 @@ class BuyingController(SubcontractingController):
|
||||
|
||||
update_regional_item_valuation_rate(self)
|
||||
|
||||
def get_valued_qty(self, row):
|
||||
"""Quantity the net amount of the row was billed for, which is what its valuation spreads
|
||||
over."""
|
||||
if not flt(row.get("rejected_qty")) or not bills_rejected_quantity(self):
|
||||
return flt(row.qty)
|
||||
|
||||
return flt(row.qty) + flt(row.rejected_qty)
|
||||
|
||||
def get_tax_details(self):
|
||||
tax_accounts = []
|
||||
total_valuation_amount = 0.0
|
||||
@@ -617,7 +760,11 @@ class BuyingController(SubcontractingController):
|
||||
return
|
||||
|
||||
if cint(self.get("is_return")):
|
||||
# Get outgoing rate based on original item cost based on valuation method
|
||||
# Material of a transfer goes back at the rate it came in with. Anything else is
|
||||
# valued from the original item cost by its valuation method.
|
||||
if self.is_internal_transfer():
|
||||
self.set_sales_incoming_rate_for_internal_transfer()
|
||||
|
||||
return
|
||||
|
||||
if not self.is_internal_transfer():
|
||||
@@ -658,8 +805,13 @@ class BuyingController(SubcontractingController):
|
||||
}
|
||||
|
||||
ref_doctype = ref_doctype_map.get(self.doctype)
|
||||
returned_field = frappe.scrub(self.doctype) + "_item"
|
||||
for d in self.get("items"):
|
||||
if not d.get(frappe.scrub(ref_doctype)):
|
||||
if self.get("is_return") and d.get(returned_field):
|
||||
d.sales_incoming_rate = flt(
|
||||
frappe.db.get_value(self.doctype + " Item", d.get(returned_field), "sales_incoming_rate")
|
||||
)
|
||||
elif not d.get(frappe.scrub(ref_doctype)):
|
||||
posting_time = self.get("posting_time")
|
||||
if not posting_time:
|
||||
posting_time = nowtime()
|
||||
@@ -754,6 +906,95 @@ class BuyingController(SubcontractingController):
|
||||
)
|
||||
)
|
||||
|
||||
def is_internal_receipt(self) -> bool:
|
||||
return self.is_internal_transfer() and self.is_stock_receipt()
|
||||
|
||||
def get_source_warehouse_qty(self, row, accepted_qty):
|
||||
if not (self.is_internal_receipt() and flt(row.rejected_qty)):
|
||||
return accepted_qty
|
||||
|
||||
if row.get("serial_and_batch_bundle") and not row.get("rejected_serial_and_batch_bundle"):
|
||||
return accepted_qty
|
||||
|
||||
rejected_qty = flt(flt(row.rejected_qty) * flt(row.conversion_factor), row.precision("stock_qty"))
|
||||
|
||||
return flt(accepted_qty + rejected_qty, row.precision("stock_qty"))
|
||||
|
||||
def get_accepted_warehouse_package(self, row, type_of_transaction, via_landed_cost_voucher):
|
||||
"""Package for the entry into the accepted warehouse, which is the package of the row itself
|
||||
when the row rejects material."""
|
||||
if flt(row.rejected_qty) and self.is_internal_receipt() and not self.is_return:
|
||||
return row.serial_and_batch_bundle
|
||||
|
||||
if self.is_internal_transfer() and not self.is_return and self.docstatus != 2:
|
||||
return self.get_package_for_target_warehouse(
|
||||
row,
|
||||
type_of_transaction=type_of_transaction,
|
||||
via_landed_cost_voucher=via_landed_cost_voucher,
|
||||
)
|
||||
|
||||
return row.serial_and_batch_bundle
|
||||
|
||||
def get_submitted_package(self, row, warehouse):
|
||||
return frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{"voucher_detail_no": row.name, "warehouse": warehouse, "is_cancelled": 0},
|
||||
"serial_and_batch_bundle",
|
||||
)
|
||||
|
||||
def get_source_warehouse_reversal_package(self, row, package):
|
||||
if not (self.is_internal_transfer() and self.is_return):
|
||||
return package
|
||||
|
||||
if existing_package := self.get_package_of_source_warehouse(row):
|
||||
return existing_package
|
||||
|
||||
if not row.get("rejected_serial_and_batch_bundle"):
|
||||
return self.get_package_for_target_warehouse(row, row.from_warehouse, "Inward")
|
||||
|
||||
return self.get_returned_source_package(row)
|
||||
|
||||
def get_source_warehouse_package(self, row, package):
|
||||
if not (row.get("rejected_serial_and_batch_bundle") and self.is_internal_receipt()):
|
||||
return package
|
||||
|
||||
if existing_package := self.get_package_of_source_warehouse(row):
|
||||
return existing_package
|
||||
|
||||
if not package:
|
||||
return self.make_package_for_transfer(
|
||||
row.rejected_serial_and_batch_bundle, row.from_warehouse, type_of_transaction="Outward"
|
||||
)
|
||||
|
||||
return self.make_package_for_transfer(
|
||||
package,
|
||||
row.from_warehouse,
|
||||
type_of_transaction="Outward",
|
||||
include_bundle=row.rejected_serial_and_batch_bundle,
|
||||
)
|
||||
|
||||
def get_package_of_source_warehouse(self, row) -> str | None:
|
||||
return frappe.db.get_value(
|
||||
"Serial and Batch Bundle",
|
||||
{
|
||||
"voucher_type": self.doctype,
|
||||
"voucher_no": self.name,
|
||||
"voucher_detail_no": row.name,
|
||||
"warehouse": row.from_warehouse,
|
||||
"docstatus": 1,
|
||||
"is_cancelled": 0,
|
||||
},
|
||||
"name",
|
||||
)
|
||||
|
||||
def get_returned_source_package(self, row):
|
||||
return self.make_package_for_transfer(
|
||||
row.serial_and_batch_bundle,
|
||||
row.from_warehouse,
|
||||
type_of_transaction="Inward",
|
||||
include_bundle=row.rejected_serial_and_batch_bundle,
|
||||
)
|
||||
|
||||
def update_stock_ledger(self, allow_negative_stock=False, via_landed_cost_voucher=False):
|
||||
self.update_ordered_and_reserved_qty()
|
||||
|
||||
@@ -764,114 +1005,114 @@ class BuyingController(SubcontractingController):
|
||||
if d.item_code not in stock_items:
|
||||
continue
|
||||
|
||||
if d.warehouse:
|
||||
pr_qty = flt(flt(d.qty) * flt(d.conversion_factor), d.precision("stock_qty"))
|
||||
source_reversal_sle = None
|
||||
|
||||
if pr_qty:
|
||||
if d.from_warehouse and (
|
||||
(not cint(self.is_return) and self.docstatus == 1)
|
||||
or (cint(self.is_return) and self.docstatus == 2)
|
||||
):
|
||||
serial_and_batch_bundle = d.get("serial_and_batch_bundle")
|
||||
if self.is_internal_transfer() and self.is_return and self.docstatus == 2:
|
||||
serial_and_batch_bundle = frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{"voucher_detail_no": d.name, "warehouse": d.from_warehouse},
|
||||
"serial_and_batch_bundle",
|
||||
)
|
||||
pr_qty = flt(flt(d.qty) * flt(d.conversion_factor), d.precision("stock_qty"))
|
||||
source_qty = self.get_source_warehouse_qty(d, pr_qty)
|
||||
|
||||
from_warehouse_sle = self.get_sl_entries(
|
||||
d,
|
||||
{
|
||||
"actual_qty": -1 * pr_qty,
|
||||
"warehouse": d.from_warehouse,
|
||||
"outgoing_rate": d.rate,
|
||||
"recalculate_rate": 1,
|
||||
"dependant_sle_voucher_detail_no": d.name,
|
||||
"serial_and_batch_bundle": serial_and_batch_bundle,
|
||||
},
|
||||
if source_qty and (d.warehouse or not pr_qty):
|
||||
if d.from_warehouse and (
|
||||
(not cint(self.is_return) and self.docstatus == 1)
|
||||
or (cint(self.is_return) and self.docstatus == 2)
|
||||
):
|
||||
serial_and_batch_bundle = d.get("serial_and_batch_bundle")
|
||||
if self.is_internal_transfer() and self.is_return and self.docstatus == 2:
|
||||
serial_and_batch_bundle = frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{"voucher_detail_no": d.name, "warehouse": d.from_warehouse},
|
||||
"serial_and_batch_bundle",
|
||||
)
|
||||
|
||||
sl_entries.append(from_warehouse_sle)
|
||||
|
||||
type_of_transaction = "Inward"
|
||||
if self.docstatus == 2:
|
||||
type_of_transaction = "Outward"
|
||||
|
||||
sle = self.get_sl_entries(
|
||||
from_warehouse_sle = self.get_sl_entries(
|
||||
d,
|
||||
{
|
||||
"actual_qty": flt(pr_qty),
|
||||
"serial_and_batch_bundle": (
|
||||
d.serial_and_batch_bundle
|
||||
if not self.is_internal_transfer()
|
||||
or self.is_return
|
||||
or (self.is_internal_transfer() and self.docstatus == 2)
|
||||
else self.get_package_for_target_warehouse(
|
||||
d,
|
||||
type_of_transaction=type_of_transaction,
|
||||
via_landed_cost_voucher=via_landed_cost_voucher,
|
||||
)
|
||||
"actual_qty": -1 * source_qty,
|
||||
"warehouse": d.from_warehouse,
|
||||
"outgoing_rate": d.rate,
|
||||
"recalculate_rate": 1,
|
||||
"dependant_sle_voucher_detail_no": d.name,
|
||||
"serial_and_batch_bundle": self.get_source_warehouse_package(
|
||||
d, serial_and_batch_bundle
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
if self.is_return:
|
||||
sl_entries.append(from_warehouse_sle)
|
||||
|
||||
type_of_transaction = "Inward"
|
||||
if self.docstatus == 2:
|
||||
type_of_transaction = "Outward"
|
||||
|
||||
sle = self.get_sl_entries(
|
||||
d,
|
||||
{
|
||||
"actual_qty": flt(pr_qty),
|
||||
"serial_and_batch_bundle": self.get_accepted_warehouse_package(
|
||||
d, type_of_transaction, via_landed_cost_voucher
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
if self.is_return:
|
||||
outgoing_rate = 0.0
|
||||
if not is_serial_no_wise_valuation_disabled(d.item_code):
|
||||
outgoing_rate = get_rate_for_return(
|
||||
self.doctype, self.name, d.item_code, self.return_against, item_row=d
|
||||
)
|
||||
|
||||
sle.update(
|
||||
{
|
||||
"outgoing_rate": outgoing_rate,
|
||||
"recalculate_rate": 1,
|
||||
"serial_and_batch_bundle": d.serial_and_batch_bundle,
|
||||
}
|
||||
)
|
||||
if d.from_warehouse:
|
||||
sle.dependant_sle_voucher_detail_no = d.name
|
||||
else:
|
||||
sle.update(
|
||||
{
|
||||
"incoming_rate": d.valuation_rate,
|
||||
"recalculate_rate": 1
|
||||
if (self.is_subcontracted and (d.bom or d.get("fg_item"))) or d.from_warehouse
|
||||
else 0,
|
||||
}
|
||||
)
|
||||
sl_entries.append(sle)
|
||||
sle.update(
|
||||
{
|
||||
"outgoing_rate": outgoing_rate,
|
||||
"recalculate_rate": 1,
|
||||
"serial_and_batch_bundle": d.serial_and_batch_bundle,
|
||||
}
|
||||
)
|
||||
if d.from_warehouse:
|
||||
sle.dependant_sle_voucher_detail_no = d.name
|
||||
else:
|
||||
sle.update(
|
||||
{
|
||||
"incoming_rate": d.valuation_rate,
|
||||
"recalculate_rate": 1
|
||||
if (self.is_subcontracted and (d.bom or d.get("fg_item"))) or d.from_warehouse
|
||||
else 0,
|
||||
}
|
||||
)
|
||||
sl_entries.append(sle)
|
||||
|
||||
if d.from_warehouse and (
|
||||
(not cint(self.is_return) and self.docstatus == 2)
|
||||
or (cint(self.is_return) and self.docstatus == 1)
|
||||
):
|
||||
serial_and_batch_bundle = None
|
||||
if self.is_internal_transfer() and self.docstatus == 2:
|
||||
serial_and_batch_bundle = frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{"voucher_detail_no": d.name, "warehouse": d.warehouse},
|
||||
"serial_and_batch_bundle",
|
||||
)
|
||||
if d.from_warehouse and (
|
||||
(not cint(self.is_return) and self.docstatus == 2)
|
||||
or (cint(self.is_return) and self.docstatus == 1)
|
||||
):
|
||||
serial_and_batch_bundle = None
|
||||
if self.is_internal_transfer() and self.docstatus == 2:
|
||||
reversed_warehouse = (
|
||||
d.from_warehouse if d.get("rejected_serial_and_batch_bundle") else d.warehouse
|
||||
)
|
||||
serial_and_batch_bundle = self.get_submitted_package(d, reversed_warehouse)
|
||||
|
||||
from_warehouse_sle = self.get_sl_entries(
|
||||
d,
|
||||
{
|
||||
"actual_qty": -1 * pr_qty,
|
||||
"warehouse": d.from_warehouse,
|
||||
"recalculate_rate": 1,
|
||||
"serial_and_batch_bundle": (
|
||||
self.get_package_for_target_warehouse(d, d.from_warehouse, "Inward")
|
||||
if self.is_internal_transfer() and self.is_return
|
||||
else serial_and_batch_bundle
|
||||
),
|
||||
},
|
||||
from_warehouse_sle = self.get_sl_entries(
|
||||
d,
|
||||
{
|
||||
"actual_qty": -1 * source_qty,
|
||||
"warehouse": d.from_warehouse,
|
||||
"recalculate_rate": 1,
|
||||
"serial_and_batch_bundle": self.get_source_warehouse_reversal_package(
|
||||
d, serial_and_batch_bundle
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
if self.is_internal_transfer() and self.is_return:
|
||||
from_warehouse_sle.incoming_rate = get_rate_for_return(
|
||||
self.doctype, self.name, d.item_code, self.return_against, item_row=d
|
||||
)
|
||||
|
||||
sl_entries.append(from_warehouse_sle)
|
||||
source_reversal_sle = from_warehouse_sle
|
||||
|
||||
if flt(d.rejected_qty) != 0:
|
||||
valuation_rate_for_rejected_item = 0.0
|
||||
if frappe.db.get_single_value("Buying Settings", "set_valuation_rate_for_rejected_materials"):
|
||||
if is_rejected_material_valued(self.doctype, d.name):
|
||||
valuation_rate_for_rejected_item = d.valuation_rate
|
||||
|
||||
sl_entries.append(
|
||||
@@ -889,6 +1130,9 @@ class BuyingController(SubcontractingController):
|
||||
)
|
||||
)
|
||||
|
||||
if source_reversal_sle:
|
||||
sl_entries.append(source_reversal_sle)
|
||||
|
||||
self.make_sl_entries(
|
||||
sl_entries,
|
||||
allow_negative_stock=allow_negative_stock,
|
||||
|
||||
@@ -20,6 +20,7 @@ REOPEN_STATUS = {
|
||||
"Sales Order": "Draft",
|
||||
"Delivery Note": "Submitted",
|
||||
"Purchase Receipt": "Submitted",
|
||||
"Blanket Order": "Submitted",
|
||||
}
|
||||
|
||||
SETTLED_BY_CLOSE = ("per_ordered", "per_received", "per_delivered", "per_billed")
|
||||
@@ -76,7 +77,10 @@ def update_closed_status(doctype: str, name: str, item_names: str | list[str], c
|
||||
for row in changed:
|
||||
row.db_set("closed", closed)
|
||||
|
||||
doc.on_item_close_status_change()
|
||||
recalculate = getattr(doc, "on_item_close_status_change", None)
|
||||
if recalculate:
|
||||
recalculate()
|
||||
|
||||
doc.reload()
|
||||
|
||||
if closed:
|
||||
|
||||
@@ -26,6 +26,7 @@ from pypika import Order
|
||||
import erpnext
|
||||
from erpnext.accounts.utils import build_qb_match_conditions
|
||||
from erpnext.stock.doctype.company_restriction.company_restriction import get_restriction_criterion
|
||||
from erpnext.stock.doctype.item.item_search import get_item_search_candidates
|
||||
from erpnext.stock.get_item_details import _get_item_tax_template
|
||||
from erpnext.stock.utils import get_combine_datetime
|
||||
from erpnext.utilities.query import get_filter_conditions_qb
|
||||
@@ -388,9 +389,11 @@ def item_query(
|
||||
db_fields = [f.fieldname for f in meta.fields] + ["name"]
|
||||
search_str = f"%{txt}%"
|
||||
search_conditions = []
|
||||
searched_fields = []
|
||||
for fieldname in fields_to_process:
|
||||
if fieldname in db_fields:
|
||||
search_conditions.append(item[fieldname].like(search_str))
|
||||
searched_fields.append(fieldname)
|
||||
|
||||
barcode_tbl = DocType("Item Barcode")
|
||||
barcode_subquery = (
|
||||
@@ -401,6 +404,9 @@ def item_query(
|
||||
# Condition for the description
|
||||
if frappe.db.estimate_count("Item") < 50000 and "description" not in fields_to_process:
|
||||
search_conditions.append(item.description.like(search_str))
|
||||
searched_fields.append("description")
|
||||
|
||||
candidates = get_item_search_candidates(txt, searched_fields)
|
||||
|
||||
txt_no_percent = txt.replace("%", "")
|
||||
|
||||
@@ -436,6 +442,11 @@ def item_query(
|
||||
.offset(start)
|
||||
)
|
||||
|
||||
if candidates is not None:
|
||||
if not candidates:
|
||||
return [] if as_dict else ()
|
||||
query = query.where(item.name.isin(candidates))
|
||||
|
||||
if company:
|
||||
query = query.where(get_restriction_criterion("Item", [company]))
|
||||
|
||||
@@ -822,16 +833,18 @@ def get_account_list(
|
||||
def get_blanket_orders(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
|
||||
bo_filters = [
|
||||
["docstatus", "=", 1],
|
||||
["status", "!=", "Closed"],
|
||||
["blanket_order_type", "=", filters.get("blanket_order_type")],
|
||||
["company", "=", filters.get("company")],
|
||||
]
|
||||
|
||||
if frappe.has_permission("Blanket Order", "read"):
|
||||
bo_filters.append(["Blanket Order Item", "item_code", "=", filters.get("item")])
|
||||
bo_filters.append(["Blanket Order Item", "closed", "=", 0])
|
||||
else:
|
||||
parents = frappe.get_all(
|
||||
"Blanket Order Item",
|
||||
filters={"item_code": filters.get("item"), "parenttype": "Blanket Order"},
|
||||
filters={"item_code": filters.get("item"), "parenttype": "Blanket Order", "closed": 0},
|
||||
pluck="parent",
|
||||
distinct=True,
|
||||
)
|
||||
@@ -840,6 +853,9 @@ def get_blanket_orders(doctype: str, txt: str, searchfield: str, start: int, pag
|
||||
if currency := filters.get("currency"):
|
||||
bo_filters.append(["currency", "=", currency])
|
||||
|
||||
if transaction_date := filters.get("transaction_date"):
|
||||
bo_filters.append(["to_date", ">=", transaction_date])
|
||||
|
||||
return frappe.get_list(
|
||||
"Blanket Order",
|
||||
filters=bo_filters,
|
||||
|
||||
@@ -170,7 +170,7 @@ def validate_returned_items(doc):
|
||||
"Delivery Note",
|
||||
"POS Invoice",
|
||||
):
|
||||
if flt(d.qty) < 0 or flt(d.get("received_qty")) < 0:
|
||||
if flt(d.qty) < 0 or flt(d.get("received_qty")) < 0 or flt(d.get("rejected_qty")) < 0:
|
||||
items_returned = True
|
||||
else:
|
||||
items_returned = True
|
||||
@@ -736,7 +736,7 @@ def make_return_doc(doctype: str, source_name: str, target_doc=None, return_agai
|
||||
if return_against_rejected_qty:
|
||||
return doc.rejected_qty
|
||||
|
||||
return doc.qty
|
||||
return doc.qty or doc.get("rejected_qty")
|
||||
|
||||
doclist = get_mapped_doc(
|
||||
doctype,
|
||||
@@ -885,8 +885,13 @@ def get_filters(
|
||||
if reference_voucher_detail_no:
|
||||
warehouses = get_warehouses_for_return(voucher_type, reference_voucher_detail_no)
|
||||
|
||||
if item_row.get("warehouse") and item_row.get("warehouse") in warehouses:
|
||||
filters["warehouse"] = item_row.get("warehouse")
|
||||
# A row that accepted nothing goes back at the rate the rejected warehouse received it at.
|
||||
warehouse_field = "warehouse"
|
||||
if not flt(item_row.get("qty")) and flt(item_row.get("rejected_qty")):
|
||||
warehouse_field = "rejected_warehouse"
|
||||
|
||||
if item_row.get(warehouse_field) and item_row.get(warehouse_field) in warehouses:
|
||||
filters["warehouse"] = item_row.get(warehouse_field)
|
||||
|
||||
return filters
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from erpnext.accounts.party import render_address
|
||||
from erpnext.controllers.accounts_controller import get_taxes_and_charges
|
||||
from erpnext.controllers.sales_and_purchase_return import get_rate_for_return, is_batch_expired
|
||||
from erpnext.controllers.stock_controller import StockController
|
||||
from erpnext.selling.doctype.customer.customer import is_customer_blocked
|
||||
from erpnext.stock.doctype.item.item import set_item_default
|
||||
from erpnext.stock.get_item_details import get_bin_details, get_conversion_factor
|
||||
from erpnext.stock.utils import _get_incoming_rate, get_combine_datetime, get_valuation_method
|
||||
@@ -51,6 +52,7 @@ class SellingController(StockController):
|
||||
|
||||
def validate(self):
|
||||
super().validate()
|
||||
self.ensure_customer_is_not_blocked()
|
||||
self.validate_items()
|
||||
if not (self.get("is_debit_note") or self.get("is_return")):
|
||||
self.validate_max_discount()
|
||||
@@ -476,6 +478,13 @@ class SellingController(StockController):
|
||||
so_warehouse = (so_item.warehouse if so_item else "") or ""
|
||||
return so_qty, so_warehouse
|
||||
|
||||
def ensure_customer_is_not_blocked(self):
|
||||
if self.doctype == "Quotation":
|
||||
return
|
||||
|
||||
if self.customer and is_customer_blocked(self.customer):
|
||||
frappe.throw(_("{0} is blocked so this transaction cannot proceed").format(self.customer))
|
||||
|
||||
def check_sales_order_on_hold_or_close(self, ref_fieldname):
|
||||
if self.is_return:
|
||||
return
|
||||
|
||||
@@ -186,6 +186,12 @@ status_map = {
|
||||
["Completed", "eval:self.purpose == 'Delivery' and self.delivery_status == 'Fully Delivered'"],
|
||||
["Cancelled", "eval:self.docstatus == 2"],
|
||||
],
|
||||
"Blanket Order": [
|
||||
["Draft", None],
|
||||
["Submitted", "eval:self.docstatus == 1"],
|
||||
["Closed", "eval:self.status == 'Closed' and self.docstatus == 1"],
|
||||
["Cancelled", "eval:self.docstatus == 2"],
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -260,12 +260,25 @@ class StockController(AccountsController):
|
||||
return SerialBatchBundleService(self).set_serial_and_batch_bundle(table_name, ignore_validate)
|
||||
|
||||
def make_package_for_transfer(
|
||||
self, serial_and_batch_bundle, warehouse, type_of_transaction=None, do_not_submit=None, qty=0
|
||||
self,
|
||||
serial_and_batch_bundle,
|
||||
warehouse,
|
||||
type_of_transaction=None,
|
||||
do_not_submit=None,
|
||||
qty=0,
|
||||
include_bundle=None,
|
||||
exclude_serial_nos=None,
|
||||
):
|
||||
from erpnext.stock.services.serial_batch_bundle_service import SerialBatchBundleService
|
||||
|
||||
return SerialBatchBundleService(self).make_package_for_transfer(
|
||||
serial_and_batch_bundle, warehouse, type_of_transaction, do_not_submit, qty
|
||||
serial_and_batch_bundle,
|
||||
warehouse,
|
||||
type_of_transaction,
|
||||
do_not_submit,
|
||||
qty,
|
||||
include_bundle,
|
||||
exclude_serial_nos,
|
||||
)
|
||||
|
||||
def get_sl_entries(self, d, args):
|
||||
@@ -941,10 +954,20 @@ def make_bundle_for_material_transfer(**kwargs):
|
||||
bundle_doc.voucher_no = "" if kwargs.is_new or kwargs.docstatus == 2 else kwargs.voucher_no
|
||||
bundle_doc.is_cancelled = 0
|
||||
|
||||
if kwargs.include_bundle:
|
||||
for entry in frappe.get_doc("Serial and Batch Bundle", kwargs.include_bundle).entries:
|
||||
bundle_doc.append("entries", entry.as_dict(no_default_fields=True))
|
||||
|
||||
if kwargs.exclude_serial_nos:
|
||||
keep = [row for row in bundle_doc.entries if row.serial_no not in set(kwargs.exclude_serial_nos)]
|
||||
bundle_doc.entries = keep
|
||||
for idx, row in enumerate(keep, start=1):
|
||||
row.idx = idx
|
||||
|
||||
qty = 0
|
||||
if (
|
||||
len(bundle_doc.entries) == 1
|
||||
and flt(kwargs.qty) < flt(bundle_doc.total_qty)
|
||||
and abs(flt(kwargs.qty)) < abs(flt(bundle_doc.total_qty))
|
||||
and not bundle_doc.has_serial_no
|
||||
):
|
||||
qty = kwargs.qty
|
||||
|
||||
@@ -13,6 +13,7 @@ from frappe.utils import cint, flt, round_based_on_smallest_currency_fraction
|
||||
import erpnext
|
||||
from erpnext.accounts.doctype.journal_entry.journal_entry import get_exchange_rate
|
||||
from erpnext.accounts.doctype.pricing_rule.utils import get_applied_pricing_rules
|
||||
from erpnext.buying.doctype.buying_settings.buying_settings import bills_rejected_quantity
|
||||
from erpnext.controllers.accounts_controller import (
|
||||
validate_conversion_rate,
|
||||
validate_inclusive_tax,
|
||||
@@ -241,13 +242,19 @@ class calculate_taxes_and_totals:
|
||||
elif not item.qty and self.doc.get("is_debit_note"):
|
||||
item.amount = flt(item.rate, item.precision("amount"))
|
||||
else:
|
||||
item.amount = flt(item.rate * item.qty, item.precision("amount"))
|
||||
item.amount = flt(item.rate * self.get_billed_qty(item), item.precision("amount"))
|
||||
item.net_amount = item.amount
|
||||
self._set_in_company_currency(
|
||||
item, ["price_list_rate", "rate_with_margin", "rate", "net_rate", "amount", "net_amount"]
|
||||
)
|
||||
item.item_tax_amount = 0.0
|
||||
|
||||
def get_billed_qty(self, item):
|
||||
if not flt(item.get("rejected_qty")) or not bills_rejected_quantity(self.doc):
|
||||
return flt(item.qty)
|
||||
|
||||
return flt(item.qty) + flt(item.rejected_qty)
|
||||
|
||||
def _set_in_company_currency(self, doc, fields):
|
||||
"""set values in base currency"""
|
||||
for f in fields:
|
||||
@@ -339,7 +346,7 @@ class calculate_taxes_and_totals:
|
||||
|
||||
item._unrounded_net_amount = amount / (1 + total_tax_slope)
|
||||
item.net_amount = flt(item._unrounded_net_amount, item.precision("net_amount"))
|
||||
item.net_rate = flt(item.net_amount / item.qty, item.precision("net_rate"))
|
||||
item.net_rate = flt(item.net_amount / self.get_billed_qty(item), item.precision("net_rate"))
|
||||
item.discount_percentage = flt(
|
||||
item.discount_percentage, item.precision("discount_percentage")
|
||||
)
|
||||
@@ -945,8 +952,9 @@ class calculate_taxes_and_totals:
|
||||
)
|
||||
net_total += rounding_difference
|
||||
|
||||
billed_qty = self.get_billed_qty(item)
|
||||
item.net_rate = (
|
||||
flt(item.net_amount / item.qty, item.precision("net_rate")) if item.qty else 0
|
||||
flt(item.net_amount / billed_qty, item.precision("net_rate")) if billed_qty else 0
|
||||
)
|
||||
|
||||
self._set_in_company_currency(item, ["net_rate", "net_amount"])
|
||||
|
||||
59
erpnext/controllers/tests/test_price_list_currency.py
Normal file
59
erpnext/controllers/tests/test_price_list_currency.py
Normal file
@@ -0,0 +1,59 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import frappe
|
||||
from frappe.tests import UnitTestCase
|
||||
|
||||
from erpnext.controllers.accounts_controller import AccountsController
|
||||
|
||||
|
||||
class TestPriceListCurrency(UnitTestCase):
|
||||
def test_price_list_currency_transition(self):
|
||||
cases = (
|
||||
("USD", "CDF", 1, 0.000444444, True),
|
||||
("EUR", "CDF", 1.2, 0.000444444, True),
|
||||
("CDF", "USD", 0.000444444, 1, False),
|
||||
("CDF", "CDF", 0.0005, 0.0005, False),
|
||||
("CDF", "CDF", 0, 0.000444444, True),
|
||||
(None, "CDF", 0.0005, 0.0005, False),
|
||||
)
|
||||
for direction in ("Selling", "Buying"):
|
||||
for previous_currency, currency, previous_rate, expected_rate, fetch_rate in cases:
|
||||
with self.subTest(
|
||||
direction=direction,
|
||||
previous_currency=previous_currency,
|
||||
currency=currency,
|
||||
previous_rate=previous_rate,
|
||||
):
|
||||
doc = frappe._dict(
|
||||
meta=Mock(),
|
||||
posting_date="2026-09-18",
|
||||
selling_price_list="New Selling Price List",
|
||||
buying_price_list="New Buying Price List",
|
||||
price_list_currency=previous_currency,
|
||||
plc_conversion_rate=previous_rate,
|
||||
company_currency="USD",
|
||||
currency="CDF",
|
||||
conversion_rate=0.000444444,
|
||||
)
|
||||
with (
|
||||
patch("erpnext.controllers.accounts_controller.frappe") as mock_frappe,
|
||||
patch(
|
||||
"erpnext.controllers.accounts_controller.get_exchange_rate",
|
||||
return_value=0.000444444,
|
||||
) as exchange_rate,
|
||||
):
|
||||
mock_frappe.db.get_value.return_value = currency
|
||||
mock_frappe.db.get_single_value.return_value = False
|
||||
AccountsController.set_price_list_currency(doc, direction)
|
||||
self.assertEqual(doc.price_list_currency, currency)
|
||||
self.assertEqual(doc.plc_conversion_rate, expected_rate)
|
||||
self.assertEqual(doc.conversion_rate, 0.000444444)
|
||||
if fetch_rate:
|
||||
exchange_rate.assert_called_once_with(
|
||||
currency, "USD", "2026-09-18", f"for_{direction.lower()}"
|
||||
)
|
||||
else:
|
||||
exchange_rate.assert_not_called()
|
||||
93
erpnext/controllers/tests/test_price_list_validation.py
Normal file
93
erpnext/controllers/tests/test_price_list_validation.py
Normal file
@@ -0,0 +1,93 @@
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestPriceListValidation(ERPNextTestSuite):
|
||||
def create_price_list(self, selling=0, buying=0, enabled=1):
|
||||
return (
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Price List",
|
||||
"price_list_name": frappe.generate_hash(length=10),
|
||||
"currency": "INR",
|
||||
"selling": selling,
|
||||
"buying": buying,
|
||||
"enabled": enabled,
|
||||
}
|
||||
)
|
||||
.insert()
|
||||
.name
|
||||
)
|
||||
|
||||
def test_selling_transaction_should_reject_a_buying_price_list(self):
|
||||
invoice = create_sales_invoice(do_not_save=1)
|
||||
invoice.selling_price_list = self.create_price_list(buying=1)
|
||||
|
||||
with self.assertRaisesRegex(frappe.ValidationError, "selling transaction"):
|
||||
invoice.save()
|
||||
|
||||
def test_buying_transaction_should_reject_a_selling_price_list(self):
|
||||
invoice = make_purchase_invoice(do_not_save=1)
|
||||
invoice.buying_price_list = self.create_price_list(selling=1)
|
||||
|
||||
with self.assertRaisesRegex(frappe.ValidationError, "buying transaction"):
|
||||
invoice.save()
|
||||
|
||||
def test_a_price_list_for_both_sides_should_be_accepted(self):
|
||||
price_list = self.create_price_list(selling=1, buying=1)
|
||||
|
||||
invoice = create_sales_invoice(do_not_save=1)
|
||||
invoice.selling_price_list = price_list
|
||||
invoice.save()
|
||||
|
||||
self.assertEqual(invoice.selling_price_list, price_list)
|
||||
|
||||
def test_a_missing_price_list_should_report_rather_than_crash(self):
|
||||
invoice = create_sales_invoice(do_not_save=1)
|
||||
invoice.selling_price_list = frappe.generate_hash(length=10)
|
||||
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
invoice.validate_price_list()
|
||||
|
||||
def test_internal_transfer_should_keep_the_outward_price_list(self):
|
||||
"""The inward document of an internal transfer takes the price list of the outward one, which
|
||||
is flagged for the opposite side."""
|
||||
from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_purchase_receipt
|
||||
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import (
|
||||
prepare_data_for_internal_transfer,
|
||||
)
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
|
||||
prepare_data_for_internal_transfer()
|
||||
company = "_Test Company with perpetual inventory"
|
||||
selling_only = self.create_price_list(selling=1)
|
||||
|
||||
delivery_note = create_delivery_note(
|
||||
company=company,
|
||||
customer="_Test Internal Customer 2",
|
||||
cost_center="Main - TCP1",
|
||||
expense_account="Cost of Goods Sold - TCP1",
|
||||
warehouse="Stores - TCP1",
|
||||
target_warehouse=create_warehouse("_Test Transit For Price List", company=company),
|
||||
do_not_submit=1,
|
||||
)
|
||||
delivery_note.selling_price_list = selling_only
|
||||
delivery_note.save()
|
||||
delivery_note.submit()
|
||||
|
||||
receipt = make_inter_company_purchase_receipt(delivery_note.name)
|
||||
receipt.items[0].warehouse = "Stores - TCP1"
|
||||
receipt.save()
|
||||
|
||||
self.assertEqual(receipt.buying_price_list, selling_only)
|
||||
|
||||
def test_disabled_price_list_should_still_report_as_disabled(self):
|
||||
invoice = create_sales_invoice(do_not_save=1)
|
||||
invoice.selling_price_list = self.create_price_list(selling=1, enabled=0)
|
||||
|
||||
with self.assertRaisesRegex(frappe.ValidationError, "is disabled"):
|
||||
invoice.save()
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
frappe.ui.form.on("Campaign", {
|
||||
refresh: function (frm) {
|
||||
erpnext.toggle_naming_series();
|
||||
erpnext.toggle_naming_series(frm);
|
||||
|
||||
if (frm.is_new()) {
|
||||
frm.toggle_display(
|
||||
|
||||
@@ -26,7 +26,7 @@ erpnext.LeadController = class LeadController extends frappe.ui.form.Controller
|
||||
refresh() {
|
||||
var me = this;
|
||||
let doc = this.frm.doc;
|
||||
erpnext.toggle_naming_series();
|
||||
erpnext.toggle_naming_series(this.frm);
|
||||
|
||||
if (!this.frm.is_new() && doc.__onload && !doc.__onload.is_customer) {
|
||||
this.frm.add_custom_button(__("Customer"), this.make_customer.bind(this), __("Create"));
|
||||
@@ -240,6 +240,4 @@ erpnext.LeadController = class LeadController extends frappe.ui.form.Controller
|
||||
}
|
||||
};
|
||||
|
||||
if (cur_frm) {
|
||||
extend_cscript(cur_frm.cscript, new erpnext.LeadController({ frm: cur_frm }));
|
||||
}
|
||||
frappe.ui.form.set_controller("Lead", erpnext.LeadController);
|
||||
|
||||
@@ -42,7 +42,7 @@ frappe.ui.form.on("Opportunity", {
|
||||
erpnext.utils.map_current_doc({
|
||||
method: "erpnext.crm.doctype.lead.mapper.make_opportunity",
|
||||
source_name: frm.doc.party_name,
|
||||
frm: frm,
|
||||
target: frm,
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -73,7 +73,7 @@ frappe.ui.form.on("Opportunity", {
|
||||
refresh: function (frm) {
|
||||
var doc = frm.doc;
|
||||
frm.trigger("setup_opportunity_from");
|
||||
erpnext.toggle_naming_series();
|
||||
erpnext.toggle_naming_series(frm);
|
||||
|
||||
if (!frm.is_new() && doc.status !== "Lost") {
|
||||
if (doc.items) {
|
||||
@@ -269,6 +269,23 @@ frappe.ui.form.on("Opportunity Item", {
|
||||
rate: function (frm, cdt, cdn) {
|
||||
frm.trigger("calculate", cdt, cdn);
|
||||
},
|
||||
item_code: function (frm, cdt, cdn) {
|
||||
let d = locals[cdt][cdn];
|
||||
if (d.item_code) {
|
||||
return frappe.call({
|
||||
method: "erpnext.crm.doctype.opportunity.opportunity.get_item_details",
|
||||
args: { item_code: d.item_code },
|
||||
callback: function (r) {
|
||||
if (r.message) {
|
||||
$.each(r.message, function (k, v) {
|
||||
frappe.model.set_value(cdt, cdn, k, v);
|
||||
});
|
||||
refresh_field("image_view", d.name, "items");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// TODO commonify this code
|
||||
@@ -285,7 +302,7 @@ erpnext.crm.Opportunity = class Opportunity extends frappe.ui.form.Controller {
|
||||
}
|
||||
|
||||
if (this.frm.is_new() && this.frm.doc.opportunity_type === undefined) {
|
||||
this.frm.doc.opportunity_type = __("Sales");
|
||||
this.frm.doc.opportunity_type = "Sales";
|
||||
}
|
||||
this.setup_queries();
|
||||
}
|
||||
@@ -372,22 +389,4 @@ erpnext.crm.Opportunity = class Opportunity extends frappe.ui.form.Controller {
|
||||
}
|
||||
};
|
||||
|
||||
extend_cscript(cur_frm.cscript, new erpnext.crm.Opportunity({ frm: cur_frm }));
|
||||
|
||||
cur_frm.cscript.item_code = function (doc, cdt, cdn) {
|
||||
var d = locals[cdt][cdn];
|
||||
if (d.item_code) {
|
||||
return frappe.call({
|
||||
method: "erpnext.crm.doctype.opportunity.opportunity.get_item_details",
|
||||
args: { item_code: d.item_code },
|
||||
callback: function (r, rt) {
|
||||
if (r.message) {
|
||||
$.each(r.message, function (k, v) {
|
||||
frappe.model.set_value(cdt, cdn, k, v);
|
||||
});
|
||||
refresh_field("image_view", d.name, "items");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
frappe.ui.form.set_controller("Opportunity", erpnext.crm.Opportunity);
|
||||
|
||||
@@ -166,7 +166,7 @@ class Opportunity(TransactionBase, CRMNote):
|
||||
|
||||
def set_opportunity_type(self):
|
||||
if self.is_new() and not self.opportunity_type:
|
||||
self.opportunity_type = _("Sales")
|
||||
self.opportunity_type = "Sales"
|
||||
|
||||
def set_exchange_rate(self):
|
||||
company_currency = frappe.get_cached_value("Company", self.company, "default_currency")
|
||||
|
||||
@@ -32,7 +32,7 @@ def get_columns():
|
||||
"label": _("Reference Document Type"),
|
||||
"fieldname": "reference_document_type",
|
||||
"fieldtype": "Link",
|
||||
"options": "Doctype",
|
||||
"options": "DocType",
|
||||
"width": 100,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -387,6 +387,8 @@ pre_submit_validation_doctypes = [
|
||||
"Sales Order",
|
||||
]
|
||||
|
||||
sqlite_search = ["erpnext.stock.doctype.item.item_search.ItemSearch"]
|
||||
|
||||
doc_events = {
|
||||
"*": {
|
||||
"validate": [
|
||||
@@ -404,6 +406,10 @@ doc_events = {
|
||||
("Item", "Customer", "Supplier"): {
|
||||
"validate": "erpnext.stock.doctype.company_restriction.company_restriction.validate_allowed_companies",
|
||||
},
|
||||
"Item": {
|
||||
"on_update": "erpnext.stock.doctype.item.item_search.reindex_item",
|
||||
"after_rename": "erpnext.stock.doctype.item.item_search.reindex_renamed_item",
|
||||
},
|
||||
"Stock Entry": {
|
||||
"on_submit": "erpnext.stock.doctype.material_request.material_request.update_completed_and_requested_qty",
|
||||
"on_cancel": "erpnext.stock.doctype.material_request.material_request.update_completed_and_requested_qty",
|
||||
|
||||
@@ -163,4 +163,4 @@ erpnext.maintenance.MaintenanceSchedule = class MaintenanceSchedule extends frap
|
||||
}
|
||||
};
|
||||
|
||||
extend_cscript(cur_frm.cscript, new erpnext.maintenance.MaintenanceSchedule({ frm: cur_frm }));
|
||||
frappe.ui.form.set_controller("Maintenance Schedule", erpnext.maintenance.MaintenanceSchedule);
|
||||
|
||||
@@ -145,4 +145,4 @@ erpnext.maintenance.MaintenanceVisit = class MaintenanceVisit extends frappe.ui.
|
||||
}
|
||||
};
|
||||
|
||||
extend_cscript(cur_frm.cscript, new erpnext.maintenance.MaintenanceVisit({ frm: cur_frm }));
|
||||
frappe.ui.form.set_controller("Maintenance Visit", erpnext.maintenance.MaintenanceVisit);
|
||||
|
||||
@@ -11,6 +11,13 @@ frappe.ui.form.on("Blanket Order", {
|
||||
},
|
||||
|
||||
setup: function (frm) {
|
||||
frm.set_indicator_formatter("item_code", function (doc) {
|
||||
if (doc.closed) {
|
||||
return "gray";
|
||||
}
|
||||
return flt(doc.ordered_qty) >= flt(doc.qty) ? "green" : "orange";
|
||||
});
|
||||
|
||||
frm.custom_make_buttons = {
|
||||
"Purchase Order": "Purchase Order",
|
||||
"Sales Order": "Sales Order",
|
||||
@@ -26,7 +33,15 @@ frappe.ui.form.on("Blanket Order", {
|
||||
refresh: function (frm) {
|
||||
erpnext.hide_company(frm);
|
||||
blanket_order_pricing.update_labels(frm);
|
||||
if (frm.doc.customer && frm.doc.docstatus === 1 && frm.doc.to_date > frappe.datetime.get_today()) {
|
||||
add_blanket_order_status_buttons(frm);
|
||||
erpnext.item_close.add_buttons(frm, get_blanket_order_item_close_config());
|
||||
if (frm.doc.status === "Closed") {
|
||||
return;
|
||||
}
|
||||
|
||||
const can_order = frm.doc.docstatus === 1 && frm.doc.to_date >= frappe.datetime.get_today();
|
||||
|
||||
if (frm.doc.customer && can_order) {
|
||||
frm.add_custom_button(
|
||||
__("Sales Order"),
|
||||
function () {
|
||||
@@ -56,7 +71,7 @@ frappe.ui.form.on("Blanket Order", {
|
||||
);
|
||||
}
|
||||
|
||||
if (frm.doc.supplier && frm.doc.docstatus === 1) {
|
||||
if (frm.doc.supplier && can_order) {
|
||||
frm.add_custom_button(
|
||||
__("Purchase Order"),
|
||||
function () {
|
||||
@@ -229,6 +244,56 @@ const blanket_order_pricing = {
|
||||
},
|
||||
};
|
||||
|
||||
function add_blanket_order_status_buttons(frm) {
|
||||
if (frm.doc.docstatus !== 1 || !frm.has_perm("submit")) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (frm.doc.status === "Closed") {
|
||||
frm.add_custom_button(
|
||||
__("Re-open"),
|
||||
() => update_blanket_order_status(frm, "Submitted"),
|
||||
__("Status")
|
||||
);
|
||||
} else {
|
||||
frm.add_custom_button(__("Close"), () => update_blanket_order_status(frm, "Closed"), __("Status"));
|
||||
}
|
||||
}
|
||||
|
||||
function get_blanket_order_item_close_config() {
|
||||
return {
|
||||
is_closable: (item) => !item.closed && flt(item.ordered_qty) < flt(item.qty),
|
||||
help: __(
|
||||
"Closed rows can no longer be ordered. They are skipped when creating an order from this Blanket Order."
|
||||
),
|
||||
summarise: (item) => ({
|
||||
item_code: item.item_code,
|
||||
item_name: item.item_name,
|
||||
qty: item.qty,
|
||||
ordered_qty: item.ordered_qty || 0,
|
||||
pending_qty: Math.max(flt(item.qty) - flt(item.ordered_qty), 0),
|
||||
stock_uom: item.stock_uom,
|
||||
}),
|
||||
columns: [
|
||||
erpnext.item_close.column("item_code", __("Item Code"), "Data", 3),
|
||||
erpnext.item_close.column("item_name", __("Item Name"), "Data", 2),
|
||||
erpnext.item_close.column("qty", __("Qty")),
|
||||
erpnext.item_close.column("ordered_qty", __("Ordered Qty")),
|
||||
erpnext.item_close.column("pending_qty", __("Pending Qty")),
|
||||
erpnext.item_close.column("stock_uom", __("Stock UOM"), "Data"),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function update_blanket_order_status(frm, status) {
|
||||
frappe.call({
|
||||
method: "erpnext.manufacturing.doctype.blanket_order.blanket_order.update_status",
|
||||
args: { status: status, name: frm.doc.name },
|
||||
freeze: true,
|
||||
callback: () => frm.reload_doc(),
|
||||
});
|
||||
}
|
||||
|
||||
function reset_party_pricing(frm) {
|
||||
return blanket_order_pricing.apply(frm, null, { reset_party_values: true });
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"from_date",
|
||||
"to_date",
|
||||
"company",
|
||||
"status",
|
||||
"currency_and_price_list",
|
||||
"currency",
|
||||
"conversion_rate",
|
||||
@@ -105,6 +106,17 @@
|
||||
"reqd": 1,
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
"default": "Draft",
|
||||
"fieldname": "status",
|
||||
"fieldtype": "Select",
|
||||
"in_standard_filter": 1,
|
||||
"label": "Status",
|
||||
"no_copy": 1,
|
||||
"options": "Draft\nSubmitted\nClosed\nCancelled",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)",
|
||||
@@ -217,7 +229,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-08-27 10:55:37.000000",
|
||||
"modified": "2026-09-23 20:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Blanket Order",
|
||||
@@ -268,6 +280,11 @@
|
||||
"search_fields": "blanket_order_type, to_date",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"states": [
|
||||
{
|
||||
"color": "Green",
|
||||
"title": "Closed"
|
||||
}
|
||||
],
|
||||
"track_changes": 1
|
||||
}
|
||||
|
||||
@@ -4,18 +4,19 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.model.mapper import get_mapped_doc
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import flt, getdate
|
||||
from frappe.utils import flt, formatdate, getdate, today
|
||||
|
||||
from erpnext import get_company_currency
|
||||
from erpnext.accounts.services.taxes import validate_conversion_rate
|
||||
from erpnext.controllers.item_close import clear_closed_rows_on_amend, validate_parent_reopen
|
||||
from erpnext.controllers.status_updater import StatusUpdater
|
||||
from erpnext.manufacturing.doctype.blanket_order import blanket_order_pricing
|
||||
from erpnext.stock.doctype.item.item import get_item_defaults
|
||||
|
||||
|
||||
class BlanketOrder(Document):
|
||||
class BlanketOrder(StatusUpdater):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
@@ -42,6 +43,7 @@ class BlanketOrder(Document):
|
||||
plc_conversion_rate: DF.Float
|
||||
price_list_currency: DF.Link | None
|
||||
selling_price_list: DF.Link | None
|
||||
status: DF.Literal["Draft", "Submitted", "Closed", "Cancelled"]
|
||||
supplier: DF.Link | None
|
||||
supplier_name: DF.Data | None
|
||||
tc_name: DF.Link | None
|
||||
@@ -60,6 +62,43 @@ class BlanketOrder(Document):
|
||||
self.validate_item_qty()
|
||||
self.set_party_item_code()
|
||||
self.set_base_rates()
|
||||
clear_closed_rows_on_amend(self)
|
||||
|
||||
def on_submit(self):
|
||||
self.set_status(update=True)
|
||||
|
||||
def on_cancel(self):
|
||||
self.set_status(update=True)
|
||||
|
||||
def update_status(self, status: str) -> None:
|
||||
if status != "Closed" and self.status == "Closed":
|
||||
validate_parent_reopen(self)
|
||||
|
||||
self.set_status(update=True, status=status)
|
||||
self.notify_update()
|
||||
|
||||
def is_item_closable(self, item) -> bool:
|
||||
return flt(item.ordered_qty) < flt(item.qty)
|
||||
|
||||
def validate_can_be_ordered(self, order_date) -> None:
|
||||
self.validate_is_open()
|
||||
self.validate_not_expired(order_date)
|
||||
|
||||
def validate_is_open(self) -> None:
|
||||
if self.status == "Closed":
|
||||
frappe.throw(
|
||||
_("Blanket Order {0} is closed").format(frappe.bold(self.name)), frappe.InvalidStatusError
|
||||
)
|
||||
|
||||
def validate_items_are_open(self, item_codes: list[str]) -> None:
|
||||
for row in self.items:
|
||||
if row.closed and row.item_code in item_codes:
|
||||
frappe.throw(
|
||||
_("Item {0} is closed in Blanket Order {1}").format(
|
||||
frappe.bold(row.item_code), frappe.bold(self.name)
|
||||
),
|
||||
frappe.InvalidStatusError,
|
||||
)
|
||||
|
||||
def set_currency(self):
|
||||
if self.currency:
|
||||
@@ -90,6 +129,15 @@ class BlanketOrder(Document):
|
||||
if getdate(self.from_date) > getdate(self.to_date):
|
||||
frappe.throw(_("From date cannot be greater than To date"))
|
||||
|
||||
def validate_not_expired(self, order_date) -> None:
|
||||
if getdate(order_date) > getdate(self.to_date):
|
||||
frappe.throw(
|
||||
_("Blanket Order {0} expired on {1}").format(
|
||||
frappe.bold(self.name), formatdate(self.to_date)
|
||||
),
|
||||
title=_("Blanket Order Expired"),
|
||||
)
|
||||
|
||||
def set_party_item_code(self):
|
||||
item_ref = {}
|
||||
if self.blanket_order_type == "Selling":
|
||||
@@ -184,8 +232,16 @@ def apply_price_list(
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def update_status(status: str, name: str):
|
||||
blanket_order = frappe.get_doc("Blanket Order", name, check_permission="submit")
|
||||
blanket_order.update_status(status)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_order(source_name: str):
|
||||
blanket_order = frappe.get_doc("Blanket Order", source_name, check_permission="read")
|
||||
blanket_order.validate_can_be_ordered(today())
|
||||
doctype = frappe.flags.args.doctype
|
||||
|
||||
def update_doc(source_doc, target_doc, source_parent):
|
||||
@@ -219,7 +275,8 @@ def make_order(source_name: str):
|
||||
"parent": "blanket_order",
|
||||
},
|
||||
"postprocess": update_item,
|
||||
"condition": lambda item: not (flt(item.qty)) or (flt(item.qty) - flt(item.ordered_qty)) > 0,
|
||||
"condition": lambda item: not item.closed
|
||||
and (not flt(item.qty) or (flt(item.qty) - flt(item.ordered_qty)) > 0),
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -235,7 +292,7 @@ def validate_against_blanket_order(order_doc):
|
||||
order_data = {}
|
||||
|
||||
for item in order_doc.get("items"):
|
||||
if item.against_blanket_order and item.blanket_order:
|
||||
if item.blanket_order:
|
||||
if item.blanket_order in order_data:
|
||||
if item.item_code in order_data[item.blanket_order]:
|
||||
order_data[item.blanket_order][item.item_code] += item.qty
|
||||
@@ -251,8 +308,10 @@ def validate_against_blanket_order(order_doc):
|
||||
"blanket_order_allowance",
|
||||
)
|
||||
)
|
||||
for bo_name, item_data in order_data.items():
|
||||
bo_doc = frappe.get_doc("Blanket Order", bo_name)
|
||||
for bo_name, item_data in sorted(order_data.items()):
|
||||
bo_doc = frappe.get_doc("Blanket Order", bo_name, for_update=True)
|
||||
bo_doc.validate_can_be_ordered(order_doc.transaction_date)
|
||||
bo_doc.validate_items_are_open(list(item_data))
|
||||
for item in bo_doc.get("items"):
|
||||
if item.item_code in item_data:
|
||||
remaining_qty = item.qty - item.ordered_qty
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_months, flt, today
|
||||
from frappe.utils import add_days, add_months, flt, today
|
||||
|
||||
from erpnext import get_company_currency
|
||||
from erpnext.accounts.services.child_item_update import update_child_qty_rate
|
||||
from erpnext.controllers.item_close import update_closed_status
|
||||
from erpnext.controllers.queries import get_blanket_orders
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.get_item_details import get_blanket_order_details
|
||||
@@ -147,6 +150,167 @@ class TestBlanketOrder(ERPNextTestSuite):
|
||||
)
|
||||
self.assertRaises(frappe.ValidationError, so.submit)
|
||||
|
||||
def test_status_follows_close_reopen_and_cancel(self):
|
||||
bo = make_blanket_order(blanket_order_type="Selling")
|
||||
self.assertEqual(bo.status, "Submitted")
|
||||
|
||||
bo.update_status("Closed")
|
||||
self.assertEqual(frappe.db.get_value("Blanket Order", bo.name, "status"), "Closed")
|
||||
|
||||
bo.update_status("Submitted")
|
||||
self.assertEqual(frappe.db.get_value("Blanket Order", bo.name, "status"), "Submitted")
|
||||
|
||||
bo.cancel()
|
||||
self.assertEqual(frappe.db.get_value("Blanket Order", bo.name, "status"), "Cancelled")
|
||||
|
||||
def test_closed_blanket_order_cannot_be_ordered_against(self):
|
||||
bo = make_blanket_order(blanket_order_type="Purchasing", quantity=100)
|
||||
po = make_purchase_order_against(bo, qty=10)
|
||||
|
||||
bo.update_status("Closed")
|
||||
self.assertRaises(frappe.InvalidStatusError, make_order, bo.name)
|
||||
self.assertRaises(frappe.InvalidStatusError, po.save)
|
||||
|
||||
filters = {"company": bo.company, "blanket_order_type": "Purchasing", "item": bo.items[0].item_code}
|
||||
orders = get_blanket_orders("Blanket Order", "", "name", 0, 20, filters)
|
||||
self.assertNotIn(bo.name, [order[0] for order in orders])
|
||||
|
||||
details = get_blanket_order_details(
|
||||
{
|
||||
"blanket_order": bo.name,
|
||||
"company": bo.company,
|
||||
"currency": bo.currency,
|
||||
"supplier": bo.supplier,
|
||||
"doctype": "Purchase Order",
|
||||
"item_code": bo.items[0].item_code,
|
||||
"transaction_date": today(),
|
||||
}
|
||||
)
|
||||
self.assertFalse(details)
|
||||
|
||||
bo.update_status("Submitted")
|
||||
po.save()
|
||||
|
||||
def test_linked_row_is_checked_without_against_blanket_order(self):
|
||||
bo = make_blanket_order(blanket_order_type="Purchasing", quantity=100)
|
||||
po = make_purchase_order_against(bo, qty=10)
|
||||
po.items[0].against_blanket_order = 0
|
||||
|
||||
bo.update_status("Closed")
|
||||
self.assertRaises(frappe.InvalidStatusError, po.save)
|
||||
|
||||
def test_update_items_cannot_raise_qty_against_closed_blanket_order(self):
|
||||
bo = make_blanket_order(blanket_order_type="Purchasing", quantity=100)
|
||||
po = make_purchase_order_against(bo, qty=10)
|
||||
po.submit()
|
||||
bo.update_status("Closed")
|
||||
row = po.items[0]
|
||||
|
||||
self.assertRaises(frappe.InvalidStatusError, update_purchase_order_row_qty, po, row, 20)
|
||||
|
||||
update_purchase_order_row_qty(po, row, 5)
|
||||
self.assertEqual(frappe.db.get_value("Purchase Order Item", row.name, "qty"), 5)
|
||||
|
||||
def test_closing_every_row_closes_the_blanket_order(self):
|
||||
bo = make_blanket_order(blanket_order_type="Selling")
|
||||
row = bo.items[0].name
|
||||
|
||||
update_closed_status("Blanket Order", bo.name, [row], 1)
|
||||
self.assertEqual(frappe.db.get_value("Blanket Order", bo.name, "status"), "Closed")
|
||||
|
||||
bo.reload()
|
||||
self.assertRaises(frappe.ValidationError, bo.update_status, "Submitted")
|
||||
|
||||
update_closed_status("Blanket Order", bo.name, [row], 0)
|
||||
self.assertEqual(frappe.db.get_value("Blanket Order", bo.name, "status"), "Submitted")
|
||||
|
||||
def test_fully_ordered_row_cannot_be_closed(self):
|
||||
bo = make_blanket_order(blanket_order_type="Purchasing", quantity=10)
|
||||
make_purchase_order_against(bo, qty=10).submit()
|
||||
|
||||
self.assertRaises(
|
||||
frappe.ValidationError, update_closed_status, "Blanket Order", bo.name, [bo.items[0].name], 1
|
||||
)
|
||||
|
||||
def test_closed_row_is_skipped_when_ordering(self):
|
||||
bo = make_two_row_purchasing_blanket_order()
|
||||
po = make_purchase_order_against(bo, qty=10)
|
||||
update_closed_status("Blanket Order", bo.name, [bo.items[0].name], 1)
|
||||
|
||||
frappe.flags.args.doctype = "Purchase Order"
|
||||
self.assertEqual([row.item_code for row in make_order(bo.name).items], [bo.items[1].item_code])
|
||||
self.assertRaises(frappe.InvalidStatusError, po.save)
|
||||
|
||||
for row, is_listed in ((bo.items[0], False), (bo.items[1], True)):
|
||||
filters = {"company": bo.company, "blanket_order_type": "Purchasing", "item": row.item_code}
|
||||
orders = get_blanket_orders("Blanket Order", "", "name", 0, 20, filters)
|
||||
self.assertEqual(bo.name in [order[0] for order in orders], is_listed)
|
||||
|
||||
details = get_blanket_order_details(
|
||||
{
|
||||
"blanket_order": bo.name,
|
||||
"company": bo.company,
|
||||
"currency": bo.currency,
|
||||
"supplier": bo.supplier,
|
||||
"doctype": "Purchase Order",
|
||||
"item_code": row.item_code,
|
||||
"transaction_date": today(),
|
||||
}
|
||||
)
|
||||
self.assertEqual(bool(details), is_listed)
|
||||
|
||||
def test_update_items_cannot_raise_qty_on_closed_row(self):
|
||||
bo = make_two_row_purchasing_blanket_order()
|
||||
po = make_purchase_order_against(bo, qty=10)
|
||||
po.submit()
|
||||
update_closed_status("Blanket Order", bo.name, [bo.items[0].name], 1)
|
||||
row = po.items[0]
|
||||
|
||||
self.assertRaises(frappe.InvalidStatusError, update_purchase_order_row_qty, po, row, 20)
|
||||
|
||||
update_purchase_order_row_qty(po, row, 5)
|
||||
self.assertEqual(frappe.db.get_value("Purchase Order Item", row.name, "qty"), 5)
|
||||
|
||||
def test_update_items_cannot_raise_qty_after_blanket_order_expires(self):
|
||||
bo = make_blanket_order(blanket_order_type="Purchasing", quantity=100)
|
||||
po = make_purchase_order_against(bo, qty=10)
|
||||
po.submit()
|
||||
bo.db_set("to_date", add_days(po.transaction_date, -1))
|
||||
row = po.items[0]
|
||||
|
||||
self.assertRaisesRegex(
|
||||
frappe.ValidationError, "expired on", update_purchase_order_row_qty, po, row, 20
|
||||
)
|
||||
|
||||
update_purchase_order_row_qty(po, row, 5)
|
||||
self.assertEqual(frappe.db.get_value("Purchase Order Item", row.name, "qty"), 5)
|
||||
|
||||
def test_expired_blanket_order_cannot_be_ordered_against(self):
|
||||
bo = make_blanket_order(blanket_order_type="Purchasing", quantity=100)
|
||||
bo.db_set("to_date", today())
|
||||
|
||||
frappe.flags.args.doctype = "Purchase Order"
|
||||
po = make_order(bo.name)
|
||||
po.currency = get_company_currency(po.company)
|
||||
po.transaction_date = add_days(today(), 1)
|
||||
po.schedule_date = po.transaction_date
|
||||
self.assertRaisesRegex(frappe.ValidationError, "expired on", po.save)
|
||||
|
||||
po.transaction_date = today()
|
||||
po.save()
|
||||
|
||||
bo.db_set("to_date", add_days(today(), -1))
|
||||
self.assertRaisesRegex(frappe.ValidationError, "expired on", make_order, bo.name)
|
||||
|
||||
filters = {
|
||||
"company": bo.company,
|
||||
"blanket_order_type": "Purchasing",
|
||||
"item": bo.items[0].item_code,
|
||||
"transaction_date": today(),
|
||||
}
|
||||
orders = get_blanket_orders("Blanket Order", "", "name", 0, 20, filters)
|
||||
self.assertNotIn(bo.name, [order[0] for order in orders])
|
||||
|
||||
def test_party_item_code(self):
|
||||
item_doc = make_item("_Test Item 1 for Blanket Order")
|
||||
item_code = item_doc.name
|
||||
@@ -438,6 +602,38 @@ def make_blanket_order(**args):
|
||||
return bo
|
||||
|
||||
|
||||
def make_purchase_order_against(blanket_order, qty):
|
||||
frappe.flags.args.doctype = "Purchase Order"
|
||||
po = make_order(blanket_order.name)
|
||||
po.currency = get_company_currency(po.company)
|
||||
po.schedule_date = today()
|
||||
po.items[0].qty = qty
|
||||
return po
|
||||
|
||||
|
||||
def make_two_row_purchasing_blanket_order():
|
||||
second_item = make_item("_Test Blanket Order Second Item", {"is_stock_item": 1}).name
|
||||
bo = new_blanket_order(blanket_order_type="Purchasing")
|
||||
bo.append("items", {"item_code": "_Test Item", "qty": 100, "rate": 100})
|
||||
bo.append("items", {"item_code": second_item, "qty": 100, "rate": 100})
|
||||
bo.insert()
|
||||
bo.submit()
|
||||
return bo
|
||||
|
||||
|
||||
def update_purchase_order_row_qty(po, row, qty):
|
||||
payload = {
|
||||
"docname": row.name,
|
||||
"item_code": row.item_code,
|
||||
"qty": qty,
|
||||
"rate": row.rate,
|
||||
"uom": row.uom,
|
||||
"conversion_factor": row.conversion_factor,
|
||||
"schedule_date": str(row.schedule_date),
|
||||
}
|
||||
update_child_qty_rate("Purchase Order", json.dumps([payload]), po.name)
|
||||
|
||||
|
||||
def make_priced_blanket_order(
|
||||
blanket_order_type="Selling",
|
||||
company="_Test Company",
|
||||
|
||||
@@ -10,11 +10,13 @@
|
||||
"party_item_code",
|
||||
"column_break_3",
|
||||
"qty",
|
||||
"stock_uom",
|
||||
"price_list_rate",
|
||||
"base_price_list_rate",
|
||||
"rate",
|
||||
"base_rate",
|
||||
"ordered_qty",
|
||||
"closed",
|
||||
"section_break_7",
|
||||
"terms_and_conditions"
|
||||
],
|
||||
@@ -44,6 +46,16 @@
|
||||
"in_list_view": 1,
|
||||
"label": "Quantity"
|
||||
},
|
||||
{
|
||||
"columns": 1,
|
||||
"fetch_from": "item_code.stock_uom",
|
||||
"fieldname": "stock_uom",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Stock UOM",
|
||||
"options": "UOM",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "price_list_rate",
|
||||
"fieldtype": "Currency",
|
||||
@@ -85,6 +97,15 @@
|
||||
"no_copy": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "closed",
|
||||
"fieldtype": "Check",
|
||||
"label": "Closed",
|
||||
"no_copy": 1,
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_7",
|
||||
"fieldtype": "Section Break"
|
||||
@@ -103,7 +124,7 @@
|
||||
],
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-08-27 10:55:37.000000",
|
||||
"modified": "2026-09-23 21:30:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Blanket Order Item",
|
||||
|
||||
@@ -16,6 +16,7 @@ class BlanketOrderItem(Document):
|
||||
|
||||
base_price_list_rate: DF.Currency
|
||||
base_rate: DF.Currency
|
||||
closed: DF.Check
|
||||
item_code: DF.Link
|
||||
item_name: DF.Data | None
|
||||
ordered_qty: DF.Float
|
||||
@@ -26,6 +27,7 @@ class BlanketOrderItem(Document):
|
||||
price_list_rate: DF.Currency
|
||||
qty: DF.Float
|
||||
rate: DF.Currency
|
||||
stock_uom: DF.Link | None
|
||||
terms_and_conditions: DF.Text | None
|
||||
# end: auto-generated types
|
||||
|
||||
|
||||
@@ -728,24 +728,47 @@ erpnext.bom.BomController = class BomController extends erpnext.TransactionContr
|
||||
this.frm.events.update_cost(this.frm);
|
||||
}
|
||||
}
|
||||
|
||||
hour_rate(doc) {
|
||||
erpnext.bom.calculate_op_cost(doc);
|
||||
erpnext.bom.calculate_total(doc);
|
||||
}
|
||||
|
||||
time_in_mins(doc) {
|
||||
this.hour_rate(doc);
|
||||
}
|
||||
|
||||
bom_no(doc, cdt, cdn) {
|
||||
get_bom_material_detail(doc, cdt, cdn, false);
|
||||
}
|
||||
|
||||
is_default(doc) {
|
||||
if (doc.is_default) this.frm.set_value("is_active", 1);
|
||||
}
|
||||
|
||||
qty(doc) {
|
||||
erpnext.bom.calculate_rm_cost(doc);
|
||||
erpnext.bom.calculate_total(doc);
|
||||
}
|
||||
|
||||
rate(doc, cdt, cdn) {
|
||||
let d = locals[cdt][cdn];
|
||||
|
||||
if (d.bom_no) {
|
||||
frappe.msgprint(__("You cannot change the rate if BOM is mentioned against any Item."));
|
||||
get_bom_material_detail(doc, cdt, cdn, false);
|
||||
} else {
|
||||
erpnext.bom.calculate_rm_cost(doc);
|
||||
erpnext.bom.calculate_total(doc);
|
||||
}
|
||||
}
|
||||
|
||||
validate(doc) {
|
||||
erpnext.bom.update_cost(doc);
|
||||
}
|
||||
};
|
||||
|
||||
extend_cscript(cur_frm.cscript, new erpnext.bom.BomController({ frm: cur_frm }));
|
||||
|
||||
cur_frm.cscript.hour_rate = function (doc) {
|
||||
erpnext.bom.calculate_op_cost(doc);
|
||||
erpnext.bom.calculate_total(doc);
|
||||
};
|
||||
|
||||
cur_frm.cscript.time_in_mins = cur_frm.cscript.hour_rate;
|
||||
|
||||
cur_frm.cscript.bom_no = function (doc, cdt, cdn) {
|
||||
get_bom_material_detail(doc, cdt, cdn, false);
|
||||
};
|
||||
|
||||
cur_frm.cscript.is_default = function (doc) {
|
||||
if (doc.is_default) cur_frm.set_value("is_active", 1);
|
||||
};
|
||||
frappe.ui.form.set_controller("BOM", erpnext.bom.BomController);
|
||||
|
||||
var get_bom_material_detail = function (doc, cdt, cdn, secondary_items) {
|
||||
if (!doc.company) {
|
||||
@@ -790,23 +813,6 @@ var get_bom_material_detail = function (doc, cdt, cdn, secondary_items) {
|
||||
}
|
||||
};
|
||||
|
||||
cur_frm.cscript.qty = function (doc) {
|
||||
erpnext.bom.calculate_rm_cost(doc);
|
||||
erpnext.bom.calculate_total(doc);
|
||||
};
|
||||
|
||||
cur_frm.cscript.rate = function (doc, cdt, cdn) {
|
||||
var d = locals[cdt][cdn];
|
||||
|
||||
if (d.bom_no) {
|
||||
frappe.msgprint(__("You cannot change the rate if BOM is mentioned against any Item."));
|
||||
get_bom_material_detail(doc, cdt, cdn, false);
|
||||
} else {
|
||||
erpnext.bom.calculate_rm_cost(doc);
|
||||
erpnext.bom.calculate_total(doc);
|
||||
}
|
||||
};
|
||||
|
||||
erpnext.bom.update_cost = function (doc) {
|
||||
erpnext.bom.calculate_op_cost(doc);
|
||||
erpnext.bom.calculate_rm_cost(doc);
|
||||
@@ -864,8 +870,8 @@ erpnext.bom.calculate_rm_cost = function (doc) {
|
||||
total_rm_cost += amount;
|
||||
base_total_rm_cost += base_amount;
|
||||
}
|
||||
cur_frm.set_value("raw_material_cost", total_rm_cost);
|
||||
cur_frm.set_value("base_raw_material_cost", base_total_rm_cost);
|
||||
frappe.model.set_value(doc.doctype, doc.name, "raw_material_cost", total_rm_cost);
|
||||
frappe.model.set_value(doc.doctype, doc.name, "base_raw_material_cost", base_total_rm_cost);
|
||||
};
|
||||
|
||||
// Calculate Total Cost
|
||||
@@ -874,12 +880,8 @@ erpnext.bom.calculate_total = function (doc) {
|
||||
var base_total_cost =
|
||||
flt(doc.base_operating_cost) + flt(doc.base_raw_material_cost) - flt(doc.base_secondary_items_cost);
|
||||
|
||||
cur_frm.set_value("total_cost", total_cost);
|
||||
cur_frm.set_value("base_total_cost", base_total_cost);
|
||||
};
|
||||
|
||||
cur_frm.cscript.validate = function (doc) {
|
||||
erpnext.bom.update_cost(doc);
|
||||
frappe.model.set_value(doc.doctype, doc.name, "total_cost", total_cost);
|
||||
frappe.model.set_value(doc.doctype, doc.name, "base_total_cost", base_total_cost);
|
||||
};
|
||||
|
||||
frappe.ui.form.on("BOM Operation", "operation", function (frm, cdt, cdn) {
|
||||
|
||||
@@ -273,4 +273,4 @@ erpnext.bom.BomConfigurator = class BomConfigurator extends erpnext.TransactionC
|
||||
}
|
||||
};
|
||||
|
||||
extend_cscript(cur_frm.cscript, new erpnext.bom.BomConfigurator({ frm: cur_frm }));
|
||||
frappe.ui.form.set_controller("BOM Creator", erpnext.bom.BomConfigurator);
|
||||
|
||||
@@ -12,13 +12,14 @@
|
||||
"job_card_dashboard",
|
||||
"section_break_fsba",
|
||||
"work_order",
|
||||
"operation",
|
||||
"for_quantity",
|
||||
"workstation",
|
||||
"column_break_uqjq",
|
||||
"stock_uom",
|
||||
"production_item",
|
||||
"bom_no",
|
||||
"column_break_qrpg",
|
||||
"for_quantity",
|
||||
"column_break_yecz",
|
||||
"stock_uom",
|
||||
"employee",
|
||||
"section_break_oisd",
|
||||
"company",
|
||||
"naming_series",
|
||||
@@ -26,30 +27,27 @@
|
||||
"posting_date",
|
||||
"semi_finished_good__finished_good_section",
|
||||
"finished_good",
|
||||
"column_break_mcnb",
|
||||
"semi_fg_bom",
|
||||
"column_break_mcnb",
|
||||
"batch_split",
|
||||
"weight_per_piece",
|
||||
"production_section",
|
||||
"workstation_type",
|
||||
"source_warehouse",
|
||||
"column_break_12",
|
||||
"skip_material_transfer",
|
||||
"backflush_from_wip_warehouse",
|
||||
"track_semi_finished_goods",
|
||||
"wip_warehouse",
|
||||
"target_warehouse",
|
||||
"section_break_folk",
|
||||
"pending_qty",
|
||||
"column_break_cyjw",
|
||||
"process_loss_qty",
|
||||
"total_completed_qty",
|
||||
"section_break_wpjf",
|
||||
"transferred_qty",
|
||||
"column_break_lgte",
|
||||
"manufactured_qty",
|
||||
"production_section",
|
||||
"operation",
|
||||
"source_warehouse",
|
||||
"wip_warehouse",
|
||||
"skip_material_transfer",
|
||||
"backflush_from_wip_warehouse",
|
||||
"column_break_12",
|
||||
"workstation_type",
|
||||
"workstation",
|
||||
"target_warehouse",
|
||||
"employee",
|
||||
"transferred_qty",
|
||||
"raw_material_tab",
|
||||
"section_break_8",
|
||||
"items",
|
||||
"quality_inspection_section",
|
||||
@@ -64,7 +62,6 @@
|
||||
"expected_end_date",
|
||||
"section_break_rzeo",
|
||||
"scheduled_time_logs",
|
||||
"timing_detail",
|
||||
"section_break_13",
|
||||
"actual_start_date",
|
||||
"total_time_in_mins",
|
||||
@@ -87,7 +84,6 @@
|
||||
"requested_qty",
|
||||
"is_paused",
|
||||
"is_subcontracted",
|
||||
"track_semi_finished_goods",
|
||||
"column_break_20",
|
||||
"project",
|
||||
"remarks",
|
||||
@@ -165,7 +161,7 @@
|
||||
"fieldtype": "Float",
|
||||
"in_list_view": 1,
|
||||
"in_preview": 1,
|
||||
"label": "Qty To Manufacture"
|
||||
"label": "Qty to Manufacture"
|
||||
},
|
||||
{
|
||||
"fieldname": "stock_uom",
|
||||
@@ -182,11 +178,6 @@
|
||||
"mandatory_depends_on": "eval:!doc.finished_good || doc.skip_material_transfer === 0 || (doc.skip_material_transfer && doc.backflush_from_wip_warehouse)",
|
||||
"options": "Warehouse"
|
||||
},
|
||||
{
|
||||
"fieldname": "timing_detail",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Actual Time"
|
||||
},
|
||||
{
|
||||
"allow_bulk_edit": 1,
|
||||
"allow_on_submit": 1,
|
||||
@@ -198,7 +189,8 @@
|
||||
{
|
||||
"fieldname": "section_break_13",
|
||||
"fieldtype": "Section Break",
|
||||
"hide_border": 1
|
||||
"hide_border": 1,
|
||||
"label": "Actual Time"
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
@@ -207,7 +199,7 @@
|
||||
"fieldname": "total_completed_qty",
|
||||
"fieldtype": "Float",
|
||||
"in_preview": 1,
|
||||
"label": "Total Completed Qty",
|
||||
"label": "Total Completed qty",
|
||||
"non_negative": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
@@ -223,8 +215,7 @@
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_8",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Raw Materials"
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "items",
|
||||
@@ -306,7 +297,7 @@
|
||||
{
|
||||
"fieldname": "production_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Operation & Materials"
|
||||
"label": "Warehouse"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_12",
|
||||
@@ -462,7 +453,7 @@
|
||||
"depends_on": "process_loss_qty",
|
||||
"fieldname": "process_loss_qty",
|
||||
"fieldtype": "Float",
|
||||
"label": "Process Loss Qty",
|
||||
"label": "Process Loss qty",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
@@ -474,6 +465,7 @@
|
||||
{
|
||||
"fieldname": "scheduled_time_section",
|
||||
"fieldtype": "Section Break",
|
||||
"hide_border": 1,
|
||||
"label": "Scheduled Time"
|
||||
},
|
||||
{
|
||||
@@ -558,6 +550,7 @@
|
||||
"default": "0",
|
||||
"fieldname": "batch_split",
|
||||
"fieldtype": "Check",
|
||||
"hidden": 1,
|
||||
"label": "Batch Split",
|
||||
"read_only": 1
|
||||
},
|
||||
@@ -573,7 +566,7 @@
|
||||
"depends_on": "eval:!doc.is_corrective_job_card",
|
||||
"fieldname": "is_subcontracted",
|
||||
"fieldtype": "Check",
|
||||
"label": " Is Subcontracted",
|
||||
"label": "Is Subcontracted",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
@@ -591,7 +584,7 @@
|
||||
{
|
||||
"fieldname": "scheduled_time_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Scheduled Time"
|
||||
"label": "Time logs"
|
||||
},
|
||||
{
|
||||
"depends_on": "finished_good",
|
||||
@@ -612,12 +605,13 @@
|
||||
"depends_on": "eval:doc.finished_good && doc.skip_material_transfer === 1",
|
||||
"fieldname": "backflush_from_wip_warehouse",
|
||||
"fieldtype": "Check",
|
||||
"label": "Backflush Materials From WIP Warehouse"
|
||||
"label": "Backflush Materials from WIP Warehouse"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "is_paused",
|
||||
"fieldtype": "Check",
|
||||
"hidden": 1,
|
||||
"label": "Is Paused",
|
||||
"read_only": 1
|
||||
},
|
||||
@@ -627,7 +621,9 @@
|
||||
"fetch_from": "work_order.track_semi_finished_goods",
|
||||
"fieldname": "track_semi_finished_goods",
|
||||
"fieldtype": "Check",
|
||||
"label": "Track Semi Finished Goods"
|
||||
"hidden": 1,
|
||||
"label": "Track Semi Finished Goods",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_jcmx",
|
||||
@@ -664,26 +660,19 @@
|
||||
{
|
||||
"fieldname": "section_break_folk",
|
||||
"fieldtype": "Section Break",
|
||||
"hide_border": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_cyjw",
|
||||
"fieldtype": "Column Break"
|
||||
"hide_border": 1,
|
||||
"label": "Progress"
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"fieldname": "pending_qty",
|
||||
"fieldtype": "Float",
|
||||
"label": "Pending Qty"
|
||||
"label": "Pending qty"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_wpjf",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_lgte",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "job_card_dashboard",
|
||||
"fieldtype": "HTML"
|
||||
@@ -696,14 +685,6 @@
|
||||
"fieldname": "column_break_uqjq",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_qrpg",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_yecz",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_smqo",
|
||||
"fieldtype": "Section Break",
|
||||
@@ -712,13 +693,18 @@
|
||||
{
|
||||
"fieldname": "section_break_fsba",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "raw_material_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Raw Materials"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"icon": "clipboard-list",
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-08-28 12:00:00.000000",
|
||||
"modified": "2026-09-23 16:35:26.917025",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Job Card",
|
||||
|
||||
@@ -88,8 +88,8 @@ class JobCard(Document):
|
||||
amended_from: DF.Link | None
|
||||
backflush_from_wip_warehouse: DF.Check
|
||||
barcode: DF.Barcode | None
|
||||
batch_split: DF.Check
|
||||
batch_no: DF.Link | None
|
||||
batch_split: DF.Check
|
||||
bom_no: DF.Link | None
|
||||
company: DF.Link
|
||||
employee: DF.TableMultiSelect[JobCardTimeLog]
|
||||
@@ -145,9 +145,9 @@ class JobCard(Document):
|
||||
time_required: DF.Float
|
||||
total_completed_qty: DF.Float
|
||||
total_time_in_mins: DF.Float
|
||||
weight_per_piece: DF.Float
|
||||
track_semi_finished_goods: DF.Check
|
||||
transferred_qty: DF.Float
|
||||
weight_per_piece: DF.Float
|
||||
wip_warehouse: DF.Link | None
|
||||
work_order: DF.Link
|
||||
workstation: DF.Link
|
||||
|
||||
@@ -9,10 +9,18 @@
|
||||
"field_order": [
|
||||
"naming_series",
|
||||
"company",
|
||||
"get_items_from",
|
||||
"column_break1",
|
||||
"status",
|
||||
"posting_date",
|
||||
"reserve_stock",
|
||||
"other_details",
|
||||
"total_planned_qty",
|
||||
"total_produced_qty",
|
||||
"column_break_32",
|
||||
"warehouses",
|
||||
"amended_from",
|
||||
"demand_tab",
|
||||
"get_items_from",
|
||||
"filters",
|
||||
"item_code",
|
||||
"customer",
|
||||
@@ -31,11 +39,13 @@
|
||||
"material_request_detail",
|
||||
"get_material_request",
|
||||
"material_requests",
|
||||
"finished_items_tab",
|
||||
"select_items_to_manufacture_section",
|
||||
"get_items",
|
||||
"po_items",
|
||||
"section_break_25",
|
||||
"prod_plan_references",
|
||||
"sub_assemblies_tab",
|
||||
"section_break_24",
|
||||
"sub_assembly_warehouse",
|
||||
"column_break_igxl",
|
||||
@@ -45,6 +55,7 @@
|
||||
"get_sub_assembly_items",
|
||||
"section_break_g4ip",
|
||||
"sub_assembly_items",
|
||||
"raw_material_tab",
|
||||
"download_materials_request_plan_section_section",
|
||||
"download_materials_required",
|
||||
"material_request_planning",
|
||||
@@ -59,14 +70,7 @@
|
||||
"get_items_for_mr",
|
||||
"transfer_materials",
|
||||
"section_break_27",
|
||||
"mr_items",
|
||||
"other_details",
|
||||
"total_planned_qty",
|
||||
"total_produced_qty",
|
||||
"column_break_32",
|
||||
"status",
|
||||
"warehouses",
|
||||
"amended_from"
|
||||
"mr_items"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@@ -105,7 +109,6 @@
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"collapsible_depends_on": "eval: doc.__islocal",
|
||||
"depends_on": "eval: doc.get_items_from",
|
||||
"fieldname": "filters",
|
||||
"fieldtype": "Section Break",
|
||||
@@ -157,7 +160,7 @@
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"collapsible_depends_on": "eval: doc.__islocal",
|
||||
"collapsible_depends_on": "eval: doc.get_items_from === \"Sales Order\"",
|
||||
"depends_on": "eval: doc.get_items_from == \"Sales Order\"",
|
||||
"fieldname": "sales_orders_detail",
|
||||
"fieldtype": "Section Break",
|
||||
@@ -177,7 +180,7 @@
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"collapsible_depends_on": "eval: doc.__islocal",
|
||||
"collapsible_depends_on": "eval: doc.get_items_from === \"Material Request\"",
|
||||
"depends_on": "eval: doc.get_items_from == \"Material Request\"",
|
||||
"fieldname": "material_request_detail",
|
||||
"fieldtype": "Section Break",
|
||||
@@ -235,7 +238,7 @@
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"description": "If enabled, formula for <b>Required Qty</b>: <br>\nRequired Qty (BOM) - <a href=\"https://docs.frappe.io/erpnext/user/manual/en/projected-quantity\">Projected Qty</a>. <br> This helps avoid over-ordering.",
|
||||
"description": "If enabled, formula for <b>Required Qty</b>: <br>\nRequired Qty (BOM) - <a href=\"https://docs.frappe.io/erpnext/user/manual/en/projected-quantity\" rel=\"noopener noreferrer\">Projected Qty</a>. <br> This helps avoid over-ordering.",
|
||||
"fieldname": "ignore_existing_ordered_qty",
|
||||
"fieldtype": "Check",
|
||||
"label": "Consider Projected Qty in Calculation (RM)"
|
||||
@@ -364,7 +367,7 @@
|
||||
"fieldname": "prod_plan_references",
|
||||
"fieldtype": "Table",
|
||||
"hidden": 1,
|
||||
"label": "Production Plan Item Reference",
|
||||
"label": "Sales Order Reference",
|
||||
"options": "Production Plan Item Reference"
|
||||
},
|
||||
{
|
||||
@@ -417,7 +420,7 @@
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"description": "If enabled, formula for <b>Qty to Order</b>: <br>\nRequired Qty (BOM) - <a href=\"https://docs.frappe.io/erpnext/user/manual/en/projected-quantity\">Projected Qty</a>. <br> This helps avoid over-ordering.",
|
||||
"description": "If enabled, formula for <b>Qty to Order</b>: <br>\nRequired Qty (BOM) - <a href=\"https://docs.frappe.io/erpnext/user/manual/en/projected-quantity\" rel=\"noopener noreferrer\">Projected Qty</a>. <br> This helps avoid over-ordering.",
|
||||
"fieldname": "skip_available_sub_assembly_item",
|
||||
"fieldtype": "Check",
|
||||
"label": "Consider Projected Qty in Calculation"
|
||||
@@ -454,6 +457,26 @@
|
||||
"fieldname": "no_of_shifts",
|
||||
"fieldtype": "Int",
|
||||
"label": "No of Shifts"
|
||||
},
|
||||
{
|
||||
"fieldname": "demand_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Demand"
|
||||
},
|
||||
{
|
||||
"fieldname": "finished_items_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Finished Items"
|
||||
},
|
||||
{
|
||||
"fieldname": "sub_assemblies_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Sub Assemblies"
|
||||
},
|
||||
{
|
||||
"fieldname": "raw_material_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Raw Material"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
@@ -461,7 +484,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-08-12 00:00:00.000000",
|
||||
"modified": "2026-09-15 13:02:23.654005",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Production Plan",
|
||||
|
||||
@@ -101,6 +101,7 @@ class ProductionPlan(Document):
|
||||
material_requests: DF.Table[ProductionPlanMaterialRequest]
|
||||
mr_items: DF.Table[MaterialRequestPlanItem]
|
||||
naming_series: DF.Literal["MFG-PP-.YYYY.-"]
|
||||
no_of_shifts: DF.Int
|
||||
po_items: DF.Table[ProductionPlanItem]
|
||||
posting_date: DF.Date
|
||||
prod_plan_references: DF.Table[ProductionPlanItemReference]
|
||||
|
||||
@@ -1623,6 +1623,22 @@ class TestWorkOrder(ERPNextTestSuite):
|
||||
available_by_attribution = {row.original_item: row.qty for row in return_entry.items}
|
||||
self.assertEqual(available_by_attribution, {None: 1.0, "_Test Item Home Desktop 100": 2.0})
|
||||
|
||||
@ERPNextTestSuite.change_settings("Manufacturing Settings", {"material_consumption": 1})
|
||||
def test_return_excludes_material_consumption_entry(self):
|
||||
rm_item = make_item(properties={"is_stock_item": 1}).name
|
||||
fg_item = make_item(properties={"is_stock_item": 1}).name
|
||||
bom = make_bom(item=fg_item, raw_materials=[rm_item], rm_qty=1)
|
||||
test_stock_entry.make_stock_entry(item_code=rm_item, target="Stores - _TC", qty=10, basic_rate=100)
|
||||
|
||||
work_order = make_wo_order_test_record(
|
||||
production_item=fg_item, bom_no=bom.name, qty=10, source_warehouse="Stores - _TC"
|
||||
)
|
||||
frappe.get_doc(make_stock_entry(work_order.name, "Material Transfer for Manufacture", 10)).submit()
|
||||
frappe.get_doc(make_stock_entry(work_order.name, "Material Consumption for Manufacture", 6)).submit()
|
||||
|
||||
return_entry = make_stock_return_entry(work_order.name)
|
||||
self.assertEqual([row.qty for row in return_entry.items], [4])
|
||||
|
||||
def test_status_in_process_when_only_one_required_item_transferred(self):
|
||||
"""Stock Entry created from a Pick List that picked only one of the required items:
|
||||
min-fraction keeps material_transferred_for_manufacturing at 0, but the work order must
|
||||
|
||||
@@ -179,7 +179,7 @@ frappe.ui.form.on("Work Order", {
|
||||
},
|
||||
|
||||
refresh: function (frm) {
|
||||
erpnext.toggle_naming_series();
|
||||
erpnext.toggle_naming_series(frm);
|
||||
erpnext.work_order.set_custom_buttons(frm);
|
||||
frm.set_intro("");
|
||||
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
// rename this file from _test_[name] to test_[name] to activate
|
||||
// and remove above this line
|
||||
|
||||
QUnit.test("test: Workstation", function (assert) {
|
||||
let done = assert.async();
|
||||
|
||||
// number of asserts
|
||||
assert.expect(1);
|
||||
|
||||
frappe.run_serially("Workstation", [
|
||||
// insert a new Workstation
|
||||
() =>
|
||||
frappe.tests.make([
|
||||
// values to be set
|
||||
{ key: "value" },
|
||||
]),
|
||||
() => {
|
||||
assert.equal(cur_frm.doc.key, "value");
|
||||
},
|
||||
() => done(),
|
||||
]);
|
||||
});
|
||||
@@ -29,7 +29,7 @@ frappe.ui.form.on("Workstation", {
|
||||
method: "erpnext.manufacturing.doctype.workstation.workstation.get_default_holiday_list",
|
||||
callback: function (r) {
|
||||
if (!r.exe && r.message) {
|
||||
cur_frm.set_value("holiday_list", r.message);
|
||||
frm.set_value("holiday_list", r.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -253,6 +253,7 @@ def submit_quality_inspection(job_card: str, readings: str | None = None):
|
||||
qi.item_code = jc.finished_good or jc.production_item
|
||||
qi.bom_no = jc.bom_no
|
||||
qi.quality_inspection_template = template
|
||||
qi.sample_size = 1
|
||||
qi.inspected_by = frappe.session.user
|
||||
qi.get_item_specification_details() # load readings from the template
|
||||
|
||||
|
||||
@@ -1224,6 +1224,13 @@ def get_item_details(item_code, company):
|
||||
if default_data:
|
||||
data.update(default_data)
|
||||
|
||||
if not data.get("default_supplier"):
|
||||
# fall back to the item's Item Group default supplier (mirrors BuyingController)
|
||||
item_group = frappe.db.get_value("Item", item_code, "item_group")
|
||||
data.default_supplier = frappe.db.get_value(
|
||||
"Item Default", {"parent": item_group, "company": company}, "default_supplier"
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
@@ -1333,6 +1340,7 @@ def make_order(selected_rows: str | list, company: str, warehouse: str | None =
|
||||
work_orders = []
|
||||
covered_rows = 0
|
||||
missing_bom = []
|
||||
missing_supplier = []
|
||||
for row in selected_rows:
|
||||
row = frappe._dict(row)
|
||||
# what is left to order once stock and the orders already placed are counted. rounding
|
||||
@@ -1343,7 +1351,10 @@ def make_order(selected_rows: str | list, company: str, warehouse: str | None =
|
||||
continue
|
||||
|
||||
if row.type_of_material == "Purchase":
|
||||
purchase_orders.setdefault((row.default_supplier, row.release_date), []).append(row)
|
||||
if row.default_supplier:
|
||||
purchase_orders.setdefault((row.default_supplier, row.release_date), []).append(row)
|
||||
elif row.item_code not in missing_supplier:
|
||||
missing_supplier.append(row.item_code)
|
||||
|
||||
if row.type_of_material == "Manufacture":
|
||||
if row.bom_no:
|
||||
@@ -1354,6 +1365,9 @@ def make_order(selected_rows: str | list, company: str, warehouse: str | None =
|
||||
if missing_bom:
|
||||
frappe.throw(_("Default BOM for {0} not found").format(", ".join(missing_bom)))
|
||||
|
||||
if missing_supplier:
|
||||
frappe.throw(_("Default Supplier for {0} not found").format(", ".join(missing_supplier)))
|
||||
|
||||
if not purchase_orders and not work_orders:
|
||||
frappe.msgprint(
|
||||
_("Nothing to order, the selected rows are already covered by stock or existing orders")
|
||||
|
||||
@@ -280,6 +280,52 @@ class TestMaterialRequirementsPlanningReport(ERPNextTestSuite):
|
||||
self.assertFalse(frappe.get_all("Work Order", filters={"mps": plan.mps}, pluck="name"))
|
||||
self.assertFalse(frappe.get_all("Purchase Order", filters={"mps": plan.mps}, pluck="name"))
|
||||
|
||||
def test_make_order_rejects_purchase_item_without_supplier(self):
|
||||
plan = make_mps_item(
|
||||
self,
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"is_purchase_item": 1,
|
||||
"item_defaults": [{"company": COMPANY, "default_warehouse": WAREHOUSE}],
|
||||
},
|
||||
)
|
||||
self.assertEqual(plan.row.type_of_material, "Purchase")
|
||||
self.assertFalse(plan.row.default_supplier)
|
||||
|
||||
with self.assertRaises(frappe.ValidationError) as ctx:
|
||||
make_order([plan.row], COMPANY, warehouse=WAREHOUSE, mps=plan.mps)
|
||||
|
||||
self.assertIn("Default Supplier", str(ctx.exception))
|
||||
self.assertFalse(frappe.get_all("Purchase Order", filters={"mps": plan.mps}, pluck="name"))
|
||||
|
||||
def test_make_order_falls_back_to_item_group_default_supplier(self):
|
||||
group = "_Test MRP Item Group With Supplier"
|
||||
if not frappe.db.exists("Item Group", group):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Item Group",
|
||||
"item_group_name": group,
|
||||
"parent_item_group": "All Item Groups",
|
||||
"item_group_defaults": [{"company": COMPANY, "default_supplier": SUPPLIER}],
|
||||
}
|
||||
).insert()
|
||||
|
||||
plan = make_mps_item(
|
||||
self,
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"is_purchase_item": 1,
|
||||
"item_group": group,
|
||||
"item_defaults": [{"company": COMPANY, "default_warehouse": WAREHOUSE}],
|
||||
},
|
||||
)
|
||||
self.assertEqual(plan.row.type_of_material, "Purchase")
|
||||
self.assertEqual(plan.row.default_supplier, SUPPLIER)
|
||||
|
||||
make_order([plan.row], COMPANY, warehouse=WAREHOUSE, mps=plan.mps)
|
||||
|
||||
self.assertEqual(get_created_order(plan.mps, "Purchase Order").supplier, SUPPLIER)
|
||||
|
||||
def test_make_order_uses_the_bom_passed_on_the_row(self):
|
||||
plan = make_mps_item(
|
||||
self,
|
||||
|
||||
@@ -172,7 +172,8 @@ def get_column(filters):
|
||||
},
|
||||
{
|
||||
"label": _("Document Type"),
|
||||
"fieldtype": "Data",
|
||||
"fieldtype": "Link",
|
||||
"options": "DocType",
|
||||
"fieldname": "document_type",
|
||||
"width": 120,
|
||||
},
|
||||
|
||||
@@ -97,7 +97,13 @@ def get_columns(filters):
|
||||
"fieldtype": "Data",
|
||||
"width": 110,
|
||||
},
|
||||
{"label": _("Document Type"), "fieldname": "reference_type", "fieldtype": "Data", "width": 90},
|
||||
{
|
||||
"label": _("Document Type"),
|
||||
"fieldname": "reference_type",
|
||||
"fieldtype": "Link",
|
||||
"options": "DocType",
|
||||
"width": 90,
|
||||
},
|
||||
{
|
||||
"label": _("Document Name"),
|
||||
"fieldname": "reference_name",
|
||||
|
||||
@@ -523,3 +523,7 @@ erpnext.patches.v16_0.set_supplier_quotation_order_status
|
||||
erpnext.patches.v16_0.recalculate_holiday_list_totals
|
||||
erpnext.patches.v16_0.recalculate_returned_delivery_note_billing_status
|
||||
erpnext.patches.v16_0.rename_component_cost_valuation_type
|
||||
erpnext.patches.v16_0.enable_serial_no_wise_valuation
|
||||
erpnext.patches.v16_0.add_voucher_index_to_repost_item_valuation
|
||||
erpnext.patches.v16_0.set_blanket_order_status
|
||||
erpnext.patches.v16_0.set_blanket_order_item_stock_uom
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
# on_doctype_update only runs when the DocType itself is re-synced, so existing sites need this.
|
||||
frappe.db.add_index("Repost Item Valuation", ["voucher_no", "voucher_type", "status"], "voucher_status")
|
||||
11
erpnext/patches/v16_0/enable_serial_no_wise_valuation.py
Normal file
11
erpnext/patches/v16_0/enable_serial_no_wise_valuation.py
Normal file
@@ -0,0 +1,11 @@
|
||||
import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
item = frappe.qb.DocType("Item")
|
||||
|
||||
(
|
||||
frappe.qb.update(item)
|
||||
.set(item.use_serial_no_wise_valuation, 1)
|
||||
.where((item.has_serial_no == 1) & (item.use_serial_no_wise_valuation == 0))
|
||||
).run()
|
||||
12
erpnext/patches/v16_0/set_blanket_order_item_stock_uom.py
Normal file
12
erpnext/patches/v16_0/set_blanket_order_item_stock_uom.py
Normal file
@@ -0,0 +1,12 @@
|
||||
import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
item_codes = set(
|
||||
frappe.get_all("Blanket Order Item", filters={"stock_uom": ("is", "not set")}, pluck="item_code")
|
||||
)
|
||||
for item_code in item_codes:
|
||||
stock_uom = frappe.get_cached_value("Item", item_code, "stock_uom")
|
||||
frappe.db.set_value(
|
||||
"Blanket Order Item", {"item_code": item_code}, "stock_uom", stock_uom, update_modified=False
|
||||
)
|
||||
8
erpnext/patches/v16_0/set_blanket_order_status.py
Normal file
8
erpnext/patches/v16_0/set_blanket_order_status.py
Normal file
@@ -0,0 +1,8 @@
|
||||
import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
for docstatus, status in ((0, "Draft"), (1, "Submitted"), (2, "Cancelled")):
|
||||
frappe.db.set_value(
|
||||
"Blanket Order", {"docstatus": docstatus}, "status", status, update_modified=False
|
||||
)
|
||||
@@ -1 +1,5 @@
|
||||
cur_frm.add_fetch("employee", "employee_name", "employee_name");
|
||||
frappe.ui.form.on("Activity Cost", {
|
||||
setup: function (frm) {
|
||||
frm.add_fetch("employee", "employee_name", "employee_name");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
QUnit.test("test project", function (assert) {
|
||||
assert.expect(6);
|
||||
let done = assert.async();
|
||||
var task_title = ["Documentation", "Implementation", "Testing"];
|
||||
|
||||
// To create a timesheet with different tasks and costs
|
||||
let timesheet = (title, start_time, end_time, bill_rate, cost_rate) => {
|
||||
return frappe.run_serially([
|
||||
() => frappe.db.get_value("Task", { subject: title }, "name"),
|
||||
(task) => {
|
||||
// Creating timesheet for a project
|
||||
return frappe.tests.make("Timesheet", [
|
||||
{
|
||||
time_logs: [
|
||||
[
|
||||
{ activity_type: "Communication" },
|
||||
{ from_time: start_time },
|
||||
{ to_time: end_time },
|
||||
{ hours: 2 },
|
||||
{ project: "Test App" },
|
||||
{ task: task.name },
|
||||
{ billable: "1" },
|
||||
{ billing_rate: bill_rate },
|
||||
{ costing_rate: cost_rate },
|
||||
],
|
||||
],
|
||||
},
|
||||
]);
|
||||
},
|
||||
// To check if a correct billable and costing amount is calculated for every task
|
||||
() => {
|
||||
if (title === "Documentation") {
|
||||
assert.ok(
|
||||
cur_frm.get_field("total_billable_amount").get_value() == 20,
|
||||
"Billable amount for Documentation task is correctly calculated"
|
||||
);
|
||||
assert.ok(
|
||||
cur_frm.get_field("total_costing_amount").get_value() == 16,
|
||||
"Costing amount for Documentation task is correctly calculated"
|
||||
);
|
||||
}
|
||||
if (title === "Implementation") {
|
||||
assert.ok(
|
||||
cur_frm.get_field("total_billable_amount").get_value() == 40,
|
||||
"Billable amount for Implementation task is correctly calculated"
|
||||
);
|
||||
assert.ok(
|
||||
cur_frm.get_field("total_costing_amount").get_value() == 32,
|
||||
"Costing amount for Implementation task is correctly calculated"
|
||||
);
|
||||
}
|
||||
if (title === "Testing") {
|
||||
assert.ok(
|
||||
cur_frm.get_field("total_billable_amount").get_value() == 60,
|
||||
"Billable amount for Testing task correctly calculated"
|
||||
);
|
||||
assert.ok(
|
||||
cur_frm.get_field("total_costing_amount").get_value() == 50,
|
||||
"Costing amount for Testing task is correctly calculated"
|
||||
);
|
||||
}
|
||||
},
|
||||
]);
|
||||
};
|
||||
frappe.run_serially([
|
||||
() => {
|
||||
// Creating project with task
|
||||
return frappe.tests.make("Project", [
|
||||
{ project_name: "Test App" },
|
||||
{ expected_start_date: "2017-07-22" },
|
||||
{ expected_end_date: "2017-09-22" },
|
||||
{ estimated_costing: "10,000.00" },
|
||||
{
|
||||
tasks: [
|
||||
[
|
||||
{ title: "Documentation" },
|
||||
{ start_date: "2017-07-24" },
|
||||
{ end_date: "2017-07-31" },
|
||||
{ description: "To make a proper documentation defining requirements etc" },
|
||||
],
|
||||
[
|
||||
{ title: "Implementation" },
|
||||
{ start_date: "2017-08-01" },
|
||||
{ end_date: "2017-08-01" },
|
||||
{ description: "Writing algorithms and to code the functionalities" },
|
||||
],
|
||||
[
|
||||
{ title: "Testing" },
|
||||
{ start_date: "2017-08-01" },
|
||||
{ end_date: "2017-08-15" },
|
||||
{ description: "To make the test cases and test the functionalities" },
|
||||
],
|
||||
],
|
||||
},
|
||||
]);
|
||||
},
|
||||
// Creating Timesheet with different tasks
|
||||
() => timesheet(task_title[0], "2017-07-24 13:00:00", "2017-07-24 13:00:00", 10, 8),
|
||||
() => timesheet(task_title[1], "2017-07-25 13:00:00", "2017-07-25 15:00:00", 20, 16),
|
||||
() => timesheet(task_title[2], "2017-07-26 13:00:00", "2017-07-26 15:00:00", 30, 25),
|
||||
() => done(),
|
||||
]);
|
||||
});
|
||||
@@ -1,4 +1,49 @@
|
||||
frappe.provide("erpnext.accounts.bank_reconciliation");
|
||||
frappe.provide("erpnext.accounts.bank_reconciliation.voucher_types");
|
||||
|
||||
// other apps can register more "Create Voucher" types: { get_fields(dm), is_applicable(bank_transaction), create(dm, values, allow_edit) }
|
||||
erpnext.accounts.bank_reconciliation.voucher_types = {
|
||||
"Payment Entry": {
|
||||
create(dialog_manager, values, allow_edit) {
|
||||
return frappe.xcall(
|
||||
"erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_payment_entry_bts",
|
||||
{
|
||||
bank_transaction_name: dialog_manager.bank_transaction.name,
|
||||
reference_number: values.reference_number,
|
||||
reference_date: values.reference_date,
|
||||
party_type: values.party_type,
|
||||
party: values.party,
|
||||
posting_date: values.posting_date,
|
||||
mode_of_payment: values.mode_of_payment,
|
||||
project: values.project,
|
||||
cost_center: values.cost_center,
|
||||
allow_edit: allow_edit,
|
||||
company_bank_account: values?.bank_account || dialog_manager?.bank_account,
|
||||
}
|
||||
);
|
||||
},
|
||||
},
|
||||
"Journal Entry": {
|
||||
create(dialog_manager, values, allow_edit) {
|
||||
return frappe.xcall(
|
||||
"erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_journal_entry_bts",
|
||||
{
|
||||
bank_transaction_name: dialog_manager.bank_transaction.name,
|
||||
reference_number: values.reference_number,
|
||||
reference_date: values.reference_date,
|
||||
party_type: values.party_type,
|
||||
party: values.party,
|
||||
posting_date: values.posting_date,
|
||||
mode_of_payment: values.mode_of_payment,
|
||||
entry_type: values.journal_entry_type,
|
||||
second_account: values.second_account,
|
||||
allow_edit: allow_edit,
|
||||
}
|
||||
);
|
||||
},
|
||||
},
|
||||
...erpnext.accounts.bank_reconciliation.voucher_types,
|
||||
};
|
||||
|
||||
erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager {
|
||||
constructor(
|
||||
@@ -49,6 +94,7 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager {
|
||||
this.bank_transaction = r.message;
|
||||
r.message.payment_entry = 1;
|
||||
r.message.journal_entry = 1;
|
||||
this.set_document_type_options();
|
||||
this.dialog.set_values(r.message);
|
||||
this.copy_data_to_voucher();
|
||||
this.dialog.show();
|
||||
@@ -57,6 +103,29 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager {
|
||||
});
|
||||
}
|
||||
|
||||
get_voucher_types() {
|
||||
return erpnext.accounts.bank_reconciliation.voucher_types;
|
||||
}
|
||||
|
||||
get_document_types() {
|
||||
return Object.entries(this.get_voucher_types())
|
||||
.filter(
|
||||
([, voucher_type]) =>
|
||||
!this.bank_transaction ||
|
||||
!voucher_type.is_applicable ||
|
||||
voucher_type.is_applicable(this.bank_transaction)
|
||||
)
|
||||
.map(([document_type]) => document_type);
|
||||
}
|
||||
|
||||
set_document_type_options() {
|
||||
const document_types = this.get_document_types();
|
||||
this.dialog.set_df_property("document_type", "options", document_types.join("\n"));
|
||||
if (!document_types.includes(this.dialog.get_value("document_type"))) {
|
||||
this.dialog.set_value("document_type", document_types[0]);
|
||||
}
|
||||
}
|
||||
|
||||
copy_data_to_voucher() {
|
||||
let copied = {
|
||||
reference_number: this.bank_transaction.reference_number || this.bank_transaction.description,
|
||||
@@ -186,7 +255,7 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager {
|
||||
label: __("Document Type"),
|
||||
fieldname: "document_type",
|
||||
fieldtype: "Select",
|
||||
options: `Payment Entry\nJournal Entry`,
|
||||
options: this.get_document_types().join("\n"),
|
||||
default: "Payment Entry",
|
||||
depends_on: "eval:doc.action=='Create Voucher'",
|
||||
},
|
||||
@@ -398,6 +467,7 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager {
|
||||
};
|
||||
},
|
||||
},
|
||||
...this.get_additional_voucher_fields(),
|
||||
{
|
||||
fieldtype: "Section Break",
|
||||
fieldname: "details_section",
|
||||
@@ -459,6 +529,12 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager {
|
||||
];
|
||||
}
|
||||
|
||||
get_additional_voucher_fields() {
|
||||
return Object.values(this.get_voucher_types()).flatMap((voucher_type) =>
|
||||
voucher_type.get_fields ? voucher_type.get_fields(this) : []
|
||||
);
|
||||
}
|
||||
|
||||
get_selected_attributes() {
|
||||
let selected_attributes = [];
|
||||
this.dialog.$wrapper.find(".checkbox input").each((i, col) => {
|
||||
@@ -477,10 +553,7 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager {
|
||||
|
||||
reconciliation_dialog_primary_action(values) {
|
||||
if (values.action == "Match Against Voucher") this.match(values);
|
||||
if (values.action == "Create Voucher" && values.document_type == "Payment Entry")
|
||||
this.add_payment_entry(values);
|
||||
if (values.action == "Create Voucher" && values.document_type == "Journal Entry")
|
||||
this.add_journal_entry(values);
|
||||
else if (values.action == "Create Voucher") this.create_voucher(values);
|
||||
else if (values.action == "Update Bank Transaction") this.update_transaction(values);
|
||||
}
|
||||
|
||||
@@ -513,54 +586,25 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager {
|
||||
});
|
||||
}
|
||||
|
||||
add_payment_entry(values) {
|
||||
frappe.call({
|
||||
method: "erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_payment_entry_bts",
|
||||
args: {
|
||||
bank_transaction_name: this.bank_transaction.name,
|
||||
reference_number: values.reference_number,
|
||||
reference_date: values.reference_date,
|
||||
party_type: values.party_type,
|
||||
party: values.party,
|
||||
posting_date: values.posting_date,
|
||||
mode_of_payment: values.mode_of_payment,
|
||||
project: values.project,
|
||||
cost_center: values.cost_center,
|
||||
company_bank_account: values?.bank_account || this?.bank_account,
|
||||
},
|
||||
callback: (response) => {
|
||||
const alert_string = __("Bank Transaction {0} added as Payment Entry", [
|
||||
this.bank_transaction.name,
|
||||
]);
|
||||
frappe.show_alert(alert_string);
|
||||
this.update_dt_cards(response.message);
|
||||
this.dialog.hide();
|
||||
},
|
||||
});
|
||||
}
|
||||
create_voucher(values, allow_edit = false) {
|
||||
const voucher_type = this.get_voucher_types()[values.document_type];
|
||||
if (!voucher_type) {
|
||||
frappe.throw(__("Cannot create {0} from a Bank Transaction", [values.document_type]));
|
||||
}
|
||||
|
||||
add_journal_entry(values) {
|
||||
frappe.call({
|
||||
method: "erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_journal_entry_bts",
|
||||
args: {
|
||||
bank_transaction_name: this.bank_transaction.name,
|
||||
reference_number: values.reference_number,
|
||||
reference_date: values.reference_date,
|
||||
party_type: values.party_type,
|
||||
party: values.party,
|
||||
posting_date: values.posting_date,
|
||||
mode_of_payment: values.mode_of_payment,
|
||||
entry_type: values.journal_entry_type,
|
||||
second_account: values.second_account,
|
||||
},
|
||||
callback: (response) => {
|
||||
const alert_string = __("Bank Transaction {0} added as Journal Entry", [
|
||||
this.bank_transaction.name,
|
||||
]);
|
||||
frappe.show_alert(alert_string);
|
||||
this.update_dt_cards(response.message);
|
||||
this.dialog.hide();
|
||||
},
|
||||
return voucher_type.create(this, values, allow_edit).then((message) => {
|
||||
if (allow_edit) {
|
||||
const doc = frappe.model.sync(message);
|
||||
track_voucher(doc[0].doctype, doc[0].name, this.bank_transaction.name);
|
||||
frappe.set_route("Form", doc[0].doctype, doc[0].name);
|
||||
return;
|
||||
}
|
||||
|
||||
frappe.show_alert(
|
||||
__("Bank Transaction {0} added as {1}", [this.bank_transaction.name, values.document_type])
|
||||
);
|
||||
this.update_dt_cards(message);
|
||||
this.dialog.hide();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -584,50 +628,7 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager {
|
||||
|
||||
edit_in_full_page() {
|
||||
const values = this.dialog.get_values(true);
|
||||
if (values.document_type == "Payment Entry") {
|
||||
frappe.call({
|
||||
method: "erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_payment_entry_bts",
|
||||
args: {
|
||||
bank_transaction_name: this.bank_transaction.name,
|
||||
reference_number: values.reference_number,
|
||||
reference_date: values.reference_date,
|
||||
party_type: values.party_type,
|
||||
party: values.party,
|
||||
posting_date: values.posting_date,
|
||||
mode_of_payment: values.mode_of_payment,
|
||||
project: values.project,
|
||||
cost_center: values.cost_center,
|
||||
allow_edit: true,
|
||||
company_bank_account: values?.bank_account || this?.bank_account,
|
||||
},
|
||||
callback: (r) => {
|
||||
const doc = frappe.model.sync(r.message);
|
||||
track_voucher(doc[0].doctype, doc[0].name, this.bank_transaction.name);
|
||||
frappe.set_route("Form", doc[0].doctype, doc[0].name);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
frappe.call({
|
||||
method: "erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_journal_entry_bts",
|
||||
args: {
|
||||
bank_transaction_name: this.bank_transaction.name,
|
||||
reference_number: values.reference_number,
|
||||
reference_date: values.reference_date,
|
||||
party_type: values.party_type,
|
||||
party: values.party,
|
||||
posting_date: values.posting_date,
|
||||
mode_of_payment: values.mode_of_payment,
|
||||
entry_type: values.journal_entry_type,
|
||||
second_account: values.second_account,
|
||||
allow_edit: true,
|
||||
},
|
||||
callback: (r) => {
|
||||
var doc = frappe.model.sync(r.message);
|
||||
track_voucher(doc[0].doctype, doc[0].name, this.bank_transaction.name);
|
||||
frappe.set_route("Form", doc[0].doctype, doc[0].name);
|
||||
},
|
||||
});
|
||||
}
|
||||
return this.create_voucher(values, true);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -639,7 +640,7 @@ const track_voucher = (doctype, docname, bank_transaction_name) => {
|
||||
pending_reconciliations.set(voucher_key(doctype, docname), bank_transaction_name);
|
||||
};
|
||||
|
||||
for (const voucher_doctype of ["Payment Entry", "Journal Entry"]) {
|
||||
for (const voucher_doctype of Object.keys(erpnext.accounts.bank_reconciliation.voucher_types)) {
|
||||
frappe.ui.form.on(voucher_doctype, {
|
||||
before_save(frm) {
|
||||
frm.__pending_reconciliation_key = voucher_key(frm.doctype, frm.doc.name);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user