Merge branch 'version-16-hotfix' into mergify/bp/version-16-hotfix/pr-51900

This commit is contained in:
rohitwaghchaure
2026-01-24 13:49:24 +05:30
committed by GitHub
32 changed files with 458 additions and 160 deletions

View File

@@ -42,8 +42,4 @@ frappe.ui.form.on("Bank Account", {
}); });
} }
}, },
is_company_account: function (frm) {
frm.set_df_property("account", "reqd", frm.doc.is_company_account);
},
}); });

View File

@@ -52,6 +52,7 @@
"fieldtype": "Link", "fieldtype": "Link",
"in_list_view": 1, "in_list_view": 1,
"label": "Company Account", "label": "Company Account",
"mandatory_depends_on": "is_company_account",
"options": "Account" "options": "Account"
}, },
{ {
@@ -98,6 +99,7 @@
"in_list_view": 1, "in_list_view": 1,
"in_standard_filter": 1, "in_standard_filter": 1,
"label": "Company", "label": "Company",
"mandatory_depends_on": "is_company_account",
"options": "Company" "options": "Company"
}, },
{ {
@@ -252,7 +254,7 @@
"link_fieldname": "default_bank_account" "link_fieldname": "default_bank_account"
} }
], ],
"modified": "2025-08-29 12:32:01.081687", "modified": "2026-01-20 00:46:16.633364",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Bank Account", "name": "Bank Account",

View File

@@ -51,25 +51,29 @@ class BankAccount(Document):
delete_contact_and_address("Bank Account", self.name) delete_contact_and_address("Bank Account", self.name)
def validate(self): def validate(self):
self.validate_company() self.validate_is_company_account()
self.validate_account()
self.update_default_bank_account() self.update_default_bank_account()
def validate_account(self): def validate_is_company_account(self):
if self.account: if self.is_company_account:
if accounts := frappe.db.get_all( if not self.company:
"Bank Account", filters={"account": self.account, "name": ["!=", self.name]}, as_list=1 frappe.throw(_("Company is mandatory for company account"))
):
frappe.throw(
_("'{0}' account is already used by {1}. Use another account.").format(
frappe.bold(self.account),
frappe.bold(comma_and([get_link_to_form(self.doctype, x[0]) for x in accounts])),
)
)
def validate_company(self): if not self.account:
if self.is_company_account and not self.company: frappe.throw(_("Company Account is mandatory"))
frappe.throw(_("Company is mandatory for company account"))
self.validate_account()
def validate_account(self):
if accounts := frappe.db.get_all(
"Bank Account", filters={"account": self.account, "name": ["!=", self.name]}, as_list=1
):
frappe.throw(
_("'{0}' account is already used by {1}. Use another account.").format(
frappe.bold(self.account),
frappe.bold(comma_and([get_link_to_form(self.doctype, x[0]) for x in accounts])),
)
)
def update_default_bank_account(self): def update_default_bank_account(self):
if self.is_default and not self.disabled: if self.is_default and not self.disabled:

View File

