feat: Serial and Batch reservation (#35946)
* feat: add `Has Serial No` and `Has Batch No` fields in SRE * chore: set `Has Serial No` and `Has Batch No` while creating SRE * feat: add field `Reserved Serial and Batch` in SRE * fix(ux): hide `Amend` button on cancelled SRE * fix: add validation for SRE amended doc * fix(ux): hide `Reserved Serial and Batch` Table for non-serial/batch item * fix(ux): set `Display Depends On` for `Has Serial No` and `Has Batch No` in SRE * fix(ux): make `serial_no` and `batch_no` fields read-only based on `has_serial_no` and `has_batch_no` * chore: remove table `Serial and Batch Entry` fieldlabel * fix(ux): set warehouse for new row * fix(ux): make qty field read-only for serial item * fix(ux): set rows qty to `1` before making the field read-only * chore: add filters for serial no * chore: add filters for batch no * chore: don't show Serial NO if already selected * chore: hide rate related fields * feat: add field `Reservation Based On` in SRE * chore: make `Reserved Qty` field editable in SCR * chore: add method to get total reserved qty against a voucher * fix: add validation for `Reserved Qty` * fix: update SRE status and Voucher's Reserved Qty * chore: enable `Track Changes` in SRE * fix: add validation to prevent delivered SRE to get updated * fix(ux): make fields `Reserved Qty` and `Reservation Based On` read-only for delivered SRE * fix: consider voucher's delivered qty while calculating max reserved qty * fix: add UOM validation for SRE Reserved Qty * fix: SRE warehouse mismatch error in DN * fix: auto cancel SRE on update if item is fully delivered for the SO * fix: skip SRE creation for group warehouse * feat: add `Set Warehouse` field in SO stock reservation dialog * fix(ux): hide `Add Row` button in SO stock reservation dialog * fix: group warehouse validation in SO * fix(ux): don't show Batch No if already selected * feat: add field `Auto Reserve Serial and Batch Nos` in `Stock Settings` * refactor: SRE reserved qty validation * feat: auto serial and batch reservation * chore: add section for `Serial and Batch Reservation` in `Stock Settings` * fix: make SRE sb_entries warehouse mandatory * fix(ux): unreserved qty calculation * fix: add validation for `Reserved Qty` against `Batch` * refactor: combine `get_available_qty_to_reserve()` and `get_available_qty_to_reserve_batch()` * fix: validate disabled batch * fix: add validation to validate serial nos availability * fix: update row qty if `Partial Reservation` is enabled * fix: ignore reserved serial nos while getting available serial nos * fix: add validation to prevent repeat batches * fix(ux): add validation for duplicate Serial No * fix: don't allow to update SRE with delivered stock * fix: ignore reserved serial and batch if reservation based on is not Serial and Batch * fix(ux): stock un-reservation confirmation before `Update Items` * chore: return list instead os set * feat: add field `Delivered Qty` in `Serial and Batch Entry` * feat: option to get SO reserved stock in Delivery Note * fix: ignore reserved batches while getting available batches * chore: `conflicts` * fix: incorrect available qty * fix: 'str' object has no attribute 'nodes_' * fix: `linter` * fix(ux): hide `Get Items From > Stock Reservation` if Stock Reservation is disabled * fix(ux): add `depends_on` for `Auto Reserve Serial and Batch Nos` * fix(ux): hide Stock Reservation field description in submitted SO * fix(ux): confirm before unreserve stock * feat: option to create DN for reserved stock from SO * fix: update delivered qty in SRE sb_entries * fix: Delivery Note (Reserved Stock) based on Delivery Date * fix(ux): SO `Update Items` confirmation on `Update` button click * feat: add dialog box to select SRE to unreserve * fix: `ZeroDivisionError` while saving the DN (Reserved Stock) * fix: don't allow to create Pick List if stock is reserved against SO * fix(ux): hide Create > Pick List button for SO with reserved stock * refactor: map reserved stock by default in DN * refactor: code cleanup and comments * fix: don't allow Stock Reservation against SO having Pick List * refactor: `create_stock_reservation_entries()` * feat: add fields to hold Pick List ref in SRE * feat: add field `Stock Reserved Qty` in Pick List Item * feat: provision to reserve stock from Pick List against Sales Order * fix: don't allow to update SRE if created against a Pick List * fix(ux): confirm before unreserve stock in Pick List * fix: don't allow to update Pick List having reserved stock * fix: circular dependency while cancelling the DN created from Pick List with Reserved Stock * chore: update `Max Reserve Qty` err msg to be more descriptive * refactor: rename field `Reserve Stock on Sales Order Submission` * fix: msg on partial reservation if disabled in stock settings * chore: add field description for `Enable Stock Reservation` * fix(test): `test_stock_reservation_against_sales_order` * fix(test): `test_stock_reservation_against_sales_order` * test: add test cases for serial and batch reservation * fix: batch stock levels qty * refactor: method `get_sre_reserved_qty_for_item_and_warehouse` * feat: show `Reserved Stock` in item master stock levels * feat: Reserved Stock Report * fix(ux): SO stock reservation dialogs width * refactor: get previous values from `_doc_before_save` instead of db * fix(ux): make `Reservation Based On` read-only if created against Pick List * feat: option to open `Reserved Stock` report from Sales Order * fix(ux): Sales Order - Reserve and Unreserve dialog box * fix: decrease SRE Delivered Qty on DN cancel * fix(ux): hide `Unreserve` button once reserved stock is delivered * chore: `linter` * fix(test): `test_reserved_stock_report` * test: add test case for DN cancellation * chore: rename field `Auto Reserve Stock on Sales Order Submission` * fix: `Insufficient Stock` error msg
This commit is contained in:
parent
0e517227ee
commit
2d8363a983
@ -3096,7 +3096,9 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
|
||||
|
||||
if has_reserved_stock(parent.doctype, parent.name):
|
||||
cancel_stock_reservation_entries(parent.doctype, parent.name)
|
||||
parent.create_stock_reservation_entries()
|
||||
|
||||
if parent.per_picked == 0:
|
||||
parent.create_stock_reservation_entries()
|
||||
|
||||
|
||||
@erpnext.allow_regional
|
||||
|
@ -571,6 +571,7 @@ erpnext.utils.update_child_items = function(opts) {
|
||||
const cannot_add_row = (typeof opts.cannot_add_row === 'undefined') ? true : opts.cannot_add_row;
|
||||
const child_docname = (typeof opts.cannot_add_row === 'undefined') ? "items" : opts.child_docname;
|
||||
const child_meta = frappe.get_meta(`${frm.doc.doctype} Item`);
|
||||
const has_reserved_stock = opts.has_reserved_stock ? true : false;
|
||||
const get_precision = (fieldname) => child_meta.fields.find(f => f.fieldname == fieldname).precision;
|
||||
|
||||
this.data = frm.doc[opts.child_docname].map((d) => {
|
||||
@ -734,6 +735,17 @@ erpnext.utils.update_child_items = function(opts) {
|
||||
},
|
||||
],
|
||||
primary_action: function() {
|
||||
if (frm.doctype == "Sales Order" && has_reserved_stock) {
|
||||
this.hide();
|
||||
frappe.confirm(
|
||||
__('The reserved stock will be released when you update items. Are you certain you wish to proceed?'),
|
||||
() => this.update_items(),
|
||||
)
|
||||
} else {
|
||||
this.update_items();
|
||||
}
|
||||
},
|
||||
update_items: function() {
|
||||
const trans_items = this.get_values()["trans_items"].filter((item) => !!item.item_code);
|
||||
frappe.call({
|
||||
method: 'erpnext.controllers.accounts_controller.update_child_qty_rate',
|
||||
@ -823,6 +835,8 @@ erpnext.utils.map_current_doc = function(opts) {
|
||||
"target_doc": cur_frm.doc,
|
||||
"args": opts.args
|
||||
},
|
||||
freeze: true,
|
||||
freeze_message: __("Mapping {0} ...", [opts.source_doctype]),
|
||||
callback: function(r) {
|
||||
if(!r.exc) {
|
||||
var doc = frappe.model.sync(r.message);
|
||||
|
@ -59,19 +59,27 @@ frappe.ui.form.on("Sales Order", {
|
||||
child_docname: "items",
|
||||
child_doctype: "Sales Order Detail",
|
||||
cannot_add_row: false,
|
||||
has_reserved_stock: frm.doc.__onload && frm.doc.__onload.has_reserved_stock
|
||||
})
|
||||
});
|
||||
|
||||
// Stock Reservation > Reserve button will be only visible if the SO has unreserved stock.
|
||||
if (frm.doc.__onload && frm.doc.__onload.has_unreserved_stock) {
|
||||
// Stock Reservation > Reserve button should only be visible if the SO has unreserved stock and no Pick List is created against the SO.
|
||||
if (frm.doc.__onload && frm.doc.__onload.has_unreserved_stock && flt(frm.doc.per_picked) === 0) {
|
||||
frm.add_custom_button(__('Reserve'), () => frm.events.create_stock_reservation_entries(frm), __('Stock Reservation'));
|
||||
}
|
||||
}
|
||||
|
||||
// Stock Reservation > Unreserve button will be only visible if the SO has reserved stock.
|
||||
// Stock Reservation > Unreserve button will be only visible if the SO has un-delivered reserved stock.
|
||||
if (frm.doc.__onload && frm.doc.__onload.has_reserved_stock) {
|
||||
frm.add_custom_button(__('Unreserve'), () => frm.events.cancel_stock_reservation_entries(frm), __('Stock Reservation'));
|
||||
}
|
||||
|
||||
frm.doc.items.forEach(item => {
|
||||
if (flt(item.stock_reserved_qty) > 0) {
|
||||
frm.add_custom_button(__('Reserved Stock'), () => frm.events.show_reserved_stock(frm), __('Stock Reservation'));
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (frm.doc.docstatus === 0) {
|
||||
@ -82,7 +90,7 @@ frappe.ui.form.on("Sales Order", {
|
||||
if (frm.is_new()) {
|
||||
frappe.db.get_single_value("Stock Settings", "enable_stock_reservation").then((value) => {
|
||||
if (value) {
|
||||
frappe.db.get_single_value("Stock Settings", "reserve_stock_on_sales_order_submission").then((value) => {
|
||||
frappe.db.get_single_value("Stock Settings", "auto_reserve_stock_for_sales_order").then((value) => {
|
||||
// If `Reserve Stock on Sales Order Submission` is enabled in Stock Settings, set Reserve Stock to 1 else 0.
|
||||
frm.set_value("reserve_stock", value ? 1 : 0);
|
||||
})
|
||||
@ -94,6 +102,11 @@ frappe.ui.form.on("Sales Order", {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Hide `Reserve Stock` field description in submitted or cancelled Sales Order.
|
||||
if (frm.doc.docstatus > 0) {
|
||||
frm.set_df_property("reserve_stock", "description", null);
|
||||
}
|
||||
},
|
||||
|
||||
get_items_from_internal_purchase_order(frm) {
|
||||
@ -171,76 +184,115 @@ frappe.ui.form.on("Sales Order", {
|
||||
},
|
||||
|
||||
create_stock_reservation_entries(frm) {
|
||||
let items_data = [];
|
||||
|
||||
const dialog = frappe.prompt({fieldname: 'items', fieldtype: 'Table', label: __('Items to Reserve'),
|
||||
const dialog = new frappe.ui.Dialog({
|
||||
title: __("Stock Reservation"),
|
||||
size: "large",
|
||||
fields: [
|
||||
{
|
||||
fieldtype: 'Data',
|
||||
fieldname: 'name',
|
||||
label: __('Name'),
|
||||
reqd: 1,
|
||||
read_only: 1,
|
||||
},
|
||||
{
|
||||
fieldtype: 'Link',
|
||||
fieldname: 'item_code',
|
||||
label: __('Item Code'),
|
||||
options: 'Item',
|
||||
reqd: 1,
|
||||
read_only: 1,
|
||||
in_list_view: 1,
|
||||
},
|
||||
{
|
||||
fieldtype: 'Link',
|
||||
fieldname: 'warehouse',
|
||||
label: __('Warehouse'),
|
||||
options: 'Warehouse',
|
||||
reqd: 1,
|
||||
in_list_view: 1,
|
||||
get_query: function () {
|
||||
fieldname: "set_warehouse",
|
||||
fieldtype: "Link",
|
||||
label: __("Set Warehouse"),
|
||||
options: "Warehouse",
|
||||
default: frm.doc.set_warehouse,
|
||||
get_query: () => {
|
||||
return {
|
||||
filters: [
|
||||
["Warehouse", "is_group", "!=", 1]
|
||||
]
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldtype: 'Float',
|
||||
fieldname: 'qty_to_reserve',
|
||||
label: __('Qty'),
|
||||
reqd: 1,
|
||||
in_list_view: 1
|
||||
}
|
||||
],
|
||||
data: items_data,
|
||||
in_place_edit: true,
|
||||
get_data: function() {
|
||||
return items_data;
|
||||
}
|
||||
}, function(data) {
|
||||
if (data.items.length > 0) {
|
||||
frappe.call({
|
||||
doc: frm.doc,
|
||||
method: 'create_stock_reservation_entries',
|
||||
args: {
|
||||
items_details: data.items,
|
||||
notify: true
|
||||
onchange: () => {
|
||||
if (dialog.get_value("set_warehouse")) {
|
||||
dialog.fields_dict.items.df.data.forEach((row) => {
|
||||
row.warehouse = dialog.get_value("set_warehouse");
|
||||
});
|
||||
dialog.fields_dict.items.grid.refresh();
|
||||
}
|
||||
},
|
||||
freeze: true,
|
||||
freeze_message: __('Reserving Stock...'),
|
||||
callback: (r) => {
|
||||
frm.doc.__onload.has_unreserved_stock = false;
|
||||
frm.reload_doc();
|
||||
}
|
||||
});
|
||||
}
|
||||
}, __("Stock Reservation"), __("Reserve Stock"));
|
||||
},
|
||||
{fieldtype: "Column Break"},
|
||||
{fieldtype: "Section Break"},
|
||||
{
|
||||
fieldname: "items",
|
||||
fieldtype: "Table",
|
||||
label: __("Items to Reserve"),
|
||||
allow_bulk_edit: false,
|
||||
cannot_add_rows: true,
|
||||
cannot_delete_rows: true,
|
||||
data: [],
|
||||
fields: [
|
||||
{
|
||||
fieldname: "name",
|
||||
fieldtype: "Data",
|
||||
label: __("Name"),
|
||||
reqd: 1,
|
||||
read_only: 1,
|
||||
},
|
||||
{
|
||||
fieldname: "item_code",
|
||||
fieldtype: "Link",
|
||||
label: __("Item Code"),
|
||||
options: "Item",
|
||||
reqd: 1,
|
||||
read_only: 1,
|
||||
in_list_view: 1,
|
||||
},
|
||||
{
|
||||
fieldname: "warehouse",
|
||||
fieldtype: "Link",
|
||||
label: __("Warehouse"),
|
||||
options: "Warehouse",
|
||||
reqd: 1,
|
||||
in_list_view: 1,
|
||||
get_query: () => {
|
||||
return {
|
||||
filters: [
|
||||
["Warehouse", "is_group", "!=", 1]
|
||||
]
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldname: "qty_to_reserve",
|
||||
fieldtype: "Float",
|
||||
label: __("Qty"),
|
||||
reqd: 1,
|
||||
in_list_view: 1
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
primary_action_label: __("Reserve Stock"),
|
||||
primary_action: () => {
|
||||
var data = {items: dialog.fields_dict.items.grid.get_selected_children()};
|
||||
|
||||
if (data.items && data.items.length > 0) {
|
||||
frappe.call({
|
||||
doc: frm.doc,
|
||||
method: "create_stock_reservation_entries",
|
||||
args: {
|
||||
items_details: data.items,
|
||||
notify: true
|
||||
},
|
||||
freeze: true,
|
||||
freeze_message: __("Reserving Stock..."),
|
||||
callback: (r) => {
|
||||
frm.doc.__onload.has_unreserved_stock = false;
|
||||
frm.reload_doc();
|
||||
}
|
||||
});
|
||||
}
|
||||
else {
|
||||
frappe.msgprint(__("Please select items to reserve."));
|
||||
}
|
||||
|
||||
dialog.hide();
|
||||
},
|
||||
});
|
||||
|
||||
frm.doc.items.forEach(item => {
|
||||
if (item.reserve_stock) {
|
||||
let unreserved_qty = (flt(item.stock_qty) - (flt(item.delivered_qty) * flt(item.conversion_factor)) - flt(item.stock_reserved_qty))
|
||||
let unreserved_qty = (flt(item.stock_qty) - (item.stock_reserved_qty ? flt(item.stock_reserved_qty) : (flt(item.delivered_qty) * flt(item.conversion_factor))))
|
||||
|
||||
if (unreserved_qty > 0) {
|
||||
dialog.fields_dict.items.df.data.push({
|
||||
@ -254,22 +306,127 @@ frappe.ui.form.on("Sales Order", {
|
||||
});
|
||||
|
||||
dialog.fields_dict.items.grid.refresh();
|
||||
dialog.show();
|
||||
},
|
||||
|
||||
cancel_stock_reservation_entries(frm) {
|
||||
const dialog = new frappe.ui.Dialog({
|
||||
title: __("Stock Unreservation"),
|
||||
size: "large",
|
||||
fields: [
|
||||
{
|
||||
fieldname: "sr_entries",
|
||||
fieldtype: "Table",
|
||||
label: __("Reserved Stock"),
|
||||
allow_bulk_edit: false,
|
||||
cannot_add_rows: true,
|
||||
cannot_delete_rows: true,
|
||||
in_place_edit: true,
|
||||
data: [],
|
||||
fields: [
|
||||
{
|
||||
fieldname: "name",
|
||||
fieldtype: "Link",
|
||||
label: __("SRE"),
|
||||
options: "Stock Reservation Entry",
|
||||
reqd: 1,
|
||||
read_only: 1,
|
||||
in_list_view: 1,
|
||||
},
|
||||
{
|
||||
fieldname: "item_code",
|
||||
fieldtype: "Link",
|
||||
label: __("Item Code"),
|
||||
options: "Item",
|
||||
reqd: 1,
|
||||
read_only: 1,
|
||||
in_list_view: 1,
|
||||
},
|
||||
{
|
||||
fieldname: "warehouse",
|
||||
fieldtype: "Link",
|
||||
label: __("Warehouse"),
|
||||
options: "Warehouse",
|
||||
reqd: 1,
|
||||
read_only: 1,
|
||||
in_list_view: 1,
|
||||
},
|
||||
{
|
||||
fieldname: "qty",
|
||||
fieldtype: "Float",
|
||||
label: __("Qty"),
|
||||
reqd: 1,
|
||||
read_only: 1,
|
||||
in_list_view: 1
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
primary_action_label: __("Unreserve Stock"),
|
||||
primary_action: () => {
|
||||
var data = {sr_entries: dialog.fields_dict.sr_entries.grid.get_selected_children()};
|
||||
|
||||
if (data.sr_entries && data.sr_entries.length > 0) {
|
||||
frappe.call({
|
||||
doc: frm.doc,
|
||||
method: "cancel_stock_reservation_entries",
|
||||
args: {
|
||||
sre_list: data.sr_entries,
|
||||
},
|
||||
freeze: true,
|
||||
freeze_message: __('Unreserving Stock...'),
|
||||
callback: (r) => {
|
||||
frm.doc.__onload.has_reserved_stock = false;
|
||||
frm.reload_doc();
|
||||
}
|
||||
});
|
||||
}
|
||||
else {
|
||||
frappe.msgprint(__("Please select items to unreserve."));
|
||||
}
|
||||
|
||||
dialog.hide();
|
||||
},
|
||||
});
|
||||
|
||||
frappe.call({
|
||||
method: 'erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry.cancel_stock_reservation_entries',
|
||||
method: 'erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry.get_stock_reservation_entries_for_voucher',
|
||||
args: {
|
||||
voucher_type: frm.doctype,
|
||||
voucher_no: frm.docname
|
||||
voucher_no: frm.docname,
|
||||
},
|
||||
freeze: true,
|
||||
freeze_message: __('Unreserving Stock...'),
|
||||
callback: (r) => {
|
||||
frm.doc.__onload.has_reserved_stock = false;
|
||||
frm.reload_doc();
|
||||
if (!r.exc && r.message) {
|
||||
r.message.forEach(sre => {
|
||||
if (flt(sre.reserved_qty) > flt(sre.delivered_qty)) {
|
||||
dialog.fields_dict.sr_entries.df.data.push({
|
||||
'name': sre.name,
|
||||
'item_code': sre.item_code,
|
||||
'warehouse': sre.warehouse,
|
||||
'qty': (flt(sre.reserved_qty) - flt(sre.delivered_qty))
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
}).then(r => {
|
||||
dialog.fields_dict.sr_entries.grid.refresh();
|
||||
dialog.show();
|
||||
});
|
||||
},
|
||||
|
||||
show_reserved_stock(frm) {
|
||||
// Get the latest modified date from the items table.
|
||||
var to_date = moment(new Date(Math.max(...frm.doc.items.map(e => new Date(e.modified))))).format('YYYY-MM-DD');
|
||||
|
||||
frappe.route_options = {
|
||||
company: frm.doc.company,
|
||||
from_date: frm.doc.transaction_date,
|
||||
to_date: to_date,
|
||||
voucher_type: frm.doc.doctype,
|
||||
voucher_no: frm.doc.name,
|
||||
}
|
||||
frappe.set_route("query-report", "Reserved Stock");
|
||||
}
|
||||
});
|
||||
|
||||
@ -335,8 +492,11 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
|
||||
}
|
||||
}
|
||||
|
||||
if (flt(doc.per_picked, 2) < 100 && flt(doc.per_delivered, 2) < 100) {
|
||||
this.frm.add_custom_button(__('Pick List'), () => this.create_pick_list(), __('Create'));
|
||||
if (!doc.__onload || !doc.__onload.has_reserved_stock) {
|
||||
// Don't show the `Reserve` button if the Sales Order has Picked Items.
|
||||
if (flt(doc.per_picked, 2) < 100 && flt(doc.per_delivered, 2) < 100) {
|
||||
this.frm.add_custom_button(__('Pick List'), () => this.create_pick_list(), __('Create'));
|
||||
}
|
||||
}
|
||||
|
||||
const order_is_a_sale = ["Sales", "Shopping Cart"].indexOf(doc.order_type) !== -1;
|
||||
@ -346,7 +506,7 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
|
||||
|
||||
// delivery note
|
||||
if(flt(doc.per_delivered, 2) < 100 && (order_is_a_sale || order_is_a_custom_sale) && allow_delivery) {
|
||||
this.frm.add_custom_button(__('Delivery Note'), () => this.make_delivery_note_based_on_delivery_date(), __('Create'));
|
||||
this.frm.add_custom_button(__('Delivery Note'), () => this.make_delivery_note_based_on_delivery_date(true), __('Create'));
|
||||
this.frm.add_custom_button(__('Work Order'), () => this.make_work_order(), __('Create'));
|
||||
}
|
||||
|
||||
@ -639,7 +799,7 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
|
||||
d.show();
|
||||
}
|
||||
|
||||
make_delivery_note_based_on_delivery_date() {
|
||||
make_delivery_note_based_on_delivery_date(for_reserved_stock=false) {
|
||||
var me = this;
|
||||
|
||||
var delivery_dates = this.frm.doc.items.map(i => i.delivery_date);
|
||||
@ -681,22 +841,25 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
|
||||
|
||||
if(!dates) return;
|
||||
|
||||
me.make_delivery_note(dates);
|
||||
me.make_delivery_note(dates, for_reserved_stock);
|
||||
dialog.hide();
|
||||
});
|
||||
dialog.show();
|
||||
} else {
|
||||
this.make_delivery_note();
|
||||
this.make_delivery_note([], for_reserved_stock);
|
||||
}
|
||||
}
|
||||
|
||||
make_delivery_note(delivery_dates) {
|
||||
make_delivery_note(delivery_dates, for_reserved_stock=false) {
|
||||
frappe.model.open_mapped_doc({
|
||||
method: "erpnext.selling.doctype.sales_order.sales_order.make_delivery_note",
|
||||
frm: this.frm,
|
||||
args: {
|
||||
delivery_dates
|
||||
}
|
||||
delivery_dates,
|
||||
for_reserved_stock: for_reserved_stock
|
||||
},
|
||||
freeze: true,
|
||||
freeze_message: __("Creating Delivery Note ...")
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -1027,7 +1027,6 @@
|
||||
"length": 240,
|
||||
"oldfieldname": "in_words_export",
|
||||
"oldfieldtype": "Data",
|
||||
"print_hide": 1,
|
||||
"read_only": 1,
|
||||
"width": "200px"
|
||||
},
|
||||
@ -1635,6 +1634,7 @@
|
||||
"description": "If checked, Stock Reservation Entries will be created on <b>Submit</b>",
|
||||
"fieldname": "reserve_stock",
|
||||
"fieldtype": "Check",
|
||||
"hidden": 1,
|
||||
"label": "Reserve Stock",
|
||||
"no_copy": 1,
|
||||
"print_hide": 1,
|
||||
@ -1645,7 +1645,7 @@
|
||||
"idx": 105,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2023-06-03 16:16:23.411247",
|
||||
"modified": "2023-07-24 08:59:11.599875",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Selling",
|
||||
"name": "Sales Order",
|
||||
|
@ -31,7 +31,6 @@ from erpnext.selling.doctype.customer.customer import check_credit_limit
|
||||
from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
|
||||
from erpnext.stock.doctype.item.item import get_item_defaults
|
||||
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
|
||||
cancel_stock_reservation_entries,
|
||||
get_sre_reserved_qty_details_for_voucher,
|
||||
has_reserved_stock,
|
||||
)
|
||||
@ -283,7 +282,7 @@ class SalesOrder(SellingController):
|
||||
self.db_set("status", "Cancelled")
|
||||
|
||||
self.update_blanket_order()
|
||||
cancel_stock_reservation_entries("Sales Order", self.name)
|
||||
self.cancel_stock_reservation_entries()
|
||||
|
||||
unlink_inter_company_doc(self.doctype, self.name, self.inter_company_order_reference)
|
||||
if self.coupon_code:
|
||||
@ -535,138 +534,26 @@ class SalesOrder(SellingController):
|
||||
return False
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_stock_reservation_entries(self, items_details=None, notify=True):
|
||||
def create_stock_reservation_entries(self, items_details=None, notify=True) -> None:
|
||||
"""Creates Stock Reservation Entries for Sales Order Items."""
|
||||
|
||||
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
|
||||
get_available_qty_to_reserve,
|
||||
validate_stock_reservation_settings,
|
||||
create_stock_reservation_entries_for_so_items as create_stock_reservation_entries,
|
||||
)
|
||||
|
||||
validate_stock_reservation_settings(self)
|
||||
create_stock_reservation_entries(so=self, items_details=items_details, notify=notify)
|
||||
|
||||
allow_partial_reservation = frappe.db.get_single_value(
|
||||
"Stock Settings", "allow_partial_reservation"
|
||||
@frappe.whitelist()
|
||||
def cancel_stock_reservation_entries(self, sre_list=None, notify=True) -> None:
|
||||
"""Cancel Stock Reservation Entries for Sales Order Items."""
|
||||
|
||||
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
|
||||
cancel_stock_reservation_entries,
|
||||
)
|
||||
|
||||
items = []
|
||||
if items_details:
|
||||
for item in items_details:
|
||||
so_item = frappe.get_doc("Sales Order Item", item["name"])
|
||||
so_item.reserve_stock = 1
|
||||
so_item.warehouse = item["warehouse"]
|
||||
so_item.qty_to_reserve = flt(item["qty_to_reserve"]) * flt(so_item.conversion_factor)
|
||||
items.append(so_item)
|
||||
|
||||
sre_count = 0
|
||||
reserved_qty_details = get_sre_reserved_qty_details_for_voucher("Sales Order", self.name)
|
||||
for item in items or self.get("items"):
|
||||
# Skip if `Reserved Stock` is not checked for the item.
|
||||
if not item.get("reserve_stock"):
|
||||
continue
|
||||
|
||||
# Skip if Non-Stock Item.
|
||||
if not frappe.get_cached_value("Item", item.item_code, "is_stock_item"):
|
||||
frappe.msgprint(
|
||||
_("Row #{0}: Stock cannot be reserved for a non-stock Item {1}").format(
|
||||
item.idx, frappe.bold(item.item_code)
|
||||
),
|
||||
title=_("Stock Reservation"),
|
||||
indicator="yellow",
|
||||
)
|
||||
item.db_set("reserve_stock", 0)
|
||||
continue
|
||||
|
||||
# Skip if Group Warehouse.
|
||||
if frappe.get_cached_value("Warehouse", item.warehouse, "is_group"):
|
||||
frappe.msgprint(
|
||||
_("Row #{0}: Stock cannot be reserved in group warehouse {1}.").format(
|
||||
item.idx, frappe.bold(item.warehouse)
|
||||
),
|
||||
title=_("Stock Reservation"),
|
||||
indicator="yellow",
|
||||
)
|
||||
continue
|
||||
|
||||
unreserved_qty = get_unreserved_qty(item, reserved_qty_details)
|
||||
|
||||
# Stock is already reserved for the item, notify the user and skip the item.
|
||||
if unreserved_qty <= 0:
|
||||
frappe.msgprint(
|
||||
_("Row #{0}: Stock is already reserved for the Item {1}.").format(
|
||||
item.idx, frappe.bold(item.item_code)
|
||||
),
|
||||
title=_("Stock Reservation"),
|
||||
indicator="yellow",
|
||||
)
|
||||
continue
|
||||
|
||||
available_qty_to_reserve = get_available_qty_to_reserve(item.item_code, item.warehouse)
|
||||
|
||||
# No stock available to reserve, notify the user and skip the item.
|
||||
if available_qty_to_reserve <= 0:
|
||||
frappe.msgprint(
|
||||
_("Row #{0}: No available stock to reserve for the Item {1} in Warehouse {2}.").format(
|
||||
item.idx, frappe.bold(item.item_code), frappe.bold(item.warehouse)
|
||||
),
|
||||
title=_("Stock Reservation"),
|
||||
indicator="orange",
|
||||
)
|
||||
continue
|
||||
|
||||
# The quantity which can be reserved.
|
||||
qty_to_be_reserved = min(unreserved_qty, available_qty_to_reserve)
|
||||
|
||||
if hasattr(item, "qty_to_reserve"):
|
||||
if item.qty_to_reserve <= 0:
|
||||
frappe.msgprint(
|
||||
_("Row #{0}: Quantity to reserve for the Item {1} should be greater than 0.").format(
|
||||
item.idx, frappe.bold(item.item_code)
|
||||
),
|
||||
title=_("Stock Reservation"),
|
||||
indicator="orange",
|
||||
)
|
||||
continue
|
||||
else:
|
||||
qty_to_be_reserved = min(qty_to_be_reserved, item.qty_to_reserve)
|
||||
|
||||
# Partial Reservation
|
||||
if qty_to_be_reserved < unreserved_qty:
|
||||
if not item.get("qty_to_reserve") or qty_to_be_reserved < flt(item.get("qty_to_reserve")):
|
||||
frappe.msgprint(
|
||||
_("Row #{0}: Only {1} available to reserve for the Item {2}").format(
|
||||
item.idx,
|
||||
frappe.bold(str(qty_to_be_reserved / item.conversion_factor) + " " + item.uom),
|
||||
frappe.bold(item.item_code),
|
||||
),
|
||||
title=_("Stock Reservation"),
|
||||
indicator="orange",
|
||||
)
|
||||
|
||||
# Skip the item if `Partial Reservation` is disabled in the Stock Settings.
|
||||
if not allow_partial_reservation:
|
||||
continue
|
||||
|
||||
# Create and Submit Stock Reservation Entry
|
||||
sre = frappe.new_doc("Stock Reservation Entry")
|
||||
sre.item_code = item.item_code
|
||||
sre.warehouse = item.warehouse
|
||||
sre.voucher_type = self.doctype
|
||||
sre.voucher_no = self.name
|
||||
sre.voucher_detail_no = item.name
|
||||
sre.available_qty = available_qty_to_reserve
|
||||
sre.voucher_qty = item.stock_qty
|
||||
sre.reserved_qty = qty_to_be_reserved
|
||||
sre.company = self.company
|
||||
sre.stock_uom = item.stock_uom
|
||||
sre.project = self.project
|
||||
sre.save()
|
||||
sre.submit()
|
||||
|
||||
sre_count += 1
|
||||
|
||||
if sre_count and notify:
|
||||
frappe.msgprint(_("Stock Reservation Entries Created"), alert=True, indicator="green")
|
||||
cancel_stock_reservation_entries(
|
||||
voucher_type=self.doctype, voucher_no=self.name, sre_list=sre_list, notify=notify
|
||||
)
|
||||
|
||||
|
||||
def get_unreserved_qty(item: object, reserved_qty_details: dict) -> float:
|
||||
@ -813,8 +700,31 @@ def make_project(source_name, target_doc=None):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_delivery_note(source_name, target_doc=None, skip_item_mapping=False):
|
||||
def make_delivery_note(source_name, target_doc=None, kwargs=None):
|
||||
from erpnext.stock.doctype.packed_item.packed_item import make_packing_list
|
||||
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
|
||||
get_sre_details_for_voucher,
|
||||
get_sre_reserved_qty_details_for_voucher,
|
||||
get_ssb_bundle_for_voucher,
|
||||
)
|
||||
|
||||
if not kwargs:
|
||||
kwargs = {
|
||||
"for_reserved_stock": frappe.flags.args and frappe.flags.args.for_reserved_stock,
|
||||
"skip_item_mapping": frappe.flags.args and frappe.flags.args.skip_item_mapping,
|
||||
}
|
||||
|
||||
kwargs = frappe._dict(kwargs)
|
||||
|
||||
sre_details = {}
|
||||
if kwargs.for_reserved_stock:
|
||||
sre_details = get_sre_reserved_qty_details_for_voucher("Sales Order", source_name)
|
||||
|
||||
mapper = {
|
||||
"Sales Order": {"doctype": "Delivery Note", "validation": {"docstatus": ["=", 1]}},
|
||||
"Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "add_if_empty": True},
|
||||
"Sales Team": {"doctype": "Sales Team", "add_if_empty": True},
|
||||
}
|
||||
|
||||
def set_missing_values(source, target):
|
||||
target.run_method("set_missing_values")
|
||||
@ -832,6 +742,18 @@ def make_delivery_note(source_name, target_doc=None, skip_item_mapping=False):
|
||||
|
||||
make_packing_list(target)
|
||||
|
||||
def condition(doc):
|
||||
if doc.name in sre_details:
|
||||
del sre_details[doc.name]
|
||||
return False
|
||||
|
||||
# make_mapped_doc sets js `args` into `frappe.flags.args`
|
||||
if frappe.flags.args and frappe.flags.args.delivery_dates:
|
||||
if cstr(doc.delivery_date) not in frappe.flags.args.delivery_dates:
|
||||
return False
|
||||
|
||||
return abs(doc.delivered_qty) < abs(doc.qty) and doc.delivered_by_supplier != 1
|
||||
|
||||
def update_item(source, target, source_parent):
|
||||
target.base_amount = (flt(source.qty) - flt(source.delivered_qty)) * flt(source.base_rate)
|
||||
target.amount = (flt(source.qty) - flt(source.delivered_qty)) * flt(source.rate)
|
||||
@ -847,21 +769,7 @@ def make_delivery_note(source_name, target_doc=None, skip_item_mapping=False):
|
||||
or item_group.get("buying_cost_center")
|
||||
)
|
||||
|
||||
mapper = {
|
||||
"Sales Order": {"doctype": "Delivery Note", "validation": {"docstatus": ["=", 1]}},
|
||||
"Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "add_if_empty": True},
|
||||
"Sales Team": {"doctype": "Sales Team", "add_if_empty": True},
|
||||
}
|
||||
|
||||
if not skip_item_mapping:
|
||||
|
||||
def condition(doc):
|
||||
# make_mapped_doc sets js `args` into `frappe.flags.args`
|
||||
if frappe.flags.args and frappe.flags.args.delivery_dates:
|
||||
if cstr(doc.delivery_date) not in frappe.flags.args.delivery_dates:
|
||||
return False
|
||||
return abs(doc.delivered_qty) < abs(doc.qty) and doc.delivered_by_supplier != 1
|
||||
|
||||
if not kwargs.skip_item_mapping:
|
||||
mapper["Sales Order Item"] = {
|
||||
"doctype": "Delivery Note Item",
|
||||
"field_map": {
|
||||
@ -869,11 +777,56 @@ def make_delivery_note(source_name, target_doc=None, skip_item_mapping=False):
|
||||
"name": "so_detail",
|
||||
"parent": "against_sales_order",
|
||||
},
|
||||
"postprocess": update_item,
|
||||
"condition": condition,
|
||||
"postprocess": update_item,
|
||||
}
|
||||
|
||||
target_doc = get_mapped_doc("Sales Order", source_name, mapper, target_doc, set_missing_values)
|
||||
so = frappe.get_doc("Sales Order", source_name)
|
||||
target_doc = get_mapped_doc("Sales Order", so.name, mapper, target_doc)
|
||||
|
||||
if not kwargs.skip_item_mapping and kwargs.for_reserved_stock:
|
||||
sre_list = get_sre_details_for_voucher("Sales Order", source_name)
|
||||
|
||||
if sre_list:
|
||||
|
||||
def update_dn_item(source, target, source_parent):
|
||||
update_item(source, target, so)
|
||||
|
||||
so_items = {d.name: d for d in so.items if d.stock_reserved_qty}
|
||||
|
||||
for sre in sre_list:
|
||||
if not condition(so_items[sre.voucher_detail_no]):
|
||||
continue
|
||||
|
||||
dn_item = get_mapped_doc(
|
||||
"Sales Order Item",
|
||||
sre.voucher_detail_no,
|
||||
{
|
||||
"Sales Order Item": {
|
||||
"doctype": "Delivery Note Item",
|
||||
"field_map": {
|
||||
"rate": "rate",
|
||||
"name": "so_detail",
|
||||
"parent": "against_sales_order",
|
||||
},
|
||||
"postprocess": update_dn_item,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
dn_item.qty = flt(sre.reserved_qty) * flt(dn_item.get("conversion_factor", 1))
|
||||
|
||||
if sre.reservation_based_on == "Serial and Batch" and (sre.has_serial_no or sre.has_batch_no):
|
||||
dn_item.serial_and_batch_bundle = get_ssb_bundle_for_voucher(sre)
|
||||
|
||||
target_doc.append("items", dn_item)
|
||||
else:
|
||||
# Correct rows index.
|
||||
for idx, item in enumerate(target_doc.items):
|
||||
item.idx = idx + 1
|
||||
|
||||
# Should be called after mapping items.
|
||||
set_missing_values(so, target_doc)
|
||||
target_doc.set_onload("ignore_price_list", True)
|
||||
|
||||
return target_doc
|
||||
@ -1436,6 +1389,16 @@ def make_inter_company_purchase_order(source_name, target_doc=None):
|
||||
def create_pick_list(source_name, target_doc=None):
|
||||
from erpnext.stock.doctype.packed_item.packed_item import is_product_bundle
|
||||
|
||||
def validate_sales_order():
|
||||
so = frappe.get_doc("Sales Order", source_name)
|
||||
for item in so.items:
|
||||
if item.stock_reserved_qty > 0:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Cannot create a pick list for Sales Order {0} because it has reserved stock. Please unreserve the stock in order to create a pick list."
|
||||
).format(frappe.bold(source_name))
|
||||
)
|
||||
|
||||
def update_item_quantity(source, target, source_parent) -> None:
|
||||
picked_qty = flt(source.picked_qty) / (flt(source.conversion_factor) or 1)
|
||||
qty_to_be_picked = flt(source.qty) - max(picked_qty, flt(source.delivered_qty))
|
||||
@ -1459,6 +1422,9 @@ def create_pick_list(source_name, target_doc=None):
|
||||
and not is_product_bundle(item.item_code)
|
||||
)
|
||||
|
||||
# Don't allow a Pick List to be created against a Sales Order that has reserved stock.
|
||||
validate_sales_order()
|
||||
|
||||
doc = get_mapped_doc(
|
||||
"Sales Order",
|
||||
source_name,
|
||||
|
@ -1789,147 +1789,6 @@ class TestSalesOrder(FrappeTestCase):
|
||||
self.assertEqual(pe.references[1].reference_name, so.name)
|
||||
self.assertEqual(pe.references[1].allocated_amount, 300)
|
||||
|
||||
@change_settings(
|
||||
"Stock Settings",
|
||||
{
|
||||
"enable_stock_reservation": 1,
|
||||
"auto_create_serial_and_batch_bundle_for_outward": 1,
|
||||
"pick_serial_and_batch_based_on": "FIFO",
|
||||
},
|
||||
)
|
||||
def test_stock_reservation_against_sales_order(self) -> None:
|
||||
from random import randint, uniform
|
||||
|
||||
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
|
||||
cancel_stock_reservation_entries,
|
||||
get_sre_reserved_qty_details_for_voucher,
|
||||
get_stock_reservation_entries_for_voucher,
|
||||
has_reserved_stock,
|
||||
)
|
||||
from erpnext.stock.doctype.stock_reservation_entry.test_stock_reservation_entry import (
|
||||
create_items,
|
||||
create_material_receipt,
|
||||
)
|
||||
|
||||
items_details, warehouse = create_items(), "_Test Warehouse - _TC"
|
||||
se = create_material_receipt(items_details, warehouse, qty=10)
|
||||
|
||||
item_list = []
|
||||
for item_code, properties in items_details.items():
|
||||
stock_uom = properties.stock_uom
|
||||
item_list.append(
|
||||
{
|
||||
"item_code": item_code,
|
||||
"warehouse": warehouse,
|
||||
"qty": flt(uniform(11, 100), 0 if stock_uom == "Nos" else 3),
|
||||
"uom": stock_uom,
|
||||
"rate": randint(10, 200),
|
||||
}
|
||||
)
|
||||
|
||||
so = make_sales_order(
|
||||
item_list=item_list,
|
||||
warehouse="_Test Warehouse - _TC",
|
||||
)
|
||||
|
||||
# Test - 1: Stock should not be reserved if the Available Qty to Reserve is less than the Ordered Qty and Partial Reservation is disabled in Stock Settings.
|
||||
with change_settings("Stock Settings", {"allow_partial_reservation": 0}):
|
||||
so.create_stock_reservation_entries()
|
||||
self.assertFalse(has_reserved_stock("Sales Order", so.name))
|
||||
|
||||
# Test - 2: Stock should be Partially Reserved if the Partial Reservation is enabled in Stock Settings.
|
||||
with change_settings("Stock Settings", {"allow_partial_reservation": 1}):
|
||||
so.create_stock_reservation_entries()
|
||||
so.load_from_db()
|
||||
self.assertTrue(has_reserved_stock("Sales Order", so.name))
|
||||
|
||||
for item in so.items:
|
||||
sre_details = get_stock_reservation_entries_for_voucher(
|
||||
"Sales Order", so.name, item.name, fields=["reserved_qty", "status"]
|
||||
)
|
||||
self.assertEqual(item.stock_reserved_qty, sre_details[0].reserved_qty)
|
||||
self.assertEqual(sre_details[0].status, "Partially Reserved")
|
||||
|
||||
se.cancel()
|
||||
|
||||
# Test - 3: Stock should be fully Reserved if the Available Qty to Reserve is greater than the Un-reserved Qty.
|
||||
create_material_receipt(items_details, warehouse, qty=110)
|
||||
so.create_stock_reservation_entries()
|
||||
so.load_from_db()
|
||||
|
||||
reserved_qty_details = get_sre_reserved_qty_details_for_voucher("Sales Order", so.name)
|
||||
for item in so.items:
|
||||
reserved_qty = reserved_qty_details[item.name]
|
||||
self.assertEqual(item.stock_reserved_qty, reserved_qty)
|
||||
self.assertEqual(item.stock_qty, item.stock_reserved_qty)
|
||||
|
||||
# Test - 4: Stock should get unreserved on cancellation of Stock Reservation Entries.
|
||||
cancel_stock_reservation_entries("Sales Order", so.name)
|
||||
so.load_from_db()
|
||||
self.assertFalse(has_reserved_stock("Sales Order", so.name))
|
||||
|
||||
for item in so.items:
|
||||
self.assertEqual(item.stock_reserved_qty, 0)
|
||||
|
||||
# Test - 5: Re-reserve the stock.
|
||||
so.create_stock_reservation_entries()
|
||||
self.assertTrue(has_reserved_stock("Sales Order", so.name))
|
||||
|
||||
# Test - 6: Stock should get unreserved on cancellation of Sales Order.
|
||||
so.cancel()
|
||||
so.load_from_db()
|
||||
self.assertFalse(has_reserved_stock("Sales Order", so.name))
|
||||
|
||||
for item in so.items:
|
||||
self.assertEqual(item.stock_reserved_qty, 0)
|
||||
|
||||
# Create Sales Order and Reserve Stock.
|
||||
so = make_sales_order(
|
||||
item_list=item_list,
|
||||
warehouse="_Test Warehouse - _TC",
|
||||
)
|
||||
so.create_stock_reservation_entries()
|
||||
|
||||
# Test - 7: Partial Delivery against Sales Order.
|
||||
dn1 = make_delivery_note(so.name)
|
||||
|
||||
for item in dn1.items:
|
||||
item.qty = flt(uniform(1, 10), 0 if item.stock_uom == "Nos" else 3)
|
||||
|
||||
dn1.save()
|
||||
dn1.submit()
|
||||
|
||||
for item in so.items:
|
||||
sre_details = get_stock_reservation_entries_for_voucher(
|
||||
"Sales Order", so.name, item.name, fields=["delivered_qty", "status"]
|
||||
)
|
||||
self.assertGreater(sre_details[0].delivered_qty, 0)
|
||||
self.assertEqual(sre_details[0].status, "Partially Delivered")
|
||||
|
||||
# Test - 8: Over Delivery against Sales Order, SRE Delivered Qty should not be greater than the SRE Reserved Qty.
|
||||
with change_settings("Stock Settings", {"over_delivery_receipt_allowance": 100}):
|
||||
dn2 = make_delivery_note(so.name)
|
||||
|
||||
for item in dn2.items:
|
||||
item.qty += flt(uniform(1, 10), 0 if item.stock_uom == "Nos" else 3)
|
||||
|
||||
dn2.save()
|
||||
dn2.submit()
|
||||
|
||||
for item in so.items:
|
||||
sre_details = frappe.db.get_all(
|
||||
"Stock Reservation Entry",
|
||||
filters={
|
||||
"voucher_type": "Sales Order",
|
||||
"voucher_no": so.name,
|
||||
"voucher_detail_no": item.name,
|
||||
},
|
||||
fields=["reserved_qty", "delivered_qty"],
|
||||
)
|
||||
|
||||
for sre_detail in sre_details:
|
||||
self.assertEqual(sre_detail.reserved_qty, sre_detail.delivered_qty)
|
||||
|
||||
def test_delivered_item_material_request(self):
|
||||
"SO -> MR (Manufacture) -> WO. Test if WO Qty is updated in SO."
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import (
|
||||
|
@ -2,6 +2,10 @@ import frappe
|
||||
from frappe.model.db_query import DatabaseQuery
|
||||
from frappe.utils import cint, flt
|
||||
|
||||
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
|
||||
get_sre_reserved_qty_for_item_and_warehouse as get_reserved_stock,
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_data(
|
||||
@ -57,6 +61,7 @@ def get_data(
|
||||
limit_page_length=21,
|
||||
)
|
||||
|
||||
sre_reserved_stock_details = get_reserved_stock(item_code, warehouse)
|
||||
precision = cint(frappe.db.get_single_value("System Settings", "float_precision"))
|
||||
|
||||
for item in items:
|
||||
@ -70,6 +75,7 @@ def get_data(
|
||||
"reserved_qty_for_production": flt(item.reserved_qty_for_production, precision),
|
||||
"reserved_qty_for_sub_contract": flt(item.reserved_qty_for_sub_contract, precision),
|
||||
"actual_qty": flt(item.actual_qty, precision),
|
||||
"reserved_stock": sre_reserved_stock_details.get((item.item_code, item.warehouse), 0),
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
@ -12,7 +12,10 @@
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<div class="col-sm-1" style="margin-top: 8px;" title="{{ __("Reserved Stock") }}">
|
||||
<a data-name="{{ d.reserved_stock }}">{{ d.reserved_stock }}</a>
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<span class="inline-graph">
|
||||
<span class="inline-graph-half" title="{{ __("Reserved Qty") }}">
|
||||
<span class="inline-graph-count">{{ d.total_reserved }}</span>
|
||||
|
@ -41,7 +41,7 @@ frappe.ui.form.on('Batch', {
|
||||
if(!frm.is_new()) {
|
||||
frappe.call({
|
||||
method: 'erpnext.stock.doctype.batch.batch.get_batch_qty',
|
||||
args: {batch_no: frm.doc.name},
|
||||
args: {batch_no: frm.doc.name, item_code: frm.doc.item},
|
||||
callback: (r) => {
|
||||
if(!r.message) {
|
||||
return;
|
||||
|
@ -150,6 +150,9 @@ erpnext.stock.DeliveryNoteController = class DeliveryNoteController extends erpn
|
||||
}
|
||||
erpnext.utils.map_current_doc({
|
||||
method: "erpnext.selling.doctype.sales_order.sales_order.make_delivery_note",
|
||||
args: {
|
||||
for_reserved_stock: 1
|
||||
},
|
||||
source_doctype: "Sales Order",
|
||||
target: me.frm,
|
||||
setters: {
|
||||
|
@ -279,6 +279,8 @@ class DeliveryNote(SellingController):
|
||||
self.update_prevdoc_status()
|
||||
self.update_billing_status()
|
||||
|
||||
self.update_stock_reservation_entries()
|
||||
|
||||
# Updating stock ledger should always be called after updating prevdoc status,
|
||||
# because updating reserved qty in bin depends upon updated delivered qty in SO
|
||||
self.update_stock_ledger()
|
||||
@ -297,55 +299,141 @@ class DeliveryNote(SellingController):
|
||||
def update_stock_reservation_entries(self) -> None:
|
||||
"""Updates Delivered Qty in Stock Reservation Entries."""
|
||||
|
||||
# Don't update Delivered Qty on Return or Cancellation.
|
||||
if self.is_return or self._action == "cancel":
|
||||
# Don't update Delivered Qty on Return.
|
||||
if self.is_return:
|
||||
return
|
||||
|
||||
for item in self.get("items"):
|
||||
# Skip if `Sales Order` or `Sales Order Item` reference is not set.
|
||||
if not item.against_sales_order or not item.so_detail:
|
||||
continue
|
||||
if self._action == "submit":
|
||||
for item in self.get("items"):
|
||||
# Skip if `Sales Order` or `Sales Order Item` reference is not set.
|
||||
if not item.against_sales_order or not item.so_detail:
|
||||
continue
|
||||
|
||||
sre_list = frappe.db.get_all(
|
||||
"Stock Reservation Entry",
|
||||
{
|
||||
"docstatus": 1,
|
||||
"voucher_type": "Sales Order",
|
||||
"voucher_no": item.against_sales_order,
|
||||
"voucher_detail_no": item.so_detail,
|
||||
"warehouse": item.warehouse,
|
||||
"status": ["not in", ["Delivered", "Cancelled"]],
|
||||
},
|
||||
order_by="creation",
|
||||
)
|
||||
sre_list = frappe.db.get_all(
|
||||
"Stock Reservation Entry",
|
||||
{
|
||||
"docstatus": 1,
|
||||
"voucher_type": "Sales Order",
|
||||
"voucher_no": item.against_sales_order,
|
||||
"voucher_detail_no": item.so_detail,
|
||||
"warehouse": item.warehouse,
|
||||
"status": ["not in", ["Delivered", "Cancelled"]],
|
||||
},
|
||||
order_by="creation",
|
||||
)
|
||||
|
||||
# Skip if no Stock Reservation Entries.
|
||||
if not sre_list:
|
||||
continue
|
||||
# Skip if no Stock Reservation Entries.
|
||||
if not sre_list:
|
||||
continue
|
||||
|
||||
available_qty_to_deliver = item.stock_qty
|
||||
for sre in sre_list:
|
||||
if available_qty_to_deliver <= 0:
|
||||
break
|
||||
qty_to_deliver = item.stock_qty
|
||||
for sre in sre_list:
|
||||
if qty_to_deliver <= 0:
|
||||
break
|
||||
|
||||
sre_doc = frappe.get_doc("Stock Reservation Entry", sre)
|
||||
sre_doc = frappe.get_doc("Stock Reservation Entry", sre)
|
||||
|
||||
# `Delivered Qty` should be less than or equal to `Reserved Qty`.
|
||||
qty_to_be_deliver = min(sre_doc.reserved_qty - sre_doc.delivered_qty, available_qty_to_deliver)
|
||||
qty_can_be_deliver = 0
|
||||
if sre_doc.reservation_based_on == "Serial and Batch":
|
||||
sbb = frappe.get_doc("Serial and Batch Bundle", item.serial_and_batch_bundle)
|
||||
if sre_doc.has_serial_no:
|
||||
delivered_serial_nos = [d.serial_no for d in sbb.entries]
|
||||
for entry in sre_doc.sb_entries:
|
||||
if entry.serial_no in delivered_serial_nos:
|
||||
entry.delivered_qty = 1 # Qty will always be 0 or 1 for Serial No.
|
||||
entry.db_update()
|
||||
qty_can_be_deliver += 1
|
||||
delivered_serial_nos.remove(entry.serial_no)
|
||||
else:
|
||||
delivered_batch_qty = {d.batch_no: -1 * d.qty for d in sbb.entries}
|
||||
for entry in sre_doc.sb_entries:
|
||||
if entry.batch_no in delivered_batch_qty:
|
||||
delivered_qty = min(
|
||||
(entry.qty - entry.delivered_qty), delivered_batch_qty[entry.batch_no]
|
||||
)
|
||||
entry.delivered_qty += delivered_qty
|
||||
entry.db_update()
|
||||
qty_can_be_deliver += delivered_qty
|
||||
delivered_batch_qty[entry.batch_no] -= delivered_qty
|
||||
else:
|
||||
# `Delivered Qty` should be less than or equal to `Reserved Qty`.
|
||||
qty_can_be_deliver = min((sre_doc.reserved_qty - sre_doc.delivered_qty), qty_to_deliver)
|
||||
|
||||
sre_doc.delivered_qty += qty_to_be_deliver
|
||||
sre_doc.db_update()
|
||||
sre_doc.delivered_qty += qty_can_be_deliver
|
||||
sre_doc.db_update()
|
||||
|
||||
# Update Stock Reservation Entry `Status` based on `Delivered Qty`.
|
||||
sre_doc.update_status()
|
||||
# Update Stock Reservation Entry `Status` based on `Delivered Qty`.
|
||||
sre_doc.update_status()
|
||||
|
||||
available_qty_to_deliver -= qty_to_be_deliver
|
||||
qty_to_deliver -= qty_can_be_deliver
|
||||
|
||||
if self._action == "cancel":
|
||||
for item in self.get("items"):
|
||||
# Skip if `Sales Order` or `Sales Order Item` reference is not set.
|
||||
if not item.against_sales_order or not item.so_detail:
|
||||
continue
|
||||
|
||||
sre_list = frappe.db.get_all(
|
||||
"Stock Reservation Entry",
|
||||
{
|
||||
"docstatus": 1,
|
||||
"voucher_type": "Sales Order",
|
||||
"voucher_no": item.against_sales_order,
|
||||
"voucher_detail_no": item.so_detail,
|
||||
"warehouse": item.warehouse,
|
||||
"status": ["in", ["Partially Delivered", "Delivered"]],
|
||||
},
|
||||
order_by="creation",
|
||||
)
|
||||
|
||||
# Skip if no Stock Reservation Entries.
|
||||
if not sre_list:
|
||||
continue
|
||||
|
||||
qty_to_undelivered = item.stock_qty
|
||||
for sre in sre_list:
|
||||
if qty_to_undelivered <= 0:
|
||||
break
|
||||
|
||||
sre_doc = frappe.get_doc("Stock Reservation Entry", sre)
|
||||
|
||||
qty_can_be_undelivered = 0
|
||||
if sre_doc.reservation_based_on == "Serial and Batch":
|
||||
sbb = frappe.get_doc("Serial and Batch Bundle", item.serial_and_batch_bundle)
|
||||
if sre_doc.has_serial_no:
|
||||
serial_nos_to_undelivered = [d.serial_no for d in sbb.entries]
|
||||
for entry in sre_doc.sb_entries:
|
||||
if entry.serial_no in serial_nos_to_undelivered:
|
||||
entry.delivered_qty = 0 # Qty will always be 0 or 1 for Serial No.
|
||||
entry.db_update()
|
||||
qty_can_be_undelivered += 1
|
||||
serial_nos_to_undelivered.remove(entry.serial_no)
|
||||
else:
|
||||
batch_qty_to_undelivered = {d.batch_no: -1 * d.qty for d in sbb.entries}
|
||||
for entry in sre_doc.sb_entries:
|
||||
if entry.batch_no in batch_qty_to_undelivered:
|
||||
undelivered_qty = min(entry.delivered_qty, batch_qty_to_undelivered[entry.batch_no])
|
||||
entry.delivered_qty -= undelivered_qty
|
||||
entry.db_update()
|
||||
qty_can_be_undelivered += undelivered_qty
|
||||
batch_qty_to_undelivered[entry.batch_no] -= undelivered_qty
|
||||
else:
|
||||
# `Qty to Undelivered` should be less than or equal to `Delivered Qty`.
|
||||
qty_can_be_undelivered = min(sre_doc.delivered_qty, qty_to_undelivered)
|
||||
|
||||
sre_doc.delivered_qty -= qty_can_be_undelivered
|
||||
sre_doc.db_update()
|
||||
|
||||
# Update Stock Reservation Entry `Status` based on `Delivered Qty`.
|
||||
sre_doc.update_status()
|
||||
|
||||
qty_to_undelivered -= qty_can_be_undelivered
|
||||
|
||||
def validate_against_stock_reservation_entries(self):
|
||||
"""Validates if Stock Reservation Entries are available for the Sales Order Item reference."""
|
||||
|
||||
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
|
||||
get_sre_reserved_qty_details_for_voucher_detail_no,
|
||||
get_sre_reserved_warehouses_for_voucher,
|
||||
)
|
||||
|
||||
# Don't validate if Return
|
||||
@ -357,26 +445,30 @@ class DeliveryNote(SellingController):
|
||||
if not item.against_sales_order or not item.so_detail:
|
||||
continue
|
||||
|
||||
sre_data = get_sre_reserved_qty_details_for_voucher_detail_no(
|
||||
reserved_warehouses = get_sre_reserved_warehouses_for_voucher(
|
||||
"Sales Order", item.against_sales_order, item.so_detail
|
||||
)
|
||||
|
||||
# Skip if stock is not reserved.
|
||||
if not sre_data:
|
||||
if not reserved_warehouses:
|
||||
continue
|
||||
|
||||
# Set `Warehouse` from SRE if not set.
|
||||
if not item.warehouse:
|
||||
item.warehouse = sre_data[0]
|
||||
item.warehouse = reserved_warehouses[0]
|
||||
else:
|
||||
# Throw if `Warehouse` is different from SRE.
|
||||
if item.warehouse != sre_data[0]:
|
||||
frappe.throw(
|
||||
_("Row #{0}: Stock is reserved for Item {1} in Warehouse {2}.").format(
|
||||
item.idx, frappe.bold(item.item_code), frappe.bold(sre_data[0])
|
||||
# Throw if `Warehouse` not in Reserved Warehouses.
|
||||
if item.warehouse not in reserved_warehouses:
|
||||
msg = _("Row #{0}: Stock is reserved for item {1} in warehouse {2}.").format(
|
||||
item.idx,
|
||||
frappe.bold(item.item_code),
|
||||
frappe.bold(reserved_warehouses[0])
|
||||
if len(reserved_warehouses) == 1
|
||||
else _("{0} and {1}").format(
|
||||
frappe.bold(", ".join(reserved_warehouses[:-1])), frappe.bold(reserved_warehouses[-1])
|
||||
),
|
||||
title=_("Stock Reservation Warehouse Mismatch"),
|
||||
)
|
||||
frappe.throw(msg, title=_("Stock Reservation Warehouse Mismatch"))
|
||||
|
||||
def check_credit_limit(self):
|
||||
from erpnext.selling.doctype.customer.customer import check_credit_limit
|
||||
|
@ -115,6 +115,22 @@ frappe.ui.form.on('Pick List', {
|
||||
frm.add_custom_button(__('Stock Entry'), () => frm.trigger('create_stock_entry'), __('Create'));
|
||||
}
|
||||
});
|
||||
|
||||
if (frm.doc.purpose === 'Delivery' && frm.doc.status === 'Open') {
|
||||
if (frm.doc.__onload && frm.doc.__onload.has_unreserved_stock) {
|
||||
frm.add_custom_button(__('Reserve'), () => frm.events.create_stock_reservation_entries(frm), __('Stock Reservation'));
|
||||
}
|
||||
|
||||
if (frm.doc.__onload && frm.doc.__onload.has_reserved_stock) {
|
||||
frm.add_custom_button(__('Unreserve'), () => {
|
||||
frappe.confirm(
|
||||
__('The reserved stock will be released. Are you certain you wish to proceed?'),
|
||||
() => frm.events.cancel_stock_reservation_entries(frm)
|
||||
)
|
||||
}, __('Stock Reservation'));
|
||||
frm.add_custom_button(__('Reserved Stock'), () => frm.events.show_reserved_stock(frm), __('Stock Reservation'));
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
work_order: (frm) => {
|
||||
@ -209,6 +225,49 @@ frappe.ui.form.on('Pick List', {
|
||||
};
|
||||
const barcode_scanner = new erpnext.utils.BarcodeScanner(opts);
|
||||
barcode_scanner.process_scan();
|
||||
},
|
||||
create_stock_reservation_entries: (frm) => {
|
||||
frappe.call({
|
||||
doc: frm.doc,
|
||||
method: "create_stock_reservation_entries",
|
||||
args: {
|
||||
notify: true
|
||||
},
|
||||
freeze: true,
|
||||
freeze_message: __("Reserving Stock..."),
|
||||
callback: (r) => {
|
||||
frm.doc.__onload.has_unreserved_stock = false;
|
||||
frm.reload_doc();
|
||||
}
|
||||
});
|
||||
},
|
||||
cancel_stock_reservation_entries: (frm) => {
|
||||
frappe.call({
|
||||
doc: frm.doc,
|
||||
method: "cancel_stock_reservation_entries",
|
||||
args: {
|
||||
notify: true
|
||||
},
|
||||
freeze: true,
|
||||
freeze_message: __('Unreserving Stock...'),
|
||||
callback: (r) => {
|
||||
frm.doc.__onload.has_reserved_stock = false;
|
||||
frm.reload_doc();
|
||||
}
|
||||
});
|
||||
},
|
||||
show_reserved_stock(frm) {
|
||||
// Get the latest modified date from the locations table.
|
||||
var to_date = moment(new Date(Math.max(...frm.doc.locations.map(e => new Date(e.modified))))).format('YYYY-MM-DD');
|
||||
|
||||
frappe.route_options = {
|
||||
company: frm.doc.company,
|
||||
from_date: moment(frm.doc.creation).format('YYYY-MM-DD'),
|
||||
to_date: to_date,
|
||||
voucher_type: "Sales Order",
|
||||
against_pick_list: frm.doc.name,
|
||||
}
|
||||
frappe.set_route("query-report", "Reserved Stock");
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -29,6 +29,14 @@ from erpnext.stock.serial_batch_bundle import SerialBatchCreation
|
||||
|
||||
|
||||
class PickList(Document):
|
||||
def onload(self) -> None:
|
||||
if frappe.get_cached_value("Stock Settings", None, "enable_stock_reservation"):
|
||||
if self.has_unreserved_stock():
|
||||
self.set_onload("has_unreserved_stock", True)
|
||||
|
||||
if self.has_reserved_stock():
|
||||
self.set_onload("has_reserved_stock", True)
|
||||
|
||||
def validate(self):
|
||||
self.validate_for_qty()
|
||||
|
||||
@ -47,8 +55,28 @@ class PickList(Document):
|
||||
)
|
||||
|
||||
def before_submit(self):
|
||||
self.validate_sales_order()
|
||||
self.validate_picked_items()
|
||||
|
||||
def validate_sales_order(self):
|
||||
"""Raises an exception if the `Sales Order` has reserved stock."""
|
||||
|
||||
if self.purpose != "Delivery":
|
||||
return
|
||||
|
||||
so_list = set(location.sales_order for location in self.locations if location.sales_order)
|
||||
|
||||
if so_list:
|
||||
for so in so_list:
|
||||
so_doc = frappe.get_doc("Sales Order", so)
|
||||
for item in so_doc.items:
|
||||
if item.stock_reserved_qty > 0:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Cannot create a pick list for Sales Order {0} because it has reserved stock. Please unreserve the stock in order to create a pick list."
|
||||
).format(frappe.bold(so))
|
||||
)
|
||||
|
||||
def validate_picked_items(self):
|
||||
for item in self.locations:
|
||||
if self.scan_mode and item.picked_qty < item.stock_qty:
|
||||
@ -70,8 +98,19 @@ class PickList(Document):
|
||||
self.update_reference_qty()
|
||||
self.update_sales_order_picking_status()
|
||||
|
||||
def on_update_after_submit(self) -> None:
|
||||
if self.has_reserved_stock():
|
||||
msg = _(
|
||||
"The Pick List having Stock Reservation Entries cannot be updated. If you need to make changes, we recommend canceling the existing Stock Reservation Entries before updating the Pick List."
|
||||
)
|
||||
frappe.throw(msg)
|
||||
|
||||
def on_cancel(self):
|
||||
self.ignore_linked_doctypes = "Serial and Batch Bundle"
|
||||
self.ignore_linked_doctypes = [
|
||||
"Serial and Batch Bundle",
|
||||
"Stock Reservation Entry",
|
||||
"Delivery Note",
|
||||
]
|
||||
|
||||
self.update_status()
|
||||
self.update_bundle_picked_qty()
|
||||
@ -186,6 +225,36 @@ class PickList(Document):
|
||||
for sales_order in sales_orders:
|
||||
frappe.get_doc("Sales Order", sales_order, for_update=True).update_picking_status()
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_stock_reservation_entries(self, notify=True) -> None:
|
||||
"""Creates Stock Reservation Entries for Sales Order Items against Pick List."""
|
||||
|
||||
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
|
||||
create_stock_reservation_entries_for_so_items,
|
||||
)
|
||||
|
||||
so_details = {}
|
||||
for location in self.locations:
|
||||
if location.warehouse and location.sales_order and location.sales_order_item:
|
||||
so_details.setdefault(location.sales_order, []).append(location)
|
||||
|
||||
if so_details:
|
||||
for so, locations in so_details.items():
|
||||
so_doc = frappe.get_doc("Sales Order", so)
|
||||
create_stock_reservation_entries_for_so_items(
|
||||
so=so_doc, items_details=locations, against_pick_list=True, notify=notify
|
||||
)
|
||||
|
||||
@frappe.whitelist()
|
||||
def cancel_stock_reservation_entries(self, notify=True) -> None:
|
||||
"""Cancel Stock Reservation Entries for Sales Order Items created against Pick List."""
|
||||
|
||||
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
|
||||
cancel_stock_reservation_entries,
|
||||
)
|
||||
|
||||
cancel_stock_reservation_entries(against_pick_list=self.name, notify=notify)
|
||||
|
||||
def validate_picked_qty(self, data):
|
||||
over_delivery_receipt_allowance = 100 + flt(
|
||||
frappe.db.get_single_value("Stock Settings", "over_delivery_receipt_allowance")
|
||||
@ -448,6 +517,26 @@ class PickList(Document):
|
||||
possible_bundles.append(0)
|
||||
return int(flt(min(possible_bundles), precision or 6))
|
||||
|
||||
def has_unreserved_stock(self):
|
||||
if self.purpose == "Delivery":
|
||||
for location in self.locations:
|
||||
if (
|
||||
location.sales_order
|
||||
and location.sales_order_item
|
||||
and (flt(location.picked_qty) - flt(location.stock_reserved_qty)) > 0
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def has_reserved_stock(self):
|
||||
if self.purpose == "Delivery":
|
||||
for location in self.locations:
|
||||
if location.sales_order and location.sales_order_item and flt(location.stock_reserved_qty) > 0:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def update_pick_list_status(pick_list):
|
||||
if pick_list:
|
||||
@ -781,7 +870,8 @@ def create_dn_with_so(sales_dict, pick_list):
|
||||
for customer in sales_dict:
|
||||
for so in sales_dict[customer]:
|
||||
delivery_note = None
|
||||
delivery_note = create_delivery_note_from_sales_order(so, delivery_note, skip_item_mapping=True)
|
||||
kwargs = {"skip_item_mapping": True}
|
||||
delivery_note = create_delivery_note_from_sales_order(so, delivery_note, kwargs=kwargs)
|
||||
break
|
||||
if delivery_note:
|
||||
# map all items of all sales orders of that customer
|
||||
|
@ -1,10 +1,13 @@
|
||||
def get_data():
|
||||
return {
|
||||
"fieldname": "pick_list",
|
||||
"non_standard_fieldnames": {
|
||||
"Stock Reservation Entry": "against_pick_list",
|
||||
},
|
||||
"internal_links": {
|
||||
"Sales Order": ["locations", "sales_order"],
|
||||
},
|
||||
"transactions": [
|
||||
{"items": ["Stock Entry", "Sales Order", "Delivery Note"]},
|
||||
{"items": ["Stock Entry", "Sales Order", "Delivery Note", "Stock Reservation Entry"]},
|
||||
],
|
||||
}
|
||||
|
@ -16,6 +16,7 @@
|
||||
"qty",
|
||||
"stock_qty",
|
||||
"picked_qty",
|
||||
"stock_reserved_qty",
|
||||
"column_break_11",
|
||||
"uom",
|
||||
"conversion_factor",
|
||||
@ -46,7 +47,7 @@
|
||||
"fieldname": "picked_qty",
|
||||
"fieldtype": "Float",
|
||||
"in_list_view": 1,
|
||||
"label": "Picked Qty"
|
||||
"label": "Picked Qty (in Stock UOM)"
|
||||
},
|
||||
{
|
||||
"fieldname": "warehouse",
|
||||
@ -154,8 +155,7 @@
|
||||
"fieldtype": "Data",
|
||||
"hidden": 1,
|
||||
"label": "Sales Order Item",
|
||||
"read_only": 1,
|
||||
"search_index": 1
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "serial_no_and_batch_section",
|
||||
@ -207,6 +207,17 @@
|
||||
"fieldname": "pick_serial_and_batch",
|
||||
"fieldtype": "Button",
|
||||
"label": "Pick Serial / Batch No"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "stock_reserved_qty",
|
||||
"fieldtype": "Float",
|
||||
"label": "Stock Reserved Qty (in Stock UOM)",
|
||||
"no_copy": 1,
|
||||
"non_negative": 1,
|
||||
"print_hide": 1,
|
||||
"read_only": 1,
|
||||
"report_hide": 1
|
||||
}
|
||||
],
|
||||
"istable": 1,
|
||||
|
@ -66,7 +66,7 @@ class SerialandBatchBundle(Document):
|
||||
serial_nos = [d.serial_no for d in self.entries if d.serial_no]
|
||||
kwargs = {"item_code": self.item_code, "warehouse": self.warehouse}
|
||||
if self.voucher_type == "POS Invoice":
|
||||
kwargs["ignore_voucher_no"] = self.voucher_no
|
||||
kwargs["ignore_voucher_nos"] = [self.voucher_no]
|
||||
|
||||
available_serial_nos = get_available_serial_nos(frappe._dict(kwargs))
|
||||
|
||||
@ -1098,8 +1098,8 @@ def get_available_serial_nos(kwargs):
|
||||
if kwargs.warehouse:
|
||||
filters["warehouse"] = kwargs.warehouse
|
||||
|
||||
# Since SLEs are not present against POS invoices, need to ignore serial nos present in the POS invoice
|
||||
ignore_serial_nos = get_reserved_serial_nos_for_pos(kwargs)
|
||||
# Since SLEs are not present against Reserved Stock [POS invoices, SRE], need to ignore reserved serial nos.
|
||||
ignore_serial_nos = get_reserved_serial_nos(kwargs)
|
||||
|
||||
# To ignore serial nos in the same record for the draft state
|
||||
if kwargs.get("ignore_serial_nos"):
|
||||
@ -1180,6 +1180,20 @@ def get_serial_nos_based_on_posting_date(kwargs, ignore_serial_nos):
|
||||
return serial_nos
|
||||
|
||||
|
||||
def get_reserved_serial_nos(kwargs) -> list:
|
||||
"""Returns a list of `Serial No` reserved in POS Invoice and Stock Reservation Entry."""
|
||||
|
||||
ignore_serial_nos = []
|
||||
|
||||
# Extend the list by serial nos reserved in POS Invoice
|
||||
ignore_serial_nos.extend(get_reserved_serial_nos_for_pos(kwargs))
|
||||
|
||||
# Extend the list by serial nos reserved via SRE
|
||||
ignore_serial_nos.extend(get_reserved_serial_nos_for_sre(kwargs))
|
||||
|
||||
return ignore_serial_nos
|
||||
|
||||
|
||||
def get_reserved_serial_nos_for_pos(kwargs):
|
||||
from erpnext.controllers.sales_and_purchase_return import get_returned_serial_nos
|
||||
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
|
||||
@ -1199,7 +1213,7 @@ def get_reserved_serial_nos_for_pos(kwargs):
|
||||
["POS Invoice", "docstatus", "=", 1],
|
||||
["POS Invoice", "is_return", "=", 0],
|
||||
["POS Invoice Item", "item_code", "=", kwargs.item_code],
|
||||
["POS Invoice", "name", "!=", kwargs.ignore_voucher_no],
|
||||
["POS Invoice", "name", "not in", kwargs.ignore_voucher_nos],
|
||||
],
|
||||
)
|
||||
|
||||
@ -1251,7 +1265,37 @@ def get_reserved_serial_nos_for_pos(kwargs):
|
||||
return list(ignore_serial_nos_counter - returned_serial_nos_counter)
|
||||
|
||||
|
||||
def get_reserved_batches_for_pos(kwargs):
|
||||
def get_reserved_serial_nos_for_sre(kwargs) -> list:
|
||||
"""Returns a list of `Serial No` reserved in Stock Reservation Entry."""
|
||||
|
||||
sre = frappe.qb.DocType("Stock Reservation Entry")
|
||||
sb_entry = frappe.qb.DocType("Serial and Batch Entry")
|
||||
query = (
|
||||
frappe.qb.from_(sre)
|
||||
.inner_join(sb_entry)
|
||||
.on(sre.name == sb_entry.parent)
|
||||
.select(sb_entry.serial_no)
|
||||
.where(
|
||||
(sre.docstatus == 1)
|
||||
& (sre.item_code == kwargs.item_code)
|
||||
& (sre.reserved_qty >= sre.delivered_qty)
|
||||
& (sre.status.notin(["Delivered", "Cancelled"]))
|
||||
& (sre.reservation_based_on == "Serial and Batch")
|
||||
)
|
||||
)
|
||||
|
||||
if kwargs.warehouse:
|
||||
query = query.where(sre.warehouse == kwargs.warehouse)
|
||||
|
||||
if kwargs.ignore_voucher_nos:
|
||||
query = query.where(sre.name.notin(kwargs.ignore_voucher_nos))
|
||||
|
||||
return [row[0] for row in query.run()]
|
||||
|
||||
|
||||
def get_reserved_batches_for_pos(kwargs) -> dict:
|
||||
"""Returns a dict of `Batch No` followed by the `Qty` reserved in POS Invoices."""
|
||||
|
||||
pos_batches = frappe._dict()
|
||||
pos_invoices = frappe.get_all(
|
||||
"POS Invoice",
|
||||
@ -1267,7 +1311,7 @@ def get_reserved_batches_for_pos(kwargs):
|
||||
["POS Invoice", "consolidated_invoice", "is", "not set"],
|
||||
["POS Invoice", "docstatus", "=", 1],
|
||||
["POS Invoice Item", "item_code", "=", kwargs.item_code],
|
||||
["POS Invoice", "name", "!=", kwargs.ignore_voucher_no],
|
||||
["POS Invoice", "name", "not in", kwargs.ignore_voucher_nos],
|
||||
],
|
||||
)
|
||||
|
||||
@ -1278,7 +1322,7 @@ def get_reserved_batches_for_pos(kwargs):
|
||||
]
|
||||
|
||||
if not ids:
|
||||
return []
|
||||
return {}
|
||||
|
||||
if ids:
|
||||
for d in get_serial_batch_ledgers(kwargs.item_code, docstatus=1, name=ids):
|
||||
@ -1314,14 +1358,65 @@ def get_reserved_batches_for_pos(kwargs):
|
||||
return pos_batches
|
||||
|
||||
|
||||
def get_reserved_batches_for_sre(kwargs) -> dict:
|
||||
"""Returns a dict of `Batch No` followed by the `Qty` reserved in Stock Reservation Entry."""
|
||||
|
||||
sre = frappe.qb.DocType("Stock Reservation Entry")
|
||||
sb_entry = frappe.qb.DocType("Serial and Batch Entry")
|
||||
query = (
|
||||
frappe.qb.from_(sre)
|
||||
.inner_join(sb_entry)
|
||||
.on(sre.name == sb_entry.parent)
|
||||
.select(
|
||||
sb_entry.batch_no, sre.warehouse, (-1 * Sum(sb_entry.qty - sb_entry.delivered_qty)).as_("qty")
|
||||
)
|
||||
.where(
|
||||
(sre.docstatus == 1)
|
||||
& (sre.item_code == kwargs.item_code)
|
||||
& (sre.reserved_qty >= sre.delivered_qty)
|
||||
& (sre.status.notin(["Delivered", "Cancelled"]))
|
||||
& (sre.reservation_based_on == "Serial and Batch")
|
||||
)
|
||||
.groupby(sb_entry.batch_no, sre.warehouse)
|
||||
)
|
||||
|
||||
if kwargs.batch_no:
|
||||
if isinstance(kwargs.batch_no, list):
|
||||
query = query.where(sb_entry.batch_no.isin(kwargs.batch_no))
|
||||
else:
|
||||
query = query.where(sb_entry.batch_no == kwargs.batch_no)
|
||||
|
||||
if kwargs.warehouse:
|
||||
query = query.where(sre.warehouse == kwargs.warehouse)
|
||||
|
||||
if kwargs.ignore_voucher_nos:
|
||||
query = query.where(sre.name.notin(kwargs.ignore_voucher_nos))
|
||||
|
||||
data = query.run(as_dict=True)
|
||||
|
||||
reserved_batches_details = frappe._dict()
|
||||
if data:
|
||||
reserved_batches_details = frappe._dict(
|
||||
{
|
||||
(d.batch_no, d.warehouse): frappe._dict({"warehouse": d.warehouse, "qty": d.qty}) for d in data
|
||||
}
|
||||
)
|
||||
|
||||
return reserved_batches_details
|
||||
|
||||
|
||||
def get_auto_batch_nos(kwargs):
|
||||
available_batches = get_available_batches(kwargs)
|
||||
qty = flt(kwargs.qty)
|
||||
|
||||
pos_invoice_batches = get_reserved_batches_for_pos(kwargs)
|
||||
stock_ledgers_batches = get_stock_ledgers_batches(kwargs)
|
||||
if stock_ledgers_batches or pos_invoice_batches:
|
||||
update_available_batches(available_batches, stock_ledgers_batches, pos_invoice_batches)
|
||||
pos_invoice_batches = get_reserved_batches_for_pos(kwargs)
|
||||
sre_reserved_batches = get_reserved_batches_for_sre(kwargs)
|
||||
|
||||
if stock_ledgers_batches or pos_invoice_batches or sre_reserved_batches:
|
||||
update_available_batches(
|
||||
available_batches, stock_ledgers_batches, pos_invoice_batches, sre_reserved_batches
|
||||
)
|
||||
|
||||
available_batches = list(filter(lambda x: x.qty > 0, available_batches))
|
||||
|
||||
@ -1364,8 +1459,8 @@ def get_qty_based_available_batches(available_batches, qty):
|
||||
return batches
|
||||
|
||||
|
||||
def update_available_batches(available_batches, reserved_batches=None, pos_invoice_batches=None):
|
||||
for batches in [reserved_batches, pos_invoice_batches]:
|
||||
def update_available_batches(available_batches, *reserved_batches) -> None:
|
||||
for batches in reserved_batches:
|
||||
if batches:
|
||||
for key, data in batches.items():
|
||||
batch_no, warehouse = key
|
||||
|
@ -10,6 +10,7 @@
|
||||
"column_break_2",
|
||||
"qty",
|
||||
"warehouse",
|
||||
"delivered_qty",
|
||||
"section_break_6",
|
||||
"incoming_rate",
|
||||
"column_break_8",
|
||||
@ -104,12 +105,24 @@
|
||||
"fieldtype": "Small Text",
|
||||
"label": "FIFO Stock Queue (qty, rate)",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "eval: parent.doctype == \"Stock Reservation Entry\"",
|
||||
"fieldname": "delivered_qty",
|
||||
"fieldtype": "Float",
|
||||
"label": "Delivered Qty",
|
||||
"no_copy": 1,
|
||||
"non_negative": 1,
|
||||
"print_hide": 1,
|
||||
"read_only": 1,
|
||||
"report_hide": 1
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2023-03-31 11:18:59.809486",
|
||||
"modified": "2023-07-03 15:29:50.199075",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Serial and Batch Entry",
|
||||
|
@ -346,7 +346,7 @@ class StockReconciliation(StockController):
|
||||
"""Raises an exception if there is any reserved stock for the items in the Stock Reconciliation."""
|
||||
|
||||
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
|
||||
get_sre_reserved_qty_details_for_item_and_warehouse as get_sre_reserved_qty_details,
|
||||
get_sre_reserved_qty_for_item_and_warehouse as get_sre_reserved_qty_details,
|
||||
)
|
||||
|
||||
item_code_list, warehouse_list = [], []
|
||||
|
@ -3,6 +3,124 @@
|
||||
|
||||
frappe.ui.form.on("Stock Reservation Entry", {
|
||||
refresh(frm) {
|
||||
frm.page.btn_primary.hide()
|
||||
frm.trigger("set_queries");
|
||||
frm.trigger("toggle_read_only_fields");
|
||||
frm.trigger("hide_rate_related_fields");
|
||||
frm.trigger("hide_primary_action_button");
|
||||
frm.trigger("make_sb_entries_warehouse_read_only");
|
||||
},
|
||||
|
||||
has_serial_no(frm) {
|
||||
frm.trigger("toggle_read_only_fields");
|
||||
},
|
||||
|
||||
has_batch_no(frm) {
|
||||
frm.trigger("toggle_read_only_fields");
|
||||
},
|
||||
|
||||
warehouse(frm) {
|
||||
if (frm.doc.warehouse) {
|
||||
frm.doc.sb_entries.forEach((row) => {
|
||||
frappe.model.set_value(row.doctype, row.name, "warehouse", frm.doc.warehouse);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
set_queries(frm) {
|
||||
frm.set_query("warehouse", () => {
|
||||
return {
|
||||
filters: {
|
||||
"is_group": 0,
|
||||
"company": frm.doc.company,
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
frm.set_query("serial_no", "sb_entries", function(doc, cdt, cdn) {
|
||||
var selected_serial_nos = doc.sb_entries.map(row => {
|
||||
return row.serial_no;
|
||||
});
|
||||
var row = locals[cdt][cdn];
|
||||
return {
|
||||
filters: {
|
||||
item_code: doc.item_code,
|
||||
warehouse: row.warehouse,
|
||||
status: "Active",
|
||||
name: ["not in", selected_serial_nos],
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
frm.set_query("batch_no", "sb_entries", function(doc, cdt, cdn) {
|
||||
let filters = {
|
||||
item: doc.item_code,
|
||||
batch_qty: [">", 0],
|
||||
disabled: 0,
|
||||
}
|
||||
|
||||
if (!doc.has_serial_no) {
|
||||
var selected_batch_nos = doc.sb_entries.map(row => {
|
||||
return row.batch_no;
|
||||
});
|
||||
|
||||
filters.name = ["not in", selected_batch_nos];
|
||||
}
|
||||
|
||||
return { filters: filters }
|
||||
});
|
||||
},
|
||||
|
||||
toggle_read_only_fields(frm) {
|
||||
if (frm.doc.has_serial_no) {
|
||||
frm.doc.sb_entries.forEach(row => {
|
||||
if (row.qty !== 1) {
|
||||
frappe.model.set_value(row.doctype, row.name, "qty", 1);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
frm.fields_dict.sb_entries.grid.update_docfield_property(
|
||||
"serial_no", "read_only", !frm.doc.has_serial_no
|
||||
);
|
||||
|
||||
frm.fields_dict.sb_entries.grid.update_docfield_property(
|
||||
"batch_no", "read_only", !frm.doc.has_batch_no
|
||||
);
|
||||
|
||||
// Qty will always be 1 for Serial No.
|
||||
frm.fields_dict.sb_entries.grid.update_docfield_property(
|
||||
"qty", "read_only", frm.doc.has_serial_no
|
||||
);
|
||||
|
||||
frm.set_df_property("sb_entries", "allow_on_submit", frm.doc.against_pick_list ? 0 : 1);
|
||||
},
|
||||
|
||||
hide_rate_related_fields(frm) {
|
||||
["incoming_rate", "outgoing_rate", "stock_value_difference", "is_outward", "stock_queue"].forEach(field => {
|
||||
frm.fields_dict.sb_entries.grid.update_docfield_property(
|
||||
field, "hidden", 1
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
hide_primary_action_button(frm) {
|
||||
// Hide "Amend" button on cancelled document
|
||||
if (frm.doc.docstatus == 2) {
|
||||
frm.page.btn_primary.hide()
|
||||
}
|
||||
},
|
||||
|
||||
make_sb_entries_warehouse_read_only(frm) {
|
||||
frm.fields_dict.sb_entries.grid.update_docfield_property(
|
||||
"warehouse", "read_only", 1
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
frappe.ui.form.on("Serial and Batch Entry", {
|
||||
sb_entries_add(frm, cdt, cdn) {
|
||||
if (frm.doc.warehouse) {
|
||||
frappe.model.set_value(cdt, cdn, "warehouse", frm.doc.warehouse);
|
||||
}
|
||||
},
|
||||
});
|
@ -2,7 +2,7 @@
|
||||
"actions": [],
|
||||
"allow_copy": 1,
|
||||
"autoname": "MAT-SRE-.YYYY.-.#####",
|
||||
"creation": "2023-03-20 10:45:59.258959",
|
||||
"creation": "2023-06-06 15:20:48.016846",
|
||||
"default_view": "List",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
@ -10,17 +10,26 @@
|
||||
"field_order": [
|
||||
"item_code",
|
||||
"warehouse",
|
||||
"has_serial_no",
|
||||
"has_batch_no",
|
||||
"column_break_elik",
|
||||
"voucher_type",
|
||||
"voucher_no",
|
||||
"voucher_detail_no",
|
||||
"column_break_7dxj",
|
||||
"against_pick_list",
|
||||
"against_pick_list_item",
|
||||
"section_break_xt4m",
|
||||
"stock_uom",
|
||||
"column_break_grdt",
|
||||
"available_qty",
|
||||
"voucher_qty",
|
||||
"stock_uom",
|
||||
"column_break_o6ex",
|
||||
"reserved_qty",
|
||||
"delivered_qty",
|
||||
"serial_and_batch_reservation_section",
|
||||
"reservation_based_on",
|
||||
"sb_entries",
|
||||
"section_break_3vb3",
|
||||
"company",
|
||||
"column_break_jbyr",
|
||||
@ -36,6 +45,7 @@
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Item Code",
|
||||
"no_copy": 1,
|
||||
"oldfieldname": "item_code",
|
||||
"oldfieldtype": "Link",
|
||||
"options": "Item",
|
||||
@ -51,6 +61,7 @@
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Warehouse",
|
||||
"no_copy": 1,
|
||||
"oldfieldname": "warehouse",
|
||||
"oldfieldtype": "Link",
|
||||
"options": "Warehouse",
|
||||
@ -64,6 +75,7 @@
|
||||
"fieldtype": "Select",
|
||||
"in_filter": 1,
|
||||
"label": "Voucher Type",
|
||||
"no_copy": 1,
|
||||
"oldfieldname": "voucher_type",
|
||||
"oldfieldtype": "Data",
|
||||
"options": "\nSales Order",
|
||||
@ -78,17 +90,20 @@
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Voucher No",
|
||||
"no_copy": 1,
|
||||
"oldfieldname": "voucher_no",
|
||||
"oldfieldtype": "Data",
|
||||
"options": "voucher_type",
|
||||
"print_width": "150px",
|
||||
"read_only": 1,
|
||||
"search_index": 1,
|
||||
"width": "150px"
|
||||
},
|
||||
{
|
||||
"fieldname": "voucher_detail_no",
|
||||
"fieldtype": "Data",
|
||||
"label": "Voucher Detail No",
|
||||
"no_copy": 1,
|
||||
"oldfieldname": "voucher_detail_no",
|
||||
"oldfieldtype": "Data",
|
||||
"print_width": "150px",
|
||||
@ -100,6 +115,7 @@
|
||||
"fieldname": "stock_uom",
|
||||
"fieldtype": "Link",
|
||||
"label": "Stock UOM",
|
||||
"no_copy": 1,
|
||||
"oldfieldname": "stock_uom",
|
||||
"oldfieldtype": "Data",
|
||||
"options": "UOM",
|
||||
@ -111,14 +127,17 @@
|
||||
"fieldname": "project",
|
||||
"fieldtype": "Link",
|
||||
"label": "Project",
|
||||
"no_copy": 1,
|
||||
"options": "Project",
|
||||
"read_only": 1
|
||||
"read_only": 1,
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "company",
|
||||
"fieldtype": "Link",
|
||||
"in_filter": 1,
|
||||
"label": "Company",
|
||||
"no_copy": 1,
|
||||
"oldfieldname": "company",
|
||||
"oldfieldtype": "Data",
|
||||
"options": "Company",
|
||||
@ -128,23 +147,26 @@
|
||||
"width": "150px"
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"fieldname": "reserved_qty",
|
||||
"fieldtype": "Float",
|
||||
"in_filter": 1,
|
||||
"in_list_view": 1,
|
||||
"label": "Reserved Qty",
|
||||
"no_copy": 1,
|
||||
"non_negative": 1,
|
||||
"oldfieldname": "actual_qty",
|
||||
"oldfieldtype": "Currency",
|
||||
"print_width": "150px",
|
||||
"read_only": 1,
|
||||
"read_only_depends_on": "eval: ((doc.reservation_based_on == \"Serial and Batch\") || (doc.against_pick_list) || (doc.delivered_qty > 0))",
|
||||
"width": "150px"
|
||||
},
|
||||
{
|
||||
"default": "Draft",
|
||||
"fieldname": "status",
|
||||
"fieldtype": "Select",
|
||||
"hidden": 1,
|
||||
"label": "Status",
|
||||
"no_copy": 1,
|
||||
"options": "Draft\nPartially Reserved\nReserved\nPartially Delivered\nDelivered\nCancelled",
|
||||
"read_only": 1
|
||||
},
|
||||
@ -153,6 +175,8 @@
|
||||
"fieldname": "delivered_qty",
|
||||
"fieldtype": "Float",
|
||||
"label": "Delivered Qty",
|
||||
"no_copy": 1,
|
||||
"non_negative": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
@ -170,6 +194,7 @@
|
||||
"fieldtype": "Float",
|
||||
"label": "Available Qty to Reserve",
|
||||
"no_copy": 1,
|
||||
"non_negative": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
@ -178,6 +203,7 @@
|
||||
"fieldtype": "Float",
|
||||
"label": "Voucher Qty",
|
||||
"no_copy": 1,
|
||||
"non_negative": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
@ -193,12 +219,84 @@
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"fieldname": "section_break_3vb3",
|
||||
"fieldtype": "Section Break"
|
||||
"fieldtype": "Section Break",
|
||||
"label": "More Information"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_jbyr",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "eval: doc.has_serial_no",
|
||||
"fieldname": "has_serial_no",
|
||||
"fieldtype": "Check",
|
||||
"label": "Has Serial No",
|
||||
"no_copy": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "eval: doc.has_batch_no",
|
||||
"fieldname": "has_batch_no",
|
||||
"fieldtype": "Check",
|
||||
"label": "Has Batch No",
|
||||
"no_copy": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"depends_on": "eval: (doc.has_serial_no || doc.has_batch_no) && doc.reservation_based_on == \"Serial and Batch\"",
|
||||
"fieldname": "sb_entries",
|
||||
"fieldtype": "Table",
|
||||
"options": "Serial and Batch Entry",
|
||||
"read_only_depends_on": "eval: (doc.delivered_qty > 0)"
|
||||
},
|
||||
{
|
||||
"fieldname": "serial_and_batch_reservation_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Serial and Batch Reservation"
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"default": "Qty",
|
||||
"depends_on": "eval: parent.has_serial_no || parent.has_batch_no",
|
||||
"fieldname": "reservation_based_on",
|
||||
"fieldtype": "Select",
|
||||
"label": "Reservation Based On",
|
||||
"no_copy": 1,
|
||||
"options": "Qty\nSerial and Batch",
|
||||
"read_only_depends_on": "eval: (doc.delivered_qty > 0 || doc.against_pick_list)"
|
||||
},
|
||||
{
|
||||
"fieldname": "against_pick_list",
|
||||
"fieldtype": "Link",
|
||||
"label": "Against Pick List",
|
||||
"no_copy": 1,
|
||||
"options": "Pick List",
|
||||
"print_hide": 1,
|
||||
"read_only": 1,
|
||||
"report_hide": 1,
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "against_pick_list_item",
|
||||
"fieldtype": "Data",
|
||||
"label": "Against Pick List Item",
|
||||
"no_copy": 1,
|
||||
"print_hide": 1,
|
||||
"read_only": 1,
|
||||
"report_hide": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_7dxj",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_grdt",
|
||||
"fieldtype": "Column Break"
|
||||
}
|
||||
],
|
||||
"hide_toolbar": 1,
|
||||
@ -206,7 +304,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2023-03-29 18:36:26.752872",
|
||||
"modified": "2023-08-08 17:15:13.317706",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Stock Reservation Entry",
|
||||
@ -230,5 +328,6 @@
|
||||
],
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
File diff suppressed because it is too large
Load Diff
@ -1,23 +1,38 @@
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
from random import randint
|
||||
|
||||
import frappe
|
||||
from frappe.tests.utils import FrappeTestCase, change_settings
|
||||
|
||||
from erpnext.selling.doctype.sales_order.sales_order import create_pick_list, make_delivery_note
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry import StockEntry
|
||||
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
|
||||
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
|
||||
cancel_stock_reservation_entries,
|
||||
get_sre_reserved_qty_details_for_voucher,
|
||||
get_stock_reservation_entries_for_voucher,
|
||||
has_reserved_stock,
|
||||
)
|
||||
from erpnext.stock.utils import get_stock_balance
|
||||
|
||||
|
||||
class TestStockReservationEntry(FrappeTestCase):
|
||||
def setUp(self) -> None:
|
||||
self.items = create_items()
|
||||
create_material_receipt(self.items)
|
||||
self.warehouse = "_Test Warehouse - _TC"
|
||||
self.sr_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100})
|
||||
create_material_receipt(
|
||||
items={self.sr_item.name: self.sr_item}, warehouse=self.warehouse, qty=100
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
cancel_all_stock_reservation_entries()
|
||||
return super().tearDown()
|
||||
|
||||
@change_settings("Stock Settings", {"allow_negative_stock": 0})
|
||||
def test_validate_stock_reservation_settings(self) -> None:
|
||||
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
|
||||
validate_stock_reservation_settings,
|
||||
@ -47,28 +62,29 @@ class TestStockReservationEntry(FrappeTestCase):
|
||||
get_available_qty_to_reserve,
|
||||
)
|
||||
|
||||
item_code, warehouse = "SR Item 1", "_Test Warehouse - _TC"
|
||||
|
||||
# Case - 1: When `Reserved Qty` is `0`, Available Qty to Reserve = Actual Qty
|
||||
cancel_all_stock_reservation_entries()
|
||||
available_qty_to_reserve = get_available_qty_to_reserve(item_code, warehouse)
|
||||
expected_available_qty_to_reserve = get_stock_balance(item_code, warehouse)
|
||||
available_qty_to_reserve = get_available_qty_to_reserve(self.sr_item.name, self.warehouse)
|
||||
expected_available_qty_to_reserve = get_stock_balance(self.sr_item.name, self.warehouse)
|
||||
|
||||
self.assertEqual(available_qty_to_reserve, expected_available_qty_to_reserve)
|
||||
|
||||
# Case - 2: When `Reserved Qty` is `> 0`, Available Qty to Reserve = Actual Qty - Reserved Qty
|
||||
sre = make_stock_reservation_entry(
|
||||
item_code=item_code,
|
||||
warehouse=warehouse,
|
||||
item_code=self.sr_item.name,
|
||||
warehouse=self.warehouse,
|
||||
ignore_validate=True,
|
||||
)
|
||||
available_qty_to_reserve = get_available_qty_to_reserve(item_code, warehouse)
|
||||
expected_available_qty_to_reserve = get_stock_balance(item_code, warehouse) - sre.reserved_qty
|
||||
available_qty_to_reserve = get_available_qty_to_reserve(self.sr_item.name, self.warehouse)
|
||||
expected_available_qty_to_reserve = (
|
||||
get_stock_balance(self.sr_item.name, self.warehouse) - sre.reserved_qty
|
||||
)
|
||||
|
||||
self.assertEqual(available_qty_to_reserve, expected_available_qty_to_reserve)
|
||||
|
||||
def test_update_status(self) -> None:
|
||||
sre = make_stock_reservation_entry(
|
||||
item_code=self.sr_item.name,
|
||||
warehouse=self.warehouse,
|
||||
reserved_qty=30,
|
||||
ignore_validate=True,
|
||||
do_not_submit=True,
|
||||
@ -109,14 +125,12 @@ class TestStockReservationEntry(FrappeTestCase):
|
||||
sre.load_from_db()
|
||||
self.assertEqual(sre.status, "Cancelled")
|
||||
|
||||
@change_settings("Stock Settings", {"enable_stock_reservation": 1})
|
||||
@change_settings("Stock Settings", {"allow_negative_stock": 0, "enable_stock_reservation": 1})
|
||||
def test_update_reserved_qty_in_voucher(self) -> None:
|
||||
item_code, warehouse = "SR Item 1", "_Test Warehouse - _TC"
|
||||
|
||||
# Step - 1: Create a `Sales Order`
|
||||
so = make_sales_order(
|
||||
item_code=item_code,
|
||||
warehouse=warehouse,
|
||||
item_code=self.sr_item.name,
|
||||
warehouse=self.warehouse,
|
||||
qty=50,
|
||||
rate=100,
|
||||
do_not_submit=True,
|
||||
@ -128,8 +142,8 @@ class TestStockReservationEntry(FrappeTestCase):
|
||||
|
||||
# Step - 2: Create a `Stock Reservation Entry[1]` for the `Sales Order Item`
|
||||
sre1 = make_stock_reservation_entry(
|
||||
item_code=item_code,
|
||||
warehouse=warehouse,
|
||||
item_code=self.sr_item.name,
|
||||
warehouse=self.warehouse,
|
||||
voucher_type="Sales Order",
|
||||
voucher_no=so.name,
|
||||
voucher_detail_no=so.items[0].name,
|
||||
@ -143,8 +157,8 @@ class TestStockReservationEntry(FrappeTestCase):
|
||||
|
||||
# Step - 3: Create a `Stock Reservation Entry[2]` for the `Sales Order Item`
|
||||
sre2 = make_stock_reservation_entry(
|
||||
item_code=item_code,
|
||||
warehouse=warehouse,
|
||||
item_code=self.sr_item.name,
|
||||
warehouse=self.warehouse,
|
||||
voucher_type="Sales Order",
|
||||
voucher_no=so.name,
|
||||
voucher_detail_no=so.items[0].name,
|
||||
@ -163,26 +177,32 @@ class TestStockReservationEntry(FrappeTestCase):
|
||||
self.assertEqual(sre1.status, "Cancelled")
|
||||
self.assertEqual(so.items[0].stock_reserved_qty, sre2.reserved_qty)
|
||||
|
||||
# Step - 5: Cancel `Stock Reservation Entry[2]`
|
||||
# Step - 5: Update `Stock Reservation Entry[2]` Reserved Qty
|
||||
sre2.reserved_qty += sre1.reserved_qty
|
||||
sre2.save()
|
||||
so.load_from_db()
|
||||
sre1.load_from_db()
|
||||
self.assertEqual(sre2.status, "Reserved")
|
||||
self.assertEqual(so.items[0].stock_reserved_qty, sre2.reserved_qty)
|
||||
|
||||
# Step - 6: Cancel `Stock Reservation Entry[2]`
|
||||
sre2.cancel()
|
||||
so.load_from_db()
|
||||
sre2.load_from_db()
|
||||
self.assertEqual(sre1.status, "Cancelled")
|
||||
self.assertEqual(so.items[0].stock_reserved_qty, 0)
|
||||
|
||||
@change_settings("Stock Settings", {"enable_stock_reservation": 1})
|
||||
@change_settings("Stock Settings", {"allow_negative_stock": 0, "enable_stock_reservation": 1})
|
||||
def test_cant_consume_reserved_stock(self) -> None:
|
||||
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
|
||||
cancel_stock_reservation_entries,
|
||||
)
|
||||
from erpnext.stock.stock_ledger import NegativeStockError
|
||||
|
||||
item_code, warehouse = "SR Item 1", "_Test Warehouse - _TC"
|
||||
|
||||
# Step - 1: Create a `Sales Order`
|
||||
so = make_sales_order(
|
||||
item_code=item_code,
|
||||
warehouse=warehouse,
|
||||
item_code=self.sr_item.name,
|
||||
warehouse=self.warehouse,
|
||||
qty=50,
|
||||
rate=100,
|
||||
do_not_submit=True,
|
||||
@ -192,13 +212,13 @@ class TestStockReservationEntry(FrappeTestCase):
|
||||
so.save()
|
||||
so.submit()
|
||||
|
||||
actual_qty = get_stock_balance(item_code, warehouse)
|
||||
actual_qty = get_stock_balance(self.sr_item.name, self.warehouse)
|
||||
|
||||
# Step - 2: Try to consume (Transfer/Issue/Deliver) the Available Qty via Stock Entry or Delivery Note, should throw `NegativeStockError`.
|
||||
se = make_stock_entry(
|
||||
item_code=item_code,
|
||||
item_code=self.sr_item.name,
|
||||
qty=actual_qty,
|
||||
from_warehouse=warehouse,
|
||||
from_warehouse=self.warehouse,
|
||||
rate=100,
|
||||
purpose="Material Issue",
|
||||
do_not_submit=True,
|
||||
@ -210,9 +230,9 @@ class TestStockReservationEntry(FrappeTestCase):
|
||||
cancel_stock_reservation_entries(so.doctype, so.name)
|
||||
|
||||
se = make_stock_entry(
|
||||
item_code=item_code,
|
||||
item_code=self.sr_item.name,
|
||||
qty=actual_qty,
|
||||
from_warehouse=warehouse,
|
||||
from_warehouse=self.warehouse,
|
||||
rate=100,
|
||||
purpose="Material Issue",
|
||||
do_not_submit=True,
|
||||
@ -220,52 +240,369 @@ class TestStockReservationEntry(FrappeTestCase):
|
||||
se.submit()
|
||||
se.cancel()
|
||||
|
||||
@change_settings(
|
||||
"Stock Settings",
|
||||
{
|
||||
"allow_negative_stock": 0,
|
||||
"enable_stock_reservation": 1,
|
||||
"auto_reserve_serial_and_batch": 0,
|
||||
"pick_serial_and_batch_based_on": "FIFO",
|
||||
"auto_create_serial_and_batch_bundle_for_outward": 1,
|
||||
},
|
||||
)
|
||||
def test_stock_reservation_against_sales_order(self) -> None:
|
||||
items_details = create_items()
|
||||
se = create_material_receipt(items_details, self.warehouse, qty=10)
|
||||
|
||||
item_list = []
|
||||
for item_code, properties in items_details.items():
|
||||
item_list.append(
|
||||
{
|
||||
"item_code": item_code,
|
||||
"warehouse": self.warehouse,
|
||||
"qty": randint(11, 100),
|
||||
"uom": properties.stock_uom,
|
||||
"rate": randint(10, 400),
|
||||
}
|
||||
)
|
||||
|
||||
so = make_sales_order(
|
||||
item_list=item_list,
|
||||
warehouse=self.warehouse,
|
||||
)
|
||||
|
||||
# Test - 1: Stock should not be reserved if the Available Qty to Reserve is less than the Ordered Qty and Partial Reservation is disabled in Stock Settings.
|
||||
with change_settings("Stock Settings", {"allow_partial_reservation": 0}):
|
||||
so.create_stock_reservation_entries()
|
||||
self.assertFalse(has_reserved_stock("Sales Order", so.name))
|
||||
|
||||
# Test - 2: Stock should be Partially Reserved if the Partial Reservation is enabled in Stock Settings.
|
||||
with change_settings("Stock Settings", {"allow_partial_reservation": 1}):
|
||||
so.create_stock_reservation_entries()
|
||||
so.load_from_db()
|
||||
self.assertTrue(has_reserved_stock("Sales Order", so.name))
|
||||
|
||||
for item in so.items:
|
||||
sre_details = get_stock_reservation_entries_for_voucher(
|
||||
"Sales Order", so.name, item.name, fields=["reserved_qty", "status"]
|
||||
)[0]
|
||||
self.assertEqual(item.stock_reserved_qty, sre_details.reserved_qty)
|
||||
self.assertEqual(sre_details.status, "Partially Reserved")
|
||||
|
||||
se.cancel()
|
||||
|
||||
# Test - 3: Stock should be fully Reserved if the Available Qty to Reserve is greater than the Un-reserved Qty.
|
||||
create_material_receipt(items_details, self.warehouse, qty=110)
|
||||
so.create_stock_reservation_entries()
|
||||
so.load_from_db()
|
||||
|
||||
reserved_qty_details = get_sre_reserved_qty_details_for_voucher("Sales Order", so.name)
|
||||
for item in so.items:
|
||||
reserved_qty = reserved_qty_details[item.name]
|
||||
self.assertEqual(item.stock_reserved_qty, reserved_qty)
|
||||
self.assertEqual(item.stock_qty, item.stock_reserved_qty)
|
||||
|
||||
# Test - 4: Stock should get unreserved on cancellation of Stock Reservation Entries.
|
||||
cancel_stock_reservation_entries("Sales Order", so.name)
|
||||
so.load_from_db()
|
||||
self.assertFalse(has_reserved_stock("Sales Order", so.name))
|
||||
|
||||
for item in so.items:
|
||||
self.assertEqual(item.stock_reserved_qty, 0)
|
||||
|
||||
# Test - 5: Re-reserve the stock.
|
||||
so.create_stock_reservation_entries()
|
||||
self.assertTrue(has_reserved_stock("Sales Order", so.name))
|
||||
|
||||
# Test - 6: Stock should get unreserved on cancellation of Sales Order.
|
||||
so.cancel()
|
||||
so.load_from_db()
|
||||
self.assertFalse(has_reserved_stock("Sales Order", so.name))
|
||||
|
||||
for item in so.items:
|
||||
self.assertEqual(item.stock_reserved_qty, 0)
|
||||
|
||||
# Create Sales Order and Reserve Stock.
|
||||
so = make_sales_order(
|
||||
item_list=item_list,
|
||||
warehouse=self.warehouse,
|
||||
)
|
||||
so.create_stock_reservation_entries()
|
||||
|
||||
# Test - 7: Partial Delivery against Sales Order.
|
||||
dn1 = make_delivery_note(so.name)
|
||||
|
||||
for item in dn1.items:
|
||||
item.qty = randint(1, 10)
|
||||
|
||||
dn1.save()
|
||||
dn1.submit()
|
||||
|
||||
for item in so.items:
|
||||
sre_details = get_stock_reservation_entries_for_voucher(
|
||||
"Sales Order", so.name, item.name, fields=["delivered_qty", "status"]
|
||||
)[0]
|
||||
self.assertGreater(sre_details.delivered_qty, 0)
|
||||
self.assertEqual(sre_details.status, "Partially Delivered")
|
||||
|
||||
# Test - 8: Over Delivery against Sales Order, SRE Delivered Qty should not be greater than the SRE Reserved Qty.
|
||||
with change_settings("Stock Settings", {"over_delivery_receipt_allowance": 100}):
|
||||
dn2 = make_delivery_note(so.name)
|
||||
|
||||
for item in dn2.items:
|
||||
item.qty += randint(1, 10)
|
||||
|
||||
dn2.save()
|
||||
dn2.submit()
|
||||
|
||||
for item in so.items:
|
||||
sre_details = get_stock_reservation_entries_for_voucher(
|
||||
"Sales Order",
|
||||
so.name,
|
||||
item.name,
|
||||
fields=["reserved_qty", "delivered_qty"],
|
||||
ignore_status=True,
|
||||
)
|
||||
|
||||
for sre_detail in sre_details:
|
||||
self.assertEqual(sre_detail.reserved_qty, sre_detail.delivered_qty)
|
||||
|
||||
@change_settings(
|
||||
"Stock Settings",
|
||||
{
|
||||
"allow_negative_stock": 0,
|
||||
"enable_stock_reservation": 1,
|
||||
"auto_reserve_serial_and_batch": 1,
|
||||
"pick_serial_and_batch_based_on": "FIFO",
|
||||
},
|
||||
)
|
||||
def test_auto_reserve_serial_and_batch(self) -> None:
|
||||
items_details = create_items()
|
||||
create_material_receipt(items_details, self.warehouse, qty=100)
|
||||
|
||||
item_list = []
|
||||
for item_code, properties in items_details.items():
|
||||
item_list.append(
|
||||
{
|
||||
"item_code": item_code,
|
||||
"warehouse": self.warehouse,
|
||||
"qty": randint(11, 100),
|
||||
"uom": properties.stock_uom,
|
||||
"rate": randint(10, 400),
|
||||
}
|
||||
)
|
||||
|
||||
so = make_sales_order(
|
||||
item_list=item_list,
|
||||
warehouse=self.warehouse,
|
||||
)
|
||||
so.create_stock_reservation_entries()
|
||||
so.load_from_db()
|
||||
|
||||
for item in so.items:
|
||||
sre_details = get_stock_reservation_entries_for_voucher(
|
||||
"Sales Order", so.name, item.name, fields=["status", "reserved_qty"]
|
||||
)[0]
|
||||
|
||||
# Test - 1: SRE Reserved Qty should be updated in Sales Order Item.
|
||||
self.assertEqual(item.stock_reserved_qty, sre_details.reserved_qty)
|
||||
|
||||
# Test - 2: SRE status should be `Reserved`.
|
||||
self.assertEqual(sre_details.status, "Reserved")
|
||||
|
||||
dn = make_delivery_note(so.name, kwargs={"for_reserved_stock": 1})
|
||||
dn.save()
|
||||
dn.submit()
|
||||
|
||||
for item in so.items:
|
||||
sre_details = get_stock_reservation_entries_for_voucher(
|
||||
"Sales Order", so.name, item.name, fields=["status", "delivered_qty", "reserved_qty"]
|
||||
)[0]
|
||||
|
||||
# Test - 3: After Delivery Note, SRE status should be `Delivered`.
|
||||
self.assertEqual(sre_details.status, "Delivered")
|
||||
|
||||
# Test - 4: After Delivery Note, SRE Delivered Qty should be equal to SRE Reserved Qty.
|
||||
self.assertEqual(sre_details.delivered_qty, sre_details.reserved_qty)
|
||||
|
||||
sre = frappe.qb.DocType("Stock Reservation Entry")
|
||||
sb_entry = frappe.qb.DocType("Serial and Batch Entry")
|
||||
for item in dn.items:
|
||||
if item.serial_and_batch_bundle:
|
||||
reserved_sb_entries = (
|
||||
frappe.qb.from_(sre)
|
||||
.inner_join(sb_entry)
|
||||
.on(sre.name == sb_entry.parent)
|
||||
.select(sb_entry.serial_no, sb_entry.batch_no, sb_entry.qty, sb_entry.delivered_qty)
|
||||
.where(
|
||||
(sre.voucher_type == "Sales Order")
|
||||
& (sre.voucher_no == item.against_sales_order)
|
||||
& (sre.voucher_detail_no == item.so_detail)
|
||||
)
|
||||
).run(as_dict=True)
|
||||
|
||||
reserved_sb_details: set[tuple] = set()
|
||||
for sb_details in reserved_sb_entries:
|
||||
# Test - 5: After Delivery Note, SB Entry Delivered Qty should be equal to SB Entry Reserved Qty.
|
||||
self.assertEqual(sb_details.qty, sb_details.delivered_qty)
|
||||
|
||||
reserved_sb_details.add((sb_details.serial_no, sb_details.batch_no, -1 * sb_details.qty))
|
||||
|
||||
delivered_sb_entries = frappe.db.get_all(
|
||||
"Serial and Batch Entry",
|
||||
filters={"parent": item.serial_and_batch_bundle},
|
||||
fields=["serial_no", "batch_no", "qty"],
|
||||
as_list=True,
|
||||
)
|
||||
delivered_sb_details: set[tuple] = set(delivered_sb_entries)
|
||||
|
||||
# Test - 6: Reserved Serial/Batch Nos should be equal to Delivered Serial/Batch Nos.
|
||||
self.assertSetEqual(reserved_sb_details, delivered_sb_details)
|
||||
|
||||
dn.cancel()
|
||||
so.load_from_db()
|
||||
|
||||
for item in so.items:
|
||||
sre_details = get_stock_reservation_entries_for_voucher(
|
||||
"Sales Order",
|
||||
so.name,
|
||||
item.name,
|
||||
fields=["name", "status", "delivered_qty", "reservation_based_on"],
|
||||
)[0]
|
||||
|
||||
# Test - 7: After Delivery Note cancellation, SRE status should be `Reserved`.
|
||||
self.assertEqual(sre_details.status, "Reserved")
|
||||
|
||||
# Test - 8: After Delivery Note cancellation, SRE Delivered Qty should be `0`.
|
||||
self.assertEqual(sre_details.delivered_qty, 0)
|
||||
|
||||
if sre_details.reservation_based_on == "Serial and Batch":
|
||||
sb_entries = frappe.db.get_all(
|
||||
"Serial and Batch Entry",
|
||||
filters={"parenttype": "Stock Reservation Entry", "parent": sre_details.name},
|
||||
fields=["delivered_qty"],
|
||||
)
|
||||
|
||||
for sb_entry in sb_entries:
|
||||
# Test - 9: After Delivery Note cancellation, SB Entry Delivered Qty should be `0`.
|
||||
self.assertEqual(sb_entry.delivered_qty, 0)
|
||||
|
||||
@change_settings(
|
||||
"Stock Settings",
|
||||
{
|
||||
"allow_negative_stock": 0,
|
||||
"enable_stock_reservation": 1,
|
||||
"auto_reserve_serial_and_batch": 1,
|
||||
"pick_serial_and_batch_based_on": "FIFO",
|
||||
},
|
||||
)
|
||||
def test_stock_reservation_from_pick_list(self):
|
||||
items_details = create_items()
|
||||
create_material_receipt(items_details, self.warehouse, qty=100)
|
||||
|
||||
item_list = []
|
||||
for item_code, properties in items_details.items():
|
||||
item_list.append(
|
||||
{
|
||||
"item_code": item_code,
|
||||
"warehouse": self.warehouse,
|
||||
"qty": randint(11, 100),
|
||||
"uom": properties.stock_uom,
|
||||
"rate": randint(10, 400),
|
||||
}
|
||||
)
|
||||
|
||||
so = make_sales_order(
|
||||
item_list=item_list,
|
||||
warehouse=self.warehouse,
|
||||
)
|
||||
pl = create_pick_list(so.name)
|
||||
pl.save()
|
||||
pl.submit()
|
||||
pl.create_stock_reservation_entries()
|
||||
pl.load_from_db()
|
||||
so.load_from_db()
|
||||
|
||||
for item in so.items:
|
||||
sre_details = get_stock_reservation_entries_for_voucher(
|
||||
"Sales Order", so.name, item.name, fields=["reserved_qty"]
|
||||
)[0]
|
||||
|
||||
# Test - 1: SRE Reserved Qty should be updated in Sales Order Item.
|
||||
self.assertEqual(item.stock_reserved_qty, sre_details.reserved_qty)
|
||||
|
||||
sre = frappe.qb.DocType("Stock Reservation Entry")
|
||||
sb_entry = frappe.qb.DocType("Serial and Batch Entry")
|
||||
for location in pl.locations:
|
||||
# Test - 2: Reserved Qty should be updated in Pick List Item.
|
||||
self.assertEqual(location.stock_reserved_qty, location.qty)
|
||||
|
||||
if location.serial_and_batch_bundle:
|
||||
picked_sb_entries = frappe.db.get_all(
|
||||
"Serial and Batch Entry",
|
||||
filters={"parent": location.serial_and_batch_bundle},
|
||||
fields=["serial_no", "batch_no", "qty"],
|
||||
as_list=True,
|
||||
)
|
||||
picked_sb_details: set[tuple] = set(picked_sb_entries)
|
||||
|
||||
reserved_sb_entries = (
|
||||
frappe.qb.from_(sre)
|
||||
.inner_join(sb_entry)
|
||||
.on(sre.name == sb_entry.parent)
|
||||
.select(sb_entry.serial_no, sb_entry.batch_no, sb_entry.qty)
|
||||
.where(
|
||||
(sre.voucher_type == "Sales Order")
|
||||
& (sre.voucher_no == location.sales_order)
|
||||
& (sre.voucher_detail_no == location.sales_order_item)
|
||||
& (sre.against_pick_list == pl.name)
|
||||
& (sre.against_pick_list_item == location.name)
|
||||
)
|
||||
).run(as_dict=True)
|
||||
reserved_sb_details: set[tuple] = {
|
||||
(sb_details.serial_no, sb_details.batch_no, -1 * sb_details.qty)
|
||||
for sb_details in reserved_sb_entries
|
||||
}
|
||||
|
||||
# Test - 3: Reserved Serial/Batch Nos should be equal to Picked Serial/Batch Nos.
|
||||
self.assertSetEqual(picked_sb_details, reserved_sb_details)
|
||||
|
||||
|
||||
def create_items() -> dict:
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
items_details = {
|
||||
# Stock Items
|
||||
"SR Item 1": {"is_stock_item": 1, "valuation_rate": 100},
|
||||
"SR Item 2": {"is_stock_item": 1, "valuation_rate": 200, "stock_uom": "Kg"},
|
||||
# Batch Items
|
||||
"SR Batch Item 1": {
|
||||
"is_stock_item": 1,
|
||||
"valuation_rate": 100,
|
||||
"has_batch_no": 1,
|
||||
"create_new_batch": 1,
|
||||
"batch_number_series": "SRBI-1-.#####.",
|
||||
},
|
||||
"SR Batch Item 2": {
|
||||
items_properties = [
|
||||
# SR STOCK ITEM
|
||||
{"is_stock_item": 1, "valuation_rate": 100},
|
||||
# SR SERIAL ITEM
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"valuation_rate": 200,
|
||||
"has_serial_no": 1,
|
||||
"serial_no_series": "SRSI-.#####",
|
||||
},
|
||||
# SR BATCH ITEM
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"valuation_rate": 300,
|
||||
"has_batch_no": 1,
|
||||
"create_new_batch": 1,
|
||||
"batch_number_series": "SRBI-2-.#####.",
|
||||
"stock_uom": "Kg",
|
||||
"batch_number_series": "SRBI-.#####.",
|
||||
},
|
||||
# Serial Item
|
||||
"SR Serial Item 1": {
|
||||
# SR SERIAL AND BATCH ITEM
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"valuation_rate": 100,
|
||||
"valuation_rate": 400,
|
||||
"has_serial_no": 1,
|
||||
"serial_no_series": "SRSI-1-.#####",
|
||||
},
|
||||
# Batch and Serial Item
|
||||
"SR Batch and Serial Item 1": {
|
||||
"is_stock_item": 1,
|
||||
"valuation_rate": 100,
|
||||
"serial_no_series": "SRSBI-.#####",
|
||||
"has_batch_no": 1,
|
||||
"create_new_batch": 1,
|
||||
"batch_number_series": "SRBSI-1-.#####.",
|
||||
"has_serial_no": 1,
|
||||
"serial_no_series": "SRBSI-1-.#####",
|
||||
"batch_number_series": "SRSBI-.#####.",
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
items = {}
|
||||
for item_code, properties in items_details.items():
|
||||
items[item_code] = make_item(item_code, properties)
|
||||
for properties in items_properties:
|
||||
item = make_item(properties=properties)
|
||||
items[item.name] = item
|
||||
|
||||
return items
|
||||
|
||||
@ -313,7 +650,7 @@ def make_stock_reservation_entry(**args):
|
||||
doc = frappe.new_doc("Stock Reservation Entry")
|
||||
args = frappe._dict(args)
|
||||
|
||||
doc.item_code = args.item_code or "SR Item 1"
|
||||
doc.item_code = args.item_code
|
||||
doc.warehouse = args.warehouse or "_Test Warehouse - _TC"
|
||||
doc.voucher_type = args.voucher_type
|
||||
doc.voucher_no = args.voucher_no
|
||||
|
@ -34,8 +34,10 @@
|
||||
"stock_reservation_tab",
|
||||
"enable_stock_reservation",
|
||||
"column_break_rx3e",
|
||||
"reserve_stock_on_sales_order_submission",
|
||||
"auto_reserve_stock_for_sales_order",
|
||||
"allow_partial_reservation",
|
||||
"serial_and_batch_reservation_section",
|
||||
"auto_reserve_serial_and_batch",
|
||||
"serial_and_batch_item_settings_tab",
|
||||
"section_break_7",
|
||||
"auto_create_serial_and_batch_bundle_for_outward",
|
||||
@ -59,7 +61,8 @@
|
||||
"stock_frozen_upto_days",
|
||||
"column_break_26",
|
||||
"role_allowed_to_create_edit_back_dated_transactions",
|
||||
"stock_auth_role"
|
||||
"stock_auth_role",
|
||||
"section_break_plhx"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@ -337,18 +340,11 @@
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "Allows to keep aside a specific quantity of inventory for a particular order.",
|
||||
"fieldname": "enable_stock_reservation",
|
||||
"fieldtype": "Check",
|
||||
"label": "Enable Stock Reservation"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "eval: doc.enable_stock_reservation",
|
||||
"description": "If enabled, <b>Stock Reservation Entries</b> will be created on submission of <b>Sales Order</b>",
|
||||
"fieldname": "reserve_stock_on_sales_order_submission",
|
||||
"fieldtype": "Check",
|
||||
"label": "Reserve Stock on Sales Order Submission"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_rx3e",
|
||||
"fieldtype": "Column Break"
|
||||
@ -356,7 +352,7 @@
|
||||
{
|
||||
"default": "1",
|
||||
"depends_on": "eval: doc.enable_stock_reservation",
|
||||
"description": "If enabled, <b>Partial Stock Reservation Entries</b> can be created. For example, If you have a <b>Sales Order</b> of 100 units and the Available Stock is 90 units then a Stock Reservation Entry will be created for 90 units. ",
|
||||
"description": "If enabled, <b>Partial Stock Reservation Entries</b> can be created. For example, If you have a Sales Order of 100 units and the Available Stock is 90 units then a Stock Reservation Entry will be created for 90 units. ",
|
||||
"fieldname": "allow_partial_reservation",
|
||||
"fieldtype": "Check",
|
||||
"label": "Allow Partial Reservation"
|
||||
@ -383,6 +379,27 @@
|
||||
"fieldname": "auto_create_serial_and_batch_bundle_for_outward",
|
||||
"fieldtype": "Check",
|
||||
"label": "Auto Create Serial and Batch Bundle For Outward"
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"depends_on": "eval: doc.enable_stock_reservation",
|
||||
"description": "If enabled, Serial and Batch Nos will be auto-reserved based on <b>Pick Serial / Batch Based On</b>",
|
||||
"fieldname": "auto_reserve_serial_and_batch",
|
||||
"fieldtype": "Check",
|
||||
"label": "Auto Reserve Serial and Batch Nos"
|
||||
},
|
||||
{
|
||||
"fieldname": "serial_and_batch_reservation_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Serial and Batch Reservation"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "eval: doc.enable_stock_reservation",
|
||||
"description": "If enabled, <b>Stock Reservation Entries</b> will be created on submission of <b>Sales Order</b>",
|
||||
"fieldname": "auto_reserve_stock_for_sales_order",
|
||||
"fieldtype": "Check",
|
||||
"label": "Auto Reserve Stock for Sales Order"
|
||||
}
|
||||
],
|
||||
"icon": "icon-cog",
|
||||
@ -390,7 +407,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2023-05-29 15:10:54.959411",
|
||||
"modified": "2023-09-01 16:16:34.018947",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Stock Settings",
|
||||
|
@ -69,9 +69,9 @@ class StockSettings(Document):
|
||||
)
|
||||
|
||||
def cant_change_valuation_method(self):
|
||||
db_valuation_method = frappe.db.get_single_value("Stock Settings", "valuation_method")
|
||||
previous_valuation_method = self.get_doc_before_save().get("valuation_method")
|
||||
|
||||
if db_valuation_method and db_valuation_method != self.valuation_method:
|
||||
if previous_valuation_method and previous_valuation_method != self.valuation_method:
|
||||
# check if there are any stock ledger entries against items
|
||||
# which does not have it's own valuation method
|
||||
sle = frappe.db.sql(
|
||||
@ -108,13 +108,8 @@ class StockSettings(Document):
|
||||
if frappe.flags.in_test:
|
||||
return
|
||||
|
||||
db_allow_negative_stock = frappe.db.get_single_value("Stock Settings", "allow_negative_stock")
|
||||
db_enable_stock_reservation = frappe.db.get_single_value(
|
||||
"Stock Settings", "enable_stock_reservation"
|
||||
)
|
||||
|
||||
# Change in value of `Allow Negative Stock`
|
||||
if db_allow_negative_stock != self.allow_negative_stock:
|
||||
if self.has_value_changed("allow_negative_stock"):
|
||||
|
||||
# Disable -> Enable: Don't allow if `Stock Reservation` is enabled
|
||||
if self.allow_negative_stock and self.enable_stock_reservation:
|
||||
@ -125,7 +120,7 @@ class StockSettings(Document):
|
||||
)
|
||||
|
||||
# Change in value of `Enable Stock Reservation`
|
||||
if db_enable_stock_reservation != self.enable_stock_reservation:
|
||||
if self.has_value_changed("enable_stock_reservation"):
|
||||
|
||||
# Disable -> Enable
|
||||
if self.enable_stock_reservation:
|
||||
|
0
erpnext/stock/report/reserved_stock/__init__.py
Normal file
0
erpnext/stock/report/reserved_stock/__init__.py
Normal file
170
erpnext/stock/report/reserved_stock/reserved_stock.js
Normal file
170
erpnext/stock/report/reserved_stock/reserved_stock.js
Normal file
@ -0,0 +1,170 @@
|
||||
// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
frappe.query_reports["Reserved Stock"] = {
|
||||
filters: [
|
||||
{
|
||||
fieldname: "company",
|
||||
label: __("Company"),
|
||||
fieldtype: "Link",
|
||||
options: "Company",
|
||||
reqd: 1,
|
||||
default: frappe.defaults.get_user_default("Company"),
|
||||
},
|
||||
{
|
||||
fieldname: "from_date",
|
||||
label: __("From Date"),
|
||||
fieldtype: "Date",
|
||||
default: frappe.datetime.add_months(
|
||||
frappe.datetime.get_today(),
|
||||
-1
|
||||
),
|
||||
reqd: 1,
|
||||
},
|
||||
{
|
||||
fieldname: "to_date",
|
||||
label: __("To Date"),
|
||||
fieldtype: "Date",
|
||||
default: frappe.datetime.get_today(),
|
||||
reqd: 1,
|
||||
},
|
||||
{
|
||||
fieldname: "item_code",
|
||||
label: __("Item"),
|
||||
fieldtype: "Link",
|
||||
options: "Item",
|
||||
get_query: () => ({
|
||||
filters: {
|
||||
is_stock_item: 1,
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
fieldname: "warehouse",
|
||||
label: __("Warehouse"),
|
||||
fieldtype: "Link",
|
||||
options: "Warehouse",
|
||||
get_query: () => ({
|
||||
filters: {
|
||||
is_group: 0,
|
||||
company: frappe.query_report.get_filter_value("company"),
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
fieldname: "stock_reservation_entry",
|
||||
label: __("Stock Reservation Entry"),
|
||||
fieldtype: "Link",
|
||||
options: "Stock Reservation Entry",
|
||||
get_query: () => ({
|
||||
filters: {
|
||||
docstatus: 1,
|
||||
company: frappe.query_report.get_filter_value("company"),
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
fieldname: "voucher_type",
|
||||
label: __("Voucher Type"),
|
||||
fieldtype: "Link",
|
||||
options: "DocType",
|
||||
default: "Sales Order",
|
||||
get_query: () => ({
|
||||
filters: {
|
||||
name: ["in", ["Sales Order"]],
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
fieldname: "voucher_no",
|
||||
label: __("Voucher No"),
|
||||
fieldtype: "Dynamic Link",
|
||||
options: "voucher_type",
|
||||
get_query: () => ({
|
||||
filters: {
|
||||
docstatus: 1,
|
||||
company: frappe.query_report.get_filter_value("company"),
|
||||
},
|
||||
}),
|
||||
get_options: function () {
|
||||
return frappe.query_report.get_filter_value("voucher_type");
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldname: "against_pick_list",
|
||||
label: __("Against Pick List"),
|
||||
fieldtype: "Link",
|
||||
options: "Pick List",
|
||||
get_query: () => ({
|
||||
filters: {
|
||||
docstatus: 1,
|
||||
company: frappe.query_report.get_filter_value("company"),
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
fieldname: "reservation_based_on",
|
||||
label: __("Reservation Based On"),
|
||||
fieldtype: "Select",
|
||||
options: ["", "Qty", "Serial and Batch"],
|
||||
},
|
||||
{
|
||||
fieldname: "status",
|
||||
label: __("Status"),
|
||||
fieldtype: "Select",
|
||||
options: [
|
||||
"",
|
||||
"Partially Reserved",
|
||||
"Reserved",
|
||||
"Partially Delivered",
|
||||
"Delivered",
|
||||
],
|
||||
},
|
||||
{
|
||||
fieldname: "project",
|
||||
label: __("Project"),
|
||||
fieldtype: "Link",
|
||||
options: "Project",
|
||||
get_query: () => ({
|
||||
filters: {
|
||||
company: frappe.query_report.get_filter_value("company"),
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
formatter: (value, row, column, data, default_formatter) => {
|
||||
value = default_formatter(value, row, column, data);
|
||||
|
||||
if (column.fieldname == "status") {
|
||||
switch (data.status) {
|
||||
case "Partially Reserved":
|
||||
value = "<span style='color:orange'>" + value + "</span>";
|
||||
break;
|
||||
case "Reserved":
|
||||
value = "<span style='color:blue'>" + value + "</span>";
|
||||
break;
|
||||
case "Partially Delivered":
|
||||
value = "<span style='color:purple'>" + value + "</span>";
|
||||
break;
|
||||
case "Delivered":
|
||||
value = "<span style='color:green'>" + value + "</span>";
|
||||
break;
|
||||
}
|
||||
}
|
||||
else if (column.fieldname == "delivered_qty") {
|
||||
if (data.delivered_qty > 0) {
|
||||
if (data.reserved_qty > data.delivered_qty) {
|
||||
value = "<span style='color:blue'>" + value + "</span>";
|
||||
}
|
||||
else {
|
||||
value = "<span style='color:green'>" + value + "</span>";
|
||||
}
|
||||
}
|
||||
else {
|
||||
value = "<span style='color:red'>" + value + "</span>";
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
},
|
||||
};
|
26
erpnext/stock/report/reserved_stock/reserved_stock.json
Normal file
26
erpnext/stock/report/reserved_stock/reserved_stock.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"add_total_row": 0,
|
||||
"columns": [],
|
||||
"creation": "2023-08-02 22:11:19.439620",
|
||||
"disabled": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "Report",
|
||||
"filters": [],
|
||||
"idx": 0,
|
||||
"is_standard": "Yes",
|
||||
"letterhead": null,
|
||||
"modified": "2023-08-03 12:46:33.780222",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Reserved Stock",
|
||||
"owner": "Administrator",
|
||||
"prepared_report": 0,
|
||||
"ref_doctype": "Stock Reservation Entry",
|
||||
"report_name": "Reserved Stock",
|
||||
"report_type": "Script Report",
|
||||
"roles": [
|
||||
{
|
||||
"role": "System Manager"
|
||||
}
|
||||
]
|
||||
}
|
191
erpnext/stock/report/reserved_stock/reserved_stock.py
Normal file
191
erpnext/stock/report/reserved_stock/reserved_stock.py
Normal file
@ -0,0 +1,191 @@
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder.functions import Date
|
||||
|
||||
|
||||
def execute(filters=None):
|
||||
columns, data = [], []
|
||||
|
||||
validate_filters(filters)
|
||||
|
||||
columns = get_columns()
|
||||
data = get_data(filters)
|
||||
|
||||
return columns, data
|
||||
|
||||
|
||||
def validate_filters(filters):
|
||||
if not filters:
|
||||
frappe.throw(_("Please set filters"))
|
||||
|
||||
for field in ["company", "from_date", "to_date"]:
|
||||
if not filters.get(field):
|
||||
frappe.throw(_("Please set {0}").format(field))
|
||||
|
||||
if filters.get("from_date") > filters.get("to_date"):
|
||||
frappe.throw(_("From Date cannot be greater than To Date"))
|
||||
|
||||
|
||||
def get_data(filters):
|
||||
sre = frappe.qb.DocType("Stock Reservation Entry")
|
||||
query = (
|
||||
frappe.qb.from_(sre)
|
||||
.select(
|
||||
sre.creation,
|
||||
sre.warehouse,
|
||||
sre.item_code,
|
||||
sre.stock_uom,
|
||||
sre.voucher_qty,
|
||||
sre.reserved_qty,
|
||||
sre.delivered_qty,
|
||||
(sre.available_qty - sre.reserved_qty).as_("available_qty"),
|
||||
sre.voucher_type,
|
||||
sre.voucher_no,
|
||||
sre.against_pick_list,
|
||||
sre.name.as_("stock_reservation_entry"),
|
||||
sre.status,
|
||||
sre.project,
|
||||
sre.company,
|
||||
)
|
||||
.where(
|
||||
(sre.docstatus == 1)
|
||||
& (sre.company == filters.get("company"))
|
||||
& (
|
||||
(Date(sre.creation) >= filters.get("from_date"))
|
||||
& (Date(sre.creation) <= filters.get("to_date"))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
for field in [
|
||||
"item_code",
|
||||
"warehouse",
|
||||
"voucher_type",
|
||||
"voucher_no",
|
||||
"against_pick_list",
|
||||
"reservation_based_on",
|
||||
"status",
|
||||
"project",
|
||||
]:
|
||||
if value := filters.get(field):
|
||||
query = query.where((sre[field] == value))
|
||||
|
||||
if value := filters.get("stock_reservation_entry"):
|
||||
query = query.where((sre.name == value))
|
||||
|
||||
data = query.run(as_list=True)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def get_columns():
|
||||
columns = [
|
||||
{
|
||||
"label": _("Date"),
|
||||
"fieldname": "date",
|
||||
"fieldtype": "Datetime",
|
||||
"width": 150,
|
||||
},
|
||||
{
|
||||
"fieldname": "warehouse",
|
||||
"label": _("Warehouse"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Warehouse",
|
||||
"width": 150,
|
||||
},
|
||||
{
|
||||
"fieldname": "item_code",
|
||||
"label": _("Item"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Item",
|
||||
"width": 100,
|
||||
},
|
||||
{
|
||||
"fieldname": "stock_uom",
|
||||
"label": _("Stock UOM"),
|
||||
"fieldtype": "Link",
|
||||
"options": "UOM",
|
||||
"width": 100,
|
||||
},
|
||||
{
|
||||
"fieldname": "voucher_qty",
|
||||
"label": _("Voucher Qty"),
|
||||
"fieldtype": "Float",
|
||||
"width": 110,
|
||||
"convertible": "qty",
|
||||
},
|
||||
{
|
||||
"fieldname": "reserved_qty",
|
||||
"label": _("Reserved Qty"),
|
||||
"fieldtype": "Float",
|
||||
"width": 110,
|
||||
"convertible": "qty",
|
||||
},
|
||||
{
|
||||
"fieldname": "delivered_qty",
|
||||
"label": _("Delivered Qty"),
|
||||
"fieldtype": "Float",
|
||||
"width": 110,
|
||||
"convertible": "qty",
|
||||
},
|
||||
{
|
||||
"fieldname": "available_qty",
|
||||
"label": _("Available Qty to Reserve"),
|
||||
"fieldtype": "Float",
|
||||
"width": 120,
|
||||
"convertible": "qty",
|
||||
},
|
||||
{
|
||||
"fieldname": "voucher_type",
|
||||
"label": _("Voucher Type"),
|
||||
"fieldtype": "Data",
|
||||
"options": "Warehouse",
|
||||
"width": 110,
|
||||
},
|
||||
{
|
||||
"fieldname": "voucher_no",
|
||||
"label": _("Voucher No"),
|
||||
"fieldtype": "Dynamic Link",
|
||||
"options": "voucher_type",
|
||||
"width": 120,
|
||||
},
|
||||
{
|
||||
"fieldname": "against_pick_list",
|
||||
"label": _("Against Pick List"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Pick List",
|
||||
"width": 130,
|
||||
},
|
||||
{
|
||||
"fieldname": "stock_reservation_entry",
|
||||
"label": _("Stock Reservation Entry"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Stock Reservation Entry",
|
||||
"width": 150,
|
||||
},
|
||||
{
|
||||
"fieldname": "status",
|
||||
"label": _("Status"),
|
||||
"fieldtype": "Data",
|
||||
"width": 120,
|
||||
},
|
||||
{
|
||||
"fieldname": "project",
|
||||
"label": _("Project"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Project",
|
||||
"width": 100,
|
||||
},
|
||||
{
|
||||
"fieldname": "company",
|
||||
"label": _("Company"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Company",
|
||||
"width": 110,
|
||||
},
|
||||
]
|
||||
|
||||
return columns
|
54
erpnext/stock/report/reserved_stock/test_reserved_stock.py
Normal file
54
erpnext/stock/report/reserved_stock/test_reserved_stock.py
Normal file
@ -0,0 +1,54 @@
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
from random import randint
|
||||
|
||||
import frappe
|
||||
from frappe.tests.utils import FrappeTestCase, change_settings
|
||||
from frappe.utils.data import today
|
||||
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
from erpnext.stock.doctype.stock_reservation_entry.test_stock_reservation_entry import (
|
||||
cancel_all_stock_reservation_entries,
|
||||
create_items,
|
||||
create_material_receipt,
|
||||
)
|
||||
from erpnext.stock.report.reserved_stock.reserved_stock import get_data as reserved_stock_report
|
||||
|
||||
|
||||
class TestReservedStock(FrappeTestCase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.stock_qty = 100
|
||||
self.warehouse = "_Test Warehouse - _TC"
|
||||
|
||||
def tearDown(self) -> None:
|
||||
cancel_all_stock_reservation_entries()
|
||||
return super().tearDown()
|
||||
|
||||
@change_settings(
|
||||
"Stock Settings",
|
||||
{
|
||||
"allow_negative_stock": 0,
|
||||
"enable_stock_reservation": 1,
|
||||
"auto_reserve_serial_and_batch": 1,
|
||||
"pick_serial_and_batch_based_on": "FIFO",
|
||||
},
|
||||
)
|
||||
def test_reserved_stock_report(self):
|
||||
items_details = create_items()
|
||||
create_material_receipt(items_details, self.warehouse, qty=self.stock_qty)
|
||||
|
||||
for item_code, properties in items_details.items():
|
||||
so = make_sales_order(
|
||||
item_code=item_code, qty=randint(11, 100), warehouse=self.warehouse, uom=properties.stock_uom
|
||||
)
|
||||
so.create_stock_reservation_entries()
|
||||
|
||||
data = reserved_stock_report(
|
||||
filters={
|
||||
"company": so.company,
|
||||
"from_date": today(),
|
||||
"to_date": today(),
|
||||
}
|
||||
)
|
||||
self.assertEqual(len(data), len(items_details))
|
@ -165,7 +165,7 @@ class StockBalanceReport(object):
|
||||
|
||||
def get_sre_reserved_qty_details(self) -> dict:
|
||||
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
|
||||
get_sre_reserved_qty_details_for_item_and_warehouse as get_reserved_qty_details,
|
||||
get_sre_reserved_qty_for_item_and_warehouse as get_reserved_qty_details,
|
||||
)
|
||||
|
||||
item_code_list, warehouse_list = [], []
|
||||
|
@ -862,7 +862,7 @@ class SerialBatchCreation:
|
||||
if self.get("serial_nos"):
|
||||
serial_no_wise_batch = frappe._dict({})
|
||||
if self.has_batch_no:
|
||||
serial_no_wise_batch = self.get_serial_nos_batch(self.serial_nos)
|
||||
serial_no_wise_batch = get_serial_nos_batch(self.serial_nos)
|
||||
|
||||
qty = -1 if self.type_of_transaction == "Outward" else 1
|
||||
for serial_no in self.serial_nos:
|
||||
@ -887,16 +887,6 @@ class SerialBatchCreation:
|
||||
},
|
||||
)
|
||||
|
||||
def get_serial_nos_batch(self, serial_nos):
|
||||
return frappe._dict(
|
||||
frappe.get_all(
|
||||
"Serial No",
|
||||
fields=["name", "batch_no"],
|
||||
filters={"name": ("in", serial_nos)},
|
||||
as_list=1,
|
||||
)
|
||||
)
|
||||
|
||||
def create_batch(self):
|
||||
from erpnext.stock.doctype.batch.batch import make_batch
|
||||
|
||||
@ -974,3 +964,14 @@ def get_serial_or_batch_items(items):
|
||||
serial_or_batch_items = [d.name for d in serial_or_batch_items]
|
||||
|
||||
return serial_or_batch_items
|
||||
|
||||
|
||||
def get_serial_nos_batch(serial_nos):
|
||||
return frappe._dict(
|
||||
frappe.get_all(
|
||||
"Serial No",
|
||||
fields=["name", "batch_no"],
|
||||
filters={"name": ("in", serial_nos)},
|
||||
as_list=1,
|
||||
)
|
||||
)
|
||||
|
@ -1214,9 +1214,15 @@ class update_entries_after(object):
|
||||
if msg:
|
||||
if self.reserved_stock:
|
||||
allowed_qty = abs(exceptions[0]["actual_qty"]) - abs(exceptions[0]["diff"])
|
||||
msg = "{0} As {1} units are reserved, you are allowed to consume only {2} units.".format(
|
||||
msg, frappe.bold(self.reserved_stock), frappe.bold(allowed_qty)
|
||||
)
|
||||
|
||||
if allowed_qty > 0:
|
||||
msg = "{0} As {1} units are reserved for other sales orders, you are allowed to consume only {2} units.".format(
|
||||
msg, frappe.bold(self.reserved_stock), frappe.bold(allowed_qty)
|
||||
)
|
||||
else:
|
||||
msg = "{0} As the full stock is reserved for other sales orders, you're not allowed to consume the stock.".format(
|
||||
msg,
|
||||
)
|
||||
|
||||
msg_list.append(msg)
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user