Merge pull request #39404 from frappe/version-14-hotfix

chore: release v14
This commit is contained in:
Deepesh Garg
2024-01-17 22:34:31 +05:30
committed by GitHub
45 changed files with 535 additions and 275 deletions

View File

@@ -2,6 +2,7 @@
# For license information, please see license.txt # For license information, please see license.txt
import frappe import frappe
from frappe.model.docstatus import DocStatus
from frappe.utils import flt from frappe.utils import flt
from erpnext.controllers.status_updater import StatusUpdater from erpnext.controllers.status_updater import StatusUpdater
@@ -68,7 +69,7 @@ class BankTransaction(StatusUpdater):
"payment_entry": voucher["payment_name"], "payment_entry": voucher["payment_name"],
"allocated_amount": 0.0, # Temporary "allocated_amount": 0.0, # Temporary
} }
child = self.append("payment_entries", pe) self.append("payment_entries", pe)
added = True added = True
# runs on_update_after_submit # runs on_update_after_submit
@@ -393,3 +394,21 @@ def unclear_reference_payment(doctype, docname, bt_name):
bt = frappe.get_doc("Bank Transaction", bt_name) bt = frappe.get_doc("Bank Transaction", bt_name)
set_voucher_clearance(doctype, docname, None, bt) set_voucher_clearance(doctype, docname, None, bt)
return docname return docname
def remove_from_bank_transaction(doctype, docname):
"""Remove a (cancelled) voucher from all Bank Transactions."""
for bt_name in get_reconciled_bank_transactions(doctype, docname):
bt = frappe.get_doc("Bank Transaction", bt_name)
if bt.docstatus == DocStatus.cancelled():
continue
modified = False
for pe in bt.payment_entries:
if pe.payment_document == doctype and pe.payment_entry == docname:
bt.remove(pe)
modified = True
if modified:
bt.save()

View File

