Merge pull request #49566 from frappe/version-15-hotfix

chore: release v15
This commit is contained in:
ruthra kumar
2025-09-16 20:12:09 +05:30
committed by GitHub
21 changed files with 251 additions and 154 deletions

View File

@@ -7,6 +7,9 @@ from frappe.utils import nowdate
from erpnext.accounts.doctype.bank_transaction.test_bank_transaction import create_bank_account
IBAN_1 = "DE02000000003716541159"
IBAN_2 = "DE02500105170137075030"
class TestAutoMatchParty(FrappeTestCase):
@classmethod
@@ -22,24 +25,24 @@ class TestAutoMatchParty(FrappeTestCase):
frappe.db.set_single_value("Accounts Settings", "enable_fuzzy_matching", 0)
def test_match_by_account_number(self):
create_supplier_for_match(account_no="000000003716541159")
create_supplier_for_match(account_no=IBAN_1[11:])
doc = create_bank_transaction(
withdrawal=1200,
transaction_id="562213b0ca1bf838dab8f2c6a39bbc3b",
account_no="000000003716541159",
iban="DE02000000003716541159",
account_no=IBAN_1[11:],
iban=IBAN_1,
)
self.assertEqual(doc.party_type, "Supplier")
self.assertEqual(doc.party, "John Doe & Co.")
def test_match_by_iban(self):
create_supplier_for_match(iban="DE02000000003716541159")
create_supplier_for_match(iban=IBAN_1)
doc = create_bank_transaction(
withdrawal=1200,
transaction_id="c5455a224602afaa51592a9d9250600d",
account_no="000000003716541159",
iban="DE02000000003716541159",
account_no=IBAN_1[11:],
iban=IBAN_1,
)
self.assertEqual(doc.party_type, "Supplier")
@@ -51,7 +54,7 @@ class TestAutoMatchParty(FrappeTestCase):
withdrawal=1200,
transaction_id="1f6f661f347ff7b1ea588665f473adb1",
party_name="Ella Jackson",
iban="DE04000000003716545346",
iban=IBAN_2,
)
self.assertEqual(doc.party_type, "Supplier")
self.assertEqual(doc.party, "Jackson Ella W.")

View File

@@ -74,6 +74,6 @@ def create_party_link(primary_role, primary_party, secondary_party):
party_link.secondary_role = "Customer" if primary_role == "Supplier" else "Supplier"
party_link.secondary_party = secondary_party
party_link.save(ignore_permissions=True)
party_link.save()
return party_link

View File

@@ -281,6 +281,7 @@
"read_only": 1
},
{
"fetch_from": "customer.tax_id",
"fieldname": "tax_id",
"fieldtype": "Data",
"hide_days": 1,
@@ -2198,7 +2199,7 @@
"link_fieldname": "consolidated_invoice"
}
],
"modified": "2025-08-04 19:20:28.732039",
"modified": "2025-09-09 14:48:59.472826",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice",

View File