@@ -1104,7 +1104,7 @@ frappe.ui.form.on("Payment Entry", {
allocate_party_amount_against_ref_docs: async function (frm, paid_amount, paid_amount_change) { allocate_party_amount_against_ref_docs: async function (frm, paid_amount, paid_amount_change) {
await frm.call("allocate_amount_to_references", { await frm.call("allocate_amount_to_references", {
paid_amount: paid_amount, paid_amount: flt(paid_amount),
paid_amount_change: paid_amount_change, paid_amount_change: paid_amount_change,
allocate_payment_amount: frappe.flags.allocate_payment_amount ?? false, allocate_payment_amount: frappe.flags.allocate_payment_amount ?? false,
}); });

View File

@@ -115,18 +115,21 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
} }
if (cint(doc.update_stock) != 1) { if (cint(doc.update_stock) != 1) {
// show Make Delivery Note button only if Sales Invoice is not created from Delivery Note if (!is_delivered_by_supplier) {
var from_delivery_note = false; const should_create_delivery_note = doc.items.some(
from_delivery_note = this.frm.doc.items.some(function (item) { (item) =>
return item.delivery_note ? true : false; item.qty - item.delivered_qty > 0 &&
}); !item.scio_detail &&
!item.dn_detail &&
if (!from_delivery_note && !is_delivered_by_supplier) { !item.delivered_by_supplier
this.frm.add_custom_button(
__("Delivery"),
this.frm.cscript["Make Delivery Note"],
__("Create")
); );
if (should_create_delivery_note) {
this.frm.add_custom_button(
__("Delivery Note"),
this.frm.cscript["Make Delivery Note"],
__("Create")
);
}
} }
} }

View File

@@ -2422,7 +2422,10 @@ def make_delivery_note(source_name, target_doc=None):
"cost_center": "cost_center", "cost_center": "cost_center",
}, },
"postprocess": update_item, "postprocess": update_item,
"condition": lambda doc: doc.delivered_by_supplier != 1 and not doc.scio_detail, "condition": lambda doc: doc.delivered_by_supplier != 1
and not doc.scio_detail
and not doc.dn_detail
and doc.qty - doc.delivered_qty > 0,
}, },
"Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "reset_value": True}, "Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "reset_value": True},
"Sales Team": { "Sales Team": {

View File

@@ -116,14 +116,6 @@ frappe.ui.form.on("Asset", {
__("Manage") __("Manage")
); );
frm.add_custom_button(
__("Repair Asset"),
function () {
frm.trigger("create_asset_repair");
},
__("Manage")
);
frm.add_custom_button( frm.add_custom_button(
__("Split Asset"), __("Split Asset"),
function () { function () {
@@ -155,6 +147,14 @@ frappe.ui.form.on("Asset", {
}, },
__("Manage") __("Manage")
); );
frm.add_custom_button(
__("Repair Asset"),
function () {
frm.trigger("create_asset_repair");
},
__("Manage")
);
} }
if (!frm.doc.calculate_depreciation) { if (!frm.doc.calculate_depreciation) {

View File

@@ -552,7 +552,7 @@ class StockController(AccountsController):
if is_rejected: if is_rejected:
serial_nos = row.get("rejected_serial_no") serial_nos = row.get("rejected_serial_no")
type_of_transaction = "Inward" if not self.is_return else "Outward" type_of_transaction = "Inward" if not self.is_return else "Outward"
qty = row.get("rejected_qty") qty = row.get("rejected_qty") * row.get("conversion_factor", 1.0)
warehouse = row.get("rejected_warehouse") warehouse = row.get("rejected_warehouse")
if ( if (

View File

@@ -166,29 +166,46 @@ class SubcontractingController(StockController):
_("Row {0}: Item {1} must be a subcontracted item.").format(item.idx, item.item_name) _("Row {0}: Item {1} must be a subcontracted item.").format(item.idx, item.item_name)
) )
if self.doctype != "Subcontracting Receipt" and item.qty > flt( if self.doctype != "Subcontracting Receipt":
get_pending_subcontracted_quantity( order_item_doctype = (
self.doctype,
self.purchase_order if self.doctype == "Subcontracting Order" else self.sales_order,
).get(
item.purchase_order_item
if self.doctype == "Subcontracting Order"
else item.sales_order_item
)
/ item.subcontracting_conversion_factor,
frappe.get_precision(
"Purchase Order Item" "Purchase Order Item"
if self.doctype == "Subcontracting Order" if self.doctype == "Subcontracting Order"
else "Sales Order Item", else "Sales Order Item"
"qty",
),
):
frappe.throw(
_(
"Row {0}: Item {1}'s quantity cannot be higher than the available quantity."
).format(item.idx, item.item_name)
) )
order_name = (
self.purchase_order if self.doctype == "Subcontracting Order" else self.sales_order
)
order_item_field = frappe.scrub(order_item_doctype)
if not item.get(order_item_field):
frappe.throw(
_("Row {0}: Item {1} must be linked to a {2}.").format(
item.idx, item.item_name, order_item_doctype
)
)
pending_qty = flt(
flt(
get_pending_subcontracted_quantity(
order_item_doctype,
order_name,
).get(item.get(order_item_field))
)
/ item.subcontracting_conversion_factor,
frappe.get_precision(
order_item_doctype,
"qty",
),
)
if item.qty > pending_qty:
frappe.throw(
_(
"Row {0}: Item {1}'s quantity cannot be higher than the available quantity."
).format(item.idx, item.item_name)
)
if self.doctype != "Subcontracting Inward Order": if self.doctype != "Subcontracting Inward Order":
item.amount = item.qty * item.rate item.amount = item.qty * item.rate
@@ -1333,9 +1350,7 @@ def get_item_details(items):
def get_pending_subcontracted_quantity(doctype, name): def get_pending_subcontracted_quantity(doctype, name):
table = frappe.qb.DocType( table = frappe.qb.DocType(doctype)
"Purchase Order Item" if doctype == "Subcontracting Order" else "Sales Order Item"
)
query = ( query = (
frappe.qb.from_(table) frappe.qb.from_(table)
.select(table.name, table.stock_qty, table.subcontracted_qty) .select(table.name, table.stock_qty, table.subcontracted_qty)

View File

@@ -720,6 +720,7 @@ class SubcontractingInwardController:
item.db_set("scio_detail", scio_rm.name) item.db_set("scio_detail", scio_rm.name)
if data: if data:
precision = self.precision("customer_provided_item_cost", "items")
result = frappe.get_all( result = frappe.get_all(
"Subcontracting Inward Order Received Item", "Subcontracting Inward Order Received Item",
filters={ filters={
@@ -734,10 +735,17 @@ class SubcontractingInwardController:
table = frappe.qb.DocType("Subcontracting Inward Order Received Item") table = frappe.qb.DocType("Subcontracting Inward Order Received Item")
case_expr_qty, case_expr_rate = Case(), Case() case_expr_qty, case_expr_rate = Case(), Case()
for d in result: for d in result:
d.received_qty += ( current_qty = flt(data[d.name].transfer_qty) * (1 if self._action == "submit" else -1)
data[d.name].transfer_qty if self._action == "submit" else -data[d.name].transfer_qty current_rate = flt(data[d.name].rate)
# Calculate weighted average rate
old_total = d.rate * d.received_qty
current_total = current_rate * current_qty
d.received_qty = d.received_qty + current_qty
d.rate = (
flt((old_total + current_total) / d.received_qty, precision) if d.received_qty else 0.0
) )
d.rate += data[d.name].rate if self._action == "submit" else -data[d.name].rate
if not d.required_qty and not d.received_qty: if not d.required_qty and not d.received_qty:
deleted_docs.append(d.name) deleted_docs.append(d.name)

View File

@@ -569,6 +569,7 @@ accounting_dimension_doctypes = [
"Payment Request", "Payment Request",
"Asset Movement Item", "Asset Movement Item",
"Asset Depreciation Schedule", "Asset Depreciation Schedule",
"Advance Taxes and Charges",
] ]
get_matching_queries = ( get_matching_queries = (

View File

@@ -3725,6 +3725,53 @@ class TestWorkOrder(IntegrationTestCase):
wo = make_wo_order_test_record(item="Top Level Parent") wo = make_wo_order_test_record(item="Top Level Parent")
self.assertEqual([item.item_code for item in wo.required_items], expected) self.assertEqual([item.item_code for item in wo.required_items], expected)
def test_reserved_qty_for_pp_with_extra_material_transfer(self):
from erpnext.stock.doctype.stock_entry.test_stock_entry import (
make_stock_entry as make_stock_entry_test_record,
)
rm_item_code = make_item(
"_Test Reserved Qty PP Item",
{
"is_stock_item": 1,
},
).name
fg_item_code = make_item(
"_Test Reserved Qty PP FG Item",
{
"is_stock_item": 1,
},
).name
make_stock_entry_test_record(
item_code=rm_item_code, target="_Test Warehouse - _TC", qty=10, basic_rate=100
)
make_bom(
item=fg_item_code,
raw_materials=[rm_item_code],
)
wo_order = make_wo_order_test_record(
item=fg_item_code,
qty=1,
source_warehouse="_Test Warehouse - _TC",
skip_transfer=0,
target_warehouse="_Test Warehouse - _TC",
)
bin1_at_completion = get_bin(rm_item_code, "_Test Warehouse - _TC")
self.assertEqual(bin1_at_completion.reserved_qty_for_production, 1)
s = frappe.get_doc(make_stock_entry(wo_order.name, "Material Transfer for Manufacture", 1))
s.items[0].qty += 2 # extra material transfer
s.submit()
bin1_at_completion = get_bin(rm_item_code, "_Test Warehouse - _TC")
self.assertEqual(bin1_at_completion.reserved_qty_for_production, 0)
def get_reserved_entries(voucher_no, warehouse=None): def get_reserved_entries(voucher_no, warehouse=None):
doctype = frappe.qb.DocType("Stock Reservation Entry") doctype = frappe.qb.DocType("Stock Reservation Entry")

View File

@@ -829,7 +829,7 @@ erpnext.work_order = {
} }
} }
if (counter > 0) { if (counter > 0) {
var consumption_btn = frm.add_custom_button( frm.add_custom_button(
__("Material Consumption"), __("Material Consumption"),
function () { function () {
const backflush_raw_materials_based_on = const backflush_raw_materials_based_on =

View File

@@ -770,6 +770,7 @@ class WorkOrder(Document):
self.db_set("status", "Cancelled") self.db_set("status", "Cancelled")
self.on_close_or_cancel() self.on_close_or_cancel()
self.delete_job_card()
def on_close_or_cancel(self): def on_close_or_cancel(self):
if self.production_plan and frappe.db.exists( if self.production_plan and frappe.db.exists(
@@ -779,7 +780,6 @@ class WorkOrder(Document):
else: else:
self.update_work_order_qty_in_so() self.update_work_order_qty_in_so()
self.delete_job_card()
self.update_completed_qty_in_material_request() self.update_completed_qty_in_material_request()
self.update_planned_qty() self.update_planned_qty()
self.update_ordered_qty() self.update_ordered_qty()
@@ -2654,6 +2654,9 @@ def get_reserved_qty_for_production(
qty_field = wo_item.required_qty qty_field = wo_item.required_qty
else: else:
qty_field = Case() qty_field = Case()
qty_field = qty_field.when(
((wo.skip_transfer == 0) & (wo_item.transferred_qty > wo_item.required_qty)), 0.0
)
qty_field = qty_field.when(wo.skip_transfer == 0, wo_item.required_qty - wo_item.transferred_qty) qty_field = qty_field.when(wo.skip_transfer == 0, wo_item.required_qty - wo_item.transferred_qty)
qty_field = qty_field.else_(wo_item.required_qty - wo_item.consumed_qty) qty_field = qty_field.else_(wo_item.required_qty - wo_item.consumed_qty)

View File

@@ -458,3 +458,4 @@ erpnext.patches.v16_0.update_corrected_cancelled_status
erpnext.patches.v16_0.fix_barcode_typo erpnext.patches.v16_0.fix_barcode_typo
erpnext.patches.v16_0.set_post_change_gl_entries_on_pos_settings erpnext.patches.v16_0.set_post_change_gl_entries_on_pos_settings
execute:frappe.delete_doc_if_exists("Workspace Sidebar", "Opening & Closing") execute:frappe.delete_doc_if_exists("Workspace Sidebar", "Opening & Closing")
erpnext.patches.v15_0.create_accounting_dimensions_in_advance_taxes_and_charges

View File

@@ -0,0 +1,7 @@
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
create_accounting_dimensions_for_doctype,
)
def execute():
create_accounting_dimensions_for_doctype(doctype="Advance Taxes and Charges")

View File

@@ -603,7 +603,7 @@ def send_project_update_email_to_users(project):
"sent": 0, "sent": 0,
"date": today(), "date": today(),
"time": nowtime(), "time": nowtime(),
"naming_series": "UPDATE-.project.-.YY.MM.DD.-", "naming_series": "UPDATE-.project.-.YY.MM.DD.-.####",
} }
).insert() ).insert()

View File

@@ -3131,10 +3131,16 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
set_warehouse() { set_warehouse() {
this.autofill_warehouse(this.frm.doc.items, "warehouse", this.frm.doc.set_warehouse); this.autofill_warehouse(this.frm.doc.items, "warehouse", this.frm.doc.set_warehouse);
this.autofill_warehouse(this.frm.doc.packed_items, "warehouse", this.frm.doc.set_warehouse);
} }
set_target_warehouse() { set_target_warehouse() {
this.autofill_warehouse(this.frm.doc.items, "target_warehouse", this.frm.doc.set_target_warehouse); this.autofill_warehouse(this.frm.doc.items, "target_warehouse", this.frm.doc.set_target_warehouse);
this.autofill_warehouse(
this.frm.doc.packed_items,
"target_warehouse",
this.frm.doc.set_target_warehouse
);
} }
set_from_warehouse() { set_from_warehouse() {

View File

@@ -183,6 +183,7 @@
"in_list_view": 1, "in_list_view": 1,
"in_standard_filter": 1, "in_standard_filter": 1,
"label": "Customer Group", "label": "Customer Group",
"link_filters": "[[\"Customer Group\", \"is_group\", \"=\", 0]]",
"oldfieldname": "customer_group", "oldfieldname": "customer_group",
"oldfieldtype": "Link", "oldfieldtype": "Link",
"options": "Customer Group", "options": "Customer Group",
@@ -625,7 +626,7 @@
"link_fieldname": "party" "link_fieldname": "party"
} }
], ],
"modified": "2026-01-16 15:56:05.967663", "modified": "2026-01-21 17:23:42.151114",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Selling", "module": "Selling",
"name": "Customer", "name": "Customer",

View File

@@ -61,6 +61,7 @@
"fieldtype": "Link", "fieldtype": "Link",
"in_list_view": 1, "in_list_view": 1,
"label": "Default Customer Group", "label": "Default Customer Group",
"link_filters": "[[\"Customer Group\", \"is_group\", \"=\", 0]]",
"options": "Customer Group" "options": "Customer Group"
}, },
{ {
@@ -297,7 +298,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2026-01-02 18:17:05.734945", "modified": "2026-01-21 17:28:37.027837",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Selling", "module": "Selling",
"name": "Selling Settings", "name": "Selling Settings",

View File

@@ -6,14 +6,14 @@
} }
}, },
"Algeria": { "Algeria": {
"Algeria VAT 17%": { "Algeria TVA 19%": {
"account_name": "VAT 17%", "account_name": "TVA 19%",
"tax_rate": 17.00, "tax_rate": 19.00,
"default": 1 "default": 1
}, },
"Algeria VAT 7%": { "Algeria TVA 9%": {
"account_name": "VAT 7%", "account_name": "TVA 9%",
"tax_rate": 7.00 "tax_rate": 9.00
} }
}, },

View File

@@ -97,7 +97,6 @@ class DeprecatedBatchNoValuation:
for ledger in entries: for ledger in entries:
self.stock_value_differece[ledger.batch_no] += flt(ledger.batch_value) self.stock_value_differece[ledger.batch_no] += flt(ledger.batch_value)
self.available_qty[ledger.batch_no] += flt(ledger.batch_qty) self.available_qty[ledger.batch_no] += flt(ledger.batch_qty)
self.total_qty[ledger.batch_no] += flt(ledger.batch_qty)
@deprecated( @deprecated(
"erpnext.stock.serial_batch_bundle.BatchNoValuation.get_sle_for_batches", "erpnext.stock.serial_batch_bundle.BatchNoValuation.get_sle_for_batches",
@@ -271,7 +270,6 @@ class DeprecatedBatchNoValuation:
batch_data = query.run(as_dict=True) batch_data = query.run(as_dict=True)
for d in batch_data: for d in batch_data:
self.available_qty[d.batch_no] += flt(d.batch_qty) self.available_qty[d.batch_no] += flt(d.batch_qty)
self.total_qty[d.batch_no] += flt(d.batch_qty)
for d in batch_data: for d in batch_data:
if self.available_qty.get(d.batch_no): if self.available_qty.get(d.batch_no):
@@ -383,7 +381,6 @@ class DeprecatedBatchNoValuation:
batch_data = query.run(as_dict=True) batch_data = query.run(as_dict=True)
for d in batch_data: for d in batch_data:
self.available_qty[d.batch_no] += flt(d.batch_qty) self.available_qty[d.batch_no] += flt(d.batch_qty)
self.total_qty[d.batch_no] += flt(d.batch_qty)
if not self.last_sle: if not self.last_sle:
return return

View File

@@ -282,7 +282,6 @@
{ {
"fieldname": "set_warehouse", "fieldname": "set_warehouse",
"fieldtype": "Link", "fieldtype": "Link",
"ignore_user_permissions": 1,
"in_list_view": 1, "in_list_view": 1,
"label": "Set Target Warehouse", "label": "Set Target Warehouse",
"options": "Warehouse" "options": "Warehouse"
@@ -378,7 +377,7 @@
"idx": 70, "idx": 70,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2026-01-10 15:34:59.000603", "modified": "2026-01-21 12:48:40.792323",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Material Request", "name": "Material Request",

View File

@@ -4997,6 +4997,45 @@ class TestPurchaseReceipt(IntegrationTestCase):
self.assertEqual(frappe.parse_json(stock_queue), [[20, 0.0]]) self.assertEqual(frappe.parse_json(stock_queue), [[20, 0.0]])
def test_negative_stock_error_for_purchase_return(self):
from erpnext.controllers.sales_and_purchase_return import make_return_doc
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
item_code = make_item(
"Test Negative Stock for Purchase Return Item",
{"has_batch_no": 1, "create_new_batch": 1, "batch_number_series": "TNSFPRI.#####"},
).name
pr = make_purchase_receipt(
item_code=item_code,
posting_date=add_days(today(), -3),
qty=10,
rate=100,
warehouse="_Test Warehouse - _TC",
)
batch_no = get_batch_from_bundle(pr.items[0].serial_and_batch_bundle)
make_purchase_receipt(
item_code=item_code,
posting_date=add_days(today(), -4),
qty=10,
rate=100,
warehouse="_Test Warehouse - _TC",
)
make_stock_entry(
item_code=item_code,
qty=10,
source="_Test Warehouse - _TC",
target="_Test Warehouse 1 - _TC",
batch_no=batch_no,
use_serial_batch_fields=1,
)
return_pr = make_return_doc("Purchase Receipt", pr.name)
self.assertRaises(frappe.ValidationError, return_pr.submit)
def prepare_data_for_internal_transfer(): def prepare_data_for_internal_transfer():
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier

View File

@@ -576,14 +576,12 @@ class SerialandBatchBundle(Document):
d.incoming_rate = abs(flt(sn_obj.batch_avg_rate.get(d.batch_no))) d.incoming_rate = abs(flt(sn_obj.batch_avg_rate.get(d.batch_no)))
precision = d.precision("qty") precision = d.precision("qty")
for field in ["available_qty", "total_qty"]: available_qty = flt(sn_obj.available_qty.get(d.batch_no), precision)
value = getattr(sn_obj, field) if self.docstatus == 1:
available_qty = flt(value.get(d.batch_no), precision) available_qty += flt(d.qty, precision)
if self.docstatus == 1:
available_qty += flt(d.qty, precision)
if not allow_negative_stock: if not allow_negative_stock:
self.validate_negative_batch(d.batch_no, available_qty, field) self.validate_negative_batch(d.batch_no, available_qty)
d.stock_value_difference = flt(d.qty) * flt(d.incoming_rate) d.stock_value_difference = flt(d.qty) * flt(d.incoming_rate)
@@ -596,8 +594,8 @@ class SerialandBatchBundle(Document):
} }
) )
def validate_negative_batch(self, batch_no, available_qty, field=None): def validate_negative_batch(self, batch_no, available_qty):
if available_qty < 0 and not self.is_stock_reco_for_valuation_adjustment(available_qty, field=field): if available_qty < 0 and not self.is_stock_reco_for_valuation_adjustment(available_qty):
msg = f"""Batch No {bold(batch_no)} of an Item {bold(self.item_code)} msg = f"""Batch No {bold(batch_no)} of an Item {bold(self.item_code)}
has negative stock has negative stock
of quantity {bold(available_qty)} in the of quantity {bold(available_qty)} in the
@@ -605,7 +603,7 @@ class SerialandBatchBundle(Document):
frappe.throw(_(msg), BatchNegativeStockError) frappe.throw(_(msg), BatchNegativeStockError)
def is_stock_reco_for_valuation_adjustment(self, available_qty, field=None): def is_stock_reco_for_valuation_adjustment(self, available_qty):
if ( if (
self.voucher_type == "Stock Reconciliation" self.voucher_type == "Stock Reconciliation"
and self.type_of_transaction == "Outward" and self.type_of_transaction == "Outward"
@@ -613,7 +611,6 @@ class SerialandBatchBundle(Document):
and ( and (
abs(frappe.db.get_value("Stock Reconciliation Item", self.voucher_detail_no, "qty")) abs(frappe.db.get_value("Stock Reconciliation Item", self.voucher_detail_no, "qty"))
== abs(available_qty) == abs(available_qty)
or field == "total_qty"
) )
): ):
return True return True
@@ -1344,6 +1341,7 @@ class SerialandBatchBundle(Document):
def on_submit(self): def on_submit(self):
self.validate_docstatus() self.validate_docstatus()
self.validate_serial_nos_inventory() self.validate_serial_nos_inventory()
self.validate_batch_quantity()
def validate_docstatus(self): def validate_docstatus(self):
for row in self.entries: for row in self.entries:
@@ -1437,6 +1435,106 @@ class SerialandBatchBundle(Document):
def on_cancel(self): def on_cancel(self):
self.validate_voucher_no_docstatus() self.validate_voucher_no_docstatus()
self.validate_batch_quantity()
def validate_batch_quantity(self):
if not self.has_batch_no:
return
if self.type_of_transaction != "Outward" or (
self.voucher_type == "Stock Reconciliation" and self.type_of_transaction == "Outward"
):
return
batch_wise_available_qty = self.get_batchwise_available_qty()
precision = frappe.get_precision("Serial and Batch Entry", "qty")
for d in self.entries:
available_qty = batch_wise_available_qty.get(d.batch_no, 0)
if flt(available_qty, precision) < 0:
frappe.throw(
_(
"""
The Batch {0} of an item {1} has negative stock in the warehouse {2}. Please add a stock quantity of {3} to proceed with this entry."""
).format(
bold(d.batch_no),
bold(self.item_code),
bold(self.warehouse),
bold(abs(flt(available_qty, precision))),
),
title=_("Negative Stock Error"),
)
def get_batchwise_available_qty(self):
available_qty = self.get_available_qty_from_sabb()
available_qty_from_ledger = self.get_available_qty_from_stock_ledger()
if not available_qty_from_ledger:
return available_qty
for batch_no, qty in available_qty_from_ledger.items():
if batch_no in available_qty:
available_qty[batch_no] += qty
else:
available_qty[batch_no] = qty
return available_qty
def get_available_qty_from_stock_ledger(self):
batches = [d.batch_no for d in self.entries if d.batch_no]
sle = frappe.qb.DocType("Stock Ledger Entry")
query = (
frappe.qb.from_(sle)
.select(
sle.batch_no,
Sum(sle.actual_qty).as_("available_qty"),
)
.where(
(sle.item_code == self.item_code)
& (sle.warehouse == self.warehouse)
& (sle.is_cancelled == 0)
& (sle.batch_no.isin(batches))
& (sle.docstatus == 1)
& (sle.serial_and_batch_bundle.isnull())
& (sle.batch_no.isnotnull())
)
.for_update()
.groupby(sle.batch_no)
)
res = query.run(as_list=True)
return frappe._dict(res) if res else frappe._dict()
def get_available_qty_from_sabb(self):
batches = [d.batch_no for d in self.entries if d.batch_no]
child = frappe.qb.DocType("Serial and Batch Entry")
query = (
frappe.qb.from_(child)
.select(
child.batch_no,
Sum(child.qty).as_("available_qty"),
)
.where(
(child.item_code == self.item_code)
& (child.warehouse == self.warehouse)
& (child.is_cancelled == 0)
& (child.batch_no.isin(batches))
& (child.docstatus == 1)
& (child.type_of_transaction.isin(["Inward", "Outward"]))
)
.for_update()
.groupby(child.batch_no)
)
query = query.where(child.voucher_type != "Pick List")
res = query.run(as_list=True)
return frappe._dict(res) if res else frappe._dict()
def validate_voucher_no_docstatus(self): def validate_voucher_no_docstatus(self):
if self.voucher_type == "POS Invoice": if self.voucher_type == "POS Invoice":

View File

@@ -959,7 +959,9 @@ class StockEntry(StockController, SubcontractingInwardController):
if matched_item := self.get_matched_items(item_code): if matched_item := self.get_matched_items(item_code):
if flt(details.get("qty"), precision) != flt(matched_item.qty, precision): if flt(details.get("qty"), precision) != flt(matched_item.qty, precision):
frappe.throw( frappe.throw(
_("For the item {0}, the quantity should be {1} according to the BOM {2}.").format( _(
"For the item {0}, the consumed quantity should be {1} according to the BOM {2}."
).format(
frappe.bold(item_code), frappe.bold(item_code),
flt(details.get("qty")), flt(details.get("qty")),
get_link_to_form("BOM", self.bom_no), get_link_to_form("BOM", self.bom_no),
@@ -1024,12 +1026,37 @@ class StockEntry(StockController, SubcontractingInwardController):
) )
def get_matched_items(self, item_code): def get_matched_items(self, item_code):
for row in self.items: items = [item for item in self.items if item.s_warehouse]
for row in items or self.get_consumed_items():
if row.item_code == item_code or row.original_item == item_code: if row.item_code == item_code or row.original_item == item_code:
return row return row
return {} return {}
def get_consumed_items(self):
"""Get all raw materials consumed through consumption entries"""
parent = frappe.qb.DocType("Stock Entry")
child = frappe.qb.DocType("Stock Entry Detail")
query = (
frappe.qb.from_(parent)
.join(child)
.on(parent.name == child.parent)
.select(
child.item_code,
Sum(child.qty).as_("qty"),
child.original_item,
)
.where(
(parent.docstatus == 1)
& (parent.purpose == "Material Consumption for Manufacture")
& (parent.work_order == self.work_order)
)
.groupby(child.item_code, child.original_item)
)
return query.run(as_dict=True)
@frappe.whitelist() @frappe.whitelist()
def get_stock_and_rate(self): def get_stock_and_rate(self):
""" """

View File

@@ -2384,6 +2384,33 @@ class TestStockEntry(IntegrationTestCase):
frappe.db.set_single_value("Manufacturing Settings", "material_consumption", original_value) frappe.db.set_single_value("Manufacturing Settings", "material_consumption", original_value)
@IntegrationTestCase.change_settings(
"Manufacturing Settings",
{
"material_consumption": 1,
"backflush_raw_materials_based_on": "BOM",
"validate_components_quantities_per_bom": 1,
},
)
def test_validation_as_per_bom_with_continuous_raw_material_consumption(self):
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
from erpnext.manufacturing.doctype.work_order.work_order import make_stock_entry as _make_stock_entry
from erpnext.manufacturing.doctype.work_order.work_order import make_work_order
fg_item = make_item("_Mobiles", properties={"is_stock_item": 1}).name
rm_item1 = make_item("_Battery", properties={"is_stock_item": 1}).name
warehouse = "Stores - WP"
bom_no = make_bom(item=fg_item, raw_materials=[rm_item1]).name
make_stock_entry(item_code=rm_item1, target=warehouse, qty=5, rate=10, purpose="Material Receipt")
work_order = make_work_order(bom_no, fg_item, 5)
work_order.skip_transfer = 1
work_order.fg_warehouse = warehouse
work_order.submit()
frappe.get_doc(_make_stock_entry(work_order.name, "Material Consumption for Manufacture", 5)).submit()
frappe.get_doc(_make_stock_entry(work_order.name, "Manufacture", 5)).submit()
def make_serialized_item(self, **args): def make_serialized_item(self, **args):
args = frappe._dict(args) args = frappe._dict(args)

View File

@@ -75,6 +75,7 @@ class StockReconciliation(StockController):
self.validate_duplicate_serial_and_batch_bundle("items") self.validate_duplicate_serial_and_batch_bundle("items")
self.remove_items_with_no_change() self.remove_items_with_no_change()
self.validate_data() self.validate_data()
self.change_row_indexes()
self.validate_expense_account() self.validate_expense_account()
self.validate_customer_provided_item() self.validate_customer_provided_item()
self.set_zero_value_for_customer_provided_items() self.set_zero_value_for_customer_provided_items()
@@ -556,8 +557,7 @@ class StockReconciliation(StockController):
elif len(items) != len(self.items): elif len(items) != len(self.items):
self.items = items self.items = items
for i, item in enumerate(self.items): self.change_idx = True
item.idx = i + 1
frappe.msgprint(_("Removed items with no change in quantity or value.")) frappe.msgprint(_("Removed items with no change in quantity or value."))
def calculate_difference_amount(self, item, item_dict): def calculate_difference_amount(self, item, item_dict):
@@ -574,14 +574,14 @@ class StockReconciliation(StockController):
def validate_data(self): def validate_data(self):
def _get_msg(row_num, msg): def _get_msg(row_num, msg):
return _("Row # {0}:").format(row_num + 1) + " " + msg return _("Row #{0}:").format(row_num) + " " + msg
self.validation_messages = [] self.validation_messages = []
item_warehouse_combinations = [] item_warehouse_combinations = []
default_currency = frappe.db.get_default("currency") default_currency = frappe.db.get_default("currency")
for row_num, row in enumerate(self.items): for row in self.items:
# find duplicates # find duplicates
key = [row.item_code, row.warehouse] key = [row.item_code, row.warehouse]
for field in ["serial_no", "batch_no"]: for field in ["serial_no", "batch_no"]:
@@ -594,7 +594,7 @@ class StockReconciliation(StockController):
if key in item_warehouse_combinations: if key in item_warehouse_combinations:
self.validation_messages.append( self.validation_messages.append(
_get_msg(row_num, _("Same item and warehouse combination already entered.")) _get_msg(row.idx, _("Same item and warehouse combination already entered."))
) )
else: else:
item_warehouse_combinations.append(key) item_warehouse_combinations.append(key)
@@ -604,7 +604,7 @@ class StockReconciliation(StockController):
if row.serial_no and not row.qty: if row.serial_no and not row.qty:
self.validation_messages.append( self.validation_messages.append(
_get_msg( _get_msg(
row_num, row.idx,
f"Quantity should not be zero for the {bold(row.item_code)} since serial nos are specified", f"Quantity should not be zero for the {bold(row.item_code)} since serial nos are specified",
) )
) )
@@ -612,17 +612,17 @@ class StockReconciliation(StockController):
# if both not specified # if both not specified
if row.qty in ["", None] and row.valuation_rate in ["", None]: if row.qty in ["", None] and row.valuation_rate in ["", None]:
self.validation_messages.append( self.validation_messages.append(
_get_msg(row_num, _("Please specify either Quantity or Valuation Rate or both")) _get_msg(row.idx, _("Please specify either Quantity or Valuation Rate or both"))
) )
# do not allow negative quantity # do not allow negative quantity
if flt(row.qty) < 0: if flt(row.qty) < 0:
self.validation_messages.append(_get_msg(row_num, _("Negative Quantity is not allowed"))) self.validation_messages.append(_get_msg(row.idx, _("Negative Quantity is not allowed")))
# do not allow negative valuation # do not allow negative valuation
if flt(row.valuation_rate) < 0: if flt(row.valuation_rate) < 0:
self.validation_messages.append( self.validation_messages.append(
_get_msg(row_num, _("Negative Valuation Rate is not allowed")) _get_msg(row.idx, _("Negative Valuation Rate is not allowed"))
) )
if row.qty and row.valuation_rate in ["", None]: if row.qty and row.valuation_rate in ["", None]:
@@ -654,6 +654,11 @@ class StockReconciliation(StockController):
raise frappe.ValidationError(self.validation_messages) raise frappe.ValidationError(self.validation_messages)
def change_row_indexes(self):
if getattr(self, "change_idx", False):
for i, item in enumerate(self.items):
item.idx = i + 1
def validate_item(self, item_code, row): def validate_item(self, item_code, row):
from erpnext.stock.doctype.item.item import ( from erpnext.stock.doctype.item.item import (
validate_cancelled_item, validate_cancelled_item,
@@ -661,6 +666,16 @@ class StockReconciliation(StockController):
validate_is_stock_item, validate_is_stock_item,
) )
def validate_serial_batch_items():
has_batch_no, has_serial_no = frappe.get_value(
"Item", item_code, ["has_batch_no", "has_serial_no"]
)
if row.use_serial_batch_fields and self.purpose == "Stock Reconciliation":
if has_batch_no and not row.batch_no:
raise frappe.ValidationError(_("Please enter Batch No"))
if has_serial_no and not row.serial_no:
raise frappe.ValidationError(_("Please enter Serial No"))
# using try except to catch all validation msgs and display together # using try except to catch all validation msgs and display together
try: try:
@@ -669,12 +684,13 @@ class StockReconciliation(StockController):
# end of life and stock item # end of life and stock item
validate_end_of_life(item_code, item.end_of_life, item.disabled) validate_end_of_life(item_code, item.end_of_life, item.disabled)
validate_is_stock_item(item_code, item.is_stock_item) validate_is_stock_item(item_code, item.is_stock_item)
validate_serial_batch_items()
# docstatus should be < 2 # docstatus should be < 2
validate_cancelled_item(item_code, item.docstatus) validate_cancelled_item(item_code, item.docstatus)
except Exception as e: except Exception as e:
self.validation_messages.append(_("Row #") + " " + ("%d: " % (row.idx)) + cstr(e)) self.validation_messages.append(_("Row #") + ("%d: " % (row.idx)) + cstr(e))
def validate_reserved_stock(self) -> None: def validate_reserved_stock(self) -> None:
"""Raises an exception if there is any reserved stock for the items in the Stock Reconciliation.""" """Raises an exception if there is any reserved stock for the items in the Stock Reconciliation."""

View File

@@ -1450,6 +1450,7 @@ class TestStockReconciliation(IntegrationTestCase, StockTestMixin):
qty=10, qty=10,
rate=100, rate=100,
use_serial_batch_fields=1, use_serial_batch_fields=1,
purpose="Opening Stock",
) )
sr.reload() sr.reload()
@@ -1592,6 +1593,7 @@ class TestStockReconciliation(IntegrationTestCase, StockTestMixin):
qty=10, qty=10,
rate=80, rate=80,
use_serial_batch_fields=1, use_serial_batch_fields=1,
purpose="Opening Stock",
) )
batch_no = get_batch_from_bundle(reco.items[0].serial_and_batch_bundle) batch_no = get_batch_from_bundle(reco.items[0].serial_and_batch_bundle)
@@ -1676,6 +1678,7 @@ class TestStockReconciliation(IntegrationTestCase, StockTestMixin):
qty=10, qty=10,
rate=100, rate=100,
use_serial_batch_fields=1, use_serial_batch_fields=1,
purpose="Opening Stock",
) )
sr.reload() sr.reload()

View File

@@ -439,8 +439,10 @@ def get_basic_details(ctx: ItemDetailsCtx, item, overwrite_warehouse=True) -> It
if not ctx.uom: if not ctx.uom:
if ctx.doctype in sales_doctypes: if ctx.doctype in sales_doctypes:
ctx.uom = item.sales_uom if item.sales_uom else item.stock_uom ctx.uom = item.sales_uom if item.sales_uom else item.stock_uom
elif (ctx.doctype in ["Purchase Order", "Purchase Receipt", "Purchase Invoice"]) or ( elif (
ctx.doctype == "Material Request" and ctx.material_request_type == "Purchase" (ctx.doctype in ["Purchase Order", "Purchase Receipt", "Purchase Invoice"])
or (ctx.doctype == "Material Request" and ctx.material_request_type == "Purchase")
or (ctx.doctype == "Supplier Quotation")
): ):
ctx.uom = item.purchase_uom if item.purchase_uom else item.stock_uom ctx.uom = item.purchase_uom if item.purchase_uom else item.stock_uom
else: else:

View File

@@ -807,62 +807,11 @@ class BatchNoValuation(DeprecatedBatchNoValuation):
for ledger in entries: for ledger in entries:
self.stock_value_differece[ledger.batch_no] += flt(ledger.incoming_rate) self.stock_value_differece[ledger.batch_no] += flt(ledger.incoming_rate)
self.available_qty[ledger.batch_no] += flt(ledger.qty) self.available_qty[ledger.batch_no] += flt(ledger.qty)
self.total_qty[ledger.batch_no] += flt(ledger.qty)
entries = self.get_batch_stock_after_date()
for row in entries:
self.total_qty[row.batch_no] += flt(row.total_qty)
self.calculate_avg_rate_from_deprecarated_ledgers() self.calculate_avg_rate_from_deprecarated_ledgers()
self.calculate_avg_rate_for_non_batchwise_valuation() self.calculate_avg_rate_for_non_batchwise_valuation()
self.set_stock_value_difference() self.set_stock_value_difference()
def get_batch_stock_after_date(self) -> list[dict]:
# Get total qty of each batch no from Serial and Batch Bundle without checking time condition
if not self.batchwise_valuation_batches:
return []
child = frappe.qb.DocType("Serial and Batch Entry")
timestamp_condition = ""
if self.sle.posting_datetime:
timestamp_condition = child.posting_datetime > self.sle.posting_datetime
if self.sle.creation:
timestamp_condition |= (child.posting_datetime == self.sle.posting_datetime) & (
child.creation > self.sle.creation
)
query = (
frappe.qb.from_(child)
.select(
child.batch_no,
Sum(child.qty).as_("total_qty"),
)
.where(
(child.item_code == self.sle.item_code)
& (child.warehouse == self.sle.warehouse)
& (child.batch_no.isin(self.batchwise_valuation_batches))
& (child.docstatus == 1)
& (child.type_of_transaction.isin(["Inward", "Outward"]))
)
.for_update()
.groupby(child.batch_no)
)
# Important to exclude the current voucher detail no / voucher no to calculate the correct stock value difference
if self.sle.voucher_detail_no:
query = query.where(child.voucher_detail_no != self.sle.voucher_detail_no)
elif self.sle.voucher_no:
query = query.where(child.voucher_no != self.sle.voucher_no)
query = query.where(child.voucher_type != "Pick List")
if timestamp_condition:
query = query.where(timestamp_condition)
return query.run(as_dict=True)
def get_batch_stock_before_date(self) -> list[dict]: def get_batch_stock_before_date(self) -> list[dict]:
# Get batch wise stock value difference from Serial and Batch Bundle considering time condition # Get batch wise stock value difference from Serial and Batch Bundle considering time condition
if not self.batchwise_valuation_batches: if not self.batchwise_valuation_batches:

View File

@@ -51,6 +51,49 @@ class IntegrationTestSubcontractingInwardOrder(IntegrationTestCase):
for item in rm_in.get("items"): for item in rm_in.get("items"):
self.assertEqual(item.customer_provided_item_cost, 15) self.assertEqual(item.customer_provided_item_cost, 15)
def test_customer_provided_item_cost_with_multiple_receipts(self):
"""
Validate that rate is calculated correctly (Weighted Average) when multiple receipts
occur for the same SCIO Received Item.
"""
so, scio = create_so_scio()
rm_item = "Basic RM"
# Receipt 1: 5 Qty @ Unit Cost 10
rm_in_1 = frappe.new_doc("Stock Entry").update(scio.make_rm_stock_entry_inward())
rm_in_1.items = [item for item in rm_in_1.items if item.item_code == rm_item]
rm_in_1.items[0].qty = 5
rm_in_1.items[0].basic_rate = 10
rm_in_1.items[0].transfer_qty = 5
rm_in_1.submit()
scio.reload()
received_item = next(item for item in scio.received_items if item.rm_item_code == rm_item)
self.assertEqual(received_item.rate, 10)
# Receipt 2: 5 Qty @ Unit Cost 20
rm_in_2 = frappe.new_doc("Stock Entry").update(scio.make_rm_stock_entry_inward())
rm_in_2.items = [item for item in rm_in_2.items if item.item_code == rm_item]
rm_in_2.items[0].qty = 5
rm_in_2.items[0].basic_rate = 20
rm_in_2.items[0].transfer_qty = 5
rm_in_2.save()
rm_in_2.submit()
# Check 2: Rate should be Weighted Average
# (5 * 10 + 5 * 20) / 10 = 150 / 10 = 15
scio.reload()
received_item = next(item for item in scio.received_items if item.rm_item_code == rm_item)
self.assertEqual(received_item.rate, 15)
# Cancel Receipt 2: Rate should revert to original
# (15 * 10 - 20 * 5) / 5 = 50 / 5 = 10
rm_in_2.cancel()
scio.reload()
received_item = next(item for item in scio.received_items if item.rm_item_code == rm_item)
self.assertEqual(received_item.received_qty, 5)
self.assertEqual(received_item.rate, 10)
def test_add_extra_customer_provided_item(self): def test_add_extra_customer_provided_item(self):
so, scio = create_so_scio() so, scio = create_so_scio()