@@ -2,10 +2,10 @@
# See license.txt # See license.txt
import json import json
import unittest
import frappe import frappe
from frappe import utils from frappe import utils
from frappe.model.docstatus import DocStatus
from frappe.tests.utils import FrappeTestCase from frappe.tests.utils import FrappeTestCase
from erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool import ( from erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool import (
@@ -81,6 +81,29 @@ class TestBankTransaction(FrappeTestCase):
clearance_date = frappe.db.get_value("Payment Entry", payment.name, "clearance_date") clearance_date = frappe.db.get_value("Payment Entry", payment.name, "clearance_date")
self.assertFalse(clearance_date) self.assertFalse(clearance_date)
def test_cancel_voucher(self):
bank_transaction = frappe.get_doc(
"Bank Transaction",
dict(description="1512567 BG/000003025 OPSKATTUZWXXX AT776000000098709849 Herr G"),
)
payment = frappe.get_doc("Payment Entry", dict(party="Mr G", paid_amount=1700))
vouchers = json.dumps(
[
{
"payment_doctype": "Payment Entry",
"payment_name": payment.name,
"amount": bank_transaction.unallocated_amount,
}
]
)
reconcile_vouchers(bank_transaction.name, vouchers)
payment.reload()
payment.cancel()
bank_transaction.reload()
self.assertEqual(bank_transaction.docstatus, DocStatus.submitted())
self.assertEqual(bank_transaction.unallocated_amount, 1700)
self.assertEqual(bank_transaction.payment_entries, [])
# Check if ERPNext can correctly filter a linked payments based on the debit/credit amount # Check if ERPNext can correctly filter a linked payments based on the debit/credit amount
def test_debit_credit_output(self): def test_debit_credit_output(self):
bank_transaction = frappe.get_doc( bank_transaction = frappe.get_doc(

View File

@@ -8,7 +8,7 @@ frappe.provide("erpnext.journal_entry");
frappe.ui.form.on("Journal Entry", { frappe.ui.form.on("Journal Entry", {
setup: function(frm) { setup: function(frm) {
frm.add_fetch("bank_account", "account", "account"); frm.add_fetch("bank_account", "account", "account");
frm.ignore_doctypes_on_cancel_all = ['Sales Invoice', 'Purchase Invoice', 'Journal Entry', "Repost Payment Ledger", 'Asset', 'Asset Movement', "Repost Accounting Ledger", "Unreconcile Payment", "Unreconcile Payment Entries"]; frm.ignore_doctypes_on_cancel_all = ["Sales Invoice", "Purchase Invoice", "Journal Entry", "Repost Payment Ledger", "Asset", "Asset Movement", "Repost Accounting Ledger", "Unreconcile Payment", "Unreconcile Payment Entries", "Bank Transaction"];
}, },
refresh: function(frm) { refresh: function(frm) {

View File

@@ -7,7 +7,7 @@ cur_frm.cscript.tax_table = "Advance Taxes and Charges";
frappe.ui.form.on('Payment Entry', { frappe.ui.form.on('Payment Entry', {
onload: function(frm) { onload: function(frm) {
frm.ignore_doctypes_on_cancel_all = ['Sales Invoice', 'Purchase Invoice', 'Journal Entry', 'Repost Payment Ledger','Repost Accounting Ledger', 'Unreconcile Payment', 'Unreconcile Payment Entries']; frm.ignore_doctypes_on_cancel_all = ['Sales Invoice', 'Purchase Invoice', 'Journal Entry', 'Repost Payment Ledger', 'Repost Accounting Ledger', 'Unreconcile Payment', 'Unreconcile Payment Entries', 'Bank Transaction'];
if(frm.doc.__islocal) { if(frm.doc.__islocal) {
if (!frm.doc.paid_from) frm.set_value("paid_from_account_currency", null); if (!frm.doc.paid_from) frm.set_value("paid_from_account_currency", null);

View File

@@ -31,7 +31,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
super.onload(); super.onload();
// Ignore linked advances // Ignore linked advances
this.frm.ignore_doctypes_on_cancel_all = ['Journal Entry', 'Payment Entry', 'Purchase Invoice', "Repost Payment Ledger", "Repost Accounting Ledger", "Unreconcile Payment", "Unreconcile Payment Entries"]; this.frm.ignore_doctypes_on_cancel_all = ['Journal Entry', 'Payment Entry', 'Purchase Invoice', "Repost Payment Ledger", "Repost Accounting Ledger", "Unreconcile Payment", "Unreconcile Payment Entries", "Bank Transaction"];
if(!this.frm.doc.__islocal) { if(!this.frm.doc.__islocal) {
// show credit_to in print format // show credit_to in print format

View File

@@ -131,6 +131,18 @@ class PurchaseInvoice(BuyingController):
self.reset_default_field_value("set_warehouse", "items", "warehouse") self.reset_default_field_value("set_warehouse", "items", "warehouse")
self.reset_default_field_value("rejected_warehouse", "items", "rejected_warehouse") self.reset_default_field_value("rejected_warehouse", "items", "rejected_warehouse")
self.reset_default_field_value("set_from_warehouse", "items", "from_warehouse") self.reset_default_field_value("set_from_warehouse", "items", "from_warehouse")
self.set_percentage_received()
def set_percentage_received(self):
total_billed_qty = 0.0
total_received_qty = 0.0
for row in self.items:
if row.purchase_receipt and row.pr_detail and row.received_qty:
total_billed_qty += row.qty
total_received_qty += row.received_qty
if total_billed_qty and total_received_qty:
self.per_received = total_received_qty / total_billed_qty * 100
def validate_release_date(self): def validate_release_date(self):
if self.release_date and getdate(nowdate()) >= getdate(self.release_date): if self.release_date and getdate(nowdate()) >= getdate(self.release_date):

View File

@@ -126,7 +126,7 @@
"fieldname": "rate", "fieldname": "rate",
"fieldtype": "Float", "fieldtype": "Float",
"in_list_view": 1, "in_list_view": 1,
"label": "Rate", "label": "Tax Rate",
"oldfieldname": "rate", "oldfieldname": "rate",
"oldfieldtype": "Currency" "oldfieldtype": "Currency"
}, },
@@ -230,7 +230,7 @@
"idx": 1, "idx": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2021-08-05 20:04:36.618240", "modified": "2024-01-14 10:04:36.618240",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Purchase Taxes and Charges", "name": "Purchase Taxes and Charges",
@@ -239,4 +239,4 @@
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"track_changes": 1 "track_changes": 1
} }

View File

@@ -34,7 +34,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends e
super.onload(); super.onload();
this.frm.ignore_doctypes_on_cancel_all = ['POS Invoice', 'Timesheet', 'POS Invoice Merge Log', this.frm.ignore_doctypes_on_cancel_all = ['POS Invoice', 'Timesheet', 'POS Invoice Merge Log',
'POS Closing Entry', 'Journal Entry', 'Payment Entry', "Repost Payment Ledger", "Repost Accounting Ledger", "Unreconcile Payment", "Unreconcile Payment Entries"]; 'POS Closing Entry', 'Journal Entry', 'Payment Entry', "Repost Payment Ledger", "Repost Accounting Ledger", "Unreconcile Payment", "Unreconcile Payment Entries", "Bank Transaction"];
if(!this.frm.doc.__islocal && !this.frm.doc.customer && this.frm.doc.debit_to) { if(!this.frm.doc.__islocal && !this.frm.doc.customer && this.frm.doc.debit_to) {
// show debit_to in print format // show debit_to in print format

View File

@@ -138,6 +138,7 @@
"loyalty_amount", "loyalty_amount",
"column_break_77", "column_break_77",
"loyalty_program", "loyalty_program",
"dont_create_loyalty_points",
"loyalty_redemption_account", "loyalty_redemption_account",
"loyalty_redemption_cost_center", "loyalty_redemption_cost_center",
"contact_and_address_tab", "contact_and_address_tab",
@@ -1039,8 +1040,7 @@
"label": "Loyalty Program", "label": "Loyalty Program",
"no_copy": 1, "no_copy": 1,
"options": "Loyalty Program", "options": "Loyalty Program",
"print_hide": 1, "print_hide": 1
"read_only": 1
}, },
{ {
"allow_on_submit": 1, "allow_on_submit": 1,
@@ -2153,6 +2153,14 @@
"fieldname": "update_billed_amount_in_delivery_note", "fieldname": "update_billed_amount_in_delivery_note",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Update Billed Amount in Delivery Note" "label": "Update Billed Amount in Delivery Note"
},
{
"default": "0",
"depends_on": "loyalty_program",
"fieldname": "dont_create_loyalty_points",
"fieldtype": "Check",
"label": "Don't Create Loyalty Points",
"no_copy": 1
} }
], ],
"icon": "fa fa-file-text", "icon": "fa fa-file-text",
@@ -2165,7 +2173,7 @@
"link_fieldname": "consolidated_invoice" "link_fieldname": "consolidated_invoice"
} }
], ],
"modified": "2023-11-23 16:56:29.679499", "modified": "2024-01-02 17:25:46.027523",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Sales Invoice", "name": "Sales Invoice",

View File

@@ -300,7 +300,12 @@ class SalesInvoice(SellingController):
update_linked_doc(self.doctype, self.name, self.inter_company_invoice_reference) update_linked_doc(self.doctype, self.name, self.inter_company_invoice_reference)
# create the loyalty point ledger entry if the customer is enrolled in any loyalty program # create the loyalty point ledger entry if the customer is enrolled in any loyalty program
if not self.is_return and not self.is_consolidated and self.loyalty_program: if (
not self.is_return
and not self.is_consolidated
and self.loyalty_program
and not self.dont_create_loyalty_points
):
self.make_loyalty_point_entry() self.make_loyalty_point_entry()
elif ( elif (
self.is_return and self.return_against and not self.is_consolidated and self.loyalty_program self.is_return and self.return_against and not self.is_consolidated and self.loyalty_program

View File

@@ -108,7 +108,7 @@
"fieldname": "rate", "fieldname": "rate",
"fieldtype": "Float", "fieldtype": "Float",
"in_list_view": 1, "in_list_view": 1,
"label": "Rate", "label": "Tax Rate",
"oldfieldname": "rate", "oldfieldname": "rate",
"oldfieldtype": "Currency" "oldfieldtype": "Currency"
}, },
@@ -218,7 +218,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2022-10-17 13:08:17.776528", "modified": "2024-01-14 10:08:17.776528",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Sales Taxes and Charges", "name": "Sales Taxes and Charges",
@@ -227,4 +227,4 @@
"sort_field": "modified", "sort_field": "modified",
"sort_order": "ASC", "sort_order": "ASC",
"states": [] "states": []
} }

View File

@@ -84,10 +84,6 @@ function get_filters() {
options: budget_against_options, options: budget_against_options,
default: "Cost Center", default: "Cost Center",
reqd: 1, reqd: 1,
get_data: function() {
console.log(this.options);
return ["Emacs", "Rocks"];
},
on_change: function() { on_change: function() {
frappe.query_report.set_filter_value("budget_against_filter", []); frappe.query_report.set_filter_value("budget_against_filter", []);
frappe.query_report.refresh(); frappe.query_report.refresh();

View File

@@ -129,7 +129,7 @@ frappe.require("assets/erpnext/js/financial_statements.js", function() {
} }
value = default_formatter(value, row, column, data); value = default_formatter(value, row, column, data);
if (!data.parent_account) { if (data && !data.parent_account) {
value = $(`<span>${value}</span>`); value = $(`<span>${value}</span>`);
var $value = $(value).css("font-weight", "bold"); var $value = $(value).css("font-weight", "bold");

View File

@@ -376,6 +376,10 @@ class PartyLedgerSummaryReport(object):
if not income_or_expense_accounts: if not income_or_expense_accounts:
# prevent empty 'in' condition # prevent empty 'in' condition
income_or_expense_accounts.append("") income_or_expense_accounts.append("")
else:
# escape '%' in account name
# ignoring frappe.db.escape as it replaces single quotes with double quotes
income_or_expense_accounts = [x.replace("%", "%%") for x in income_or_expense_accounts]
accounts_query = ( accounts_query = (
qb.from_(gl) qb.from_(gl)

View File

@@ -89,6 +89,8 @@ def _execute(filters=None, additional_table_columns=None):
"payable_account": inv.credit_to, "payable_account": inv.credit_to,
"mode_of_payment": inv.mode_of_payment, "mode_of_payment": inv.mode_of_payment,
"project": ", ".join(project) if inv.doctype == "Purchase Invoice" else inv.project, "project": ", ".join(project) if inv.doctype == "Purchase Invoice" else inv.project,
"bill_no": inv.bill_no,
"bill_date": inv.bill_date,
"remarks": inv.remarks, "remarks": inv.remarks,
"purchase_order": ", ".join(purchase_order), "purchase_order": ", ".join(purchase_order),
"purchase_receipt": ", ".join(purchase_receipt), "purchase_receipt": ", ".join(purchase_receipt),
@@ -433,7 +435,7 @@ def get_payments(filters):
account_fieldname="paid_to", account_fieldname="paid_to",
party="supplier", party="supplier",
party_name="supplier_name", party_name="supplier_name",
party_account=get_party_account("Supplier", filters.supplier, filters.company), party_account=[get_party_account("Supplier", filters.supplier, filters.company)],
) )
payment_entries = get_payment_entries(filters, args) payment_entries = get_payment_entries(filters, args)
journal_entries = get_journal_entries(filters, args) journal_entries = get_journal_entries(filters, args)

View File

@@ -477,7 +477,7 @@ def get_payments(filters):
account_fieldname="paid_from", account_fieldname="paid_from",
party="customer", party="customer",
party_name="customer_name", party_name="customer_name",
party_account=get_party_account("Customer", filters.customer, filters.company), party_account=[get_party_account("Customer", filters.customer, filters.company)],
) )
payment_entries = get_payment_entries(filters, args) payment_entries = get_payment_entries(filters, args)
journal_entries = get_journal_entries(filters, args) journal_entries = get_journal_entries(filters, args)

View File

@@ -251,8 +251,9 @@ def get_journal_entries(filters, args):
) )
.where( .where(
(je.voucher_type == "Journal Entry") (je.voucher_type == "Journal Entry")
& (je.docstatus == 1)
& (journal_account.party == filters.get(args.party)) & (journal_account.party == filters.get(args.party))
& (journal_account.account == args.party_account) & (journal_account.account.isin(args.party_account))
) )
.orderby(je.posting_date, je.name, order=Order.desc) .orderby(je.posting_date, je.name, order=Order.desc)
) )
@@ -281,7 +282,9 @@ def get_payment_entries(filters, args):
pe.cost_center, pe.cost_center,
) )
.where( .where(
(pe.party == filters.get(args.party)) & (pe[args.account_fieldname] == args.party_account) (pe.docstatus == 1)
& (pe.party == filters.get(args.party))
& (pe[args.account_fieldname].isin(args.party_account))
) )
.orderby(pe.posting_date, pe.name, order=Order.desc) .orderby(pe.posting_date, pe.name, order=Order.desc)
) )
@@ -365,7 +368,7 @@ def filter_invoices_based_on_dimensions(filters, query, parent_doc):
dimension.document_type, filters.get(dimension.fieldname) dimension.document_type, filters.get(dimension.fieldname)
) )
fieldname = dimension.fieldname fieldname = dimension.fieldname
query = query.where(parent_doc[fieldname] == filters.fieldname) query = query.where(parent_doc[fieldname].isin(filters[fieldname]))
return query return query

View File

@@ -22,6 +22,10 @@ class TestUtils(unittest.TestCase):
super(TestUtils, cls).setUpClass() super(TestUtils, cls).setUpClass()
make_test_objects("Address", ADDRESS_RECORDS) make_test_objects("Address", ADDRESS_RECORDS)
@classmethod
def tearDownClass(cls):
frappe.db.rollback()
def test_get_party_shipping_address(self): def test_get_party_shipping_address(self):
address = get_party_shipping_address("Customer", "_Test Customer 1") address = get_party_shipping_address("Customer", "_Test Customer 1")
self.assertEqual(address, "_Test Billing Address 2 Title-Billing") self.assertEqual(address, "_Test Billing Address 2 Title-Billing")
@@ -125,6 +129,38 @@ class TestUtils(unittest.TestCase):
self.assertEqual(len(payment_entry.references), 1) self.assertEqual(len(payment_entry.references), 1)
self.assertEqual(payment_entry.difference_amount, 0) self.assertEqual(payment_entry.difference_amount, 0)
def test_naming_series_variable_parsing(self):
"""
Tests parsing utility used by Naming Series Variable hook for FY
"""
from frappe.custom.doctype.property_setter.property_setter import make_property_setter
from frappe.utils import nowdate
from erpnext.accounts.utils import get_fiscal_year
from erpnext.buying.doctype.supplier.test_supplier import create_supplier
# Configure Supplier Naming in Buying Settings
frappe.db.set_default("supp_master_name", "Auto Name")
# Configure Autoname in Supplier DocType
make_property_setter(
"Supplier", None, "naming_rule", "Expression", "Data", for_doctype="Doctype"
)
make_property_setter(
"Supplier", None, "autoname", "SUP-.FY.-.#####", "Data", for_doctype="Doctype"
)
fiscal_year = get_fiscal_year(nowdate())[0]
# Create Supplier
supplier = create_supplier()
# Check Naming Series in generated Supplier ID
doc_name = supplier.name.split("-")
self.assertEqual(len(doc_name), 3)
self.assertSequenceEqual(doc_name[0:2], ("SUP", fiscal_year))
frappe.db.set_default("supp_master_name", "Supplier Name")
ADDRESS_RECORDS = [ ADDRESS_RECORDS = [
{ {

View File

@@ -1222,8 +1222,13 @@ def get_autoname_with_number(number_value, doc_title, company):
def parse_naming_series_variable(doc, variable): def parse_naming_series_variable(doc, variable):
if variable == "FY": if variable == "FY":
date = doc.get("posting_date") or doc.get("transaction_date") or getdate() if doc:
return get_fiscal_year(date=date, company=doc.get("company"))[0] date = doc.get("posting_date") or doc.get("transaction_date") or getdate()
company = doc.get("company")
else:
date = getdate()
company = None
return get_fiscal_year(date=date, company=company)[0]
@frappe.whitelist() @frappe.whitelist()

View File

@@ -202,8 +202,7 @@
"fieldname": "purchase_date", "fieldname": "purchase_date",
"fieldtype": "Date", "fieldtype": "Date",
"label": "Purchase Date", "label": "Purchase Date",
"mandatory_depends_on": "eval:!doc.is_existing_asset", "mandatory_depends_on": "eval:!doc.is_existing_asset && !doc.is_composite_asset",
"read_only": 1,
"read_only_depends_on": "eval:!doc.is_existing_asset && !doc.is_composite_asset" "read_only_depends_on": "eval:!doc.is_existing_asset && !doc.is_composite_asset"
}, },
{ {
@@ -583,7 +582,7 @@
"link_fieldname": "target_asset" "link_fieldname": "target_asset"
} }
], ],
"modified": "2024-01-05 17:36:53.131512", "modified": "2024-01-15 17:35:49.226603",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Assets", "module": "Assets",
"name": "Asset", "name": "Asset",

View File

@@ -61,6 +61,7 @@ class Asset(AccountsController):
def on_cancel(self): def on_cancel(self):
self.validate_cancellation() self.validate_cancellation()
self.cancel_movement_entries() self.cancel_movement_entries()
self.cancel_capitalization()
self.delete_depreciation_entries() self.delete_depreciation_entries()
self.set_status() self.set_status()
self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry") self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry")
@@ -831,6 +832,16 @@ class Asset(AccountsController):
movement = frappe.get_doc("Asset Movement", movement.get("name")) movement = frappe.get_doc("Asset Movement", movement.get("name"))
movement.cancel() movement.cancel()
def cancel_capitalization(self):
asset_capitalization = frappe.db.get_value(
"Asset Capitalization",
{"target_asset": self.name, "docstatus": 1, "entry_type": "Capitalization"},
)
if asset_capitalization:
asset_capitalization = frappe.get_doc("Asset Capitalization", asset_capitalization)
asset_capitalization.cancel()
def delete_depreciation_entries(self): def delete_depreciation_entries(self):
if self.calculate_depreciation: if self.calculate_depreciation:
for d in self.get("schedules"): for d in self.get("schedules"):
@@ -1351,6 +1362,8 @@ def is_cwip_accounting_enabled(asset_category):
@frappe.whitelist() @frappe.whitelist()
def get_asset_value_after_depreciation(asset_name, finance_book=None): def get_asset_value_after_depreciation(asset_name, finance_book=None):
asset = frappe.get_doc("Asset", asset_name) asset = frappe.get_doc("Asset", asset_name)
if not asset.calculate_depreciation:
return flt(asset.value_after_depreciation)
return asset.get_value_after_depreciation(finance_book) return asset.get_value_after_depreciation(finance_book)

