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:
s-aga-r
2023-09-02 11:02:24 +05:30
committed by GitHub
parent 0e517227ee
commit 2d8363a983
32 changed files with 2760 additions and 665 deletions

View File

@@ -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): if has_reserved_stock(parent.doctype, parent.name):
cancel_stock_reservation_entries(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 @erpnext.allow_regional

View File

@@ -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 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_docname = (typeof opts.cannot_add_row === 'undefined') ? "items" : opts.child_docname;
const child_meta = frappe.get_meta(`${frm.doc.doctype} Item`); 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; const get_precision = (fieldname) => child_meta.fields.find(f => f.fieldname == fieldname).precision;
this.data = frm.doc[opts.child_docname].map((d) => { this.data = frm.doc[opts.child_docname].map((d) => {
@@ -734,6 +735,17 @@ erpnext.utils.update_child_items = function(opts) {
}, },
], ],
primary_action: function() { 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); const trans_items = this.get_values()["trans_items"].filter((item) => !!item.item_code);
frappe.call({ frappe.call({
method: 'erpnext.controllers.accounts_controller.update_child_qty_rate', 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, "target_doc": cur_frm.doc,
"args": opts.args "args": opts.args
}, },
freeze: true,
freeze_message: __("Mapping {0} ...", [opts.source_doctype]),
callback: function(r) { callback: function(r) {
if(!r.exc) { if(!r.exc) {
var doc = frappe.model.sync(r.message); var doc = frappe.model.sync(r.message);

View File

@@ -59,19 +59,27 @@ frappe.ui.form.on("Sales Order", {
child_docname: "items", child_docname: "items",
child_doctype: "Sales Order Detail", child_doctype: "Sales Order Detail",
cannot_add_row: false, 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. // 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) { 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')); 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) { 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.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) { if (frm.doc.docstatus === 0) {
@@ -82,7 +90,7 @@ frappe.ui.form.on("Sales Order", {
if (frm.is_new()) { if (frm.is_new()) {
frappe.db.get_single_value("Stock Settings", "enable_stock_reservation").then((value) => { frappe.db.get_single_value("Stock Settings", "enable_stock_reservation").then((value) => {
if (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. // 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); 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) { get_items_from_internal_purchase_order(frm) {
@@ -171,76 +184,115 @@ frappe.ui.form.on("Sales Order", {
}, },
create_stock_reservation_entries(frm) { create_stock_reservation_entries(frm) {
let items_data = []; const dialog = new frappe.ui.Dialog({
title: __("Stock Reservation"),
const dialog = frappe.prompt({fieldname: 'items', fieldtype: 'Table', label: __('Items to Reserve'), size: "large",
fields: [ fields: [
{ {
fieldtype: 'Data', fieldname: "set_warehouse",
fieldname: 'name', fieldtype: "Link",
label: __('Name'), label: __("Set Warehouse"),
reqd: 1, options: "Warehouse",
read_only: 1, default: frm.doc.set_warehouse,
}, get_query: () => {
{
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 () {
return { return {
filters: [ filters: [
["Warehouse", "is_group", "!=", 1] ["Warehouse", "is_group", "!=", 1]
] ]
}; };
}, },
}, onchange: () => {
{ if (dialog.get_value("set_warehouse")) {
fieldtype: 'Float', dialog.fields_dict.items.df.data.forEach((row) => {
fieldname: 'qty_to_reserve', row.warehouse = dialog.get_value("set_warehouse");
label: __('Qty'), });
reqd: 1, dialog.fields_dict.items.grid.refresh();
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
}, },
freeze: true, },
freeze_message: __('Reserving Stock...'), {fieldtype: "Column Break"},
callback: (r) => { {fieldtype: "Section Break"},
frm.doc.__onload.has_unreserved_stock = false; {
frm.reload_doc(); fieldname: "items",
} fieldtype: "Table",
}); label: __("Items to Reserve"),
} allow_bulk_edit: false,
}, __("Stock Reservation"), __("Reserve Stock")); 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 => { frm.doc.items.forEach(item => {
if (item.reserve_stock) { 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) { if (unreserved_qty > 0) {
dialog.fields_dict.items.df.data.push({ dialog.fields_dict.items.df.data.push({
@@ -254,22 +306,127 @@ frappe.ui.form.on("Sales Order", {
}); });
dialog.fields_dict.items.grid.refresh(); dialog.fields_dict.items.grid.refresh();
dialog.show();
}, },
cancel_stock_reservation_entries(frm) { 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({ 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: { args: {
voucher_type: frm.doctype, voucher_type: frm.doctype,
voucher_no: frm.docname voucher_no: frm.docname,
}, },
freeze: true,
freeze_message: __('Unreserving Stock...'),
callback: (r) => { callback: (r) => {
frm.doc.__onload.has_reserved_stock = false; if (!r.exc && r.message) {
frm.reload_doc(); 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) { if (!doc.__onload || !doc.__onload.has_reserved_stock) {
this.frm.add_custom_button(__('Pick List'), () => this.create_pick_list(), __('Create')); // 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; 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 // delivery note
if(flt(doc.per_delivered, 2) < 100 && (order_is_a_sale || order_is_a_custom_sale) && allow_delivery) { 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')); 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(); d.show();
} }
make_delivery_note_based_on_delivery_date() { make_delivery_note_based_on_delivery_date(for_reserved_stock=false) {
var me = this; var me = this;
var delivery_dates = this.frm.doc.items.map(i => i.delivery_date); 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; if(!dates) return;
me.make_delivery_note(dates); me.make_delivery_note(dates, for_reserved_stock);
dialog.hide(); dialog.hide();
}); });
dialog.show(); dialog.show();
} else { } 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({ frappe.model.open_mapped_doc({
method: "erpnext.selling.doctype.sales_order.sales_order.make_delivery_note", method: "erpnext.selling.doctype.sales_order.sales_order.make_delivery_note",
frm: this.frm, frm: this.frm,
args: { args: {
delivery_dates delivery_dates,
} for_reserved_stock: for_reserved_stock
},
freeze: true,
freeze_message: __("Creating Delivery Note ...")
}) })
} }

View File

@@ -1027,7 +1027,6 @@
"length": 240, "length": 240,
"oldfieldname": "in_words_export", "oldfieldname": "in_words_export",
"oldfieldtype": "Data", "oldfieldtype": "Data",
"print_hide": 1,
"read_only": 1, "read_only": 1,
"width": "200px" "width": "200px"
}, },
@@ -1635,6 +1634,7 @@
"description": "If checked, Stock Reservation Entries will be created on <b>Submit</b>", "description": "If checked, Stock Reservation Entries will be created on <b>Submit</b>",
"fieldname": "reserve_stock", "fieldname": "reserve_stock",
"fieldtype": "Check", "fieldtype": "Check",
"hidden": 1,
"label": "Reserve Stock", "label": "Reserve Stock",
"no_copy": 1, "no_copy": 1,
"print_hide": 1, "print_hide": 1,
@@ -1645,7 +1645,7 @@
"idx": 105, "idx": 105,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2023-06-03 16:16:23.411247", "modified": "2023-07-24 08:59:11.599875",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Selling", "module": "Selling",
"name": "Sales Order", "name": "Sales Order",

View File

@@ -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.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.item.item import get_item_defaults
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
cancel_stock_reservation_entries,
get_sre_reserved_qty_details_for_voucher, get_sre_reserved_qty_details_for_voucher,
has_reserved_stock, has_reserved_stock,
) )
@@ -283,7 +282,7 @@ class SalesOrder(SellingController):
self.db_set("status", "Cancelled") self.db_set("status", "Cancelled")
self.update_blanket_order() 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) unlink_inter_company_doc(self.doctype, self.name, self.inter_company_order_reference)
if self.coupon_code: if self.coupon_code:
@@ -535,138 +534,26 @@ class SalesOrder(SellingController):
return False return False
@frappe.whitelist() @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.""" """Creates Stock Reservation Entries for Sales Order Items."""
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
get_available_qty_to_reserve, create_stock_reservation_entries_for_so_items as create_stock_reservation_entries,
validate_stock_reservation_settings,
) )
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( @frappe.whitelist()
"Stock Settings", "allow_partial_reservation" 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 = [] cancel_stock_reservation_entries(
if items_details: voucher_type=self.doctype, voucher_no=self.name, sre_list=sre_list, notify=notify
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")
def get_unreserved_qty(item: object, reserved_qty_details: dict) -> float: 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() @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.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): def set_missing_values(source, target):
target.run_method("set_missing_values") 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) 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): def update_item(source, target, source_parent):
target.base_amount = (flt(source.qty) - flt(source.delivered_qty)) * flt(source.base_rate) 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) 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") or item_group.get("buying_cost_center")
) )
mapper = { if not kwargs.skip_item_mapping:
"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
mapper["Sales Order Item"] = { mapper["Sales Order Item"] = {
"doctype": "Delivery Note Item", "doctype": "Delivery Note Item",
"field_map": { "field_map": {
@@ -869,11 +777,56 @@ def make_delivery_note(source_name, target_doc=None, skip_item_mapping=False):
"name": "so_detail", "name": "so_detail",
"parent": "against_sales_order", "parent": "against_sales_order",
}, },
"postprocess": update_item,
"condition": condition, "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) target_doc.set_onload("ignore_price_list", True)
return target_doc 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): def create_pick_list(source_name, target_doc=None):
from erpnext.stock.doctype.packed_item.packed_item import is_product_bundle 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: def update_item_quantity(source, target, source_parent) -> None:
picked_qty = flt(source.picked_qty) / (flt(source.conversion_factor) or 1) 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)) 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) 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( doc = get_mapped_doc(
"Sales Order", "Sales Order",
source_name, source_name,

View File

@@ -1789,147 +1789,6 @@ class TestSalesOrder(FrappeTestCase):
self.assertEqual(pe.references[1].reference_name, so.name) self.assertEqual(pe.references[1].reference_name, so.name)
self.assertEqual(pe.references[1].allocated_amount, 300) 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): def test_delivered_item_material_request(self):
"SO -> MR (Manufacture) -> WO. Test if WO Qty is updated in SO." "SO -> MR (Manufacture) -> WO. Test if WO Qty is updated in SO."
from erpnext.manufacturing.doctype.work_order.work_order import ( from erpnext.manufacturing.doctype.work_order.work_order import (

View File

@@ -2,6 +2,10 @@ import frappe
from frappe.model.db_query import DatabaseQuery from frappe.model.db_query import DatabaseQuery
from frappe.utils import cint, flt 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() @frappe.whitelist()
def get_data( def get_data(
@@ -57,6 +61,7 @@ def get_data(
limit_page_length=21, 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")) precision = cint(frappe.db.get_single_value("System Settings", "float_precision"))
for item in items: 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_production": flt(item.reserved_qty_for_production, precision),
"reserved_qty_for_sub_contract": flt(item.reserved_qty_for_sub_contract, precision), "reserved_qty_for_sub_contract": flt(item.reserved_qty_for_sub_contract, precision),
"actual_qty": flt(item.actual_qty, precision), "actual_qty": flt(item.actual_qty, precision),
"reserved_stock": sre_reserved_stock_details.get((item.item_code, item.warehouse), 0),
} }
) )
return items return items

View File

@@ -12,7 +12,10 @@
</a> </a>
{% endif %} {% endif %}
</div> </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">
<span class="inline-graph-half" title="{{ __("Reserved Qty") }}"> <span class="inline-graph-half" title="{{ __("Reserved Qty") }}">
<span class="inline-graph-count">{{ d.total_reserved }}</span> <span class="inline-graph-count">{{ d.total_reserved }}</span>

View File

@@ -41,7 +41,7 @@ frappe.ui.form.on('Batch', {
if(!frm.is_new()) { if(!frm.is_new()) {
frappe.call({ frappe.call({
method: 'erpnext.stock.doctype.batch.batch.get_batch_qty', 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) => { callback: (r) => {
if(!r.message) { if(!r.message) {
return; return;

View File

@@ -150,6 +150,9 @@ erpnext.stock.DeliveryNoteController = class DeliveryNoteController extends erpn
} }
erpnext.utils.map_current_doc({ erpnext.utils.map_current_doc({
method: "erpnext.selling.doctype.sales_order.sales_order.make_delivery_note", method: "erpnext.selling.doctype.sales_order.sales_order.make_delivery_note",
args: {
for_reserved_stock: 1
},
source_doctype: "Sales Order", source_doctype: "Sales Order",
target: me.frm, target: me.frm,
setters: { setters: {

View File

@@ -279,6 +279,8 @@ class DeliveryNote(SellingController):
self.update_prevdoc_status() self.update_prevdoc_status()
self.update_billing_status() self.update_billing_status()
self.update_stock_reservation_entries()
# Updating stock ledger should always be called after updating prevdoc status, # Updating stock ledger should always be called after updating prevdoc status,
# because updating reserved qty in bin depends upon updated delivered qty in SO # because updating reserved qty in bin depends upon updated delivered qty in SO
self.update_stock_ledger() self.update_stock_ledger()
@@ -297,55 +299,141 @@ class DeliveryNote(SellingController):
def update_stock_reservation_entries(self) -> None: def update_stock_reservation_entries(self) -> None:
"""Updates Delivered Qty in Stock Reservation Entries.""" """Updates Delivered Qty in Stock Reservation Entries."""
# Don't update Delivered Qty on Return or Cancellation. # Don't update Delivered Qty on Return.
if self.is_return or self._action == "cancel": if self.is_return:
return return
for item in self.get("items"): if self._action == "submit":
# Skip if `Sales Order` or `Sales Order Item` reference is not set. for item in self.get("items"):
if not item.against_sales_order or not item.so_detail: # Skip if `Sales Order` or `Sales Order Item` reference is not set.
continue if not item.against_sales_order or not item.so_detail:
continue
sre_list = frappe.db.get_all( sre_list = frappe.db.get_all(
"Stock Reservation Entry", "Stock Reservation Entry",
{ {
"docstatus": 1, "docstatus": 1,
"voucher_type": "Sales Order", "voucher_type": "Sales Order",
"voucher_no": item.against_sales_order, "voucher_no": item.against_sales_order,
"voucher_detail_no": item.so_detail, "voucher_detail_no": item.so_detail,
"warehouse": item.warehouse, "warehouse": item.warehouse,
"status": ["not in", ["Delivered", "Cancelled"]], "status": ["not in", ["Delivered", "Cancelled"]],
}, },
order_by="creation", order_by="creation",
) )
# Skip if no Stock Reservation Entries. # Skip if no Stock Reservation Entries.
if not sre_list: if not sre_list:
continue continue
available_qty_to_deliver = item.stock_qty qty_to_deliver = item.stock_qty
for sre in sre_list: for sre in sre_list:
if available_qty_to_deliver <= 0: if qty_to_deliver <= 0:
break 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_can_be_deliver = 0
qty_to_be_deliver = min(sre_doc.reserved_qty - sre_doc.delivered_qty, available_qty_to_deliver) 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.delivered_qty += qty_can_be_deliver
sre_doc.db_update() sre_doc.db_update()
# Update Stock Reservation Entry `Status` based on `Delivered Qty`. # Update Stock Reservation Entry `Status` based on `Delivered Qty`.
sre_doc.update_status() 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): def validate_against_stock_reservation_entries(self):
"""Validates if Stock Reservation Entries are available for the Sales Order Item reference.""" """Validates if Stock Reservation Entries are available for the Sales Order Item reference."""
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( 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 # Don't validate if Return
@@ -357,26 +445,30 @@ class DeliveryNote(SellingController):
if not item.against_sales_order or not item.so_detail: if not item.against_sales_order or not item.so_detail:
continue 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 "Sales Order", item.against_sales_order, item.so_detail
) )
# Skip if stock is not reserved. # Skip if stock is not reserved.
if not sre_data: if not reserved_warehouses:
continue continue
# Set `Warehouse` from SRE if not set. # Set `Warehouse` from SRE if not set.
if not item.warehouse: if not item.warehouse:
item.warehouse = sre_data[0] item.warehouse = reserved_warehouses[0]
else: else:
# Throw if `Warehouse` is different from SRE. # Throw if `Warehouse` not in Reserved Warehouses.
if item.warehouse != sre_data[0]: if item.warehouse not in reserved_warehouses:
frappe.throw( msg = _("Row #{0}: Stock is reserved for item {1} in warehouse {2}.").format(
_("Row #{0}: Stock is reserved for Item {1} in Warehouse {2}.").format( item.idx,
item.idx, frappe.bold(item.item_code), frappe.bold(sre_data[0]) 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): def check_credit_limit(self):
from erpnext.selling.doctype.customer.customer import check_credit_limit from erpnext.selling.doctype.customer.customer import check_credit_limit

View File

@@ -115,6 +115,22 @@ frappe.ui.form.on('Pick List', {
frm.add_custom_button(__('Stock Entry'), () => frm.trigger('create_stock_entry'), __('Create')); 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) => { work_order: (frm) => {
@@ -209,6 +225,49 @@ frappe.ui.form.on('Pick List', {
}; };
const barcode_scanner = new erpnext.utils.BarcodeScanner(opts); const barcode_scanner = new erpnext.utils.BarcodeScanner(opts);
barcode_scanner.process_scan(); 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");
} }
}); });

View File

@@ -29,6 +29,14 @@ from erpnext.stock.serial_batch_bundle import SerialBatchCreation
class PickList(Document): 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): def validate(self):
self.validate_for_qty() self.validate_for_qty()
@@ -47,8 +55,28 @@ class PickList(Document):
) )
def before_submit(self): def before_submit(self):
self.validate_sales_order()
self.validate_picked_items() 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): def validate_picked_items(self):
for item in self.locations: for item in self.locations:
if self.scan_mode and item.picked_qty < item.stock_qty: if self.scan_mode and item.picked_qty < item.stock_qty:
@@ -70,8 +98,19 @@ class PickList(Document):
self.update_reference_qty() self.update_reference_qty()
self.update_sales_order_picking_status() 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): 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_status()
self.update_bundle_picked_qty() self.update_bundle_picked_qty()
@@ -186,6 +225,36 @@ class PickList(Document):
for sales_order in sales_orders: for sales_order in sales_orders:
frappe.get_doc("Sales Order", sales_order, for_update=True).update_picking_status() 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): def validate_picked_qty(self, data):
over_delivery_receipt_allowance = 100 + flt( over_delivery_receipt_allowance = 100 + flt(
frappe.db.get_single_value("Stock Settings", "over_delivery_receipt_allowance") frappe.db.get_single_value("Stock Settings", "over_delivery_receipt_allowance")
@@ -448,6 +517,26 @@ class PickList(Document):
possible_bundles.append(0) possible_bundles.append(0)
return int(flt(min(possible_bundles), precision or 6)) 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): def update_pick_list_status(pick_list):
if pick_list: if pick_list:
@@ -781,7 +870,8 @@ def create_dn_with_so(sales_dict, pick_list):
for customer in sales_dict: for customer in sales_dict:
for so in sales_dict[customer]: for so in sales_dict[customer]:
delivery_note = None 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 break
if delivery_note: if delivery_note:
# map all items of all sales orders of that customer # map all items of all sales orders of that customer

View File

@@ -1,10 +1,13 @@
def get_data(): def get_data():
return { return {
"fieldname": "pick_list", "fieldname": "pick_list",
"non_standard_fieldnames": {
"Stock Reservation Entry": "against_pick_list",
},
"internal_links": { "internal_links": {
"Sales Order": ["locations", "sales_order"], "Sales Order": ["locations", "sales_order"],
}, },
"transactions": [ "transactions": [
{"items": ["Stock Entry", "Sales Order", "Delivery Note"]}, {"items": ["Stock Entry", "Sales Order", "Delivery Note", "Stock Reservation Entry"]},
], ],
} }

View File

@@ -16,6 +16,7 @@
"qty", "qty",
"stock_qty", "stock_qty",
"picked_qty", "picked_qty",
"stock_reserved_qty",
"column_break_11", "column_break_11",
"uom", "uom",
"conversion_factor", "conversion_factor",
@@ -46,7 +47,7 @@
"fieldname": "picked_qty", "fieldname": "picked_qty",
"fieldtype": "Float", "fieldtype": "Float",
"in_list_view": 1, "in_list_view": 1,
"label": "Picked Qty" "label": "Picked Qty (in Stock UOM)"
}, },
{ {
"fieldname": "warehouse", "fieldname": "warehouse",
@@ -154,8 +155,7 @@
"fieldtype": "Data", "fieldtype": "Data",
"hidden": 1, "hidden": 1,
"label": "Sales Order Item", "label": "Sales Order Item",
"read_only": 1, "read_only": 1
"search_index": 1
}, },
{ {
"fieldname": "serial_no_and_batch_section", "fieldname": "serial_no_and_batch_section",
@@ -207,6 +207,17 @@
"fieldname": "pick_serial_and_batch", "fieldname": "pick_serial_and_batch",
"fieldtype": "Button", "fieldtype": "Button",
"label": "Pick Serial / Batch No" "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, "istable": 1,

View File

@@ -66,7 +66,7 @@ class SerialandBatchBundle(Document):
serial_nos = [d.serial_no for d in self.entries if d.serial_no] serial_nos = [d.serial_no for d in self.entries if d.serial_no]
kwargs = {"item_code": self.item_code, "warehouse": self.warehouse} kwargs = {"item_code": self.item_code, "warehouse": self.warehouse}
if self.voucher_type == "POS Invoice": 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)) available_serial_nos = get_available_serial_nos(frappe._dict(kwargs))
@@ -1098,8 +1098,8 @@ def get_available_serial_nos(kwargs):
if kwargs.warehouse: if kwargs.warehouse:
filters["warehouse"] = kwargs.warehouse filters["warehouse"] = kwargs.warehouse
# Since SLEs are not present against POS invoices, need to ignore serial nos present in the POS invoice # Since SLEs are not present against Reserved Stock [POS invoices, SRE], need to ignore reserved serial nos.
ignore_serial_nos = get_reserved_serial_nos_for_pos(kwargs) ignore_serial_nos = get_reserved_serial_nos(kwargs)
# To ignore serial nos in the same record for the draft state # To ignore serial nos in the same record for the draft state
if kwargs.get("ignore_serial_nos"): 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 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): def get_reserved_serial_nos_for_pos(kwargs):
from erpnext.controllers.sales_and_purchase_return import get_returned_serial_nos from erpnext.controllers.sales_and_purchase_return import get_returned_serial_nos
from erpnext.stock.doctype.serial_no.serial_no import get_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", "docstatus", "=", 1],
["POS Invoice", "is_return", "=", 0], ["POS Invoice", "is_return", "=", 0],
["POS Invoice Item", "item_code", "=", kwargs.item_code], ["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) 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_batches = frappe._dict()
pos_invoices = frappe.get_all( pos_invoices = frappe.get_all(
"POS Invoice", "POS Invoice",
@@ -1267,7 +1311,7 @@ def get_reserved_batches_for_pos(kwargs):
["POS Invoice", "consolidated_invoice", "is", "not set"], ["POS Invoice", "consolidated_invoice", "is", "not set"],
["POS Invoice", "docstatus", "=", 1], ["POS Invoice", "docstatus", "=", 1],
["POS Invoice Item", "item_code", "=", kwargs.item_code], ["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: if not ids:
return [] return {}
if ids: if ids:
for d in get_serial_batch_ledgers(kwargs.item_code, docstatus=1, name=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 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): def get_auto_batch_nos(kwargs):
available_batches = get_available_batches(kwargs) available_batches = get_available_batches(kwargs)
qty = flt(kwargs.qty) qty = flt(kwargs.qty)
pos_invoice_batches = get_reserved_batches_for_pos(kwargs)
stock_ledgers_batches = get_stock_ledgers_batches(kwargs) stock_ledgers_batches = get_stock_ledgers_batches(kwargs)
if stock_ledgers_batches or pos_invoice_batches: pos_invoice_batches = get_reserved_batches_for_pos(kwargs)
update_available_batches(available_batches, stock_ledgers_batches, pos_invoice_batches) 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)) 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 return batches
def update_available_batches(available_batches, reserved_batches=None, pos_invoice_batches=None): def update_available_batches(available_batches, *reserved_batches) -> None:
for batches in [reserved_batches, pos_invoice_batches]: for batches in reserved_batches:
if batches: if batches:
for key, data in batches.items(): for key, data in batches.items():
batch_no, warehouse = key batch_no, warehouse = key

View File

@@ -10,6 +10,7 @@
"column_break_2", "column_break_2",
"qty", "qty",
"warehouse", "warehouse",
"delivered_qty",
"section_break_6", "section_break_6",
"incoming_rate", "incoming_rate",
"column_break_8", "column_break_8",
@@ -104,12 +105,24 @@
"fieldtype": "Small Text", "fieldtype": "Small Text",
"label": "FIFO Stock Queue (qty, rate)", "label": "FIFO Stock Queue (qty, rate)",
"read_only": 1 "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, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2023-03-31 11:18:59.809486", "modified": "2023-07-03 15:29:50.199075",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Serial and Batch Entry", "name": "Serial and Batch Entry",

View File

@@ -346,7 +346,7 @@ class StockReconciliation(StockController):
"""Raises an exception if there is any reserved stock for the items in the Stock Reconciliation.""" """Raises an exception if there is any reserved stock for the items in the Stock Reconciliation."""
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( 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 = [], [] item_code_list, warehouse_list = [], []

View File

@@ -3,6 +3,124 @@
frappe.ui.form.on("Stock Reservation Entry", { frappe.ui.form.on("Stock Reservation Entry", {
refresh(frm) { 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);
}
},
});

View File

@@ -2,7 +2,7 @@
"actions": [], "actions": [],
"allow_copy": 1, "allow_copy": 1,
"autoname": "MAT-SRE-.YYYY.-.#####", "autoname": "MAT-SRE-.YYYY.-.#####",
"creation": "2023-03-20 10:45:59.258959", "creation": "2023-06-06 15:20:48.016846",
"default_view": "List", "default_view": "List",
"doctype": "DocType", "doctype": "DocType",
"editable_grid": 1, "editable_grid": 1,
@@ -10,17 +10,26 @@
"field_order": [ "field_order": [
"item_code", "item_code",
"warehouse", "warehouse",
"has_serial_no",
"has_batch_no",
"column_break_elik", "column_break_elik",
"voucher_type", "voucher_type",
"voucher_no", "voucher_no",
"voucher_detail_no", "voucher_detail_no",
"column_break_7dxj",
"against_pick_list",
"against_pick_list_item",
"section_break_xt4m", "section_break_xt4m",
"stock_uom",
"column_break_grdt",
"available_qty", "available_qty",
"voucher_qty", "voucher_qty",
"stock_uom",
"column_break_o6ex", "column_break_o6ex",
"reserved_qty", "reserved_qty",
"delivered_qty", "delivered_qty",
"serial_and_batch_reservation_section",
"reservation_based_on",
"sb_entries",
"section_break_3vb3", "section_break_3vb3",
"company", "company",
"column_break_jbyr", "column_break_jbyr",
@@ -36,6 +45,7 @@
"in_list_view": 1, "in_list_view": 1,
"in_standard_filter": 1, "in_standard_filter": 1,
"label": "Item Code", "label": "Item Code",
"no_copy": 1,
"oldfieldname": "item_code", "oldfieldname": "item_code",
"oldfieldtype": "Link", "oldfieldtype": "Link",
"options": "Item", "options": "Item",
@@ -51,6 +61,7 @@
"in_list_view": 1, "in_list_view": 1,
"in_standard_filter": 1, "in_standard_filter": 1,
"label": "Warehouse", "label": "Warehouse",
"no_copy": 1,
"oldfieldname": "warehouse", "oldfieldname": "warehouse",
"oldfieldtype": "Link", "oldfieldtype": "Link",
"options": "Warehouse", "options": "Warehouse",
@@ -64,6 +75,7 @@
"fieldtype": "Select", "fieldtype": "Select",
"in_filter": 1, "in_filter": 1,
"label": "Voucher Type", "label": "Voucher Type",
"no_copy": 1,
"oldfieldname": "voucher_type", "oldfieldname": "voucher_type",
"oldfieldtype": "Data", "oldfieldtype": "Data",
"options": "\nSales Order", "options": "\nSales Order",
@@ -78,17 +90,20 @@
"in_list_view": 1, "in_list_view": 1,
"in_standard_filter": 1, "in_standard_filter": 1,
"label": "Voucher No", "label": "Voucher No",
"no_copy": 1,
"oldfieldname": "voucher_no", "oldfieldname": "voucher_no",
"oldfieldtype": "Data", "oldfieldtype": "Data",
"options": "voucher_type", "options": "voucher_type",
"print_width": "150px", "print_width": "150px",
"read_only": 1, "read_only": 1,
"search_index": 1,
"width": "150px" "width": "150px"
}, },
{ {
"fieldname": "voucher_detail_no", "fieldname": "voucher_detail_no",
"fieldtype": "Data", "fieldtype": "Data",
"label": "Voucher Detail No", "label": "Voucher Detail No",
"no_copy": 1,
"oldfieldname": "voucher_detail_no", "oldfieldname": "voucher_detail_no",
"oldfieldtype": "Data", "oldfieldtype": "Data",
"print_width": "150px", "print_width": "150px",
@@ -100,6 +115,7 @@
"fieldname": "stock_uom", "fieldname": "stock_uom",
"fieldtype": "Link", "fieldtype": "Link",
"label": "Stock UOM", "label": "Stock UOM",
"no_copy": 1,
"oldfieldname": "stock_uom", "oldfieldname": "stock_uom",
"oldfieldtype": "Data", "oldfieldtype": "Data",
"options": "UOM", "options": "UOM",
@@ -111,14 +127,17 @@
"fieldname": "project", "fieldname": "project",
"fieldtype": "Link", "fieldtype": "Link",
"label": "Project", "label": "Project",
"no_copy": 1,
"options": "Project", "options": "Project",
"read_only": 1 "read_only": 1,
"search_index": 1
}, },
{ {
"fieldname": "company", "fieldname": "company",
"fieldtype": "Link", "fieldtype": "Link",
"in_filter": 1, "in_filter": 1,
"label": "Company", "label": "Company",
"no_copy": 1,
"oldfieldname": "company", "oldfieldname": "company",
"oldfieldtype": "Data", "oldfieldtype": "Data",
"options": "Company", "options": "Company",
@@ -128,23 +147,26 @@
"width": "150px" "width": "150px"
}, },
{ {
"allow_on_submit": 1,
"fieldname": "reserved_qty", "fieldname": "reserved_qty",
"fieldtype": "Float", "fieldtype": "Float",
"in_filter": 1, "in_filter": 1,
"in_list_view": 1, "in_list_view": 1,
"label": "Reserved Qty", "label": "Reserved Qty",
"no_copy": 1,
"non_negative": 1,
"oldfieldname": "actual_qty", "oldfieldname": "actual_qty",
"oldfieldtype": "Currency", "oldfieldtype": "Currency",
"print_width": "150px", "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" "width": "150px"
}, },
{ {
"default": "Draft", "default": "Draft",
"fieldname": "status", "fieldname": "status",
"fieldtype": "Select", "fieldtype": "Select",
"hidden": 1,
"label": "Status", "label": "Status",
"no_copy": 1,
"options": "Draft\nPartially Reserved\nReserved\nPartially Delivered\nDelivered\nCancelled", "options": "Draft\nPartially Reserved\nReserved\nPartially Delivered\nDelivered\nCancelled",
"read_only": 1 "read_only": 1
}, },
@@ -153,6 +175,8 @@
"fieldname": "delivered_qty", "fieldname": "delivered_qty",
"fieldtype": "Float", "fieldtype": "Float",
"label": "Delivered Qty", "label": "Delivered Qty",
"no_copy": 1,
"non_negative": 1,
"read_only": 1 "read_only": 1
}, },
{ {
@@ -170,6 +194,7 @@
"fieldtype": "Float", "fieldtype": "Float",
"label": "Available Qty to Reserve", "label": "Available Qty to Reserve",
"no_copy": 1, "no_copy": 1,
"non_negative": 1,
"read_only": 1 "read_only": 1
}, },
{ {
@@ -178,6 +203,7 @@
"fieldtype": "Float", "fieldtype": "Float",
"label": "Voucher Qty", "label": "Voucher Qty",
"no_copy": 1, "no_copy": 1,
"non_negative": 1,
"read_only": 1 "read_only": 1
}, },
{ {
@@ -193,12 +219,84 @@
"fieldtype": "Column Break" "fieldtype": "Column Break"
}, },
{ {
"collapsible": 1,
"fieldname": "section_break_3vb3", "fieldname": "section_break_3vb3",
"fieldtype": "Section Break" "fieldtype": "Section Break",
"label": "More Information"
}, },
{ {
"fieldname": "column_break_jbyr", "fieldname": "column_break_jbyr",
"fieldtype": "Column Break" "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, "hide_toolbar": 1,
@@ -206,7 +304,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2023-03-29 18:36:26.752872", "modified": "2023-08-08 17:15:13.317706",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Stock Reservation Entry", "name": "Stock Reservation Entry",
@@ -230,5 +328,6 @@
], ],
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [] "states": [],
"track_changes": 1
} }

View File

@@ -1,23 +1,38 @@
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors # Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt # See license.txt
from random import randint
import frappe import frappe
from frappe.tests.utils import FrappeTestCase, change_settings 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.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.stock_entry import StockEntry
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry 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 from erpnext.stock.utils import get_stock_balance
class TestStockReservationEntry(FrappeTestCase): class TestStockReservationEntry(FrappeTestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.items = create_items() self.warehouse = "_Test Warehouse - _TC"
create_material_receipt(self.items) 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: def tearDown(self) -> None:
cancel_all_stock_reservation_entries()
return super().tearDown() return super().tearDown()
@change_settings("Stock Settings", {"allow_negative_stock": 0})
def test_validate_stock_reservation_settings(self) -> None: def test_validate_stock_reservation_settings(self) -> None:
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
validate_stock_reservation_settings, validate_stock_reservation_settings,
@@ -47,28 +62,29 @@ class TestStockReservationEntry(FrappeTestCase):
get_available_qty_to_reserve, 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 # 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(self.sr_item.name, self.warehouse)
available_qty_to_reserve = get_available_qty_to_reserve(item_code, warehouse) expected_available_qty_to_reserve = get_stock_balance(self.sr_item.name, self.warehouse)
expected_available_qty_to_reserve = get_stock_balance(item_code, warehouse)
self.assertEqual(available_qty_to_reserve, expected_available_qty_to_reserve) 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 # Case - 2: When `Reserved Qty` is `> 0`, Available Qty to Reserve = Actual Qty - Reserved Qty
sre = make_stock_reservation_entry( sre = make_stock_reservation_entry(
item_code=item_code, item_code=self.sr_item.name,
warehouse=warehouse, warehouse=self.warehouse,
ignore_validate=True, ignore_validate=True,
) )
available_qty_to_reserve = get_available_qty_to_reserve(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(item_code, warehouse) - sre.reserved_qty 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) self.assertEqual(available_qty_to_reserve, expected_available_qty_to_reserve)
def test_update_status(self) -> None: def test_update_status(self) -> None:
sre = make_stock_reservation_entry( sre = make_stock_reservation_entry(
item_code=self.sr_item.name,
warehouse=self.warehouse,
reserved_qty=30, reserved_qty=30,
ignore_validate=True, ignore_validate=True,
do_not_submit=True, do_not_submit=True,
@@ -109,14 +125,12 @@ class TestStockReservationEntry(FrappeTestCase):
sre.load_from_db() sre.load_from_db()
self.assertEqual(sre.status, "Cancelled") 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: def test_update_reserved_qty_in_voucher(self) -> None:
item_code, warehouse = "SR Item 1", "_Test Warehouse - _TC"
# Step - 1: Create a `Sales Order` # Step - 1: Create a `Sales Order`
so = make_sales_order( so = make_sales_order(
item_code=item_code, item_code=self.sr_item.name,
warehouse=warehouse, warehouse=self.warehouse,
qty=50, qty=50,
rate=100, rate=100,
do_not_submit=True, do_not_submit=True,
@@ -128,8 +142,8 @@ class TestStockReservationEntry(FrappeTestCase):
# Step - 2: Create a `Stock Reservation Entry[1]` for the `Sales Order Item` # Step - 2: Create a `Stock Reservation Entry[1]` for the `Sales Order Item`
sre1 = make_stock_reservation_entry( sre1 = make_stock_reservation_entry(
item_code=item_code, item_code=self.sr_item.name,
warehouse=warehouse, warehouse=self.warehouse,
voucher_type="Sales Order", voucher_type="Sales Order",
voucher_no=so.name, voucher_no=so.name,
voucher_detail_no=so.items[0].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` # Step - 3: Create a `Stock Reservation Entry[2]` for the `Sales Order Item`
sre2 = make_stock_reservation_entry( sre2 = make_stock_reservation_entry(
item_code=item_code, item_code=self.sr_item.name,
warehouse=warehouse, warehouse=self.warehouse,
voucher_type="Sales Order", voucher_type="Sales Order",
voucher_no=so.name, voucher_no=so.name,
voucher_detail_no=so.items[0].name, voucher_detail_no=so.items[0].name,
@@ -163,26 +177,32 @@ class TestStockReservationEntry(FrappeTestCase):
self.assertEqual(sre1.status, "Cancelled") self.assertEqual(sre1.status, "Cancelled")
self.assertEqual(so.items[0].stock_reserved_qty, sre2.reserved_qty) 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() sre2.cancel()
so.load_from_db() so.load_from_db()
sre2.load_from_db() sre2.load_from_db()
self.assertEqual(sre1.status, "Cancelled") self.assertEqual(sre1.status, "Cancelled")
self.assertEqual(so.items[0].stock_reserved_qty, 0) 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: def test_cant_consume_reserved_stock(self) -> None:
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
cancel_stock_reservation_entries, cancel_stock_reservation_entries,
) )
from erpnext.stock.stock_ledger import NegativeStockError from erpnext.stock.stock_ledger import NegativeStockError
item_code, warehouse = "SR Item 1", "_Test Warehouse - _TC"
# Step - 1: Create a `Sales Order` # Step - 1: Create a `Sales Order`
so = make_sales_order( so = make_sales_order(
item_code=item_code, item_code=self.sr_item.name,
warehouse=warehouse, warehouse=self.warehouse,
qty=50, qty=50,
rate=100, rate=100,
do_not_submit=True, do_not_submit=True,
@@ -192,13 +212,13 @@ class TestStockReservationEntry(FrappeTestCase):
so.save() so.save()
so.submit() 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`. # Step - 2: Try to consume (Transfer/Issue/Deliver) the Available Qty via Stock Entry or Delivery Note, should throw `NegativeStockError`.
se = make_stock_entry( se = make_stock_entry(
item_code=item_code, item_code=self.sr_item.name,
qty=actual_qty, qty=actual_qty,
from_warehouse=warehouse, from_warehouse=self.warehouse,
rate=100, rate=100,
purpose="Material Issue", purpose="Material Issue",
do_not_submit=True, do_not_submit=True,
@@ -210,9 +230,9 @@ class TestStockReservationEntry(FrappeTestCase):
cancel_stock_reservation_entries(so.doctype, so.name) cancel_stock_reservation_entries(so.doctype, so.name)
se = make_stock_entry( se = make_stock_entry(
item_code=item_code, item_code=self.sr_item.name,
qty=actual_qty, qty=actual_qty,
from_warehouse=warehouse, from_warehouse=self.warehouse,
rate=100, rate=100,
purpose="Material Issue", purpose="Material Issue",
do_not_submit=True, do_not_submit=True,
@@ -220,52 +240,369 @@ class TestStockReservationEntry(FrappeTestCase):
se.submit() se.submit()
se.cancel() 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: def create_items() -> dict:
from erpnext.stock.doctype.item.test_item import make_item items_properties = [
# SR STOCK ITEM
items_details = { {"is_stock_item": 1, "valuation_rate": 100},
# Stock Items # SR SERIAL ITEM
"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": {
"is_stock_item": 1, "is_stock_item": 1,
"valuation_rate": 200, "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, "has_batch_no": 1,
"create_new_batch": 1, "create_new_batch": 1,
"batch_number_series": "SRBI-2-.#####.", "batch_number_series": "SRBI-.#####.",
"stock_uom": "Kg",
}, },
# Serial Item # SR SERIAL AND BATCH ITEM
"SR Serial Item 1": { {
"is_stock_item": 1, "is_stock_item": 1,
"valuation_rate": 100, "valuation_rate": 400,
"has_serial_no": 1, "has_serial_no": 1,
"serial_no_series": "SRSI-1-.#####", "serial_no_series": "SRSBI-.#####",
},
# Batch and Serial Item
"SR Batch and Serial Item 1": {
"is_stock_item": 1,
"valuation_rate": 100,
"has_batch_no": 1, "has_batch_no": 1,
"create_new_batch": 1, "create_new_batch": 1,
"batch_number_series": "SRBSI-1-.#####.", "batch_number_series": "SRSBI-.#####.",
"has_serial_no": 1,
"serial_no_series": "SRBSI-1-.#####",
}, },
} ]
items = {} items = {}
for item_code, properties in items_details.items(): for properties in items_properties:
items[item_code] = make_item(item_code, properties) item = make_item(properties=properties)
items[item.name] = item
return items return items
@@ -313,7 +650,7 @@ def make_stock_reservation_entry(**args):
doc = frappe.new_doc("Stock Reservation Entry") doc = frappe.new_doc("Stock Reservation Entry")
args = frappe._dict(args) 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.warehouse = args.warehouse or "_Test Warehouse - _TC"
doc.voucher_type = args.voucher_type doc.voucher_type = args.voucher_type
doc.voucher_no = args.voucher_no doc.voucher_no = args.voucher_no

View File

@@ -34,8 +34,10 @@
"stock_reservation_tab", "stock_reservation_tab",
"enable_stock_reservation", "enable_stock_reservation",
"column_break_rx3e", "column_break_rx3e",
"reserve_stock_on_sales_order_submission", "auto_reserve_stock_for_sales_order",
"allow_partial_reservation", "allow_partial_reservation",
"serial_and_batch_reservation_section",
"auto_reserve_serial_and_batch",
"serial_and_batch_item_settings_tab", "serial_and_batch_item_settings_tab",
"section_break_7", "section_break_7",
"auto_create_serial_and_batch_bundle_for_outward", "auto_create_serial_and_batch_bundle_for_outward",
@@ -59,7 +61,8 @@
"stock_frozen_upto_days", "stock_frozen_upto_days",
"column_break_26", "column_break_26",
"role_allowed_to_create_edit_back_dated_transactions", "role_allowed_to_create_edit_back_dated_transactions",
"stock_auth_role" "stock_auth_role",
"section_break_plhx"
], ],
"fields": [ "fields": [
{ {
@@ -337,18 +340,11 @@
}, },
{ {
"default": "0", "default": "0",
"description": "Allows to keep aside a specific quantity of inventory for a particular order.",
"fieldname": "enable_stock_reservation", "fieldname": "enable_stock_reservation",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Enable Stock Reservation" "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", "fieldname": "column_break_rx3e",
"fieldtype": "Column Break" "fieldtype": "Column Break"
@@ -356,7 +352,7 @@
{ {
"default": "1", "default": "1",
"depends_on": "eval: doc.enable_stock_reservation", "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", "fieldname": "allow_partial_reservation",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Allow Partial Reservation" "label": "Allow Partial Reservation"
@@ -383,6 +379,27 @@
"fieldname": "auto_create_serial_and_batch_bundle_for_outward", "fieldname": "auto_create_serial_and_batch_bundle_for_outward",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Auto Create Serial and Batch Bundle For Outward" "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", "icon": "icon-cog",
@@ -390,7 +407,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2023-05-29 15:10:54.959411", "modified": "2023-09-01 16:16:34.018947",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Stock Settings", "name": "Stock Settings",

View File

@@ -69,9 +69,9 @@ class StockSettings(Document):
) )
def cant_change_valuation_method(self): 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 # check if there are any stock ledger entries against items
# which does not have it's own valuation method # which does not have it's own valuation method
sle = frappe.db.sql( sle = frappe.db.sql(
@@ -108,13 +108,8 @@ class StockSettings(Document):
if frappe.flags.in_test: if frappe.flags.in_test:
return 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` # 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 # Disable -> Enable: Don't allow if `Stock Reservation` is enabled
if self.allow_negative_stock and self.enable_stock_reservation: if self.allow_negative_stock and self.enable_stock_reservation:
@@ -125,7 +120,7 @@ class StockSettings(Document):
) )
# Change in value of `Enable Stock Reservation` # 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 # Disable -> Enable
if self.enable_stock_reservation: if self.enable_stock_reservation:

View 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;
},
};

View 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"
}
]
}

View 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

View 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))

View File

@@ -165,7 +165,7 @@ class StockBalanceReport(object):
def get_sre_reserved_qty_details(self) -> dict: def get_sre_reserved_qty_details(self) -> dict:
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( 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 = [], [] item_code_list, warehouse_list = [], []

View File

@@ -862,7 +862,7 @@ class SerialBatchCreation:
if self.get("serial_nos"): if self.get("serial_nos"):
serial_no_wise_batch = frappe._dict({}) serial_no_wise_batch = frappe._dict({})
if self.has_batch_no: 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 qty = -1 if self.type_of_transaction == "Outward" else 1
for serial_no in self.serial_nos: 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): def create_batch(self):
from erpnext.stock.doctype.batch.batch import make_batch 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] serial_or_batch_items = [d.name for d in serial_or_batch_items]
return 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,
)
)

View File

@@ -1214,9 +1214,15 @@ class update_entries_after(object):
if msg: if msg:
if self.reserved_stock: if self.reserved_stock:
allowed_qty = abs(exceptions[0]["actual_qty"]) - abs(exceptions[0]["diff"]) 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) msg_list.append(msg)