@@ -369,6 +369,10 @@ class AssetDepreciationSchedule(Document):
original_schedule_date=schedule_date,
)
depreciation_amount = flt(depreciation_amount, asset_doc.precision("gross_purchase_amount"))
if depreciation_amount > row.value_after_depreciation - row.expected_value_after_useful_life:
depreciation_amount = row.value_after_depreciation - row.expected_value_after_useful_life
if depreciation_amount > 0:
self.add_depr_schedule_row(date_of_disposal, depreciation_amount, n)
@@ -654,6 +658,7 @@ def _get_pro_rata_amt(
total_days = get_total_days(original_schedule_date or to_date, 12)
else:
total_days = get_total_days(original_schedule_date or to_date, row.frequency_of_depreciation)
return (depreciation_amount * flt(days)) / flt(total_days), days, months

View File

@@ -61,7 +61,7 @@ class AssetMovement(Document):
if d.source_location:
if current_location != d.source_location:
frappe.throw(
_("Asset {0} does not belongs to the location {1}").format(d.asset, d.source_location)
_("Asset {0} does not belong to the location {1}").format(d.asset, d.source_location)
)
else:
d.source_location = current_location
@@ -75,11 +75,11 @@ class AssetMovement(Document):
frappe.throw(_("Target Location is required while receiving Asset {0}").format(d.asset))
if d.to_employee and frappe.db.get_value("Employee", d.to_employee, "company") != self.company:
frappe.throw(
_("Employee {0} does not belongs to the company {1}").format(d.to_employee, self.company)
_("Employee {0} does not belong to the company {1}").format(d.to_employee, self.company)
)
def validate_employee(self, d):
if self.purpose == "Tranfer and Issue":
if self.purpose == "Transfer and Issue":
if not d.from_employee:
frappe.throw(_("From Employee is required while issuing Asset {0}").format(d.asset))
@@ -88,7 +88,7 @@ class AssetMovement(Document):
if current_custodian != d.from_employee:
frappe.throw(
_("Asset {0} does not belongs to the custodian {1}").format(d.asset, d.from_employee)
_("Asset {0} does not belong to the custodian {1}").format(d.asset, d.from_employee)
)
if not d.to_employee:
@@ -96,7 +96,7 @@ class AssetMovement(Document):
if d.to_employee and frappe.db.get_value("Employee", d.to_employee, "company") != self.company:
frappe.throw(
_("Employee {0} does not belongs to the company {1}").format(d.to_employee, self.company)
_("Employee {0} does not belong to the company {1}").format(d.to_employee, self.company)
)
def on_submit(self):

View File

@@ -133,7 +133,10 @@ frappe.ui.form.on("Supplier", {
__("Actions")
);
if (cint(frappe.defaults.get_default("enable_common_party_accounting"))) {
if (
cint(frappe.defaults.get_default("enable_common_party_accounting")) &&
frappe.model.can_create("Party Link")
) {
frm.add_custom_button(
__("Link with Customer"),
function () {

View File

@@ -957,7 +957,9 @@ class StockController(AccountsController):
from erpnext.stock.stock_ledger import make_sl_entries
make_sl_entries(sl_entries, allow_negative_stock, via_landed_cost_voucher)
update_batch_qty(self.doctype, self.name, via_landed_cost_voucher=via_landed_cost_voucher)
update_batch_qty(
self.doctype, self.name, self.docstatus, via_landed_cost_voucher=via_landed_cost_voucher
)
def make_gl_entries_on_cancel(self, from_repost=False):
if not from_repost:

View File

@@ -116,20 +116,6 @@ frappe.ui.form.on("Work Order", {
frm.set_indicator_formatter("operation", function (doc) {
return frm.doc.qty == doc.completed_qty ? "green" : "orange";
});
if (frm.doc.docstatus == 0 && frm.doc.bom_no) {
frappe.call({
method: "erpnext.manufacturing.doctype.work_order.work_order.check_if_scrap_warehouse_mandatory",
args: {
bom_no: frm.doc.bom_no,
},
callback: function (r) {
if (r.message["set_scrap_wh_mandatory"]) {
frm.toggle_reqd("scrap_warehouse", true);
}
},
});
}
},
onload: function (frm) {
@@ -144,6 +130,20 @@ frappe.ui.form.on("Work Order", {
});
erpnext.work_order.set_default_warehouse(frm);
}
if (frm.doc.docstatus == 0 && frm.doc.bom_no) {
frappe.call({
method: "erpnext.manufacturing.doctype.work_order.work_order.check_if_scrap_warehouse_mandatory",
args: {
bom_no: frm.doc.bom_no,
},
callback: function (r) {
if (r.message["set_scrap_wh_mandatory"]) {
frm.toggle_reqd("scrap_warehouse", true);
}
},
});
}
},
source_warehouse: function (frm) {

View File

@@ -175,7 +175,10 @@ frappe.ui.form.on("Customer", {
__("Actions")
);
if (cint(frappe.defaults.get_default("enable_common_party_accounting"))) {
if (
cint(frappe.defaults.get_default("enable_common_party_accounting")) &&
frappe.model.can_create("Party Link")
) {
frm.add_custom_button(
__("Link with Supplier"),
function () {

View File

@@ -3,10 +3,12 @@
import frappe
from frappe import _
field_map = {
"Contact": ["first_name", "last_name", "phone", "mobile_no", "email_id", "is_primary_contact"],
"Contact": ["name", "first_name", "last_name", "phone", "mobile_no", "email_id", "is_primary_contact"],
"Address": [
"name",
"address_line1",
"address_line2",
"city",
@@ -29,6 +31,12 @@ def get_columns(filters):
return [
f"{party_type}:Link/{party_type}",
f"{frappe.unscrub(str(party_type_value))}::150",
{
"label": _("Address"),
"fieldtype": "Link",
"options": "Address",
"hidden": 1,
},
"Address Line 1",
"Address Line 2",
"Postal Code",
@@ -36,6 +44,7 @@ def get_columns(filters):
"State",
"Country",
"Is Primary Address:Check",
{"label": _("Contact"), "fieldtype": "Link", "options": "Contact", "hidden": 1},
"First Name",
"Last Name",
"Phone",

View File

@@ -75,7 +75,7 @@ def create_demo_company():
frappe.db.set_single_value("Global Defaults", "demo_company", new_company.name)
frappe.db.set_default("company", new_company.name)
bank_account = create_bank_account({"company_name": new_company.name})
bank_account = create_bank_account({"company_name": new_company.name}, demo=True)
frappe.db.set_value("Company", new_company.name, "default_bank_account", bank_account.name)
return new_company.name

View File

@@ -513,9 +513,11 @@ def update_stock_settings():
stock_settings.save()
def create_bank_account(args):
def create_bank_account(args, demo=False):
if not args.get("bank_account"):
args["bank_account"] = _("Bank Account")
if not demo:
return
args["bank_account"] = _("Demo Bank Account")
company_name = args.get("company_name")
bank_account_group = frappe.db.get_value(

View File

@@ -108,7 +108,8 @@
"in_list_view": 1,
"label": "Voucher Type",
"options": "DocType",
"reqd": 1
"reqd": 1,
"search_index": 1
},
{
"fieldname": "voucher_no",
@@ -195,8 +196,7 @@
"fieldtype": "Select",
"label": "Type of Transaction",
"options": "\nInward\nOutward\nMaintenance\nAsset Repair",
"reqd": 1,
"search_index": 1
"reqd": 1
},
{
"default": "0",
@@ -256,7 +256,7 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2025-02-17 18:22:36.056205",
"modified": "2025-09-15 14:37:26.441742",
"modified_by": "Administrator",
"module": "Stock",
"name": "Serial and Batch Bundle",

View File

@@ -118,8 +118,8 @@ class SerialandBatchBundle(Document):
self.allow_existing_serial_nos()
if not self.flags.ignore_validate_serial_batch or frappe.flags.in_test:
self.validate_serial_nos_duplicate()
self.check_future_entries_exists()
self.check_future_entries_exists()
self.set_is_outward()
self.calculate_total_qty()
self.set_warehouse()
@@ -228,7 +228,7 @@ class SerialandBatchBundle(Document):
return
if self.voucher_type == "Stock Reconciliation":
serial_nos = self.get_serial_nos_for_validate()
serial_nos, batches = self.get_serial_nos_for_validate()
else:
serial_nos = [d.serial_no for d in self.entries if d.serial_no]
@@ -716,15 +716,22 @@ class SerialandBatchBundle(Document):
if self.flags and self.flags.via_landed_cost_voucher:
return
if not self.has_serial_no:
return
serial_nos = []
batches = []
if self.voucher_type == "Stock Reconciliation":
serial_nos = self.get_serial_nos_for_validate(is_cancelled=is_cancelled)
serial_nos, batches = self.get_serial_nos_for_validate(is_cancelled=is_cancelled)
else:
batches = [d.batch_no for d in self.entries if d.batch_no]
if (
self.voucher_type != "Stock Reconciliation"
and not self.flags.ignore_validate_serial_batch
and self.has_serial_no
):
serial_nos = [d.serial_no for d in self.entries if d.serial_no]
if not serial_nos:
if self.has_batch_no and not self.has_serial_no and not batches:
return
parent = frappe.qb.DocType("Serial and Batch Bundle")
@@ -740,65 +747,117 @@ class SerialandBatchBundle(Document):
.on(parent.name == child.parent)
.select(
child.serial_no,
child.batch_no,
parent.voucher_type,
parent.voucher_no,
)
.where(
(child.serial_no.isin(serial_nos))
& (child.parent != self.name)
(child.parent != self.name)
& (parent.item_code == self.item_code)
& (parent.docstatus == 1)
& (parent.is_cancelled == 0)
& (parent.type_of_transaction.isin(["Inward", "Outward"]))
)
.where(timestamp_condition)
).run(as_dict=True)
)
if self.has_batch_no and not self.has_serial_no:
future_entries = future_entries.where(parent.voucher_type == "Stock Reconciliation")
if serial_nos:
future_entries = future_entries.where(
(child.serial_no.isin(serial_nos))
| ((parent.warehouse == self.warehouse) & (parent.voucher_type == "Stock Reconciliation"))
)
elif self.has_serial_no:
future_entries = future_entries.where(
(parent.warehouse == self.warehouse) & (parent.voucher_type == "Stock Reconciliation")
)
elif batches:
future_entries = future_entries.where(child.batch_no.isin(batches))
future_entries = future_entries.run(as_dict=True)
if future_entries:
msg = """The serial nos has been used in the future
transactions so you need to cancel them first.
The list of serial nos and their respective
transactions are as below."""
if self.has_serial_no:
title = "Serial No Exists In Future Transaction(s)"
else:
title = "Batches Exists In Future Transaction(s)"
msg = """Since the stock reconciliation exists
for future dates, cancel it first. For Serial/Batch,
if you want to make a backdated transaction,
avoid using stock reconciliation.
For more details about the transaction,
please refer to the list below.
"""
msg += "<br><br><ul>"
for d in future_entries:
msg += f"<li>{d.serial_no} in {get_link_to_form(d.voucher_type, d.voucher_no)}</li>"
if self.has_serial_no:
msg += f"<li>{d.serial_no} in {get_link_to_form(d.voucher_type, d.voucher_no)}</li>"
else:
msg += f"<li>{d.batch_no} in {get_link_to_form(d.voucher_type, d.voucher_no)}</li>"
msg += "</li></ul>"
title = "Serial No Exists In Future Transaction(s)"
frappe.throw(_(msg), title=_(title), exc=SerialNoExistsInFutureTransactionError)
def get_serial_nos_for_validate(self, is_cancelled=False):
serial_nos = [d.serial_no for d in self.entries if d.serial_no]
skip_serial_nos = self.get_skip_serial_nos_for_stock_reconciliation(is_cancelled=is_cancelled)
serial_nos = list(set(sorted(serial_nos)) - set(sorted(skip_serial_nos)))
batches = [d.batch_no for d in self.entries if d.batch_no]
return serial_nos
skip_serial_nos, skip_batches = self.get_skip_serial_nos_for_stock_reconciliation(
is_cancelled=is_cancelled
)
serial_nos = list(set(sorted(serial_nos)) - set(sorted(skip_serial_nos)))
batch_nos = list(set(sorted(batches)) - set(sorted(skip_batches)))
return serial_nos, batch_nos
def get_skip_serial_nos_for_stock_reconciliation(self, is_cancelled=False):
data = get_stock_reco_details(self.voucher_detail_no)
if not data:
return []
return [], []
current_serial_nos = set()
serial_nos = set()
current_batches = set()
batches = set()
if data.current_serial_no:
current_serial_nos = set(parse_serial_nos(data.current_serial_no))
serial_nos = set(parse_serial_nos(data.serial_no)) if data.serial_no else set([])
return list(serial_nos.intersection(current_serial_nos))
return list(serial_nos.intersection(current_serial_nos)), []
elif data.batch_no and data.current_qty == data.qty:
return [], [data.batch_no]
elif data.current_serial_and_batch_bundle:
current_serial_nos = set(get_serial_nos_from_bundle(data.current_serial_and_batch_bundle))
if self.has_serial_no:
current_serial_nos = set(get_serial_nos_from_bundle(data.current_serial_and_batch_bundle))
else:
current_batches = set(get_batches_from_bundle(data.current_serial_and_batch_bundle))
if is_cancelled:
return current_serial_nos
return list(current_serial_nos), list(current_batches)
serial_nos = (
set(get_serial_nos_from_bundle(data.serial_and_batch_bundle))
if data.serial_and_batch_bundle
else set([])
if self.has_serial_no:
serial_nos = (
set(get_serial_nos_from_bundle(data.serial_and_batch_bundle))
if data.serial_and_batch_bundle
else set([])
)
elif self.has_batch_no and data.serial_and_batch_bundle:
batches = set(get_batches_from_bundle(data.serial_and_batch_bundle))
return list(serial_nos.intersection(current_serial_nos)), list(
batches.intersection(current_batches)
)
return list(serial_nos.intersection(current_serial_nos))
return []
return [], []
def reset_qty(self, row, qty_field=None):
qty_field = self.get_qty_field(row, qty_field=qty_field)
@@ -2673,6 +2732,14 @@ def get_stock_reco_details(voucher_detail_no):
return frappe.db.get_value(
"Stock Reconciliation Item",
voucher_detail_no,
["current_serial_no", "serial_no", "serial_and_batch_bundle", "current_serial_and_batch_bundle"],
[
"current_serial_no",
"serial_no",
"serial_and_batch_bundle",
"current_serial_and_batch_bundle",
"batch_no",
"qty",
"current_qty",
],
as_dict=True,
)

View File

@@ -222,7 +222,12 @@ class TestSerialandBatchBundle(FrappeTestCase):
).insert(ignore_permissions=True)
self.assertTrue(batch_doc.use_batchwise_valuation)
batch_doc.db_set("use_batchwise_valuation", 0)
batch_doc.db_set(
{
"use_batchwise_valuation": 0,
"batch_qty": 30,
}
)
stock_queue = []
qty_after_transaction = 0

View File

@@ -962,6 +962,7 @@ frappe.ui.form.on("Stock Entry Detail", {
});
}
frm.events.set_basic_rate(frm, cdt, cdn);
validate_sample_quantity(frm, cdt, cdn);
},

View File

@@ -2123,14 +2123,16 @@ class StockEntry(StockController):
"Work Order", self.work_order, "allow_alternative_item"
)
skip_transfer, from_wip_warehouse = frappe.get_value(
"Work Order", self.work_order, ["skip_transfer", "from_wip_warehouse"]
)
item.from_warehouse = (
frappe.get_value(
"Work Order Item",
{"parent": self.work_order, "item_code": item.item_code},
"source_warehouse",
)
if frappe.get_value("Work Order", self.work_order, "skip_transfer")
and not frappe.get_value("Work Order", self.work_order, "from_wip_warehouse")
if skip_transfer and not from_wip_warehouse
else self.from_warehouse or item.source_warehouse or item.default_warehouse
)
if item.item_code in used_alternative_items:

View File

@@ -696,7 +696,7 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin):
batch_no = get_batch_from_bundle(se1.items[0].serial_and_batch_bundle)
# Removed 50 Qty, Balace Qty 50
se2 = make_stock_entry(
make_stock_entry(
item_code=item_code,
batch_no=batch_no,
posting_time="10:00:00",
@@ -729,33 +729,13 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin):
batch_no=batch_no,
posting_time="12:00:00",
source=warehouse,
qty=50,
qty=52,
basic_rate=700,
)
self.assertFalse(frappe.db.exists("Repost Item Valuation", {"voucher_no": stock_reco.name}))
# Cancel the backdated Stock Entry se2,
# Since Stock Reco entry in the future the Balace Qty should remain as it's (50)
se2.cancel()
sle = frappe.get_all(
"Stock Ledger Entry",
filters={"item_code": item_code, "warehouse": warehouse, "is_cancelled": 0},
fields=["qty_after_transaction", "actual_qty", "voucher_type", "voucher_no"],
order_by="posting_time desc, creation desc",
)
self.assertEqual(flt(sle[0].qty_after_transaction), flt(50.0))
sle = frappe.get_all(
"Stock Ledger Entry",
filters={"is_cancelled": 0, "voucher_no": stock_reco.name, "actual_qty": ("<", 0)},
fields=["actual_qty"],
)
self.assertEqual(flt(sle[0].actual_qty), flt(-100.0))
self.assertRaises(frappe.ValidationError, stock_reco.cancel)
def test_update_stock_reconciliation_while_reposting(self):
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
@@ -905,27 +885,16 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin):
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)
pr = make_purchase_receipt(
item_code=item_code,
warehouse=warehouse,
qty=10,
rate=200,
posting_date=add_days(nowdate(), -5),
do_not_submit=True,
)
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)
self.assertRaises(frappe.ValidationError, pr.submit)
def test_balance_qty_for_batch_with_backdated_stock_reco_and_future_entries(self):
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
@@ -1463,6 +1432,7 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin):
sr = create_stock_reconciliation(
item_code=item_code,
posting_date=add_days(nowdate(), -2),
warehouse=warehouse,
qty=10,
rate=100,
@@ -1482,9 +1452,9 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin):
self.assertTrue(len(stock_ledgers) == 1)
make_stock_entry(
se = make_stock_entry(
item_code=item_code,
target=warehouse,
source=warehouse,
qty=10,
basic_rate=100,
use_serial_batch_fields=1,
@@ -1496,23 +1466,19 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin):
item_code=item_code,
warehouse=warehouse,
qty=10,
rate=100,
rate=200,
use_serial_batch_fields=1,
batch_no=batch_no,
posting_date=add_days(nowdate(), -1),
)
stock_ledgers = frappe.get_all(
stock_ledger = frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_no": sr.name, "is_cancelled": 0},
pluck="name",
filters={"voucher_no": se.name, "is_cancelled": 0},
fields=["stock_value_difference"],
)
sr.reload()
self.assertEqual(sr.items[0].current_qty, 10)
self.assertEqual(sr.items[0].current_valuation_rate, 100)
self.assertTrue(len(stock_ledgers) == 2)
self.assertEqual(stock_ledger[0].stock_value_difference, 2000.0 * -1)
def test_serial_no_backdated_stock_reco(self):
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
@@ -1564,7 +1530,7 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin):
self.assertTrue(status == "Active")
make_stock_entry(
se = make_stock_entry(
item_code=serial_item,
source=warehouse,
qty=1,

View File

@@ -72,6 +72,7 @@ def execute(filters=None):
batch_balance_dict[sle.batch_no] = [0, 0]
batch_balance_dict[sle.batch_no][0] += sle.actual_qty
batch_balance_dict[sle.batch_no][1] += stock_value
if filters.get("segregate_serial_batch_bundle"):
actual_qty = batch_balance_dict[sle.batch_no][0]

View File

@@ -307,6 +307,18 @@ class SerialBatchBundle:
if docstatus == 0:
self.submit_serial_and_batch_bundle()
if (
frappe.db.count(
"Serial and Batch Entry", {"parent": self.sle.serial_and_batch_bundle, "docstatus": 0}
)
> 0
):
frappe.throw(
_("Serial and Batch Bundle {0} is not submitted").format(
bold(self.sle.serial_and_batch_bundle)
)
)
if self.item_details.has_serial_no == 1:
self.set_warehouse_and_status_in_serial_nos()
@@ -1326,40 +1338,40 @@ def get_serial_nos_batch(serial_nos):
)
def update_batch_qty(voucher_type, voucher_no, via_landed_cost_voucher=False):
from erpnext.stock.doctype.batch.batch import get_available_batches
batches = get_distinct_batches(voucher_type, voucher_no)
def update_batch_qty(voucher_type, voucher_no, docstatus, via_landed_cost_voucher=False):
batches = get_batchwise_qty(voucher_type, voucher_no)
if not batches:
return
precision = frappe.get_precision("Batch", "batch_qty")
batch_data = get_available_batches(
frappe._dict({"batch_no": batches, "consider_negative_batches": 1, "based_on_warehouse": True})
)
batchwise_qty = defaultdict(float)
for batch, qty in batches.items():
current_qty = get_batch_current_qty(batch)
current_qty += flt(qty, precision) * (-1 if docstatus == 2 else 1)
for (batch_no, warehouse), qty in batch_data.items():
if not via_landed_cost_voucher and flt(qty, precision) < 0:
throw_negative_batch_validation(batch_no, warehouse, qty)
if not via_landed_cost_voucher and current_qty < 0:
throw_negative_batch_validation(batch, current_qty)
batchwise_qty[batch_no] += qty
for batch_no in batches:
qty = flt(batchwise_qty.get(batch_no, 0), precision)
frappe.db.set_value("Batch", batch_no, "batch_qty", qty)
frappe.db.set_value("Batch", batch, "batch_qty", current_qty)
def throw_negative_batch_validation(batch_no, warehouse, qty):
def get_batch_current_qty(batch):
doctype = frappe.qb.DocType("Batch")
query = frappe.qb.from_(doctype).select(doctype.batch_qty).where(doctype.name == batch).for_update()
batch_qty = query.run()
return flt(batch_qty[0][0]) if batch_qty else 0.0
def throw_negative_batch_validation(batch_no, qty):
frappe.throw(
_("The Batch {0} has negative quantity {1} in warehouse {2}. Please correct the quantity.").format(
bold(batch_no), bold(qty), bold(warehouse)
_("The Batch {0} has negative quantity {1}. Please correct the quantity.").format(
bold(batch_no), bold(qty)
),
title=_("Negative Batch Quantity"),
)
def get_distinct_batches(voucher_type, voucher_no):
def get_batchwise_qty(voucher_type, voucher_no):
bundles = frappe.get_all(
"Serial and Batch Bundle",
filters={"voucher_no": voucher_no, "voucher_type": voucher_type},
@@ -1368,9 +1380,15 @@ def get_distinct_batches(voucher_type, voucher_no):
if not bundles:
return
return frappe.get_all(
batches = frappe.get_all(
"Serial and Batch Entry",
filters={"parent": ("in", bundles), "batch_no": ("is", "set")},
fields=["batch_no", "SUM(qty) as qty"],
group_by="batch_no",
pluck="batch_no",
as_list=1,
)
if not batches:
return frappe._dict({})
return frappe._dict(batches)

View File

@@ -817,7 +817,7 @@ class update_entries_after:
if (
sle.voucher_type == "Stock Reconciliation"
and (sle.batch_no or sle.serial_no or sle.serial_and_batch_bundle)
and (sle.serial_and_batch_bundle)
and sle.voucher_detail_no
and not self.args.get("sle_id")
and sle.is_cancelled == 0
@@ -883,10 +883,7 @@ class update_entries_after:
self.wh_data.valuation_rate
)
if (
sle.actual_qty < 0
and flt(self.wh_data.qty_after_transaction, self.flt_precision) != 0
):
if flt(self.wh_data.qty_after_transaction, self.flt_precision) != 0:
self.wh_data.valuation_rate = flt(
self.wh_data.stock_value, self.currency_precision
) / flt(self.wh_data.qty_after_transaction, self.flt_precision)
@@ -974,10 +971,12 @@ class update_entries_after:
self.wh_data.valuation_rate = self.get_fallback_rate(sle)
def reset_actual_qty_for_stock_reco(self, sle):
doc = frappe.get_cached_doc("Stock Reconciliation", sle.voucher_no)
doc = frappe.get_doc("Stock Reconciliation", sle.voucher_no)
doc.recalculate_current_qty(sle.voucher_detail_no, sle.creation, sle.actual_qty > 0)
if sle.actual_qty < 0:
doc.reload()
sle.actual_qty = (
flt(frappe.db.get_value("Stock Reconciliation Item", sle.voucher_detail_no, "current_qty"))
* -1
@@ -986,6 +985,16 @@ class update_entries_after:
if abs(sle.actual_qty) == 0.0:
sle.is_cancelled = 1
if sle.serial_and_batch_bundle:
for row in doc.items:
if row.name == sle.voucher_detail_no:
row.db_set("current_serial_and_batch_bundle", "")
sabb_doc = frappe.get_doc("Serial and Batch Bundle", sle.serial_and_batch_bundle)
sabb_doc.voucher_detail_no = None
sabb_doc.voucher_no = None
sabb_doc.cancel()
if sle.serial_and_batch_bundle and frappe.get_cached_value("Item", sle.item_code, "has_serial_no"):
self.update_serial_no_status(sle)