View File

@@ -20,10 +20,10 @@ erpnext.assets.AssetCapitalization = class AssetCapitalization extends erpnext.s
this.show_stock_ledger(); this.show_stock_ledger();
} }
if (this.frm.doc.stock_items && !this.frm.doc.stock_items.length && this.frm.doc.target_asset && this.frm.doc.capitalization_method === "Choose a WIP composite asset") { // if (this.frm.doc.stock_items && !this.frm.doc.stock_items.length && this.frm.doc.target_asset && this.frm.doc.capitalization_method === "Choose a WIP composite asset") {
this.set_consumed_stock_items_tagged_to_wip_composite_asset(this.frm.doc.target_asset); // this.set_consumed_stock_items_tagged_to_wip_composite_asset(this.frm.doc.target_asset);
this.get_target_asset_details(); // this.get_target_asset_details();
} // }
} }
setup_queries() { setup_queries() {
@@ -119,13 +119,20 @@ erpnext.assets.AssetCapitalization = class AssetCapitalization extends erpnext.s
}, },
callback: function (r) { callback: function (r) {
if (!r.exc && r.message) { if (!r.exc && r.message) {
me.frm.clear_table("stock_items"); if(r.message[0] && r.message[0].length) {
me.frm.clear_table("stock_items");
for (let item of r.message) { for (let item of r.message[0]) {
me.frm.add_child("stock_items", item); me.frm.add_child("stock_items", item);
}
refresh_field("stock_items");
}
if (r.message[1] && r.message[1].length) {
me.frm.clear_table("asset_items");
for (let item of r.message[1]) {
me.frm.add_child("asset_items", item);
}
me.frm.refresh_field("asset_items");
} }
refresh_field("stock_items");
me.calculate_totals(); me.calculate_totals();
} }

View File

@@ -72,11 +72,23 @@ class AssetCapitalization(StockController):
self.update_target_asset() self.update_target_asset()
def on_cancel(self): def on_cancel(self):
self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Repost Item Valuation") self.ignore_linked_doctypes = (
"GL Entry",
"Stock Ledger Entry",
"Repost Item Valuation",
"Asset",
)
self.cancel_target_asset()
self.update_stock_ledger() self.update_stock_ledger()
self.make_gl_entries() self.make_gl_entries()
self.restore_consumed_asset_items() self.restore_consumed_asset_items()
def cancel_target_asset(self):
if self.entry_type == "Capitalization" and self.target_asset:
asset_doc = frappe.get_doc("Asset", self.target_asset)
if asset_doc.docstatus == 1:
asset_doc.cancel()
def set_title(self): def set_title(self):
self.title = self.target_asset_name or self.target_item_name or self.target_item_code self.title = self.target_asset_name or self.target_item_name or self.target_item_code
@@ -782,7 +794,6 @@ def get_consumed_asset_details(args):
out.cost_center = get_default_cost_center( out.cost_center = get_default_cost_center(
args, item_defaults, item_group_defaults, brand_defaults args, item_defaults, item_group_defaults, brand_defaults
) )
return out return out
@@ -830,10 +841,27 @@ def get_items_tagged_to_wip_composite_asset(asset):
"qty", "qty",
"valuation_rate", "valuation_rate",
"amount", "amount",
"is_fixed_asset",
"parent",
] ]
pr_items = frappe.get_all( pr_items = frappe.get_all(
"Purchase Receipt Item", filters={"wip_composite_asset": asset}, fields=fields "Purchase Receipt Item", filters={"wip_composite_asset": asset, "docstatus": 1}, fields=fields
) )
return pr_items stock_items = []
asset_items = []
for d in pr_items:
if not d.is_fixed_asset:
stock_items.append(frappe._dict(d))
else:
asset_details = frappe.db.get_value(
"Asset",
{"item_code": d.item_code, "purchase_receipt": d.parent},
["name as asset", "asset_name"],
as_dict=1,
)
d.update(asset_details)
asset_items.append(frappe._dict(d))
return stock_items, asset_items

