mirror of
https://github.com/frappe/erpnext.git
synced 2026-04-13 20:05:09 +00:00
Merge branch 'version-16-hotfix' into mergify/bp/version-16-hotfix/pr-51900
This commit is contained in:
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Company Account",
|
||||
"mandatory_depends_on": "is_company_account",
|
||||
"options": "Account"
|
||||
},
|
||||
{
|
||||
@@ -98,6 +99,7 @@
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Company",
|
||||
"mandatory_depends_on": "is_company_account",
|
||||
"options": "Company"
|
||||
},
|
||||
{
|
||||
@@ -252,7 +254,7 @@
|
||||
"link_fieldname": "default_bank_account"
|
||||
}
|
||||
],
|
||||
"modified": "2025-08-29 12:32:01.081687",
|
||||
"modified": "2026-01-20 00:46:16.633364",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Bank Account",
|
||||
|
||||
@@ -51,25 +51,29 @@ class BankAccount(Document):
|
||||
delete_contact_and_address("Bank Account", self.name)
|
||||
|
||||
def validate(self):
|
||||
self.validate_company()
|
||||
self.validate_account()
|
||||
self.validate_is_company_account()
|
||||
self.update_default_bank_account()
|
||||
|
||||
def validate_account(self):
|
||||
if self.account:
|
||||
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 validate_is_company_account(self):
|
||||
if self.is_company_account:
|
||||
if not self.company:
|
||||
frappe.throw(_("Company is mandatory for company account"))
|
||||
|
||||
def validate_company(self):
|
||||
if self.is_company_account and not self.company:
|
||||
frappe.throw(_("Company is mandatory for company account"))
|
||||
if not self.account:
|
||||
frappe.throw(_("Company Account is mandatory"))
|
||||
|
||||
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):
|
||||
if self.is_default and not self.disabled:
|
||||
|
||||
@@ -1104,7 +1104,7 @@ frappe.ui.form.on("Payment Entry", {
|
||||
|
||||
allocate_party_amount_against_ref_docs: async function (frm, paid_amount, paid_amount_change) {
|
||||
await frm.call("allocate_amount_to_references", {
|
||||
paid_amount: paid_amount,
|
||||
paid_amount: flt(paid_amount),
|
||||
paid_amount_change: paid_amount_change,
|
||||
allocate_payment_amount: frappe.flags.allocate_payment_amount ?? false,
|
||||
});
|
||||
|
||||
@@ -115,18 +115,21 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
|
||||
}
|
||||
|
||||
if (cint(doc.update_stock) != 1) {
|
||||
// show Make Delivery Note button only if Sales Invoice is not created from Delivery Note
|
||||
var from_delivery_note = false;
|
||||
from_delivery_note = this.frm.doc.items.some(function (item) {
|
||||
return item.delivery_note ? true : false;
|
||||
});
|
||||
|
||||
if (!from_delivery_note && !is_delivered_by_supplier) {
|
||||
this.frm.add_custom_button(
|
||||
__("Delivery"),
|
||||
this.frm.cscript["Make Delivery Note"],
|
||||
__("Create")
|
||||
if (!is_delivered_by_supplier) {
|
||||
const should_create_delivery_note = doc.items.some(
|
||||
(item) =>
|
||||
item.qty - item.delivered_qty > 0 &&
|
||||
!item.scio_detail &&
|
||||
!item.dn_detail &&
|
||||
!item.delivered_by_supplier
|
||||
);
|
||||
if (should_create_delivery_note) {
|
||||
this.frm.add_custom_button(
|
||||
__("Delivery Note"),
|
||||
this.frm.cscript["Make Delivery Note"],
|
||||
__("Create")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2422,7 +2422,10 @@ def make_delivery_note(source_name, target_doc=None):
|
||||
"cost_center": "cost_center",
|
||||
},
|
||||
"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 Team": {
|
||||
|
||||
@@ -116,14 +116,6 @@ frappe.ui.form.on("Asset", {
|
||||
__("Manage")
|
||||
);
|
||||
|
||||
frm.add_custom_button(
|
||||
__("Repair Asset"),
|
||||
function () {
|
||||
frm.trigger("create_asset_repair");
|
||||
},
|
||||
__("Manage")
|
||||
);
|
||||
|
||||
frm.add_custom_button(
|
||||
__("Split Asset"),
|
||||
function () {
|
||||
@@ -155,6 +147,14 @@ frappe.ui.form.on("Asset", {
|
||||
},
|
||||
__("Manage")
|
||||
);
|
||||
|
||||
frm.add_custom_button(
|
||||
__("Repair Asset"),
|
||||
function () {
|
||||
frm.trigger("create_asset_repair");
|
||||
},
|
||||
__("Manage")
|
||||
);
|
||||
}
|
||||
|
||||
if (!frm.doc.calculate_depreciation) {
|
||||
|
||||
@@ -552,7 +552,7 @@ class StockController(AccountsController):
|
||||
if is_rejected:
|
||||
serial_nos = row.get("rejected_serial_no")
|
||||
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")
|
||||
|
||||
if (
|
||||
|
||||
@@ -166,29 +166,46 @@ class SubcontractingController(StockController):
|
||||
_("Row {0}: Item {1} must be a subcontracted item.").format(item.idx, item.item_name)
|
||||
)
|
||||
|
||||
if self.doctype != "Subcontracting Receipt" and item.qty > flt(
|
||||
get_pending_subcontracted_quantity(
|
||||
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(
|
||||
if self.doctype != "Subcontracting Receipt":
|
||||
order_item_doctype = (
|
||||
"Purchase Order Item"
|
||||
if self.doctype == "Subcontracting Order"
|
||||
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)
|
||||
else "Sales Order Item"
|
||||
)
|
||||
|
||||
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":
|
||||
item.amount = item.qty * item.rate
|
||||
|
||||
@@ -1333,9 +1350,7 @@ def get_item_details(items):
|
||||
|
||||
|
||||
def get_pending_subcontracted_quantity(doctype, name):
|
||||
table = frappe.qb.DocType(
|
||||
"Purchase Order Item" if doctype == "Subcontracting Order" else "Sales Order Item"
|
||||
)
|
||||
table = frappe.qb.DocType(doctype)
|
||||
query = (
|
||||
frappe.qb.from_(table)
|
||||
.select(table.name, table.stock_qty, table.subcontracted_qty)
|
||||
|
||||
@@ -720,6 +720,7 @@ class SubcontractingInwardController:
|
||||
item.db_set("scio_detail", scio_rm.name)
|
||||
|
||||
if data:
|
||||
precision = self.precision("customer_provided_item_cost", "items")
|
||||
result = frappe.get_all(
|
||||
"Subcontracting Inward Order Received Item",
|
||||
filters={
|
||||
@@ -734,10 +735,17 @@ class SubcontractingInwardController:
|
||||
table = frappe.qb.DocType("Subcontracting Inward Order Received Item")
|
||||
case_expr_qty, case_expr_rate = Case(), Case()
|
||||
for d in result:
|
||||
d.received_qty += (
|
||||
data[d.name].transfer_qty if self._action == "submit" else -data[d.name].transfer_qty
|
||||
current_qty = flt(data[d.name].transfer_qty) * (1 if self._action == "submit" else -1)
|
||||
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:
|
||||
deleted_docs.append(d.name)
|
||||
|
||||
@@ -569,6 +569,7 @@ accounting_dimension_doctypes = [
|
||||
"Payment Request",
|
||||
"Asset Movement Item",
|
||||
"Asset Depreciation Schedule",
|
||||
"Advance Taxes and Charges",
|
||||
]
|
||||
|
||||
get_matching_queries = (
|
||||
|
||||
@@ -3725,6 +3725,53 @@ class TestWorkOrder(IntegrationTestCase):
|
||||
wo = make_wo_order_test_record(item="Top Level Parent")
|
||||
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):
|
||||
doctype = frappe.qb.DocType("Stock Reservation Entry")
|
||||
|
||||
@@ -829,7 +829,7 @@ erpnext.work_order = {
|
||||
}
|
||||
}
|
||||
if (counter > 0) {
|
||||
var consumption_btn = frm.add_custom_button(
|
||||
frm.add_custom_button(
|
||||
__("Material Consumption"),
|
||||
function () {
|
||||
const backflush_raw_materials_based_on =
|
||||
|
||||
@@ -770,6 +770,7 @@ class WorkOrder(Document):
|
||||
self.db_set("status", "Cancelled")
|
||||
|
||||
self.on_close_or_cancel()
|
||||
self.delete_job_card()
|
||||
|
||||
def on_close_or_cancel(self):
|
||||
if self.production_plan and frappe.db.exists(
|
||||
@@ -779,7 +780,6 @@ class WorkOrder(Document):
|
||||
else:
|
||||
self.update_work_order_qty_in_so()
|
||||
|
||||
self.delete_job_card()
|
||||
self.update_completed_qty_in_material_request()
|
||||
self.update_planned_qty()
|
||||
self.update_ordered_qty()
|
||||
@@ -2654,6 +2654,9 @@ def get_reserved_qty_for_production(
|
||||
qty_field = wo_item.required_qty
|
||||
else:
|
||||
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.else_(wo_item.required_qty - wo_item.consumed_qty)
|
||||
|
||||
|
||||
@@ -458,3 +458,4 @@ erpnext.patches.v16_0.update_corrected_cancelled_status
|
||||
erpnext.patches.v16_0.fix_barcode_typo
|
||||
erpnext.patches.v16_0.set_post_change_gl_entries_on_pos_settings
|
||||
execute:frappe.delete_doc_if_exists("Workspace Sidebar", "Opening & Closing")
|
||||
erpnext.patches.v15_0.create_accounting_dimensions_in_advance_taxes_and_charges
|
||||
|
||||
@@ -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")
|
||||
@@ -603,7 +603,7 @@ def send_project_update_email_to_users(project):
|
||||
"sent": 0,
|
||||
"date": today(),
|
||||
"time": nowtime(),
|
||||
"naming_series": "UPDATE-.project.-.YY.MM.DD.-",
|
||||
"naming_series": "UPDATE-.project.-.YY.MM.DD.-.####",
|
||||
}
|
||||
).insert()
|
||||
|
||||
|
||||
@@ -3131,10 +3131,16 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
|
||||
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() {
|
||||
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() {
|
||||
|
||||
@@ -183,6 +183,7 @@
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Customer Group",
|
||||
"link_filters": "[[\"Customer Group\", \"is_group\", \"=\", 0]]",
|
||||
"oldfieldname": "customer_group",
|
||||
"oldfieldtype": "Link",
|
||||
"options": "Customer Group",
|
||||
@@ -625,7 +626,7 @@
|
||||
"link_fieldname": "party"
|
||||
}
|
||||
],
|
||||
"modified": "2026-01-16 15:56:05.967663",
|
||||
"modified": "2026-01-21 17:23:42.151114",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Selling",
|
||||
"name": "Customer",
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Default Customer Group",
|
||||
"link_filters": "[[\"Customer Group\", \"is_group\", \"=\", 0]]",
|
||||
"options": "Customer Group"
|
||||
},
|
||||
{
|
||||
@@ -297,7 +298,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2026-01-02 18:17:05.734945",
|
||||
"modified": "2026-01-21 17:28:37.027837",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Selling",
|
||||
"name": "Selling Settings",
|
||||
|
||||
@@ -6,14 +6,14 @@
|
||||
}
|
||||
},
|
||||
"Algeria": {
|
||||
"Algeria VAT 17%": {
|
||||
"account_name": "VAT 17%",
|
||||
"tax_rate": 17.00,
|
||||
"Algeria TVA 19%": {
|
||||
"account_name": "TVA 19%",
|
||||
"tax_rate": 19.00,
|
||||
"default": 1
|
||||
},
|
||||
"Algeria VAT 7%": {
|
||||
"account_name": "VAT 7%",
|
||||
"tax_rate": 7.00
|
||||
"Algeria TVA 9%": {
|
||||
"account_name": "TVA 9%",
|
||||
"tax_rate": 9.00
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -97,7 +97,6 @@ class DeprecatedBatchNoValuation:
|
||||
for ledger in entries:
|
||||
self.stock_value_differece[ledger.batch_no] += flt(ledger.batch_value)
|
||||
self.available_qty[ledger.batch_no] += flt(ledger.batch_qty)
|
||||
self.total_qty[ledger.batch_no] += flt(ledger.batch_qty)
|
||||
|
||||
@deprecated(
|
||||
"erpnext.stock.serial_batch_bundle.BatchNoValuation.get_sle_for_batches",
|
||||
@@ -271,7 +270,6 @@ class DeprecatedBatchNoValuation:
|
||||
batch_data = query.run(as_dict=True)
|
||||
for d in batch_data:
|
||||
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:
|
||||
if self.available_qty.get(d.batch_no):
|
||||
@@ -383,7 +381,6 @@ class DeprecatedBatchNoValuation:
|
||||
batch_data = query.run(as_dict=True)
|
||||
for d in batch_data:
|
||||
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:
|
||||
return
|
||||
|
||||
@@ -282,7 +282,6 @@
|
||||
{
|
||||
"fieldname": "set_warehouse",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
"in_list_view": 1,
|
||||
"label": "Set Target Warehouse",
|
||||
"options": "Warehouse"
|
||||
@@ -378,7 +377,7 @@
|
||||
"idx": 70,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-01-10 15:34:59.000603",
|
||||
"modified": "2026-01-21 12:48:40.792323",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Material Request",
|
||||
|
||||
@@ -4997,6 +4997,45 @@ class TestPurchaseReceipt(IntegrationTestCase):
|
||||
|
||||
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():
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier
|
||||
|
||||
@@ -576,14 +576,12 @@ class SerialandBatchBundle(Document):
|
||||
d.incoming_rate = abs(flt(sn_obj.batch_avg_rate.get(d.batch_no)))
|
||||
|
||||
precision = d.precision("qty")
|
||||
for field in ["available_qty", "total_qty"]:
|
||||
value = getattr(sn_obj, field)
|
||||
available_qty = flt(value.get(d.batch_no), precision)
|
||||
if self.docstatus == 1:
|
||||
available_qty += flt(d.qty, precision)
|
||||
available_qty = flt(sn_obj.available_qty.get(d.batch_no), precision)
|
||||
if self.docstatus == 1:
|
||||
available_qty += flt(d.qty, precision)
|
||||
|
||||
if not allow_negative_stock:
|
||||
self.validate_negative_batch(d.batch_no, available_qty, field)
|
||||
if not allow_negative_stock:
|
||||
self.validate_negative_batch(d.batch_no, available_qty)
|
||||
|
||||
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):
|
||||
if available_qty < 0 and not self.is_stock_reco_for_valuation_adjustment(available_qty, field=field):
|
||||
def validate_negative_batch(self, batch_no, available_qty):
|
||||
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)}
|
||||
has negative stock
|
||||
of quantity {bold(available_qty)} in the
|
||||
@@ -605,7 +603,7 @@ class SerialandBatchBundle(Document):
|
||||
|
||||
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 (
|
||||
self.voucher_type == "Stock Reconciliation"
|
||||
and self.type_of_transaction == "Outward"
|
||||
@@ -613,7 +611,6 @@ class SerialandBatchBundle(Document):
|
||||
and (
|
||||
abs(frappe.db.get_value("Stock Reconciliation Item", self.voucher_detail_no, "qty"))
|
||||
== abs(available_qty)
|
||||
or field == "total_qty"
|
||||
)
|
||||
):
|
||||
return True
|
||||
@@ -1344,6 +1341,7 @@ class SerialandBatchBundle(Document):
|
||||
def on_submit(self):
|
||||
self.validate_docstatus()
|
||||
self.validate_serial_nos_inventory()
|
||||
self.validate_batch_quantity()
|
||||
|
||||
def validate_docstatus(self):
|
||||
for row in self.entries:
|
||||
@@ -1437,6 +1435,106 @@ class SerialandBatchBundle(Document):
|
||||
|
||||
def on_cancel(self):
|
||||
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):
|
||||
if self.voucher_type == "POS Invoice":
|
||||
|
||||
@@ -959,7 +959,9 @@ class StockEntry(StockController, SubcontractingInwardController):
|
||||
if matched_item := self.get_matched_items(item_code):
|
||||
if flt(details.get("qty"), precision) != flt(matched_item.qty, precision):
|
||||
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),
|
||||
flt(details.get("qty")),
|
||||
get_link_to_form("BOM", self.bom_no),
|
||||
@@ -1024,12 +1026,37 @@ class StockEntry(StockController, SubcontractingInwardController):
|
||||
)
|
||||
|
||||
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:
|
||||
return row
|
||||
|
||||
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()
|
||||
def get_stock_and_rate(self):
|
||||
"""
|
||||
|
||||
@@ -2384,6 +2384,33 @@ class TestStockEntry(IntegrationTestCase):
|
||||
|
||||
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):
|
||||
args = frappe._dict(args)
|
||||
|
||||
@@ -75,6 +75,7 @@ class StockReconciliation(StockController):
|
||||
self.validate_duplicate_serial_and_batch_bundle("items")
|
||||
self.remove_items_with_no_change()
|
||||
self.validate_data()
|
||||
self.change_row_indexes()
|
||||
self.validate_expense_account()
|
||||
self.validate_customer_provided_item()
|
||||
self.set_zero_value_for_customer_provided_items()
|
||||
@@ -556,8 +557,7 @@ class StockReconciliation(StockController):
|
||||
|
||||
elif len(items) != len(self.items):
|
||||
self.items = items
|
||||
for i, item in enumerate(self.items):
|
||||
item.idx = i + 1
|
||||
self.change_idx = True
|
||||
frappe.msgprint(_("Removed items with no change in quantity or value."))
|
||||
|
||||
def calculate_difference_amount(self, item, item_dict):
|
||||
@@ -574,14 +574,14 @@ class StockReconciliation(StockController):
|
||||
|
||||
def validate_data(self):
|
||||
def _get_msg(row_num, msg):
|
||||
return _("Row # {0}:").format(row_num + 1) + " " + msg
|
||||
return _("Row #{0}:").format(row_num) + " " + msg
|
||||
|
||||
self.validation_messages = []
|
||||
item_warehouse_combinations = []
|
||||
|
||||
default_currency = frappe.db.get_default("currency")
|
||||
|
||||
for row_num, row in enumerate(self.items):
|
||||
for row in self.items:
|
||||
# find duplicates
|
||||
key = [row.item_code, row.warehouse]
|
||||
for field in ["serial_no", "batch_no"]:
|
||||
@@ -594,7 +594,7 @@ class StockReconciliation(StockController):
|
||||
|
||||
if key in item_warehouse_combinations:
|
||||
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:
|
||||
item_warehouse_combinations.append(key)
|
||||
@@ -604,7 +604,7 @@ class StockReconciliation(StockController):
|
||||
if row.serial_no and not row.qty:
|
||||
self.validation_messages.append(
|
||||
_get_msg(
|
||||
row_num,
|
||||
row.idx,
|
||||
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 row.qty in ["", None] and row.valuation_rate in ["", None]:
|
||||
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
|
||||
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
|
||||
if flt(row.valuation_rate) < 0:
|
||||
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]:
|
||||
@@ -654,6 +654,11 @@ class StockReconciliation(StockController):
|
||||
|
||||
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):
|
||||
from erpnext.stock.doctype.item.item import (
|
||||
validate_cancelled_item,
|
||||
@@ -661,6 +666,16 @@ class StockReconciliation(StockController):
|
||||
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
|
||||
|
||||
try:
|
||||
@@ -669,12 +684,13 @@ class StockReconciliation(StockController):
|
||||
# end of life and stock item
|
||||
validate_end_of_life(item_code, item.end_of_life, item.disabled)
|
||||
validate_is_stock_item(item_code, item.is_stock_item)
|
||||
validate_serial_batch_items()
|
||||
|
||||
# docstatus should be < 2
|
||||
validate_cancelled_item(item_code, item.docstatus)
|
||||
|
||||
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:
|
||||
"""Raises an exception if there is any reserved stock for the items in the Stock Reconciliation."""
|
||||
|
||||
@@ -1450,6 +1450,7 @@ class TestStockReconciliation(IntegrationTestCase, StockTestMixin):
|
||||
qty=10,
|
||||
rate=100,
|
||||
use_serial_batch_fields=1,
|
||||
purpose="Opening Stock",
|
||||
)
|
||||
|
||||
sr.reload()
|
||||
@@ -1592,6 +1593,7 @@ class TestStockReconciliation(IntegrationTestCase, StockTestMixin):
|
||||
qty=10,
|
||||
rate=80,
|
||||
use_serial_batch_fields=1,
|
||||
purpose="Opening Stock",
|
||||
)
|
||||
|
||||
batch_no = get_batch_from_bundle(reco.items[0].serial_and_batch_bundle)
|
||||
@@ -1676,6 +1678,7 @@ class TestStockReconciliation(IntegrationTestCase, StockTestMixin):
|
||||
qty=10,
|
||||
rate=100,
|
||||
use_serial_batch_fields=1,
|
||||
purpose="Opening Stock",
|
||||
)
|
||||
|
||||
sr.reload()
|
||||
|
||||
@@ -439,8 +439,10 @@ def get_basic_details(ctx: ItemDetailsCtx, item, overwrite_warehouse=True) -> It
|
||||
if not ctx.uom:
|
||||
if ctx.doctype in sales_doctypes:
|
||||
ctx.uom = item.sales_uom if item.sales_uom else item.stock_uom
|
||||
elif (ctx.doctype in ["Purchase Order", "Purchase Receipt", "Purchase Invoice"]) or (
|
||||
ctx.doctype == "Material Request" and ctx.material_request_type == "Purchase"
|
||||
elif (
|
||||
(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
|
||||
else:
|
||||
|
||||
@@ -807,62 +807,11 @@ class BatchNoValuation(DeprecatedBatchNoValuation):
|
||||
for ledger in entries:
|
||||
self.stock_value_differece[ledger.batch_no] += flt(ledger.incoming_rate)
|
||||
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_for_non_batchwise_valuation()
|
||||
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]:
|
||||
# Get batch wise stock value difference from Serial and Batch Bundle considering time condition
|
||||
if not self.batchwise_valuation_batches:
|
||||
|
||||
@@ -51,6 +51,49 @@ class IntegrationTestSubcontractingInwardOrder(IntegrationTestCase):
|
||||
for item in rm_in.get("items"):
|
||||
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):
|
||||
so, scio = create_so_scio()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user