Files
erpnext/erpnext/public/js/utils/serial_batch_inline_editor.js

1400 lines
42 KiB
JavaScript

frappe.provide("erpnext.stock");
erpnext.stock.SerialBatchInlineEditor = class SerialBatchInlineEditor {
constructor({ frm, cdt, cdn, wrapper, is_rejected }) {
this.frm = frm;
this.cdt = cdt;
this.cdn = cdn;
this.wrapper = $(wrapper);
this.is_rejected = cint(is_rejected);
this.bundle_field = this.is_rejected ? "rejected_serial_and_batch_bundle" : "serial_and_batch_bundle";
this.config = erpnext.stock.get_sbie_config(frm.doc.doctype, cdt) || {};
this.qty_field = this.is_rejected ? "rejected_qty" : this.config.qty_field || "qty";
this.start = 0;
this.page_length = 10;
this.total_count = 0;
this.server_total_count = 0;
this.server_total_qty = 0;
this.last_entries = [];
this.make();
}
get row() {
return locals[this.cdt][this.cdn];
}
get bundle() {
return this.row[this.bundle_field];
}
get pending_key() {
return `${this.cdn}::${this.is_rejected}`;
}
get pending() {
let store = erpnext.stock.get_sbie_pending_map(this.frm);
if (!store[this.pending_key]) {
store[this.pending_key] = { new_entries: [], updates: {}, deleted: [] };
}
return store[this.pending_key];
}
has_pending() {
let p = this.pending;
return Boolean(
p.delete_all || p.new_entries.length || p.deleted.length || Object.keys(p.updates).length
);
}
clear_pending() {
delete erpnext.stock.get_sbie_pending_map(this.frm)[this.pending_key];
}
toggle_section(show) {
this.wrapper.closest(".form-section").toggle(show);
}
async make() {
if (!this.row.item_code) {
this.wrapper.empty();
this.toggle_section(false);
return;
}
this.item = await frappe.db.get_value("Item", this.row.item_code, ["has_serial_no", "has_batch_no"]);
this.item = this.item.message || {};
if (!cint(this.item.has_serial_no) && !cint(this.item.has_batch_no)) {
this.wrapper.empty();
this.toggle_section(false);
return;
}
this.toggle_section(true);
this.render_skeleton();
this.load_page();
}
inject_styles() {
if ($("#serial-batch-inline-editor-styles").length) return;
$(`<style id="serial-batch-inline-editor-styles">
.serial-batch-inline-editor input::placeholder {
text-align: left;
}
.serial-batch-inline-editor .sbie-table {
overflow-x: auto;
border: 1px solid var(--table-border-color);
border-radius: var(--radius-md);
background-color: var(--subtle-accent);
}
.serial-batch-inline-editor .sbie-table table {
width: 100%;
margin: 0;
table-layout: fixed;
border-collapse: separate;
border-spacing: 0;
font-size: var(--text-sm);
}
.serial-batch-inline-editor .sbie-table thead th {
background-color: var(--subtle-fg);
color: var(--gray-600);
font-weight: var(--weight-regular);
height: 32px;
padding: 4px 8px;
border-bottom: 1px solid var(--table-border-color);
border-right: 1px solid var(--table-border-color);
text-align: left;
}
.serial-batch-inline-editor .sbie-table thead th:first-child {
border-top-left-radius: var(--radius-md);
}
.serial-batch-inline-editor .sbie-table thead th:last-child {
border-top-right-radius: var(--radius-md);
border-right: none;
}
.serial-batch-inline-editor .sbie-table tbody td {
background-color: var(--fg-color);
color: var(--text-muted);
padding: var(--grid-padding);
border-bottom: 1px solid var(--table-border-color);
border-right: 1px solid var(--table-border-color);
vertical-align: middle;
}
.serial-batch-inline-editor .sbie-table .sbie-qty-input {
background-color: var(--fg-color);
border: none;
box-shadow: none;
outline: none;
}
.serial-batch-inline-editor .sbie-table td.sbie-input-cell,
.serial-batch-inline-editor .sbie-table tbody tr:not(.sbie-empty):hover td.sbie-input-cell {
padding: 0;
background-color: var(--fg-color);
}
.serial-batch-inline-editor .sbie-table td.sbie-input-cell .frappe-control,
.serial-batch-inline-editor .sbie-table td.sbie-input-cell .form-group,
.serial-batch-inline-editor .sbie-table td.sbie-input-cell .control-input {
margin: 0;
}
.serial-batch-inline-editor .sbie-table td.sbie-input-cell input {
width: 100%;
height: 38px;
border: none;
border-radius: 0;
box-shadow: none;
outline: none;
background-color: var(--fg-color);
padding: 0 8px;
}
.serial-batch-inline-editor .sbie-table input.form-control {
background-color: var(--fg-color);
}
.serial-batch-inline-editor .sbie-table .link-btn {
background-color: var(--fg-color);
}
.serial-batch-inline-editor .sbie-table input[type="checkbox"] {
margin: 0;
vertical-align: middle;
}
.serial-batch-inline-editor .sbie-table tbody td:last-child {
border-right: none;
}
.serial-batch-inline-editor .sbie-table tbody tr:last-child td {
border-bottom: none;
}
.serial-batch-inline-editor .sbie-table tbody tr:last-child td:first-child {
border-bottom-left-radius: var(--radius-md);
}
.serial-batch-inline-editor .sbie-table tbody tr:last-child td:last-child {
border-bottom-right-radius: var(--radius-md);
}
.serial-batch-inline-editor .sbie-table tbody tr:not(.sbie-empty):hover td {
background-color: var(--subtle-fg);
}
.serial-batch-inline-editor .sbie-table tbody tr.sbie-empty td {
background-color: var(--subtle-accent);
}
</style>`).appendTo("head");
}
esc(value) {
return frappe.utils.escape_html(cstr(value));
}
render_skeleton() {
this.inject_styles();
this.wrapper.html(`
<div class="serial-batch-inline-editor">
<div class="sbie-table"></div>
<div class="sbie-footer" style="display: flex; gap: 8px; align-items: center; margin-top: 10px; flex-wrap: wrap;">
<div style="flex: 1; display: flex; gap: 8px; align-items: center;">
<button class="btn btn-sm btn-default sbie-add-row"
style="height: 28px; padding: 2px 12px; white-space: nowrap;">${__("Add row")}</button>
<button class="btn btn-sm btn-danger sbie-delete hidden"
style="height: 28px; padding: 2px 10px; white-space: nowrap;">${__("Delete row")}</button>
</div>
<div class="sbie-pagination hidden" style="display: flex; gap: 4px; align-items: center;">
<button class="btn btn-sm btn-default sbie-first-page" title="${__("First")}"
style="height: 28px; padding: 2px 8px;">${frappe.utils.icon("chevron-first")}</button>
<button class="btn btn-sm btn-default sbie-prev" style="height: 28px; padding: 2px 8px;">
${frappe.utils.icon("chevron-left")}</button>
<input class="sbie-page-number" type="text"
style="width: 24px; height: 28px; text-align: center; border: none; outline: none;
background: transparent; padding: 0;">
<span class="text-muted small">${__("of")}</span>
<span class="sbie-total-pages text-muted small"></span>
<button class="btn btn-sm btn-default sbie-next" style="height: 28px; padding: 2px 8px;">
${frappe.utils.icon("chevron-right")}</button>
<button class="btn btn-sm btn-default sbie-last-page" title="${__("Last")}"
style="height: 28px; padding: 2px 8px;">${frappe.utils.icon("chevron-last")}</button>
</div>
<div style="flex: 1; display: flex; gap: 8px; align-items: center; justify-content: flex-end;">
<div class="sbie-summary text-muted small"></div>
<div class="dropdown" style="display: inline-block;">
<button type="button" class="btn btn-sm btn-default sbie-menu" data-toggle="dropdown"
aria-expanded="false" style="height: 28px; padding: 2px 8px;">
${frappe.utils.icon("ellipsis", "sm")}
</button>
<div class="dropdown-menu dropdown-menu-right">
${
this.get_type_of_transaction() === "Outward"
? `<a class="dropdown-item sbie-auto-fetch-action">${
cint(this.item.has_serial_no)
? __("Auto Fetch Serial Nos")
: __("Auto Fetch Batch Nos")
}</a>`
: ""
}
<a class="dropdown-item sbie-scan-action">${
cint(this.item.has_serial_no) ? __("Scan Serial Nos") : __("Scan Batch Nos")
}</a>
${
cint(this.item.has_serial_no)
? `<a class="dropdown-item sbie-range-action">${__(
"Create Serial Nos from Range"
)}</a>`
: ""
}
<a class="dropdown-item sbie-download-csv">${__("Download")}</a>
<a class="dropdown-item sbie-upload-csv">${__("Upload")}</a>
</div>
</div>
</div>
</div>
</div>
`);
this.bind_events();
}
get_csv_columns() {
if (cint(this.item.has_serial_no) && cint(this.item.has_batch_no)) {
return ["Serial No", "Batch No", "Quantity"];
}
if (cint(this.item.has_batch_no)) {
return ["Batch No", "Quantity"];
}
return ["Serial No"];
}
download_csv() {
let url;
if (this.bundle) {
url = `/api/method/erpnext.stock.doctype.serial_and_batch_bundle.inline_editor.download_bundle_entries_csv?bundle=${encodeURIComponent(
this.bundle
)}`;
} else {
url = `/api/method/erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle.download_blank_csv_template?content=${encodeURIComponent(
JSON.stringify(this.get_csv_columns())
)}`;
}
const w = window.open(frappe.urllib.get_full_url(url));
if (!w) {
frappe.msgprint(__("Please enable pop-ups"));
}
}
upload_csv() {
new frappe.ui.FileUploader({
allow_multiple: false,
restrictions: { allowed_file_types: [".csv"] },
on_success: (file) => this.import_csv_file(file.file_url),
});
}
async import_csv_file(file_url) {
let data = await this.call(
"erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle.upload_csv_file",
{ item_code: this.row.item_code, file_path: file_url }
);
let entries = [];
if (data.serial_nos && data.serial_nos.length) {
entries = data.serial_nos;
} else if (data.batch_nos && data.batch_nos.length) {
entries = data.batch_nos;
}
if (!entries.length) {
frappe.msgprint(__("No entries found in the uploaded file"));
return;
}
if (this.server_total_count || this.has_pending()) {
frappe.confirm(__("This will replace the existing entries. Continue?"), () =>
this.replace_entries(entries)
);
} else {
this.replace_entries(entries);
}
}
async replace_entries(entries) {
this.clear_pending();
await this.upsert({ entries, replace: 1 });
if (this.frm.is_dirty()) {
this.frm.save();
}
}
async add_new_row() {
if (this.is_rejected && !this.row.rejected_warehouse) {
frappe.msgprint(__("Please set Rejected Warehouse first"));
return;
}
let $pending = this.wrapper.find(".sbie-new-row");
if ($pending.length) {
this.commit_new_row($pending);
if (this.wrapper.find(".sbie-new-row").length) {
this.wrapper.find(".sbie-new-row input").first().focus();
return;
}
}
let $tbody = this.wrapper.find(".sbie-table tbody");
if (!$tbody.length) return;
this.wrapper.find(".sbie-empty").remove();
this.wrapper.find(".sbie-table").css("overflow", "visible");
let $tr = $(this.get_new_row_html()).appendTo($tbody);
this.make_new_row_controls($tr);
}
get_new_row_html() {
let show_serial = cint(this.item.has_serial_no);
let show_batch = cint(this.item.has_batch_no);
let qty_cell = show_serial
? this.format_float(1)
: `<input type="text" class="sbie-new-qty" data-fieldtype="Float"
value="${this.format_float(1)}" style="text-align: right;">`;
return `<tr class="sbie-new-row">
<td style="text-align: center;"><input type="checkbox" class="sbie-check sbie-new-check"></td>
<td style="text-align: center;">${this.get_effective_count() + 1}</td>
${show_serial ? `<td class="sbie-new-serial sbie-input-cell"></td>` : ""}
${show_batch ? `<td class="sbie-new-batch sbie-input-cell"></td>` : ""}
<td class="${show_serial ? "" : "sbie-input-cell"}" style="text-align: right;">${qty_cell}</td>
</tr>`;
}
make_new_row_controls($tr) {
this.new_serial_control = this.make_row_link_control($tr.find(".sbie-new-serial"), {
options: "Serial No",
fieldname: "sbie_new_serial",
placeholder: __("Scan / select Serial No"),
get_query: () => ({ filters: { item_code: this.row.item_code } }),
onchange: () => this.on_new_serial_change($tr),
});
this.new_batch_control = this.make_row_link_control($tr.find(".sbie-new-batch"), {
options: "Batch",
fieldname: "sbie_new_batch",
placeholder: __("Select Batch No"),
get_query: () => ({ filters: { item: this.row.item_code, disabled: 0 } }),
onchange: () => this.on_new_batch_change($tr),
});
$tr.find(".sbie-new-check")
.on("mousedown", () => $tr.data("cancelled", 1))
.on("change", (e) => {
$tr.data("cancelled", e.target.checked ? 1 : 0);
this.toggle_delete_button();
});
$tr.find("input").on("keydown", (e) => {
if (e.which === 13) this.commit_new_row($tr);
});
$tr.find(".sbie-new-qty")
.on("input", (e) => this.restrict_to_numeric(e))
.on("focus", (e) => e.target.select())
.on("change", () => this.commit_new_row($tr))
.on("blur", () => this.commit_new_row($tr));
let first_control = this.new_serial_control || this.new_batch_control;
first_control && first_control.$wrapper.find("input").focus();
}
make_row_link_control($slot, df) {
if (!$slot.length) return null;
let control = frappe.ui.form.make_control({
parent: $slot,
df: Object.assign({ fieldtype: "Link" }, df),
render_input: true,
});
this.make_control_compact(control);
return control;
}
make_control_compact(control) {
let $wrapper = control.$wrapper;
$wrapper.find(".control-label, .help-box").hide();
$wrapper.find(".form-group").css({ margin: "0", "min-height": "0" });
$wrapper.find("input").css({ "min-height": "0" });
$wrapper.css({ margin: "0", "min-height": "0" });
}
on_new_serial_change($tr) {
if (!this.new_serial_control || !this.new_serial_control.get_value()) return;
if (this.new_batch_control && !this.new_batch_control.get_value()) {
this.new_batch_control.$wrapper.find("input").focus();
return;
}
this.commit_new_row($tr);
}
on_new_batch_change($tr) {
if (!this.new_batch_control || !this.new_batch_control.get_value()) return;
if (this.new_serial_control) {
if (this.new_serial_control.get_value()) {
this.commit_new_row($tr);
}
return;
}
let committed = this.commit_new_row($tr);
committed &&
committed.then(() => {
this.wrapper.find(".sbie-qty-input[data-pending-index]").last().focus();
});
}
edit_batch_cell($td) {
this.edit_link_cell($td, {
options: "Batch",
field: "batch_no",
placeholder: __("Select Batch No"),
get_query: () => ({ filters: { item: this.row.item_code, disabled: 0 } }),
});
}
edit_serial_cell($td) {
this.edit_link_cell($td, {
options: "Serial No",
field: "serial_no",
placeholder: __("Select Serial No"),
get_query: () => ({ filters: { item_code: this.row.item_code } }),
});
}
edit_link_cell($td, opts) {
if ($td.data("editing")) return;
$td.data("editing", 1);
let name = $td.data("name");
let current = $td.text().trim();
$td.empty().addClass("sbie-input-cell").css("cursor", "default");
this.wrapper.find(".sbie-table").css("overflow", "visible");
let control = this.make_row_link_control($td, {
options: opts.options,
fieldname: "sbie_edit_link",
placeholder: opts.placeholder,
get_query: opts.get_query,
onchange: () => {
let value = control.get_value();
if (value && value !== current) {
this.update_entry(name, { [opts.field]: value });
this.refresh_view();
}
},
});
control.set_input(current);
control.$wrapper.find("input").focus();
}
commit_new_row($tr) {
if ($tr.data("committing") || $tr.data("cancelled")) return;
let serial_no = this.new_serial_control ? this.new_serial_control.get_value() : "";
let batch_no = this.new_batch_control ? this.new_batch_control.get_value() : "";
if (!serial_no && !batch_no) return;
let qty = serial_no ? 1 : flt($tr.find(".sbie-new-qty").val()) || 1;
$tr.data("committing", 1);
this.pending.new_entries.push({ serial_no, batch_no, qty });
this.frm.dirty();
return this.go_to_last_page();
}
update_entry(name, changes) {
let updates = this.pending.updates;
if (!updates[name]) {
let entry = this.last_entries.find((d) => d.name === name) || {};
updates[name] = { orig_qty: Math.abs(flt(entry.qty)) };
}
Object.assign(updates[name], changes);
this.frm.dirty();
}
bind_events() {
this.wrapper.find(".sbie-add-row").on("click", () => this.add_new_row());
this.wrapper.find(".sbie-upload-csv").on("click", () => this.upload_csv());
this.wrapper.find(".sbie-download-csv").on("click", () => this.download_csv());
this.wrapper.find(".sbie-prev").on("click", () => this.change_page(-1));
this.wrapper.find(".sbie-next").on("click", () => this.change_page(1));
this.wrapper.find(".sbie-first-page").on("click", () => this.go_to_page(1));
this.wrapper.find(".sbie-last-page").on("click", () => this.go_to_page(this.total_pages));
this.wrapper
.find(".sbie-page-number")
.on("input", (e) => {
e.target.value = e.target.value.replace(/[^0-9]/g, "");
e.target.style.width = (e.target.value.length + 1) * 8 + "px";
})
.on("keydown", (e) => {
if (e.which === 13) e.target.blur();
})
.on("blur", (e) => this.go_to_page(e.target.value))
.on("focus", (e) => e.target.select());
this.wrapper.find(".sbie-delete").on("click", () => this.delete_selected());
this.wrapper.find(".sbie-scan-action").on("click", () => this.open_scan_dialog());
this.wrapper.find(".sbie-range-action").on("click", () => this.open_range_dialog());
this.wrapper.find(".sbie-auto-fetch-action").on("click", () => this.open_auto_fetch_dialog());
}
get_type_of_transaction() {
let doc = this.frm.doc;
if (doc.doctype === "Stock Entry") {
return this.row.s_warehouse ? "Outward" : "Inward";
}
let inward =
["Purchase Receipt", "Purchase Invoice", "Stock Reconciliation"].includes(doc.doctype) ||
this.cdt === "Subcontracting Receipt Item";
if (doc.is_return) {
inward = !inward;
}
return inward ? "Inward" : "Outward";
}
async open_auto_fetch_dialog() {
let warehouse = this.row.warehouse || this.row.s_warehouse;
if (!warehouse) {
frappe.msgprint(__("Please set Warehouse first"));
return;
}
let is_serial = cint(this.item.has_serial_no);
let based_on = await erpnext.stock.get_pick_serial_batch_based_on();
let dialog = new frappe.ui.Dialog({
title: is_serial ? __("Auto Fetch Serial Nos") : __("Auto Fetch Batch Nos"),
fields: [
{
fieldtype: "Float",
fieldname: "qty",
label: __("Qty to Fetch"),
reqd: 1,
default: Math.abs(flt(this.row[this.qty_field])) || null,
description: __("Existing entries will be replaced with the fetched entries"),
},
{
fieldtype: "Select",
fieldname: "based_on",
label: __("Fetch Based On"),
options: ["FIFO", "LIFO", "Expiry"],
default: based_on,
},
],
primary_action_label: __("Fetch"),
primary_action: (values) => {
dialog.hide();
this.auto_fetch_entries(values.qty, values.based_on, warehouse);
},
});
dialog.show();
}
async auto_fetch_entries(qty, based_on, warehouse) {
let data = await this.call(
"erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle.get_auto_data",
{
item_code: this.row.item_code,
warehouse: warehouse,
has_serial_no: this.item.has_serial_no,
has_batch_no: this.item.has_batch_no,
qty: qty,
based_on: based_on,
posting_date: this.frm.doc.posting_date,
posting_time: this.frm.doc.posting_time,
}
);
if (!data || !data.length) {
frappe.msgprint(
__("No stock available for Item {0} in Warehouse {1}", [
this.esc(this.row.item_code),
this.esc(warehouse),
])
);
return;
}
this.add_auto_fetched_entries(data);
}
add_auto_fetched_entries(rows) {
let p = this.pending;
p.delete_all = 1;
p.new_entries = [];
p.updates = {};
p.deleted = [];
for (const row of rows) {
p.new_entries.push({
serial_no: row.serial_no || "",
batch_no: row.batch_no || "",
qty: Math.abs(flt(row.qty)) || 1,
});
}
this.start = 0;
this.frm.dirty();
this.go_to_last_page();
frappe.show_alert({
message: __("{0} entries fetched", [p.new_entries.length]),
indicator: "green",
});
this.frm.save();
}
open_scan_dialog() {
if (this.is_rejected && !this.row.rejected_warehouse) {
frappe.msgprint(__("Please set Rejected Warehouse first"));
return;
}
let is_serial = cint(this.item.has_serial_no);
let scanned_count = 0;
let dialog = new frappe.ui.Dialog({
title: is_serial ? __("Scan Serial Nos") : __("Scan Batch Nos"),
fields: [
{
fieldtype: "Data",
fieldname: "scan_value",
options: "Barcode",
label: is_serial ? __("Scan Serial No") : __("Scan Batch No"),
description: __("Missing Serial / Batch Nos will be created on Save"),
onchange: () => {
let value = (dialog.get_value("scan_value") || "").trim();
if (!value) return;
if (this.add_scanned_value(value)) {
scanned_count++;
}
dialog.fields_dict.scanned_info.$wrapper.html(
`<div class="text-muted small">${__("Scanned: {0}", [
scanned_count,
])} &middot; ${frappe.utils.escape_html(value)}</div>`
);
dialog.set_value("scan_value", "");
},
},
{ fieldtype: "HTML", fieldname: "scanned_info" },
],
on_hide: () => this.refresh_view(),
});
dialog.show();
}
get_active_server_row(field, value) {
let p = this.pending;
if (p.delete_all) return null;
return this.last_entries.find((d) => d[field] === value && !p.deleted.some((x) => x.name === d.name));
}
get_known_identifiers() {
let p = this.pending;
let known = new Set(p.new_entries.map((d) => d.serial_no || d.batch_no));
if (!p.delete_all) {
let deleted = new Set(p.deleted.map((d) => d.name));
for (const d of this.last_entries) {
if (!deleted.has(d.name)) {
known.add(d.serial_no || d.batch_no);
}
}
}
return known;
}
add_scanned_value(value) {
let p = this.pending;
if (cint(this.item.has_serial_no)) {
if (this.get_known_identifiers().has(value)) {
frappe.show_alert({
message: __("Serial No {0} already added", [this.esc(value)]),
indicator: "orange",
});
return false;
}
p.new_entries.push({ serial_no: value, batch_no: "", qty: 1 });
} else {
let existing = p.new_entries.find((d) => d.batch_no === value);
let server_row = this.get_active_server_row("batch_no", value);
if (existing) {
existing.qty = flt(existing.qty) + 1;
} else if (server_row) {
let update = p.updates[server_row.name];
let current = update && update.qty != null ? flt(update.qty) : Math.abs(flt(server_row.qty));
this.update_entry(server_row.name, { qty: current + 1 });
} else {
p.new_entries.push({ serial_no: "", batch_no: value, qty: 1 });
}
}
this.frm.dirty();
this.go_to_last_page();
return true;
}
open_range_dialog() {
if (this.is_rejected && !this.row.rejected_warehouse) {
frappe.msgprint(__("Please set Rejected Warehouse first"));
return;
}
let dialog = new frappe.ui.Dialog({
title: __("Create Serial Nos from Range"),
fields: [
{
fieldtype: "Data",
fieldname: "serial_no_range",
label: __("Serial No Range"),
reqd: 1,
description: __(
'"SN-01::10" for "SN-01" to "SN-10". Missing Serial Nos will be created on Save'
),
},
],
primary_action_label: __("Add"),
primary_action: ({ serial_no_range }) => {
let serial_nos = erpnext.stock.utils.get_serial_range(serial_no_range, "::");
if (!serial_nos || !serial_nos.length) {
frappe.throw(__("Invalid range. Use the format {0}", ["SN-01::10"]));
}
dialog.hide();
this.add_serial_range(serial_nos);
},
});
dialog.show();
}
add_serial_range(serial_nos) {
let p = this.pending;
let known = this.get_known_identifiers();
let added = 0;
for (const serial_no of serial_nos) {
if (known.has(serial_no)) continue;
p.new_entries.push({ serial_no: serial_no, batch_no: "", qty: 1 });
added++;
}
this.frm.dirty();
this.go_to_last_page();
frappe.show_alert({
message: __("{0} Serial Nos added. They will be saved with the document.", [added]),
indicator: "green",
});
}
get total_pages() {
return Math.ceil(this.get_effective_count() / this.page_length) || 1;
}
go_to_last_page() {
this.start = (this.total_pages - 1) * this.page_length;
return this.load_page();
}
change_page(direction) {
let current_page = Math.floor(this.start / this.page_length) + 1;
this.go_to_page(current_page + direction);
}
go_to_page(index) {
index = Math.min(Math.max(cint(index) || 1, 1), this.total_pages);
let new_start = (index - 1) * this.page_length;
if (new_start === this.start) {
this.wrapper.find(".sbie-page-number").val(index);
return;
}
this.start = new_start;
this.load_page();
}
async load_page() {
if (!this.bundle) {
this.server_total_count = 0;
this.server_total_qty = 0;
this.last_entries = [];
this._totals_loaded = true;
} else if (!this._totals_loaded || this.start < this.server_total_count) {
let data = await this.call(
"erpnext.stock.doctype.serial_and_batch_bundle.inline_editor.get_bundle_entries",
{
bundle: this.bundle,
start: this.start,
page_length: this.page_length,
}
);
this.server_total_count = data.total_count;
this.server_total_qty = flt(data.total_qty);
this.last_entries = data.entries;
this._totals_loaded = true;
} else {
this.last_entries = [];
}
this.refresh_view();
this.reconcile_row_qty();
}
refresh_view() {
this.render_rows(this.last_entries);
this.update_summary();
this.sync_row_qty();
}
get_effective_count() {
let p = this.pending;
if (p.delete_all) {
return p.new_entries.length;
}
return this.server_total_count + p.new_entries.length - p.deleted.length;
}
get_effective_qty() {
let p = this.pending;
let qty = p.delete_all ? 0 : this.server_total_qty;
for (const row of p.new_entries) {
qty += flt(row.qty);
}
if (!p.delete_all) {
for (const name in p.updates) {
const u = p.updates[name];
if (u.qty != null) {
qty += flt(u.qty) - flt(u.orig_qty);
}
}
for (const d of p.deleted) {
qty -= flt(d.qty);
}
}
return flt(qty, cint(frappe.boot.sysdefaults && frappe.boot.sysdefaults.float_precision) || 3);
}
sync_row_qty() {
if (this.frm.doc.docstatus !== 0 || !this.has_pending()) return;
let expected = this.get_effective_qty();
if (flt(this.row[this.qty_field]) !== expected) {
frappe.model.set_value(this.cdt, this.cdn, this.qty_field, expected);
}
}
reconcile_row_qty() {
if (this.frm.doc.docstatus !== 0 || this.has_pending() || !this.server_total_count) return;
if (flt(this.row[this.qty_field]) !== this.server_total_qty) {
frappe.model.set_value(this.cdt, this.cdn, this.qty_field, this.server_total_qty);
frappe.show_alert({
message: __(
"Qty updated to {0} to match the Serial and Batch Bundle. Please save the document.",
[this.server_total_qty]
),
indicator: "orange",
});
}
}
render_rows(entries) {
let p = this.pending;
let show_batch = cint(this.item.has_batch_no);
let show_serial = cint(this.item.has_serial_no);
let column_count = 3 + show_serial + show_batch;
let header = `<thead><tr>
<th style="width: 36px; text-align: center;"><input type="checkbox" class="sbie-check-all"></th>
<th style="width: 48px; text-align: center;">${__("No")}</th>
${show_serial ? `<th>${__("Serial No")}</th>` : ""}
${show_batch ? `<th>${__("Batch No")}</th>` : ""}
<th style="width: 110px; text-align: right;">${__("Qty")}</th>
</tr></thead>`;
let visible = p.delete_all ? [] : entries.filter((d) => !p.deleted.some((x) => x.name === d.name));
let body = visible
.map((d, i) => {
let update = p.updates[d.name] || {};
let qty = update.qty != null ? flt(update.qty) : Math.abs(flt(d.qty));
let batch_no = this.esc(update.batch_no || d.batch_no || "");
let serial_no = this.esc(update.serial_no || d.serial_no || "");
let name = this.esc(d.name);
return `<tr data-name="${name}">
<td style="text-align: center;">
<input type="checkbox" class="sbie-check" data-name="${name}" data-qty="${qty}"></td>
<td style="text-align: center;">${this.start + i + 1}</td>
${
show_serial
? `<td class="sbie-serial-cell" data-name="${name}" title="${__(
"Click to change Serial No"
)}" style="cursor: pointer;">${serial_no}</td>`
: ""
}
${
show_batch
? `<td class="sbie-batch-cell" data-name="${name}" title="${__(
"Click to change Batch No"
)}" style="cursor: pointer;">${batch_no}</td>`
: ""
}
<td class="${!d.serial_no && show_batch ? "sbie-input-cell" : ""}" style="text-align: right;">${
!d.serial_no && show_batch ? this.get_qty_input(d, qty) : this.format_float(qty)
}</td>
</tr>`;
})
.join("");
let base_count = p.delete_all ? 0 : this.server_total_count - p.deleted.length;
let pending_offset = Math.max(0, this.start - (p.delete_all ? 0 : this.server_total_count));
let capacity = Math.max(this.page_length - visible.length, 0);
body += p.new_entries
.slice(pending_offset, pending_offset + capacity)
.map((d, i) => {
let index = pending_offset + i;
return `<tr data-pending-index="${index}">
<td style="text-align: center;">
<input type="checkbox" class="sbie-check" data-pending-index="${index}"></td>
<td style="text-align: center;">${base_count + index + 1}</td>
${show_serial ? `<td>${this.esc(d.serial_no || "")}</td>` : ""}
${show_batch ? `<td>${this.esc(d.batch_no || "")}</td>` : ""}
<td class="${!d.serial_no && show_batch ? "sbie-input-cell" : ""}" style="text-align: right;">${
!d.serial_no && show_batch
? this.get_pending_qty_input(d, index)
: this.format_float(d.qty)
}</td>
</tr>`;
})
.join("");
if (!visible.length && !p.new_entries.length) {
body = `<tr class="sbie-empty"><td colspan="${column_count}" style="text-align: center;">
${__("Click on 'Add row' to add Serial / Batch entries")}</td></tr>`;
}
this.wrapper
.find(".sbie-table")
.css("overflow", "")
.html(`<table>${header}<tbody>${body}</tbody></table>`);
this.wrapper.find(".sbie-check-all").on("change", (e) => {
this.wrapper.find(".sbie-check").prop("checked", e.target.checked);
this.toggle_delete_button();
});
this.wrapper.find(".sbie-check").on("change", (e) => {
if (!e.target.checked) {
this.wrapper.find(".sbie-check-all").prop("checked", false);
}
this.toggle_delete_button();
});
this.wrapper.find(".sbie-batch-cell").on("click", (e) => this.edit_batch_cell($(e.currentTarget)));
this.wrapper.find(".sbie-serial-cell").on("click", (e) => this.edit_serial_cell($(e.currentTarget)));
this.wrapper.find(".sbie-qty-input").on("input", (e) => this.restrict_to_numeric(e));
this.wrapper.find(".sbie-qty-input").on("blur", (e) => this.apply_float_format(e));
this.wrapper.find(".sbie-qty-input").on("change", (e) => this.update_qty(e));
this.wrapper.find(".sbie-qty-input").on("focus", (e) => e.target.select());
this.toggle_delete_button();
}
get_qty_input(d, qty) {
return `<input type="text" class="sbie-qty-input" data-fieldtype="Float"
data-name="${this.esc(d.name)}" value="${this.format_float(qty)}" style="text-align: right;">`;
}
get_pending_qty_input(d, index) {
return `<input type="text" class="sbie-qty-input" data-fieldtype="Float"
data-pending-index="${index}" value="${this.format_float(d.qty)}" style="text-align: right;">`;
}
format_float(value) {
let precision = cint(frappe.boot.sysdefaults && frappe.boot.sysdefaults.float_precision) || 3;
let formatted = flt(value, precision).toFixed(precision).replace(/0+$/, "");
if (formatted.endsWith(".")) {
formatted += "0";
}
return formatted;
}
restrict_to_numeric(e) {
let $input = $(e.target);
let value = $input
.val()
.replace(/[^0-9.]/g, "")
.replace(/(\..*)\./g, "$1");
if (value !== $input.val()) {
$input.val(value);
}
}
apply_float_format(e) {
let $input = $(e.target);
if ($input.val() !== "") {
$input.val(this.format_float($input.val()));
}
}
toggle_delete_button() {
let checked = this.wrapper.find(".sbie-check:checked").length;
let select_all = this.wrapper.find(".sbie-check-all").prop("checked");
this.wrapper
.find(".sbie-delete")
.toggleClass("hidden", !checked)
.text(select_all ? __("Delete All") : __("Delete row"));
}
update_summary() {
this.total_count = this.server_total_count;
this.wrapper.find(".sbie-summary").text(__("Total Qty: {0}", [this.get_effective_qty()]));
let current_page = Math.floor(this.start / this.page_length) + 1;
this.wrapper
.find(".sbie-pagination")
.toggleClass("hidden", this.get_effective_count() <= this.page_length);
this.wrapper
.find(".sbie-page-number")
.val(current_page)
.css("width", (String(current_page).length + 1) * 8 + "px");
this.wrapper.find(".sbie-total-pages").text(this.total_pages);
}
update_qty(e) {
let $input = $(e.target);
let qty = flt($input.val()) || 1;
if ($input.data("pending-index") != null) {
this.pending.new_entries[$input.data("pending-index")].qty = qty;
} else {
this.update_entry($input.data("name"), { qty: qty });
}
this.update_summary();
this.sync_row_qty();
}
delete_selected() {
if (this.wrapper.find(".sbie-check-all").prop("checked")) {
this.delete_all_entries();
return;
}
let p = this.pending;
let pending_indexes = [];
this.wrapper.find(".sbie-check:checked").each((_, el) => {
let $el = $(el);
if ($el.data("pending-index") != null) {
pending_indexes.push($el.data("pending-index"));
} else if ($el.data("name")) {
let name = $el.data("name");
delete p.updates[name];
p.deleted.push({ name: name, qty: flt($el.data("qty")) });
}
});
p.new_entries = p.new_entries.filter((_, i) => !pending_indexes.includes(i));
this.frm.dirty();
this.refresh_view();
}
delete_all_entries() {
frappe.confirm(
__("This will delete all {0} entries. Continue?", [this.get_effective_count()]),
() => {
let p = this.pending;
p.delete_all = 1;
p.new_entries = [];
p.updates = {};
p.deleted = [];
this.frm.dirty();
this.start = 0;
this.refresh_view();
}
);
}
async upsert({ entries = [], deleted = [], replace = 0 }) {
let summary = await this.call(
"erpnext.stock.doctype.serial_and_batch_bundle.inline_editor.upsert_bundle_entries",
{
child_row: Object.assign({}, this.row, { is_rejected: this.is_rejected }),
doc: this.frm.doc,
entries: entries,
deleted: deleted,
replace: replace,
}
);
if (this.bundle !== summary.bundle) {
await frappe.model.set_value(this.cdt, this.cdn, this.bundle_field, summary.bundle);
}
await frappe.model.set_value(this.cdt, this.cdn, this.qty_field, summary.total_qty);
this._totals_loaded = false;
await this.load_page();
}
call(method, args) {
return new Promise((resolve, reject) => {
frappe.call({
method: method,
args: args,
callback: (r) => resolve(r.message),
error: reject,
});
});
}
};
erpnext.stock.SBIE_DOCTYPES = [
{ parent: "Purchase Receipt", child: "Purchase Receipt Item", table: "items" },
{ parent: "Purchase Invoice", child: "Purchase Invoice Item", table: "items" },
{ parent: "Sales Invoice", child: "Sales Invoice Item", table: "items" },
{ parent: "Sales Invoice", child: "Packed Item", table: "packed_items" },
{ parent: "POS Invoice", child: "POS Invoice Item", table: "items" },
{ parent: "POS Invoice", child: "Packed Item", table: "packed_items" },
{ parent: "Delivery Note", child: "Delivery Note Item", table: "items" },
{ parent: "Delivery Note", child: "Packed Item", table: "packed_items" },
{ parent: "Stock Entry", child: "Stock Entry Detail", table: "items" },
{ parent: "Stock Reconciliation", child: "Stock Reconciliation Item", table: "items" },
{ parent: "Subcontracting Receipt", child: "Subcontracting Receipt Item", table: "items" },
{
parent: "Subcontracting Receipt",
child: "Subcontracting Receipt Supplied Item",
table: "supplied_items",
qty_field: "consumed_qty",
},
{ parent: "Pick List", child: "Pick List Item", table: "locations" },
{
parent: "Asset Capitalization",
child: "Asset Capitalization Stock Item",
table: "stock_items",
qty_field: "stock_qty",
},
{
parent: "Asset Repair",
child: "Asset Repair Consumed Item",
table: "stock_items",
qty_field: "consumed_quantity",
},
];
erpnext.stock.get_sbie_config = function (doctype, child_doctype) {
return erpnext.stock.SBIE_DOCTYPES.find((d) => d.parent === doctype && d.child === child_doctype);
};
erpnext.stock.get_sbie_row = function (frm, cdn) {
for (let config of erpnext.stock.SBIE_DOCTYPES) {
if (config.parent !== frm.doc.doctype) continue;
let row = (frm.doc[config.table] || []).find((d) => d.name === cdn);
if (row) return { row, config };
}
return {};
};
erpnext.stock.get_sbie_pending_map = function (frm) {
let store = (frm._sbie_pending = frm._sbie_pending || {});
return (store[frm.doc.name] = store[frm.doc.name] || {});
};
erpnext.stock.flush_serial_batch_pending = async function (frm) {
let pending_map = erpnext.stock.get_sbie_pending_map(frm);
for (let key of Object.keys(pending_map)) {
let p = pending_map[key];
let has_changes =
p.delete_all || p.new_entries.length || p.deleted.length || Object.keys(p.updates).length;
if (!has_changes) {
delete pending_map[key];
continue;
}
let [cdn, is_rejected] = key.split("::");
let { row, config } = erpnext.stock.get_sbie_row(frm, cdn);
if (!row) {
delete pending_map[key];
continue;
}
let bundle_field = cint(is_rejected) ? "rejected_serial_and_batch_bundle" : "serial_and_batch_bundle";
if (p.delete_all && !row[bundle_field] && !p.new_entries.length) {
delete pending_map[key];
continue;
}
let entries = p.new_entries.concat(
Object.keys(p.updates).map((name) => {
let update = { name: name };
if (p.updates[name].qty != null) update.qty = p.updates[name].qty;
if (p.updates[name].batch_no) update.batch_no = p.updates[name].batch_no;
if (p.updates[name].serial_no) update.serial_no = p.updates[name].serial_no;
return update;
})
);
let summary = await frappe.xcall(
"erpnext.stock.doctype.serial_and_batch_bundle.inline_editor.upsert_bundle_entries",
{
child_row: Object.assign({}, row, { is_rejected: cint(is_rejected) }),
doc: frm.doc,
entries: entries,
deleted: p.deleted.map((d) => d.name),
replace: cint(p.delete_all),
}
);
row[bundle_field] = summary.bundle;
row[cint(is_rejected) ? "rejected_qty" : config.qty_field || "qty"] = summary.total_qty;
if (row.received_qty != null) {
row.received_qty = flt(row.qty) + flt(row.rejected_qty);
}
delete pending_map[key];
}
};
erpnext.stock.mount_serial_batch_inline_editor = async function (frm, cdt, cdn) {
let config = erpnext.stock.get_sbie_config(frm.doc.doctype, cdt);
if (!config || !frm.fields_dict[config.table]) return;
let grid_row = frm.fields_dict[config.table].grid?.grid_rows_by_docname?.[cdn];
let grid_form = grid_row && grid_row.grid_form;
if (!grid_form) return;
let editors = [
{ fieldname: "serial_batch_entries_html", is_rejected: 0 },
{ fieldname: "rejected_serial_batch_entries_html", is_rejected: 1 },
];
let enabled = await erpnext.stock.is_inline_serial_batch_editor_enabled();
let row = locals[cdt][cdn];
let show = enabled && row && !row.use_serial_batch_fields && frm.doc.docstatus === 0;
erpnext.stock.toggle_legacy_bundle_fields(grid_form, show);
let editors_store = (frm._sbie_editors = frm._sbie_editors || {});
for (let editor of editors) {
let field = grid_form.fields_dict[editor.fieldname];
if (!field) continue;
if (!show) {
field.$wrapper.closest(".form-section").hide();
continue;
}
let key = `${cdn}::${editor.is_rejected}`;
let existing = editors_store[key];
if (
existing &&
existing.wrapper[0] === field.$wrapper[0] &&
document.body.contains(field.$wrapper[0]) &&
existing.wrapper.find(".serial-batch-inline-editor").length
) {
continue;
}
editors_store[key] = new erpnext.stock.SerialBatchInlineEditor({
frm,
cdt,
cdn,
wrapper: field.$wrapper,
is_rejected: editor.is_rejected,
});
}
};
erpnext.stock.toggle_legacy_bundle_fields = function (grid_form, editor_active) {
let legacy_fields = [
"add_serial_batch_bundle",
"pick_serial_and_batch",
"serial_and_batch_bundle",
"add_serial_batch_for_rejected_qty",
"rejected_serial_and_batch_bundle",
];
for (let fieldname of legacy_fields) {
let field = grid_form.fields_dict[fieldname];
if (!field) continue;
if (editor_active) {
field.$wrapper.hide();
} else {
field.refresh();
}
}
};
erpnext.stock.setup_serial_batch_pending_flush = function (doctype) {
frappe.ui.form.on(doctype, {
validate(frm) {
return erpnext.stock.flush_serial_batch_pending(frm);
},
});
};
erpnext.stock.setup_inline_serial_batch_editor = function () {
new Set(erpnext.stock.SBIE_DOCTYPES.map((d) => d.parent)).forEach((doctype) =>
erpnext.stock.setup_serial_batch_pending_flush(doctype)
);
new Set(erpnext.stock.SBIE_DOCTYPES.map((d) => d.child)).forEach((child_doctype) => {
frappe.ui.form.on(child_doctype, {
form_render(frm, cdt, cdn) {
erpnext.stock.mount_serial_batch_inline_editor(frm, cdt, cdn);
},
use_serial_batch_fields(frm, cdt, cdn) {
erpnext.stock.mount_serial_batch_inline_editor(frm, cdt, cdn);
},
});
});
};
erpnext.stock.setup_inline_serial_batch_editor();
erpnext.stock.is_inline_serial_batch_editor_enabled = async function () {
if (erpnext.stock._inline_editor_enabled === undefined) {
let { message } = await frappe.db.get_value(
"Stock Settings",
"Stock Settings",
"use_inline_serial_batch_editor"
);
erpnext.stock._inline_editor_enabled = cint(message && message.use_inline_serial_batch_editor);
}
return erpnext.stock._inline_editor_enabled;
};
erpnext.stock.get_pick_serial_batch_based_on = async function () {
if (erpnext.stock._pick_serial_batch_based_on === undefined) {
let { message } = await frappe.db.get_value(
"Stock Settings",
"Stock Settings",
"pick_serial_and_batch_based_on"
);
erpnext.stock._pick_serial_batch_based_on =
(message && message.pick_serial_and_batch_based_on) || "FIFO";
}
return erpnext.stock._pick_serial_batch_based_on;
};