View File

@@ -188,7 +188,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2024-01-05 15:26:02.320942", "modified": "2024-01-12 16:42:01.894346",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Buying", "module": "Buying",
"name": "Buying Settings", "name": "Buying Settings",
@@ -214,39 +214,24 @@
"write": 1 "write": 1
}, },
{ {
"email": 1,
"print": 1,
"read": 1, "read": 1,
"role": "Accounts User", "role": "Accounts User"
"share": 1
}, },
{ {
"email": 1,
"print": 1,
"read": 1, "read": 1,
"role": "Accounts Manager", "role": "Accounts Manager"
"share": 1
}, },
{ {
"email": 1,
"print": 1,
"read": 1, "read": 1,
"role": "Stock Manager", "role": "Stock Manager"
"share": 1
}, },
{ {
"email": 1,
"print": 1,
"read": 1, "read": 1,
"role": "Stock User", "role": "Stock User"
"share": 1
}, },
{ {
"email": 1,
"print": 1,
"read": 1, "read": 1,
"role": "Purchase User", "role": "Purchase User"
"share": 1
} }
], ],
"sort_field": "modified", "sort_field": "modified",

View File

@@ -1347,11 +1347,16 @@ class AccountsController(TransactionBase):
reconcile_against_document(lst) reconcile_against_document(lst)
def on_cancel(self): def on_cancel(self):
from erpnext.accounts.doctype.bank_transaction.bank_transaction import (
remove_from_bank_transaction,
)
from erpnext.accounts.utils import ( from erpnext.accounts.utils import (
cancel_exchange_gain_loss_journal, cancel_exchange_gain_loss_journal,
unlink_ref_doc_from_payment_entries, unlink_ref_doc_from_payment_entries,
) )
remove_from_bank_transaction(self.doctype, self.name)
if self.doctype in ["Sales Invoice", "Purchase Invoice", "Payment Entry", "Journal Entry"]: if self.doctype in ["Sales Invoice", "Purchase Invoice", "Payment Entry", "Journal Entry"]:
# Cancel Exchange Gain/Loss Journal before unlinking # Cancel Exchange Gain/Loss Journal before unlinking
cancel_exchange_gain_loss_journal(self) cancel_exchange_gain_loss_journal(self)

View File

@@ -6,9 +6,12 @@ import json
from collections import defaultdict from collections import defaultdict
import frappe import frappe
from frappe import scrub from frappe import qb, scrub
from frappe.desk.reportview import get_filters_cond, get_match_cond from frappe.desk.reportview import get_filters_cond, get_match_cond
from frappe.query_builder import Criterion, CustomFunction
from frappe.query_builder.functions import Locate
from frappe.utils import nowdate, unique from frappe.utils import nowdate, unique
from pypika import Order
import erpnext import erpnext
from erpnext.stock.get_item_details import _get_item_tax_template from erpnext.stock.get_item_details import _get_item_tax_template
@@ -329,37 +332,46 @@ def bom(doctype, txt, searchfield, start, page_len, filters):
@frappe.whitelist() @frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs @frappe.validate_and_sanitize_search_inputs
def get_project_name(doctype, txt, searchfield, start, page_len, filters): def get_project_name(doctype, txt, searchfield, start, page_len, filters):
doctype = "Project" proj = qb.DocType("Project")
cond = "" qb_filter_and_conditions = []
qb_filter_or_conditions = []
ifelse = CustomFunction("IF", ["condition", "then", "else"])
if filters and filters.get("customer"): if filters and filters.get("customer"):
cond = """(`tabProject`.customer = %s or qb_filter_and_conditions.append(proj.customer == filters.get("customer"))
ifnull(`tabProject`.customer,"")="") and""" % (
frappe.db.escape(filters.get("customer")) qb_filter_and_conditions.append(proj.status.notin(["Completed", "Cancelled"]))
)
q = qb.from_(proj)
fields = get_fields(doctype, ["name", "project_name"]) fields = get_fields(doctype, ["name", "project_name"])
searchfields = frappe.get_meta(doctype).get_search_fields() for x in fields:
searchfields = " or ".join(["`tabProject`." + field + " like %(txt)s" for field in searchfields]) q = q.select(proj[x])
return frappe.db.sql( # don't consider 'customer' and 'status' fields for pattern search, as they must be exactly matched
"""select {fields} from `tabProject` searchfields = [
where x for x in frappe.get_meta(doctype).get_search_fields() if x not in ["customer", "status"]
`tabProject`.status not in ('Completed', 'Cancelled') ]
and {cond} {scond} {match_cond}
order by # pattern search
(case when locate(%(_txt)s, `tabProject`.name) > 0 then locate(%(_txt)s, `tabProject`.name) else 99999 end), if txt:
`tabProject`.idx desc, for x in searchfields:
`tabProject`.name asc qb_filter_or_conditions.append(proj[x].like(f"%{txt}%"))
limit {page_len} offset {start}""".format(
fields=", ".join(["`tabProject`.{0}".format(f) for f in fields]), q = q.where(Criterion.all(qb_filter_and_conditions)).where(Criterion.any(qb_filter_or_conditions))
cond=cond,
scond=searchfields, # ordering
match_cond=get_match_cond(doctype), if txt:
start=start, # project_name containing search string 'txt' will be given higher precedence
page_len=page_len, q = q.orderby(ifelse(Locate(txt, proj.project_name) > 0, Locate(txt, proj.project_name), 99999))
), q = q.orderby(proj.idx, order=Order.desc).orderby(proj.name)
{"txt": "%{0}%".format(txt), "_txt": txt.replace("%", "")},
) if page_len:
q = q.limit(page_len)
if start:
q = q.offset(start)
return q.run()
@frappe.whitelist() @frappe.whitelist()

View File

@@ -68,7 +68,7 @@ class TestQueries(unittest.TestCase):
self.assertGreaterEqual(len(query(txt="_Test Item Home Desktop Manufactured")), 1) self.assertGreaterEqual(len(query(txt="_Test Item Home Desktop Manufactured")), 1)
def test_project_query(self): def test_project_query(self):
query = add_default_params(queries.get_project_name, "BOM") query = add_default_params(queries.get_project_name, "Project")
self.assertGreaterEqual(len(query(txt="_Test Project")), 1) self.assertGreaterEqual(len(query(txt="_Test Project")), 1)

View File

@@ -239,11 +239,12 @@ def new_bank_transaction(transaction):
withdrawal = 0.0 withdrawal = 0.0
tags = [] tags = []
try: if transaction["category"]:
tags += transaction["category"] try:
tags += [f'Plaid Cat. {transaction["category_id"]}'] tags += transaction["category"]
except KeyError: tags += [f'Plaid Cat. {transaction["category_id"]}']
pass except KeyError:
pass
if not frappe.db.exists( if not frappe.db.exists(
"Bank Transaction", dict(transaction_id=transaction["transaction_id"]) "Bank Transaction", dict(transaction_id=transaction["transaction_id"])

View File

@@ -16,7 +16,7 @@ app_include_js = "erpnext.bundle.js"
app_include_css = "erpnext.bundle.css" app_include_css = "erpnext.bundle.css"
web_include_js = "erpnext-web.bundle.js" web_include_js = "erpnext-web.bundle.js"
web_include_css = "erpnext-web.bundle.css" web_include_css = "erpnext-web.bundle.css"
email_css = "email_erpnext.bundle.css" email_css = "erpnext_email.bundle.scss"
doctype_js = { doctype_js = {
"Address": "public/js/address.js", "Address": "public/js/address.js",

View File

@@ -21,7 +21,7 @@ $.extend(erpnext, {
}, },
toggle_naming_series: function() { toggle_naming_series: function() {
if(cur_frm.fields_dict.naming_series) { if(cur_frm && cur_frm.fields_dict.naming_series) {
cur_frm.toggle_display("naming_series", cur_frm.doc.__islocal?true:false); cur_frm.toggle_display("naming_series", cur_frm.doc.__islocal?true:false);
} }
}, },

View File

@@ -16,6 +16,8 @@ erpnext.accounts.dimensions = {
}, },
callback: function(r) { callback: function(r) {
me.accounting_dimensions = r.message[0]; me.accounting_dimensions = r.message[0];
// Ignoring "Project" as it is already handled specifically in Sales Order and Delivery Note
me.accounting_dimensions = me.accounting_dimensions.filter(x=>{return x.document_type != "Project"});
me.default_dimensions = r.message[1]; me.default_dimensions = r.message[1];
me.setup_filters(frm, doctype); me.setup_filters(frm, doctype);
} }

View File

@@ -63,7 +63,7 @@ class HolidayList(Document):
for holiday_date, holiday_name in country_holidays( for holiday_date, holiday_name in country_holidays(
self.country, self.country,
subdiv=self.subdivision, subdiv=self.subdivision,
years=[from_date.year, to_date.year], years=list(range(from_date.year, to_date.year + 1)),
language=frappe.local.lang, language=frappe.local.lang,
).items(): ).items():
if holiday_date in existing_holidays: if holiday_date in existing_holidays:

View File

@@ -48,17 +48,58 @@ class TestHolidayList(unittest.TestCase):
def test_local_holidays(self): def test_local_holidays(self):
holiday_list = frappe.new_doc("Holiday List") holiday_list = frappe.new_doc("Holiday List")
holiday_list.from_date = "2023-04-01" holiday_list.from_date = "2022-01-01"
holiday_list.to_date = "2023-04-30" holiday_list.to_date = "2024-12-31"
holiday_list.country = "DE" holiday_list.country = "DE"
holiday_list.subdivision = "SN" holiday_list.subdivision = "SN"
holiday_list.get_local_holidays() holiday_list.get_local_holidays()
holidays = [holiday.holiday_date for holiday in holiday_list.holidays] holidays = holiday_list.get_holidays()
self.assertNotIn(date(2023, 1, 1), holidays) self.assertIn(date(2022, 1, 1), holidays)
self.assertIn(date(2022, 4, 15), holidays)
self.assertIn(date(2022, 4, 18), holidays)
self.assertIn(date(2022, 5, 1), holidays)
self.assertIn(date(2022, 5, 26), holidays)
self.assertIn(date(2022, 6, 6), holidays)
self.assertIn(date(2022, 10, 3), holidays)
self.assertIn(date(2022, 10, 31), holidays)
self.assertIn(date(2022, 11, 16), holidays)
self.assertIn(date(2022, 12, 25), holidays)
self.assertIn(date(2022, 12, 26), holidays)
self.assertIn(date(2023, 1, 1), holidays)
self.assertIn(date(2023, 4, 7), holidays) self.assertIn(date(2023, 4, 7), holidays)
self.assertIn(date(2023, 4, 10), holidays) self.assertIn(date(2023, 4, 10), holidays)
self.assertNotIn(date(2023, 5, 1), holidays) self.assertIn(date(2023, 5, 1), holidays)
self.assertIn(date(2023, 5, 18), holidays)
self.assertIn(date(2023, 5, 29), holidays)
self.assertIn(date(2023, 10, 3), holidays)
self.assertIn(date(2023, 10, 31), holidays)
self.assertIn(date(2023, 11, 22), holidays)
self.assertIn(date(2023, 12, 25), holidays)
self.assertIn(date(2023, 12, 26), holidays)
self.assertIn(date(2024, 1, 1), holidays)
self.assertIn(date(2024, 3, 29), holidays)
self.assertIn(date(2024, 4, 1), holidays)
self.assertIn(date(2024, 5, 1), holidays)
self.assertIn(date(2024, 5, 9), holidays)
self.assertIn(date(2024, 5, 20), holidays)
self.assertIn(date(2024, 10, 3), holidays)
self.assertIn(date(2024, 10, 31), holidays)
self.assertIn(date(2024, 11, 20), holidays)
self.assertIn(date(2024, 12, 25), holidays)
self.assertIn(date(2024, 12, 26), holidays)
# check some random dates that should not be local holidays
self.assertNotIn(date(2022, 1, 2), holidays)
self.assertNotIn(date(2023, 4, 16), holidays)
self.assertNotIn(date(2024, 4, 19), holidays)
self.assertNotIn(date(2022, 5, 2), holidays)
self.assertNotIn(date(2023, 5, 27), holidays)
self.assertNotIn(date(2024, 6, 7), holidays)
self.assertNotIn(date(2022, 10, 4), holidays)
self.assertNotIn(date(2023, 10, 30), holidays)
self.assertNotIn(date(2024, 11, 17), holidays)
self.assertNotIn(date(2022, 12, 24), holidays)
def test_localized_country_names(self): def test_localized_country_names(self):
lang = frappe.local.lang lang = frappe.local.lang

View File

@@ -630,26 +630,12 @@ $.extend(erpnext.item, {
} }
}); });
} else { } else {
frappe.call({ let values = [];
method: "frappe.client.get", for(var i = d.from_range; i <= d.to_range; i = flt(i + d.increment, 6)) {
args: { values.push(i);
doctype: "Item Attribute", }
name: d.attribute attr_val_fields[d.attribute] = values;
} resolve();
}).then((r) => {
if(r.message) {
const from = r.message.from_range;
const to = r.message.to_range;
const increment = r.message.increment;
let values = [];
for(var i = from; i <= to; i = flt(i + increment, 6)) {
values.push(i);
}
attr_val_fields[d.attribute] = values;
resolve();
}
});
} }
}); });

View File

@@ -1077,6 +1077,7 @@ def make_purchase_invoice(source_name, target_doc=None, args=None):
"field_map": { "field_map": {
"name": "pr_detail", "name": "pr_detail",
"parent": "purchase_receipt", "parent": "purchase_receipt",
"qty": "received_qty",
"purchase_order_item": "po_detail", "purchase_order_item": "po_detail",
"purchase_order": "purchase_order", "purchase_order": "purchase_order",
"is_fixed_asset": "is_fixed_asset", "is_fixed_asset": "is_fixed_asset",

View File

@@ -13,7 +13,6 @@ from erpnext.stock.doctype.item.test_item import create_item, make_item
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import make_purchase_invoice from erpnext.stock.doctype.purchase_receipt.purchase_receipt import make_purchase_invoice
from erpnext.stock.doctype.serial_no.serial_no import SerialNoDuplicateError, get_serial_nos from erpnext.stock.doctype.serial_no.serial_no import SerialNoDuplicateError, get_serial_nos
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
from erpnext.stock.stock_ledger import SerialNoExistsInFutureTransaction
class TestPurchaseReceipt(FrappeTestCase): class TestPurchaseReceipt(FrappeTestCase):
@@ -197,84 +196,6 @@ class TestPurchaseReceipt(FrappeTestCase):
self.assertFalse(frappe.db.get_value("Batch", {"item": item.name, "reference_name": pr.name})) self.assertFalse(frappe.db.get_value("Batch", {"item": item.name, "reference_name": pr.name}))
self.assertFalse(frappe.db.get_all("Serial No", {"batch_no": batch_no})) self.assertFalse(frappe.db.get_all("Serial No", {"batch_no": batch_no}))
def test_duplicate_serial_nos(self):
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
item = frappe.db.exists("Item", {"item_name": "Test Serialized Item 123"})
if not item:
item = create_item("Test Serialized Item 123")
item.has_serial_no = 1
item.serial_no_series = "TSI123-.####"
item.save()
else:
item = frappe.get_doc("Item", {"item_name": "Test Serialized Item 123"})
# First make purchase receipt
pr = make_purchase_receipt(item_code=item.name, qty=2, rate=500)
pr.load_from_db()
serial_nos = frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_type": "Purchase Receipt", "voucher_no": pr.name, "item_code": item.name},
"serial_no",
)
serial_nos = get_serial_nos(serial_nos)
self.assertEquals(get_serial_nos(pr.items[0].serial_no), serial_nos)
# Then tried to receive same serial nos in difference company
pr_different_company = make_purchase_receipt(
item_code=item.name,
qty=2,
rate=500,
serial_no="\n".join(serial_nos),
company="_Test Company 1",
do_not_submit=True,
warehouse="Stores - _TC1",
)
self.assertRaises(SerialNoDuplicateError, pr_different_company.submit)
# Then made delivery note to remove the serial nos from stock
dn = create_delivery_note(item_code=item.name, qty=2, rate=1500, serial_no="\n".join(serial_nos))
dn.load_from_db()
self.assertEquals(get_serial_nos(dn.items[0].serial_no), serial_nos)
posting_date = add_days(today(), -3)
# Try to receive same serial nos again in the same company with backdated.
pr1 = make_purchase_receipt(
item_code=item.name,
qty=2,
rate=500,
posting_date=posting_date,
serial_no="\n".join(serial_nos),
do_not_submit=True,
)
self.assertRaises(SerialNoExistsInFutureTransaction, pr1.submit)
# Try to receive same serial nos with different company with backdated.
pr2 = make_purchase_receipt(
item_code=item.name,
qty=2,
rate=500,
posting_date=posting_date,
serial_no="\n".join(serial_nos),
company="_Test Company 1",
do_not_submit=True,
warehouse="Stores - _TC1",
)
self.assertRaises(SerialNoExistsInFutureTransaction, pr2.submit)
# Receive the same serial nos after the delivery note posting date and time
make_purchase_receipt(item_code=item.name, qty=2, rate=500, serial_no="\n".join(serial_nos))
# Raise the error for backdated deliver note entry cancel
self.assertRaises(SerialNoExistsInFutureTransaction, dn.cancel)
def test_purchase_receipt_gl_entry(self): def test_purchase_receipt_gl_entry(self):
pr = make_purchase_receipt( pr = make_purchase_receipt(
company="_Test Company with perpetual inventory", company="_Test Company with perpetual inventory",

View File

@@ -68,6 +68,9 @@ class QualityInspection(Document):
def on_cancel(self): def on_cancel(self):
self.update_qc_reference() self.update_qc_reference()
def on_trash(self):
self.update_qc_reference()
def validate_readings_status_mandatory(self): def validate_readings_status_mandatory(self):
for reading in self.readings: for reading in self.readings:
if not reading.status: if not reading.status:

View File

@@ -216,6 +216,33 @@ class TestQualityInspection(FrappeTestCase):
qa.save() qa.save()
self.assertEqual(qa.status, "Accepted") self.assertEqual(qa.status, "Accepted")
def test_delete_quality_inspection_linked_with_stock_entry(self):
item_code = create_item("_Test Cicuular Dependecy Item with QA").name
se = make_stock_entry(
item_code=item_code, target="_Test Warehouse - _TC", qty=1, basic_rate=100, do_not_submit=True
)
se.inspection_required = 1
se.save()
qa = create_quality_inspection(
item_code=item_code, reference_type="Stock Entry", reference_name=se.name, do_not_submit=True
)
se.reload()
se.items[0].quality_inspection = qa.name
se.save()
qa.delete()
se.reload()
qc = se.items[0].quality_inspection
self.assertFalse(qc)
se.delete()
def create_quality_inspection(**args): def create_quality_inspection(**args):
args = frappe._dict(args) args = frappe._dict(args)

View File

@@ -104,7 +104,8 @@
"in_standard_filter": 1, "in_standard_filter": 1,
"label": "Stock Entry Type", "label": "Stock Entry Type",
"options": "Stock Entry Type", "options": "Stock Entry Type",
"reqd": 1 "reqd": 1,
"search_index": 1
}, },
{ {
"depends_on": "eval:doc.purpose == 'Material Transfer'", "depends_on": "eval:doc.purpose == 'Material Transfer'",
@@ -546,7 +547,8 @@
"label": "Job Card", "label": "Job Card",
"options": "Job Card", "options": "Job Card",
"print_hide": 1, "print_hide": 1,
"read_only": 1 "read_only": 1,
"search_index": 1
}, },
{ {
"fieldname": "amended_from", "fieldname": "amended_from",
@@ -679,7 +681,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2023-06-19 18:23:40.748114", "modified": "2024-01-12 11:56:58.644882",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Stock Entry", "name": "Stock Entry",

View File

@@ -557,7 +557,8 @@
"label": "Job Card Item", "label": "Job Card Item",
"no_copy": 1, "no_copy": 1,
"print_hide": 1, "print_hide": 1,
"read_only": 1 "read_only": 1,
"search_index": 1
}, },
{ {
"default": "0", "default": "0",
@@ -572,7 +573,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2023-05-09 12:41:18.210864", "modified": "2024-01-12 11:56:04.626103",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Stock Entry Detail", "name": "Stock Entry Detail",

View File

@@ -634,30 +634,48 @@ class StockReconciliation(StockController):
if voucher_detail_no != row.name: if voucher_detail_no != row.name:
continue continue
current_qty = get_batch_qty_for_stock_reco( if row.serial_no:
row.item_code, row.warehouse, row.batch_no, self.posting_date, self.posting_time, self.name item_dict = get_stock_balance_for(
) row.item_code,
row.warehouse,
self.posting_date,
self.posting_time,
voucher_no=self.name,
)
current_qty = item_dict.get("qty")
row.current_serial_no = item_dict.get("serial_nos")
row.current_valuation_rate = item_dict.get("rate")
else:
current_qty = get_batch_qty_for_stock_reco(
row.item_code, row.warehouse, row.batch_no, self.posting_date, self.posting_time, self.name
)
precesion = row.precision("current_qty") precesion = row.precision("current_qty")
if flt(current_qty, precesion) != flt(row.current_qty, precesion): if flt(current_qty, precesion) != flt(row.current_qty, precesion):
val_rate = get_valuation_rate( if not row.serial_no:
row.item_code, val_rate = get_valuation_rate(
row.warehouse, row.item_code,
self.doctype, row.warehouse,
self.name, self.doctype,
company=self.company, self.name,
batch_no=row.batch_no, company=self.company,
) batch_no=row.batch_no,
)
row.current_valuation_rate = val_rate
row.current_valuation_rate = val_rate
row.current_qty = current_qty row.current_qty = current_qty
row.db_set( values_to_update = {
{ "current_qty": row.current_qty,
"current_qty": row.current_qty, "current_valuation_rate": row.current_valuation_rate,
"current_valuation_rate": row.current_valuation_rate, "current_amount": flt(row.current_qty * row.current_valuation_rate),
"current_amount": flt(row.current_qty * row.current_valuation_rate), }
}
) if row.current_serial_no:
values_to_update["current_serial_no"] = row.current_serial_no
row.db_set(values_to_update)
if ( if (
add_new_sle add_new_sle
@@ -880,6 +898,7 @@ def get_stock_balance_for(
batch_no: Optional[str] = None, batch_no: Optional[str] = None,
with_valuation_rate: bool = True, with_valuation_rate: bool = True,
inventory_dimensions_dict=None, inventory_dimensions_dict=None,
voucher_no=None,
): ):
frappe.has_permission("Stock Reconciliation", "write", throw=True) frappe.has_permission("Stock Reconciliation", "write", throw=True)
@@ -910,6 +929,7 @@ def get_stock_balance_for(
with_serial_no=has_serial_no, with_serial_no=has_serial_no,
inventory_dimensions_dict=inventory_dimensions_dict, inventory_dimensions_dict=inventory_dimensions_dict,
batch_no=batch_no, batch_no=batch_no,
voucher_no=voucher_no,
) )
if has_serial_no: if has_serial_no:

View File

@@ -1055,6 +1055,74 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin):
self.assertEqual(sr.items[0].current_qty, se2.items[0].qty) self.assertEqual(sr.items[0].current_qty, se2.items[0].qty)
self.assertEqual(len(sr.items[0].current_serial_no.split("\n")), sr.items[0].current_qty) self.assertEqual(len(sr.items[0].current_serial_no.split("\n")), sr.items[0].current_qty)
def test_backdated_purchase_receipt_with_stock_reco(self):
item_code = self.make_item(
properties={
"is_stock_item": 1,
"has_serial_no": 1,
"serial_no_series": "TEST-SERIAL-.###",
}
).name
warehouse = "_Test Warehouse - _TC"
# Step - 1: Create a Backdated Purchase Receipt
pr1 = make_purchase_receipt(
item_code=item_code, warehouse=warehouse, qty=10, rate=100, posting_date=add_days(nowdate(), -3)
)
pr1.reload()
serial_nos = sorted(get_serial_nos(pr1.items[0].serial_no))[:5]
# Step - 2: Create a Stock Reconciliation
sr1 = create_stock_reconciliation(
item_code=item_code,
warehouse=warehouse,
qty=5,
serial_no="\n".join(serial_nos),
)
data = frappe.get_all(
"Stock Ledger Entry",
fields=["serial_no", "actual_qty", "stock_value_difference"],
filters={"voucher_no": sr1.name, "is_cancelled": 0},
order_by="creation",
)
for d in data:
if d.actual_qty < 0:
self.assertEqual(d.actual_qty, -10.0)
self.assertAlmostEqual(d.stock_value_difference, -1000.0)
else:
self.assertEqual(d.actual_qty, 5.0)
self.assertAlmostEqual(d.stock_value_difference, 500.0)
# Step - 3: Create a Purchase Receipt before the first Purchase Receipt
make_purchase_receipt(
item_code=item_code, warehouse=warehouse, qty=10, rate=200, posting_date=add_days(nowdate(), -5)
)
data = frappe.get_all(
"Stock Ledger Entry",
fields=["serial_no", "actual_qty", "stock_value_difference"],
filters={"voucher_no": sr1.name, "is_cancelled": 0},
order_by="creation",
)
for d in data:
if d.actual_qty < 0:
self.assertEqual(d.actual_qty, -20.0)
self.assertAlmostEqual(d.stock_value_difference, -3000.0)
else:
self.assertEqual(d.actual_qty, 5.0)
self.assertAlmostEqual(d.stock_value_difference, 500.0)
active_serial_no = frappe.get_all(
"Serial No", filters={"status": "Active", "item_code": item_code}
)
self.assertEqual(len(active_serial_no), 5)
def create_batch_item_with_batch(item_name, batch_id): def create_batch_item_with_batch(item_name, batch_id):
batch_item_doc = create_item(item_name, is_stock_item=1) batch_item_doc = create_item(item_name, is_stock_item=1)

View File

@@ -10,8 +10,9 @@
"has_item_scanned", "has_item_scanned",
"item_code", "item_code",
"item_name", "item_name",
"warehouse", "item_group",
"column_break_6", "column_break_6",
"warehouse",
"qty", "qty",
"valuation_rate", "valuation_rate",
"amount", "amount",
@@ -49,6 +50,7 @@
"reqd": 1 "reqd": 1
}, },
{ {
"fetch_from": "item_code.item_name",
"fieldname": "item_name", "fieldname": "item_name",
"fieldtype": "Data", "fieldtype": "Data",
"in_global_search": 1, "in_global_search": 1,
@@ -186,11 +188,18 @@
"fieldtype": "Data", "fieldtype": "Data",
"label": "Has Item Scanned", "label": "Has Item Scanned",
"read_only": 1 "read_only": 1
},
{
"fetch_from": "item_code.item_group",
"fieldname": "item_group",
"fieldtype": "Link",
"label": "Item Group",
"options": "Item Group"
} }
], ],
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2023-07-25 11:58:44.992419", "modified": "2024-01-14 10:04:23.599951",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Stock Reconciliation Item", "name": "Stock Reconciliation Item",
@@ -201,4 +210,4 @@
"sort_order": "DESC", "sort_order": "DESC",
"states": [], "states": [],
"track_changes": 1 "track_changes": 1
} }

View File

@@ -1,7 +1,6 @@
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt # License: GNU General Public License v3. See license.txt
import copy
import json import json
from typing import Optional, Set, Tuple from typing import Optional, Set, Tuple
@@ -27,10 +26,6 @@ class NegativeStockError(frappe.ValidationError):
pass pass
class SerialNoExistsInFutureTransaction(frappe.ValidationError):
pass
def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_voucher=False): def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_voucher=False):
"""Create SL entries from SL entry dicts """Create SL entries from SL entry dicts
@@ -54,9 +49,6 @@ def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_vouc
future_sle_exists(args, sl_entries) future_sle_exists(args, sl_entries)
for sle in sl_entries: for sle in sl_entries:
if sle.serial_no and not via_landed_cost_voucher:
validate_serial_no(sle)
if cancel: if cancel:
sle["actual_qty"] = -flt(sle.get("actual_qty")) sle["actual_qty"] = -flt(sle.get("actual_qty"))
@@ -133,35 +125,6 @@ def get_args_for_future_sle(row):
) )
def validate_serial_no(sle):
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
for sn in get_serial_nos(sle.serial_no):
args = copy.deepcopy(sle)
args.serial_no = sn
args.warehouse = ""
vouchers = []
for row in get_stock_ledger_entries(args, ">"):
voucher_type = frappe.bold(row.voucher_type)
voucher_no = frappe.bold(get_link_to_form(row.voucher_type, row.voucher_no))
vouchers.append(f"{voucher_type} {voucher_no}")
if vouchers:
serial_no = frappe.bold(sn)
msg = (
f"""The serial no {serial_no} has been used in the future transactions so you need to cancel them first.
The list of the transactions are as below."""
+ "<br><br><ul><li>"
)
msg += "</li><li>".join(vouchers)
msg += "</li></ul>"
title = "Cannot Submit" if not sle.get("is_cancelled") else "Cannot Cancel"
frappe.throw(_(msg), title=_(title), exc=SerialNoExistsInFutureTransaction)
def validate_cancellation(args): def validate_cancellation(args):
if args[0].get("is_cancelled"): if args[0].get("is_cancelled"):
repost_entry = frappe.db.get_value( repost_entry = frappe.db.get_value(
@@ -573,7 +536,12 @@ class update_entries_after(object):
if not self.args.get("sle_id"): if not self.args.get("sle_id"):
self.get_dynamic_incoming_outgoing_rate(sle) self.get_dynamic_incoming_outgoing_rate(sle)
if sle.voucher_type == "Stock Reconciliation" and sle.batch_no and sle.voucher_detail_no: if (
sle.voucher_type == "Stock Reconciliation"
and not self.args.get("sle_id")
and sle.voucher_detail_no
and (sle.batch_no or sle.serial_no)
):
self.reset_actual_qty_for_stock_reco(sle) self.reset_actual_qty_for_stock_reco(sle)
if ( if (
@@ -651,11 +619,52 @@ class update_entries_after(object):
doc.recalculate_current_qty(sle.voucher_detail_no, sle.creation, sle.actual_qty >= 0) doc.recalculate_current_qty(sle.voucher_detail_no, sle.creation, sle.actual_qty >= 0)
if sle.actual_qty < 0: if sle.actual_qty < 0:
sle.actual_qty = ( stock_reco_details = frappe.db.get_value(
flt(frappe.db.get_value("Stock Reconciliation Item", sle.voucher_detail_no, "current_qty")) "Stock Reconciliation Item",
* -1 sle.voucher_detail_no,
["current_qty", "current_serial_no as sn_no"],
as_dict=True,
) )
sle.actual_qty = flt(stock_reco_details.current_qty) * -1
if stock_reco_details.sn_no:
sle.serial_no = stock_reco_details.sn_no
sle.qty_after_transaction = 0.0
if sle.serial_no:
self.update_serial_no_status(sle)
def update_serial_no_status(self, sle):
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
serial_nos = get_serial_nos(sle.serial_no)
warehouse = None
status = "Delivered"
if sle.actual_qty > 0:
warehouse = sle.warehouse
status = "Active"
sn_table = frappe.qb.DocType("Serial No")
query = (
frappe.qb.update(sn_table)
.set(sn_table.warehouse, warehouse)
.set(sn_table.status, status)
.where(sn_table.name.isin(serial_nos))
)
if sle.actual_qty > 0:
query = query.set(sn_table.purchase_document_type, sle.voucher_type)
query = query.set(sn_table.purchase_document_no, sle.voucher_no)
query = query.set(sn_table.delivery_document_type, None)
query = query.set(sn_table.delivery_document_no, None)
else:
query = query.set(sn_table.delivery_document_type, sle.voucher_type)
query = query.set(sn_table.delivery_document_no, sle.voucher_no)
query.run()
def validate_negative_stock(self, sle): def validate_negative_stock(self, sle):
""" """
validate negative stock for entries current datetime onwards validate negative stock for entries current datetime onwards
@@ -1282,6 +1291,9 @@ def get_stock_ledger_entries(
if operator in (">", "<=") and previous_sle.get("name"): if operator in (">", "<=") and previous_sle.get("name"):
conditions += " and name!=%(name)s" conditions += " and name!=%(name)s"
if operator in (">", "<=") and previous_sle.get("voucher_no"):
conditions += " and voucher_no!=%(voucher_no)s"
if extra_cond: if extra_cond:
conditions += f"{extra_cond}" conditions += f"{extra_cond}"

View File

@@ -96,6 +96,7 @@ def get_stock_balance(
with_serial_no=False, with_serial_no=False,
inventory_dimensions_dict=None, inventory_dimensions_dict=None,
batch_no=None, batch_no=None,
voucher_no=None,
): ):
"""Returns stock balance quantity at given warehouse on given posting date or current date. """Returns stock balance quantity at given warehouse on given posting date or current date.
@@ -115,6 +116,9 @@ def get_stock_balance(
"posting_time": posting_time, "posting_time": posting_time,
} }
if voucher_no:
args["voucher_no"] = voucher_no
extra_cond = "" extra_cond = ""
if inventory_dimensions_dict: if inventory_dimensions_dict:
for field, value in inventory_dimensions_dict.items(): for field, value in inventory_dimensions_dict.items():