From c7f92ea52d5357b97679e5f9d0d5cc185a2e9f2f Mon Sep 17 00:00:00 2001 From: Abdullah Syed Date: Wed, 16 Sep 2026 06:50:27 +0000 Subject: [PATCH 01/12] feat(file-movement): implement barcode scanning, unified matter files, and FMO/FMI workflows --- .../doctype/barcode_file_item/__init__.py | 0 .../barcode_file_item/barcode_file_item.json | 63 ++ .../barcode_file_item/barcode_file_item.py | 9 + .../doctype/file_movement/__init__.py | 0 .../doctype/file_movement/file_movement.js | 704 ++++++++++++++++++ .../doctype/file_movement/file_movement.json | 151 ++++ .../doctype/file_movement/file_movement.py | 435 +++++++++++ .../file_movement/file_movement_list.js | 20 + .../file_movement/test_file_movement.py | 311 ++++++++ .../doctype/file_movement_items/__init__.py | 0 .../file_movement_items.js | 11 + .../file_movement_items.json | 56 ++ .../file_movement_items.py | 5 + .../case_central/doctype/matter/matter.json | 7 +- .../doctype/missing_file_items/__init__.py | 0 .../missing_file_items.json | 63 ++ .../missing_file_items/missing_file_items.py | 9 + 17 files changed, 1841 insertions(+), 3 deletions(-) create mode 100644 casecentral/case_central/doctype/barcode_file_item/__init__.py create mode 100644 casecentral/case_central/doctype/barcode_file_item/barcode_file_item.json create mode 100644 casecentral/case_central/doctype/barcode_file_item/barcode_file_item.py create mode 100644 casecentral/case_central/doctype/file_movement/__init__.py create mode 100644 casecentral/case_central/doctype/file_movement/file_movement.js create mode 100644 casecentral/case_central/doctype/file_movement/file_movement.json create mode 100644 casecentral/case_central/doctype/file_movement/file_movement.py create mode 100644 casecentral/case_central/doctype/file_movement/file_movement_list.js create mode 100644 casecentral/case_central/doctype/file_movement/test_file_movement.py create mode 100644 casecentral/case_central/doctype/file_movement_items/__init__.py create mode 100644 casecentral/case_central/doctype/file_movement_items/file_movement_items.js create mode 100644 casecentral/case_central/doctype/file_movement_items/file_movement_items.json create mode 100644 casecentral/case_central/doctype/file_movement_items/file_movement_items.py create mode 100644 casecentral/case_central/doctype/missing_file_items/__init__.py create mode 100644 casecentral/case_central/doctype/missing_file_items/missing_file_items.json create mode 100644 casecentral/case_central/doctype/missing_file_items/missing_file_items.py diff --git a/casecentral/case_central/doctype/barcode_file_item/__init__.py b/casecentral/case_central/doctype/barcode_file_item/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/casecentral/case_central/doctype/barcode_file_item/barcode_file_item.json b/casecentral/case_central/doctype/barcode_file_item/barcode_file_item.json new file mode 100644 index 0000000..e8ee7ee --- /dev/null +++ b/casecentral/case_central/doctype/barcode_file_item/barcode_file_item.json @@ -0,0 +1,63 @@ +{ + "actions": [], + "allow_bulk_edit": 1, + "allow_rename": 1, + "creation": "2026-09-03 12:39:13.514136", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "matter", + "case", + "barcode", + "returned" + ], + "fields": [ + { + "columns": 3, + "fieldname": "matter", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Matter", + "options": "Matter" + }, + { + "columns": 3, + "fieldname": "case", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Case", + "options": "Case" + }, + { + "columns": 3, + "fieldname": "barcode", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Barcode" + }, + { + "columns": 1, + "default": "0", + "fieldname": "returned", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Returned" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-09-15 14:00:00.000000", + "modified_by": "Administrator", + "module": "Case Central", + "name": "Barcode File Item", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/casecentral/case_central/doctype/barcode_file_item/barcode_file_item.py b/casecentral/case_central/doctype/barcode_file_item/barcode_file_item.py new file mode 100644 index 0000000..f782659 --- /dev/null +++ b/casecentral/case_central/doctype/barcode_file_item/barcode_file_item.py @@ -0,0 +1,9 @@ +# Copyright (c) 2026, 4C Solutions and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class BarcodeFileItem(Document): + pass diff --git a/casecentral/case_central/doctype/file_movement/__init__.py b/casecentral/case_central/doctype/file_movement/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/casecentral/case_central/doctype/file_movement/file_movement.js b/casecentral/case_central/doctype/file_movement/file_movement.js new file mode 100644 index 0000000..65ccbc0 --- /dev/null +++ b/casecentral/case_central/doctype/file_movement/file_movement.js @@ -0,0 +1,704 @@ +// Copyright (c) 2026, 4C Solutions and Contributors +// See license.txt + +frappe.ui.form.on("File Movement", { + + onload: function (frm) { + setup_page_scan_button(frm); + }, + + refresh: function (frm) { + setup_page_scan_button(frm); + + frm.remove_custom_button("Create Incoming"); + frm.remove_custom_button("Return Missing Files"); + frm.remove_custom_button("Scan Barcode"); + + // Direct standalone "Create Incoming" button on submitted Outgoing movement (not inside Actions) + if (frm.doc.docstatus === 1 && frm.doc.movement_type === "Outgoing") { + add_create_incoming_button(frm); + } + + // Direct standalone "Return Missing Files" button on submitted Incoming movement with Missing Files (not inside Actions) + if (frm.doc.docstatus === 1 && frm.doc.movement_type === "Incoming" && frm.doc.status === "Missing Files") { + add_return_missing_button(frm); + } + + // Set field read-only states depending on movement type + set_form_read_only_states(frm); + }, + + movement_type: function (frm) { + set_form_read_only_states(frm); + if (frm.doc.movement_type === "Incoming" && frm.doc.docstatus === 0) { + update_missing_files_table_and_status_client(frm); + } + } +}); + + +// ========================================================= +// CHILD TABLE: BARCODE FILE ITEM (MATTER FILES) +// ========================================================= + +frappe.ui.form.on("Barcode File Item", { + matter: function (frm, cdt, cdn) { + var row = frappe.get_doc(cdt, cdn); + if (!row.matter) return; + + frappe.call({ + method: "casecentral.case_central.doctype.file_movement.file_movement.get_cases_for_matter", + args: { matter: row.matter }, + freeze: true, + freeze_message: __("Validating Matter ID & finding Cases..."), + callback: function (response) { + var cases = response.message || []; + if (!cases.length) { + // Check if matter itself exists + frappe.db.get_value("Matter", row.matter, "name").then(res => { + if (!res || !res.message || !res.message.name) { + frappe.msgprint({ + title: __("Matter Not Found"), + indicator: "red", + message: __("Matter ID '{0}' does not exist.", [row.matter]) + }); + frappe.model.set_value(cdt, cdn, "matter", ""); + frappe.model.set_value(cdt, cdn, "case", ""); + frappe.model.set_value(cdt, cdn, "barcode", ""); + } else { + frappe.show_alert({ + message: __("Matter {0} added (No linked Cases found).", [row.matter]), + indicator: "blue" + }); + } + }); + return; + } + + // Populate first case in current row + frappe.model.set_value(cdt, cdn, "case", cases[0].name); + frappe.model.set_value(cdt, cdn, "barcode", cases[0].barcode || ""); + + // If multiple cases exist for this matter, add additional rows for the remaining cases + if (cases.length > 1) { + for (var i = 1; i < cases.length; i++) { + var c = cases[i]; + var exists = (frm.doc.barcode_files || []).some(function (item) { + return item.matter === (c.matter || row.matter) && item.case === c.name; + }); + + if (!exists) { + var new_row = frm.add_child("barcode_files"); + new_row.matter = c.matter || row.matter; + new_row.case = c.name || ""; + new_row.barcode = c.barcode || ""; + new_row.returned = 0; + } + } + frm.refresh_field("barcode_files"); + } + + frappe.show_alert({ + message: __("{0} Case(s) added for Matter {1}.", [cases.length, row.matter]), + indicator: "green" + }); + + if (frm.doc.movement_type === "Incoming") { + update_missing_files_table_and_status_client(frm); + } + }, + error: function () { + frappe.model.set_value(cdt, cdn, "matter", ""); + frappe.model.set_value(cdt, cdn, "case", ""); + frappe.model.set_value(cdt, cdn, "barcode", ""); + } + }); + }, + + case: function (frm, cdt, cdn) { + var row = frappe.get_doc(cdt, cdn); + if (!row.case) return; + + frappe.db.get_value("Case", row.case, ["matter", "name"]).then(r => { + if (r && r.message) { + if (r.message.matter && !row.matter) { + frappe.model.set_value(cdt, cdn, "matter", r.message.matter); + } + } else { + frappe.msgprint({ + title: __("Case Not Found"), + indicator: "red", + message: __("Case '{0}' does not exist.", [row.case]) + }); + frappe.model.set_value(cdt, cdn, "case", ""); + } + }); + }, + + returned: function (frm) { + if (frm.doc.movement_type === "Incoming") { + update_missing_files_table_and_status_client(frm); + } + } +}); + + +// ========================================================= +// FORM FIELD READ-ONLY STATES +// ========================================================= + +function set_form_read_only_states(frm) { + var is_incoming = frm.doc.movement_type === "Incoming"; + frm.set_df_property("original_outgoing_movement", "read_only", is_incoming && frm.doc.docstatus === 1 ? 1 : 0); + frm.refresh_fields(); +} + + +// ========================================================= +// SCAN BARCODE BUTTON (ONLY IN PAGE BELOW RECEIVER) +// ========================================================= + +function setup_page_scan_button(frm) { + if (!frm.fields_dict.scan_barcode_btn) return; + + var btn_html = + '
' + + '' + + '
'; + + frm.fields_dict.scan_barcode_btn.$wrapper.html(btn_html); + + frm.fields_dict.scan_barcode_btn.$wrapper.find(".btn-page-scan-barcode").off("click").on("click", function (e) { + e.preventDefault(); + open_scan_dialog(frm); + }); +} + + +// ========================================================= +// SCAN BARCODE DIALOG (SCANNER GUN & CAMERA) +// ========================================================= + +function open_scan_dialog(frm) { + var dialog = new frappe.ui.Dialog({ + title: __("Scan Barcode / Matter ID"), + fields: [ + { + fieldname: "barcode", + label: __("Barcode / Matter ID / Case"), + fieldtype: "Data", + description: __("Scan using barcode gun, enter Matter ID (e.g. MID-26-001), or type barcode and press Enter") + }, + { + fieldname: "camera_area", + fieldtype: "HTML" + } + ], + primary_action_label: __("Add / Process"), + primary_action: function () { + var value = (dialog.get_value("barcode") || "").trim(); + if (!value) { + frappe.msgprint(__("Please scan or enter a value.")); + return; + } + process_scan(frm, value, dialog); + } + }); + + dialog.show(); + + // Auto focus barcode input field + setTimeout(function () { + var $input = dialog.fields_dict.barcode.$input; + if ($input) { + $input.focus(); + $input.on("keydown", function (e) { + if (e.which === 13) { // Enter key from scanner gun + e.preventDefault(); + var val = $(this).val().trim(); + if (val) { + process_scan(frm, val, dialog); + } + } + }); + } + }, 200); + + // Camera scanner area + var html = + '
' + + '' + + '' + + '' + + '
' + + '
'; + + dialog.fields_dict.camera_area.$wrapper.html(html); + + var wrapper = dialog.fields_dict.camera_area.$wrapper; + var video = wrapper.find(".barcode-video")[0]; + var start_button = wrapper.find(".start-camera"); + var stop_button = wrapper.find(".stop-camera"); + var camera_message = wrapper.find(".camera-message"); + + var stream = null; + var scanning = false; + var scan_interval = null; + + start_button.on("click", function () { + if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) { + navigator.mediaDevices.getUserMedia({ video: { facingMode: "environment" } }) + .then(function (s) { + stream = s; + video.srcObject = s; + video.style.display = "block"; + start_button.hide(); + stop_button.show(); + camera_message.html("" + __("Point camera at barcode...") + ""); + scanning = true; + start_camera_detection(); + }) + .catch(function (err) { + camera_message.html("" + __("Camera error: {0}", [err.message]) + ""); + }); + } else { + camera_message.html("" + __("Camera not supported on this device.") + ""); + } + }); + + stop_button.on("click", function () { + stop_camera(); + }); + + dialog.on_page_show = function () { + dialog.fields_dict.barcode?.$input?.focus(); + }; + + dialog.$wrapper.on("hidden.bs.modal", function () { + stop_camera(); + }); + + function stop_camera() { + scanning = false; + if (scan_interval) { + clearInterval(scan_interval); + scan_interval = null; + } + if (stream) { + stream.getTracks().forEach(function (track) { track.stop(); }); + stream = null; + } + video.style.display = "none"; + start_button.show(); + stop_button.hide(); + camera_message.html(""); + } + + function start_camera_detection() { + if ("BarcodeDetector" in window) { + var barcodeDetector = new BarcodeDetector(); + scan_interval = setInterval(function () { + if (!scanning || !video.videoWidth) return; + barcodeDetector.detect(video) + .then(function (barcodes) { + if (barcodes.length > 0 && scanning) { + var detected_code = barcodes[0].rawValue; + stop_camera(); + process_scan(frm, detected_code, dialog); + } + }) + .catch(function () {}); + }, 300); + } else { + camera_message.html("" + __("Live camera detection not supported by browser. Please type or scan using barcode gun.") + ""); + } + } +} + + +// ========================================================= +// PROCESS SCANNED VALUE +// ========================================================= + +function process_scan(frm, value, dialog) { + if (!value) return; + value = value.trim(); + + frappe.call({ + method: "casecentral.case_central.doctype.file_movement.file_movement.get_scan_information", + args: { value: value }, + freeze: true, + freeze_message: __("Validating scanned code..."), + callback: function (r) { + var data = r.message; + + // STRICT REJECTION IF VALUE DOES NOT MATCH ANYTHING + if (!data || data.type === "not_found") { + frappe.msgprint({ + title: __("Not Found"), + indicator: "red", + message: __("Scanned value '{0}' does not match any existing Matter ID, Case, or Barcode.

The table was not populated.", [value]) + }); + + if (dialog) { + dialog.set_value("barcode", ""); + dialog.fields_dict.barcode?.$input?.focus(); + } + return; + } + + // OUTGOING MOVEMENT SCAN HANDLING + if (frm.doc.movement_type === "Outgoing") { + handle_outgoing_scan(frm, data, value); + } + // INCOMING MOVEMENT SCAN HANDLING + else if (frm.doc.movement_type === "Incoming") { + handle_incoming_scan(frm, data, value); + } + + if (dialog) { + dialog.set_value("barcode", ""); + dialog.fields_dict.barcode?.$input?.focus(); + } + } + }); +} + +function handle_outgoing_scan(frm, data, original_value) { + var added_count = 0; + + if (data.type === "matter") { + var cases = data.cases || []; + if (cases.length > 0) { + cases.forEach(function (c) { + var exists = (frm.doc.barcode_files || []).some(function (item) { + return item.matter === (c.matter || data.matter) && item.case === c.name; + }); + + if (!exists) { + var row = frm.add_child("barcode_files"); + row.matter = c.matter || data.matter; + row.case = c.name || ""; + row.barcode = c.barcode || ""; + row.returned = 0; + added_count++; + } + }); + } else { + var exists = (frm.doc.barcode_files || []).some(function (item) { + return item.matter === data.matter; + }); + if (!exists) { + var row = frm.add_child("barcode_files"); + row.matter = data.matter; + row.case = ""; + row.barcode = ""; + row.returned = 0; + added_count++; + } + } + + frm.refresh_field("barcode_files"); + + if (added_count > 0) { + frappe.show_alert({ + message: __("Added Matter {0} ({1} Case(s)).", [data.matter, added_count]), + indicator: "green" + }); + } else { + frappe.show_alert({ + message: __("Matter {0} is already in the table.", [data.matter]), + indicator: "orange" + }); + } + } else if (data.type === "case") { + var exists = (frm.doc.barcode_files || []).some(function (item) { + return item.case === data.case_name; + }); + + if (!exists) { + var row = frm.add_child("barcode_files"); + row.matter = data.matter || ""; + row.case = data.case_name || ""; + row.barcode = data.barcode || original_value; + row.returned = 0; + frm.refresh_field("barcode_files"); + + frappe.show_alert({ + message: __("Added Case {0} (Matter: {1}).", [data.case_name, data.matter || "N/A"]), + indicator: "green" + }); + } else { + frappe.show_alert({ + message: __("Case {0} is already in the table.", [data.case_name]), + indicator: "orange" + }); + } + } else if (data.type === "case_file") { + var exists = (frm.doc.barcode_files || []).some(function (item) { + return item.barcode === data.barcode; + }); + + if (!exists) { + var row = frm.add_child("barcode_files"); + row.matter = data.matter || ""; + row.case = data.case || ""; + row.barcode = data.barcode || original_value; + row.returned = 0; + frm.refresh_field("barcode_files"); + + frappe.show_alert({ + message: __("Added Barcode {0}.", [data.barcode]), + indicator: "green" + }); + } else { + frappe.show_alert({ + message: __("Barcode {0} is already in the table.", [data.barcode]), + indicator: "orange" + }); + } + } +} + +function handle_incoming_scan(frm, data, original_value) { + var matched_rows = []; + + (frm.doc.barcode_files || []).forEach(function (row) { + if (data.type === "matter" && row.matter === data.matter) { + matched_rows.push(row); + } else if (data.type === "case" && row.case === data.case_name) { + matched_rows.push(row); + } else if (row.barcode === original_value || (data.barcode && row.barcode === data.barcode)) { + matched_rows.push(row); + } + }); + + if (matched_rows.length > 0) { + matched_rows.forEach(function (row) { + row.returned = 1; + }); + frm.refresh_field("barcode_files"); + update_missing_files_table_and_status_client(frm); + + frappe.show_alert({ + message: __("Marked returned: {0} ({1} item(s)).", [original_value, matched_rows.length]), + indicator: "green" + }); + } else { + frappe.msgprint({ + title: __("Not in Movement"), + indicator: "orange", + message: __("Scanned item '{0}' was not found in this Incoming movement.", [original_value]) + }); + } +} + + +// ========================================================= +// REAL-TIME MISSING FILES TABLE CALCULATION (CLIENT-SIDE) +// ========================================================= + +function update_missing_files_table_and_status_client(frm) { + if (frm.doc.movement_type !== "Incoming") { + frm.set_value("missing_file_items", []); + return; + } + + var missing = []; + + (frm.doc.barcode_files || []).forEach(function (row) { + if (!row.returned) { + missing.push({ + barcode: row.barcode || "", + matter: row.matter || "", + case: row.case || "", + returned: 0 + }); + } + }); + + frm.set_value("missing_file_items", missing); + + var new_status = missing.length > 0 ? "Missing Files" : "Received"; + if (frm.doc.status !== new_status) { + frm.set_value("status", new_status); + } +} + + +// ========================================================= +// ACTION: CREATE INCOMING (STANDALONE PRIMARY BUTTON) +// ========================================================= + +function add_create_incoming_button(frm) { + var btn = frm.add_custom_button(__("Create Incoming"), function () { + frappe.call({ + method: "casecentral.case_central.doctype.file_movement.file_movement.create_incoming", + args: { + outgoing_name: frm.doc.name + }, + freeze: true, + freeze_message: __("Creating Incoming File Movement..."), + callback: function (r) { + if (r.message) { + frappe.show_alert({ + message: __("Incoming File Movement created: {0}", [r.message]), + indicator: "green" + }); + frappe.set_route("Form", "File Movement", r.message); + } + } + }); + }); + + if (btn) { + btn.addClass("btn-primary"); + } +} + + +// ========================================================= +// ACTION: RETURN MISSING FILES (STANDALONE PRIMARY BUTTON) +// ========================================================= + +function add_return_missing_button(frm) { + var btn = frm.add_custom_button(__("Return Missing Files"), function () { + frappe.call({ + method: "casecentral.case_central.doctype.file_movement.file_movement.get_missing_files", + args: { + movement_name: frm.doc.name + }, + freeze: true, + freeze_message: __("Loading missing files..."), + callback: function (r) { + var missing_files = r.message || []; + if (!missing_files.length) { + frappe.msgprint(__("There are no missing files in this movement.")); + return; + } + + show_return_missing_modal(frm, missing_files); + } + }); + }); + + if (btn) { + btn.addClass("btn-primary"); + } +} + +function show_return_missing_modal(frm, missing_files) { + var fields = [ + { + fieldname: "quick_scan", + label: __("Scan Barcode / Matter to Return"), + fieldtype: "Data", + description: __("Scan barcode gun or type code to check off returned items") + }, + { + fieldname: "missing_items_section", + fieldtype: "Section Break", + label: __("Select Missing Items to Return ({0})", [missing_files.length]) + } + ]; + + missing_files.forEach(function (item, idx) { + var label_parts = []; + if (item.matter) label_parts.push(__("Matter: {0}", [item.matter])); + if (item.case) label_parts.push(__("Case: {0}", [item.case])); + if (item.barcode) label_parts.push(__("Barcode: {0}", [item.barcode])); + + fields.push({ + fieldname: "item_" + idx, + label: label_parts.join(" | ") || __("Item {0}", [idx + 1]), + fieldtype: "Check", + default: 1 + }); + }); + + var dialog = new frappe.ui.Dialog({ + title: __("Return Missing Files"), + fields: fields, + primary_action_label: __("Return Selected Files"), + primary_action: function (values) { + var selected_items = []; + + missing_files.forEach(function (item, idx) { + if (values["item_" + idx]) { + selected_items.push(item); + } + }); + + if (!selected_items.length) { + frappe.msgprint(__("Please select at least one file to return.")); + return; + } + + frappe.call({ + method: "casecentral.case_central.doctype.file_movement.file_movement.return_missing_files", + args: { + movement_name: frm.doc.name, + files: JSON.stringify(selected_items) + }, + freeze: true, + freeze_message: __("Returning files..."), + callback: function (response) { + if (response.message) { + var res = response.message; + frappe.show_alert({ + message: res.status === "Received" + ? __("All missing files returned successfully!") + : __("Files returned. Some files remain missing."), + indicator: res.status === "Received" ? "green" : "orange" + }); + dialog.hide(); + frm.reload_doc(); + } + } + }); + } + }); + + dialog.show(); + + // Bind quick scanner in modal + setTimeout(function () { + var $input = dialog.fields_dict.quick_scan?.$input; + if ($input) { + $input.focus(); + $input.on("keydown", function (e) { + if (e.which === 13) { + e.preventDefault(); + var code = $(this).val().trim(); + if (!code) return; + + var matched = false; + missing_files.forEach(function (item, idx) { + if (item.barcode === code || item.case === code || item.matter === code) { + dialog.set_value("item_" + idx, 1); + matched = true; + } + }); + + if (matched) { + frappe.show_alert({ + message: __("Checked item: {0}", [code]), + indicator: "green" + }); + } else { + frappe.show_alert({ + message: __("Code {0} is not in the missing list.", [code]), + indicator: "orange" + }); + } + dialog.set_value("quick_scan", ""); + setTimeout(function () { $input.focus(); }, 50); + } + }); + } + }, 200); +} \ No newline at end of file diff --git a/casecentral/case_central/doctype/file_movement/file_movement.json b/casecentral/case_central/doctype/file_movement/file_movement.json new file mode 100644 index 0000000..be217ff --- /dev/null +++ b/casecentral/case_central/doctype/file_movement/file_movement.json @@ -0,0 +1,151 @@ +{ + "actions": [], + "allow_bulk_edit": 1, + "autoname": "autoname", + "creation": "2026-08-20 13:14:58.767666", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "giver", + "receiver", + "scan_barcode_btn", + "column_break_1", + "movement_date", + "status", + "movement_type", + "original_outgoing_movement", + "amended_from", + "section_break_matter_files", + "barcode_files", + "section_break_missing_files", + "missing_file_items" + ], + "fields": [ + { + "fieldname": "giver", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Giver", + "options": "Employee", + "reqd": 1 + }, + { + "fieldname": "receiver", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Receiver", + "options": "Employee", + "reqd": 1 + }, + { + "fieldname": "scan_barcode_btn", + "fieldtype": "HTML", + "label": "Scan Barcode Button" + }, + { + "fieldname": "column_break_1", + "fieldtype": "Column Break" + }, + { + "default": "Now", + "fieldname": "movement_date", + "fieldtype": "Datetime", + "in_list_view": 1, + "label": "Movement Date", + "reqd": 1 + }, + { + "allow_on_submit": 1, + "default": "Open", + "fieldname": "status", + "fieldtype": "Select", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Status", + "options": "Open\nMissing Files\nReceived" + }, + { + "fieldname": "movement_type", + "fieldtype": "Select", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Movement Type", + "options": "Outgoing\nIncoming", + "reqd": 1 + }, + { + "allow_on_submit": 1, + "depends_on": "eval:doc.movement_type=='Incoming'", + "fieldname": "original_outgoing_movement", + "fieldtype": "Link", + "label": "Original Outgoing Movement", + "options": "File Movement" + }, + { + "fieldname": "amended_from", + "fieldtype": "Link", + "label": "Amended From", + "no_copy": 1, + "options": "File Movement", + "print_hide": 1, + "read_only": 1, + "search_index": 1 + }, + { + "fieldname": "section_break_matter_files", + "fieldtype": "Section Break", + "label": "Matter Files" + }, + { + "allow_on_submit": 1, + "fieldname": "barcode_files", + "fieldtype": "Table", + "label": "Matter Files", + "options": "Barcode File Item" + }, + { + "collapsible": 1, + "depends_on": "eval:doc.status=='Missing Files' || (doc.missing_file_items && doc.missing_file_items.length > 0)", + "fieldname": "section_break_missing_files", + "fieldtype": "Section Break", + "label": "Missing Files" + }, + { + "allow_on_submit": 1, + "fieldname": "missing_file_items", + "fieldtype": "Table", + "label": "Missing Files", + "options": "Missing File Items", + "read_only": 1 + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "is_submittable": 1, + "links": [], + "modified": "2026-09-15 14:00:00.000000", + "modified_by": "Administrator", + "module": "Case Central", + "name": "File Movement", + "naming_rule": "By script", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/casecentral/case_central/doctype/file_movement/file_movement.py b/casecentral/case_central/doctype/file_movement/file_movement.py new file mode 100644 index 0000000..a3d75cd --- /dev/null +++ b/casecentral/case_central/doctype/file_movement/file_movement.py @@ -0,0 +1,435 @@ +# Copyright (c) 2026, 4C Solutions and Contributors +# See license.txt + +import json +import frappe +from frappe.model.document import Document + + +class FileMovement(Document): + + def autoname(self): + if self.name: + return + + if self.movement_type == "Outgoing": + self.name = frappe.model.naming.make_autoname("FMO-.#####") + elif self.movement_type == "Incoming": + outgoing_name = (self.original_outgoing_movement or self.get("outgoing_movement") or "").strip() + if outgoing_name: + if outgoing_name.startswith("FMO-"): + candidate = "FMI-" + outgoing_name[4:] + elif outgoing_name.startswith("FM-"): + candidate = "FMI-" + outgoing_name[3:] + else: + candidate = "FMI-" + outgoing_name + + if frappe.db.exists("File Movement", candidate): + idx = 1 + while frappe.db.exists("File Movement", f"{candidate}-{idx}"): + idx += 1 + self.name = f"{candidate}-{idx}" + else: + self.name = candidate + else: + self.name = frappe.model.naming.make_autoname("FMI-.#####") + else: + self.name = frappe.model.naming.make_autoname("FMO-.#####") + + def validate(self): + self.validate_movement() + self.validate_rows() + self.validate_duplicate_barcodes() + self.update_missing_files_and_status() + + def validate_movement(self): + if self.movement_type not in ["Outgoing", "Incoming"]: + frappe.throw("Movement Type must be Outgoing or Incoming.") + + if not self.giver: + frappe.throw("Giver is required.") + + if not self.receiver: + frappe.throw("Receiver is required.") + + if self.giver == self.receiver: + frappe.throw("Giver and Receiver cannot be the same employee.") + + if not frappe.db.exists("Employee", self.giver): + frappe.throw(f"Giver employee '{self.giver}' does not exist.") + + if not frappe.db.exists("Employee", self.receiver): + frappe.throw(f"Receiver employee '{self.receiver}' does not exist.") + + if not self.get("barcode_files"): + frappe.throw("Please add at least one row in the Matter Files table.") + + def validate_rows(self): + for idx, row in enumerate(self.get("barcode_files") or [], start=1): + # If matter is provided, verify it exists + if row.matter: + if not frappe.db.exists("Matter", row.matter): + frappe.throw(f"Matter ID '{row.matter}' in row {idx} does not exist.") + + # Auto-populate case if not set and case exists + if not row.case: + case_name = frappe.db.get_value("Case", {"matter": row.matter}, "name") + if case_name: + row.case = case_name + + # If case is provided, verify it exists + if row.case: + if not frappe.db.exists("Case", row.case): + frappe.throw(f"Case '{row.case}' in row {idx} does not exist.") + + # Auto-populate matter if not set + if not row.matter: + case_matter = frappe.db.get_value("Case", row.case, "matter") + if case_matter: + row.matter = case_matter + + # If neither matter nor case nor barcode is provided + if not row.matter and not row.case and not row.barcode: + frappe.throw(f"Row {idx} in Matter Files must have a Matter ID, Case, or Barcode.") + + def validate_duplicate_barcodes(self): + seen_barcodes = set() + seen_cases = set() + for idx, row in enumerate(self.get("barcode_files") or [], start=1): + barcode = (row.barcode or "").strip() + if barcode: + if barcode in seen_barcodes: + frappe.throw(f"Duplicate barcode '{barcode}' in row {idx}.") + seen_barcodes.add(barcode) + + case_name = (row.case or "").strip() + if case_name: + if case_name in seen_cases: + frappe.throw(f"Duplicate Case '{case_name}' in row {idx}.") + seen_cases.add(case_name) + + def update_missing_files_and_status(self): + if self.movement_type == "Outgoing": + if not self.status: + self.status = "Open" + self.set("missing_file_items", []) + return + + missing = calculate_missing_files_from_doc(self) + self.set_missing_file_table(missing) + + if self.docstatus == 1: + self.status = "Missing Files" if missing else "Received" + else: + if not self.status: + self.status = "Open" + elif self.status != "Open": + self.status = "Missing Files" if missing else "Received" + + def set_missing_file_table(self, missing): + self.set("missing_file_items", []) + for item in missing: + row = self.append("missing_file_items", {}) + row.barcode = item.get("barcode") or "" + row.matter = item.get("matter") or "" + row.case = item.get("case") or "" + row.returned = 0 + + def on_submit(self): + if self.movement_type == "Outgoing": + self.update_outgoing_linked_statuses() + elif self.movement_type == "Incoming": + missing = calculate_missing_files_from_doc(self) + self.set_missing_file_table(missing) + self.status = "Missing Files" if missing else "Received" + self.update_incoming_linked_statuses() + + def on_cancel(self): + if self.movement_type == "Outgoing": + self.restore_outgoing_linked_statuses() + + def update_outgoing_linked_statuses(self): + matters = set() + for row in self.get("barcode_files") or []: + if row.matter: + matters.add(row.matter) + for matter_name in matters: + if frappe.db.exists("Matter", matter_name): + frappe.db.set_value("Matter", matter_name, "status", "Working") + + def update_incoming_linked_statuses(self): + for row in self.get("barcode_files") or []: + if row.matter and row.returned: + if frappe.db.exists("Matter", row.matter): + frappe.db.set_value("Matter", row.matter, "status", "Open") + + def restore_outgoing_linked_statuses(self): + matters = set() + for row in self.get("barcode_files") or []: + if row.matter: + matters.add(row.matter) + for matter_name in matters: + if frappe.db.exists("Matter", matter_name): + frappe.db.set_value("Matter", matter_name, "status", "Open") + + +# ========================================================= +# SCAN & QUERY APIS +# ========================================================= + +@frappe.whitelist() +def get_scan_information(value): + value = str(value or "").strip() + if not value: + return None + + # 1. Check if exact Matter ID + if frappe.db.exists("Matter", value): + return { + "type": "matter", + "matter": value, + "cases": get_cases_for_matter(value) + } + + # 2. Check if exact Case Name + if frappe.db.exists("Case", value): + case_doc = frappe.get_doc("Case", value) + return { + "type": "case", + "case_name": case_doc.name, + "matter": case_doc.get("matter") or "", + "barcode": get_case_barcode(case_doc) + } + + # 3. Check if Case Barcode + case_name = find_case_by_barcode(value) + if case_name: + case_doc = frappe.get_doc("Case", case_name) + return { + "type": "case", + "case_name": case_doc.name, + "matter": case_doc.get("matter") or "", + "barcode": value + } + + # 4. Check if Case_Files record + if frappe.db.exists("Case_Files", value) or frappe.db.exists("Case_Files", {"case_number": value}): + cf_name = value if frappe.db.exists("Case_Files", value) else frappe.db.get_value("Case_Files", {"case_number": value}, "name") + return { + "type": "case_file", + "case_file": cf_name, + "barcode": value, + "matter": "", + "case": "" + } + + # If it does not match anything in the system + return { + "type": "not_found", + "value": value, + "message": f"Scanned value '{value}' does not match any existing Matter ID, Case, or Barcode." + } + + +@frappe.whitelist() +def get_cases_for_matter(matter): + matter = str(matter or "").strip() + if not matter: + return [] + + if not frappe.db.exists("Matter", matter): + frappe.throw(f"Matter ID '{matter}' does not exist.") + + cases = frappe.get_all( + "Case", + filters={"matter": matter}, + fields=["name", "matter"], + order_by="name asc" + ) + + result = [] + for c in cases: + case_doc = frappe.get_doc("Case", c.name) + result.append({ + "name": c.name, + "matter": c.matter or matter, + "barcode": get_case_barcode(case_doc) + }) + + return result + + +def get_case_barcode(case_doc): + meta = frappe.get_meta("Case") + for fieldname in ["barcode", "case_barcode", "file_barcode"]: + if meta.has_field(fieldname): + val = case_doc.get(fieldname) + if val: + return str(val).strip() + return case_doc.name + + +def find_case_by_barcode(barcode): + barcode = str(barcode or "").strip() + if not barcode: + return None + + meta = frappe.get_meta("Case") + for fieldname in ["barcode", "case_barcode", "file_barcode"]: + if meta.has_field(fieldname): + case_name = frappe.db.get_value("Case", {fieldname: barcode}, "name") + if case_name: + return case_name + return None + + +# ========================================================= +# INCOMING & MISSING FILES WORKFLOW +# ========================================================= + +@frappe.whitelist() +def create_incoming(outgoing_name): + outgoing_name = str(outgoing_name or "").strip() + if not outgoing_name: + frappe.throw("Outgoing movement is required.") + + outgoing = frappe.get_doc("File Movement", outgoing_name) + + if outgoing.docstatus != 1: + frappe.throw("Outgoing movement must be submitted first.") + + if outgoing.movement_type != "Outgoing": + frappe.throw("The selected movement is not Outgoing.") + + incoming = frappe.new_doc("File Movement") + incoming.movement_type = "Incoming" + incoming.status = "Open" + incoming.giver = outgoing.receiver + incoming.receiver = outgoing.giver + incoming.original_outgoing_movement = outgoing.name + + # Copy Matter Files + incoming.set("barcode_files", []) + for row in outgoing.barcode_files or []: + incoming.append("barcode_files", { + "barcode": row.barcode or "", + "matter": row.matter or "", + "case": row.case or "", + "returned": 0 + }) + + # Initially all copied files are missing + missing = calculate_missing_files_from_doc(incoming) + incoming.set_missing_file_table(missing) + + incoming.insert(ignore_permissions=True) + return incoming.name + + +def calculate_missing_files_from_doc(doc): + missing = [] + for row in doc.get("barcode_files") or []: + if not row.returned: + missing.append({ + "barcode": row.barcode or "", + "matter": row.matter or "", + "case": row.case or "" + }) + return missing + + +@frappe.whitelist() +def get_missing_files(movement_name): + movement_name = str(movement_name or "").strip() + if not movement_name: + return [] + + doc = frappe.get_doc("File Movement", movement_name) + if doc.movement_type != "Incoming": + frappe.throw("Only Incoming movements can have missing files.") + + return calculate_missing_files_from_doc(doc) + + +@frappe.whitelist() +def return_missing_files( + movement_name, + files=None, + selected_barcodes=None, + selected_matters=None, + selected_cases=None +): + movement_name = str(movement_name or "").strip() + if not movement_name: + frappe.throw("Movement name is required.") + + doc = frappe.get_doc("File Movement", movement_name) + if doc.movement_type != "Incoming": + frappe.throw("Only Incoming movements can return missing files.") + + items_to_return = set() + + # Parse files arg if passed as json or list + if isinstance(files, str): + try: + files = json.loads(files) + except Exception: + files = [files] + if isinstance(files, list): + for f in files: + if isinstance(f, dict): + if f.get("barcode"): + items_to_return.add(str(f["barcode"]).strip()) + if f.get("case"): + items_to_return.add(str(f["case"]).strip()) + if f.get("matter"): + items_to_return.add(str(f["matter"]).strip()) + elif str(f).strip(): + items_to_return.add(str(f).strip()) + + for b in selected_barcodes or []: + if str(b).strip(): + items_to_return.add(str(b).strip()) + for m in selected_matters or []: + if str(m).strip(): + items_to_return.add(str(m).strip()) + for c in selected_cases or []: + if str(c).strip(): + items_to_return.add(str(c).strip()) + + if not items_to_return: + frappe.throw("Please select at least one file or matter to return.") + + # Mark returned in barcode_files (Matter Files table) + for row in doc.barcode_files or []: + if (row.barcode and row.barcode in items_to_return) or \ + (row.case and row.case in items_to_return) or \ + (row.matter and row.matter in items_to_return): + row.returned = 1 + frappe.db.set_value("Barcode File Item", row.name, "returned", 1) + + # Recalculate remaining missing + remaining_missing = calculate_missing_files_from_doc(doc) + doc.set_missing_file_table(remaining_missing) + + doc.status = "Missing Files" if remaining_missing else "Received" + frappe.db.set_value("File Movement", doc.name, "status", doc.status) + + # Update missing table in DB + frappe.db.delete("Missing File Items", {"parent": doc.name}) + for row in doc.missing_file_items: + row.parent = doc.name + row.parenttype = "File Movement" + row.parentfield = "missing_file_items" + row.db_insert() + + # Update matter status + doc.update_incoming_linked_statuses() + + return { + "status": doc.status, + "missing": remaining_missing, + "remaining_barcodes": [item["barcode"] for item in remaining_missing if item.get("barcode")], + "remaining_matters": [item["matter"] for item in remaining_missing if item.get("matter")], + "remaining_cases": [item["case"] for item in remaining_missing if item.get("case")] + } \ No newline at end of file diff --git a/casecentral/case_central/doctype/file_movement/file_movement_list.js b/casecentral/case_central/doctype/file_movement/file_movement_list.js new file mode 100644 index 0000000..506f719 --- /dev/null +++ b/casecentral/case_central/doctype/file_movement/file_movement_list.js @@ -0,0 +1,20 @@ +frappe.listview_settings["File Movement"] = { + add_fields: ["status", "movement_type"], + get_indicator: function (doc) { + if (doc.status === "Open") { + return [__("Open"), "orange", "status,=,Open"]; + } else if (doc.status === "Missing Files") { + return [__("Missing Files"), "red", "status,=,Missing Files"]; + } else if (doc.status === "Received") { + return [__("Received"), "green", "status,=,Received"]; + } + }, + formatters: { + name: function (value, df, doc) { + if (doc.status) { + return `${value} — ${doc.status}`; + } + return value; + } + } +}; \ No newline at end of file diff --git a/casecentral/case_central/doctype/file_movement/test_file_movement.py b/casecentral/case_central/doctype/file_movement/test_file_movement.py new file mode 100644 index 0000000..1ac3619 --- /dev/null +++ b/casecentral/case_central/doctype/file_movement/test_file_movement.py @@ -0,0 +1,311 @@ +# Copyright (c) 2026, 4C Solutions and Contributors +# See license.txt + +import frappe +from frappe.tests.utils import FrappeTestCase +from casecentral.case_central.doctype.file_movement.file_movement import ( + create_incoming, + get_missing_files, + return_missing_files, + get_scan_information, + get_cases_for_matter, +) + + +class TestFileMovement(FrappeTestCase): + + def setUp(self): + super().setUp() + self.giver = self.get_or_create_employee("TEST-GIVER-01", "Test Giver") + self.receiver = self.get_or_create_employee("TEST-RECEIVER-01", "Test Receiver") + self.matter_1 = self.get_or_create_matter("TEST-MATTER-01") + self.matter_2 = self.get_or_create_matter("TEST-MATTER-02") + self.case_1 = self.get_or_create_case("CASE-TEST-001", self.matter_1, "Petitioner 1", "Respondent 1") + self.case_2 = self.get_or_create_case("CASE-TEST-002", self.matter_2, "Petitioner 2", "Respondent 2") + + def tearDown(self): + super().tearDown() + + def get_or_create_employee(self, emp_id, emp_name): + if not frappe.db.exists("Employee", emp_id): + emp = frappe.get_doc({ + "doctype": "Employee", + "name": emp_id, + "first_name": emp_name, + "employee_name": emp_name, + "status": "Active" + }) + emp.flags.ignore_mandatory = True + emp.insert(ignore_permissions=True) + return emp.name + return emp_id + + def get_or_create_matter(self, matter_name): + if not frappe.db.exists("Matter", matter_name): + customer = self.get_or_create_customer("Test Customer") + service = self.get_or_create_service("Test Service") + company = self.get_or_create_company("Test Company") + file_type = self.get_or_create_file_type("Test File Type") + + matter = frappe.get_doc({ + "doctype": "Matter", + "name": matter_name, + "customer": customer, + "service": service, + "company": company, + "file_type": file_type, + "status": "Open", + "posting_date": frappe.utils.today() + }) + matter.flags.ignore_mandatory = True + matter.insert(ignore_permissions=True) + return matter.name + else: + frappe.db.set_value("Matter", matter_name, "status", "Open") + return matter_name + + def get_or_create_case(self, case_name, matter, petitioner, respondent): + if not frappe.db.exists("Case", case_name): + case = frappe.get_doc({ + "doctype": "Case", + "name": case_name, + "matter": matter, + "petitioner": petitioner, + "respondent": respondent, + "status": "InProgress" + }) + case.flags.ignore_mandatory = True + case.insert(ignore_permissions=True) + return case.name + return case_name + + def get_or_create_customer(self, name): + if not frappe.db.exists("Customer", name): + cg = frappe.db.get_value("Customer Group", {"is_group": 0}, "name") or "Individual" + territory = frappe.db.get_value("Territory", {"is_group": 0}, "name") or "India" + doc = frappe.get_doc({ + "doctype": "Customer", + "customer_name": name, + "customer_type": "Individual", + "customer_group": cg, + "territory": territory + }) + doc.flags.ignore_mandatory = True + doc.insert(ignore_permissions=True) + return doc.name + return name + + def get_or_create_company(self, name): + existing = frappe.db.get_value("Company", {}, "name") + if existing: + return existing + if not frappe.db.exists("Company", name): + doc = frappe.get_doc({ + "doctype": "Company", + "company_name": name, + "abbr": "TC", + "default_currency": "INR", + "country": "India" + }) + doc.flags.ignore_mandatory = True + doc.insert(ignore_permissions=True) + return doc.name + return name + + def get_or_create_service(self, name): + if not frappe.db.exists("Service", name): + doc = frappe.get_doc({ + "doctype": "Service", + "service": name, + "abbr": "TS" + }) + doc.flags.ignore_mandatory = True + doc.insert(ignore_permissions=True) + return doc.name + return name + + def get_or_create_file_type(self, name): + if not frappe.db.exists("File Type", name): + doc = frappe.get_doc({ + "doctype": "File Type", + "file_type": name, + "abbr": "TFT" + }) + doc.flags.ignore_mandatory = True + doc.insert(ignore_permissions=True) + return doc.name + return name + + # ========================================================= + # TESTS + # ========================================================= + + def test_empty_movement_validation(self): + """Must fail if barcode_files is empty.""" + fm = frappe.new_doc("File Movement") + fm.movement_type = "Outgoing" + fm.giver = self.giver + fm.receiver = self.receiver + self.assertRaises(frappe.ValidationError, fm.insert) + + def test_same_giver_receiver_validation(self): + """Same giver and receiver must throw validation error.""" + fm = frappe.new_doc("File Movement") + fm.movement_type = "Outgoing" + fm.giver = self.giver + fm.receiver = self.giver + fm.append("barcode_files", {"barcode": "BC-001", "matter": self.matter_1, "case": self.case_1}) + self.assertRaises(frappe.ValidationError, fm.insert) + + def test_duplicate_barcode_validation(self): + """Duplicate barcode in the same movement must throw validation error.""" + fm = frappe.new_doc("File Movement") + fm.movement_type = "Outgoing" + fm.giver = self.giver + fm.receiver = self.receiver + fm.append("barcode_files", {"barcode": "BC-DUP-01", "matter": self.matter_1, "case": self.case_1}) + fm.append("barcode_files", {"barcode": "BC-DUP-01", "matter": self.matter_2, "case": self.case_2}) + self.assertRaises(frappe.ValidationError, fm.insert) + + def test_nonexistent_matter_scan_rejection(self): + """Scanning a non-existent Matter or barcode must return not_found.""" + res = get_scan_information("NON-EXISTENT-MATTER-999") + self.assertIsNotNone(res) + self.assertEqual(res["type"], "not_found") + + def test_outgoing_with_matter_files(self): + """Outgoing movement with matter files in barcode_files table.""" + fm = frappe.new_doc("File Movement") + fm.movement_type = "Outgoing" + fm.giver = self.giver + fm.receiver = self.receiver + fm.append("barcode_files", { + "barcode": "BC-001", + "matter": self.matter_1, + "case": self.case_1 + }) + fm.insert(ignore_permissions=True) + self.assertEqual(fm.status, "Open") + fm.submit() + self.assertEqual(frappe.db.get_value("Matter", self.matter_1, "status"), "Working") + + def test_scan_and_query_apis(self): + """Test get_scan_information and get_cases_for_matter APIs.""" + # 1. Matter Scan + matter_info = get_scan_information(self.matter_1) + self.assertIsNotNone(matter_info) + self.assertEqual(matter_info["type"], "matter") + self.assertEqual(matter_info["matter"], self.matter_1) + self.assertTrue(len(matter_info["cases"]) >= 1) + + # 2. Case Scan + case_info = get_scan_information(self.case_1) + self.assertIsNotNone(case_info) + self.assertEqual(case_info["type"], "case") + self.assertEqual(case_info["case_name"], self.case_1) + self.assertEqual(case_info["matter"], self.matter_1) + + # 3. Get cases for matter + cases = get_cases_for_matter(self.matter_1) + self.assertTrue(any(c["name"] == self.case_1 for c in cases)) + + def test_full_incoming_workflow_partial_and_full_returns(self): + """ + Complete lifecycle test with single Matter Files table: + 1. Create Outgoing with 2 items -> Submit + 2. Create Incoming via create_incoming -> Check giver/receiver reversal & copy + 3. Partial return (1 returned, 1 unreturned) -> Submit -> Status "Missing Files" & missing table populated + 4. Late return of remaining missing file -> Status becomes "Received" + """ + # Step 1: Outgoing + outgoing = frappe.new_doc("File Movement") + outgoing.movement_type = "Outgoing" + outgoing.giver = self.giver + outgoing.receiver = self.receiver + outgoing.append("barcode_files", { + "barcode": "TEST-BC-101", + "matter": self.matter_1, + "case": self.case_1, + "returned": 0 + }) + outgoing.append("barcode_files", { + "barcode": "TEST-BC-102", + "matter": self.matter_2, + "case": self.case_2, + "returned": 0 + }) + outgoing.insert(ignore_permissions=True) + outgoing.submit() + + # Step 2: Create Incoming + incoming_name = create_incoming(outgoing.name) + incoming = frappe.get_doc("File Movement", incoming_name) + self.assertEqual(incoming.movement_type, "Incoming") + self.assertTrue(outgoing.name.startswith("FMO-"), f"Outgoing name should start with FMO-, got {outgoing.name}") + self.assertTrue(incoming.name.startswith("FMI-"), f"Incoming name should start with FMI-, got {incoming.name}") + self.assertEqual(incoming.name[4:], outgoing.name[4:], f"FMI suffix {incoming.name} should match FMO suffix {outgoing.name}") + self.assertEqual(incoming.giver, self.receiver) + self.assertEqual(incoming.receiver, self.giver) + self.assertEqual(incoming.original_outgoing_movement, outgoing.name) + self.assertEqual(len(incoming.barcode_files), 2) + self.assertEqual(incoming.barcode_files[0].returned, 0) + self.assertEqual(incoming.barcode_files[1].returned, 0) + + # Step 3: Partial return - mark row 0 as returned, row 1 unreturned + incoming.barcode_files[0].returned = 1 + incoming.barcode_files[1].returned = 0 + incoming.save(ignore_permissions=True) + incoming.submit() + + self.assertEqual(incoming.status, "Missing Files") + self.assertEqual(len(incoming.missing_file_items), 1) + self.assertEqual(incoming.missing_file_items[0].case, self.case_2) + + # Step 4: Check get_missing_files + missing = get_missing_files(incoming.name) + self.assertEqual(len(missing), 1) + self.assertEqual(missing[0]["case"], self.case_2) + + # Step 5: Late return of remaining missing file + res = return_missing_files(incoming.name, files=[self.case_2]) + self.assertEqual(res["status"], "Received") + self.assertEqual(len(res["missing"]), 0) + + # Verify incoming doc status updated in database + incoming.reload() + self.assertEqual(incoming.status, "Received") + self.assertEqual(len(incoming.missing_file_items), 0) + + def test_fmo_and_fmi_matching_id_number(self): + """Test that Outgoing movement creates FMO-##### and Incoming creates matching FMI-#####.""" + outgoing = frappe.new_doc("File Movement") + outgoing.movement_type = "Outgoing" + outgoing.giver = self.giver + outgoing.receiver = self.receiver + outgoing.append("barcode_files", { + "barcode": "TEST-BC-FMO-01", + "matter": self.matter_1, + "case": self.case_1 + }) + outgoing.insert(ignore_permissions=True) + outgoing.submit() + + self.assertTrue(outgoing.name.startswith("FMO-"), f"Expected FMO- prefix, got {outgoing.name}") + + # Extract number part + num_part = outgoing.name.replace("FMO-", "") + + # Create incoming + incoming_name = create_incoming(outgoing.name) + self.assertEqual(incoming_name, f"FMI-{num_part}", f"Expected FMI-{num_part}, got {incoming_name}") + + + +def run_test_suite(): + import unittest + suite = unittest.TestLoader().loadTestsFromTestCase(TestFileMovement) + runner = unittest.TextTestRunner(verbosity=2) + result = runner.run(suite) + if not result.wasSuccessful(): + raise Exception(f"Tests failed: {len(result.failures)} failures, {len(result.errors)} errors") + print(f"\nAll {result.testsRun} tests passed successfully!") diff --git a/casecentral/case_central/doctype/file_movement_items/__init__.py b/casecentral/case_central/doctype/file_movement_items/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/casecentral/case_central/doctype/file_movement_items/file_movement_items.js b/casecentral/case_central/doctype/file_movement_items/file_movement_items.js new file mode 100644 index 0000000..8242e31 --- /dev/null +++ b/casecentral/case_central/doctype/file_movement_items/file_movement_items.js @@ -0,0 +1,11 @@ +// ============================================================= +// FILE MOVEMENT ITEMS +// ============================================================= +// +// Matter -> Case population is handled by file_movement.js. +// +// This file is intentionally kept empty so that the child +// table does not have duplicate event handlers. +// + +frappe.ui.form.on("File_Movement_Items", {}); \ No newline at end of file diff --git a/casecentral/case_central/doctype/file_movement_items/file_movement_items.json b/casecentral/case_central/doctype/file_movement_items/file_movement_items.json new file mode 100644 index 0000000..44e8f9f --- /dev/null +++ b/casecentral/case_central/doctype/file_movement_items/file_movement_items.json @@ -0,0 +1,56 @@ +{ + "actions": [], + "allow_bulk_edit": 1, + "allow_rename": 1, + "creation": "2026-08-24 16:32:19.094791", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "matter", + "case", + "returned" + ], + "fields": [ + { + "columns": 3, + "fieldname": "matter", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Matter", + "options": "Matter", + "reqd": 1 + }, + { + "columns": 3, + "fieldname": "case", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Case", + "options": "Case" + }, + { + "columns": 1, + "default": "0", + "fieldname": "returned", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Returned" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-09-05 14:40:00.000000", + "modified_by": "Administrator", + "module": "Case Central", + "name": "File_Movement_Items", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/casecentral/case_central/doctype/file_movement_items/file_movement_items.py b/casecentral/case_central/doctype/file_movement_items/file_movement_items.py new file mode 100644 index 0000000..4fe6cb0 --- /dev/null +++ b/casecentral/case_central/doctype/file_movement_items/file_movement_items.py @@ -0,0 +1,5 @@ +from frappe.model.document import Document + + +class File_Movement_Items(Document): + pass diff --git a/casecentral/case_central/doctype/matter/matter.json b/casecentral/case_central/doctype/matter/matter.json index c4c52c1..7234701 100644 --- a/casecentral/case_central/doctype/matter/matter.json +++ b/casecentral/case_central/doctype/matter/matter.json @@ -91,6 +91,7 @@ "fieldtype": "Column Break" }, { + "allow_on_submit": 1, "fieldname": "status", "fieldtype": "Select", "in_list_view": 1, @@ -338,11 +339,10 @@ "link_fieldname": "custom_matter" } ], - "modified": "2023-10-30 23:01:24.808994", + "modified": "2026-09-01 14:51:14.805232", "modified_by": "Administrator", "module": "Case Central", "name": "Matter", - "name_case": "Title Case", "naming_rule": "By \"Naming Series\" field", "owner": "Administrator", "permissions": [ @@ -386,6 +386,7 @@ "write": 1 } ], + "row_format": "Dynamic", "show_name_in_global_search": 1, "show_preview_popup": 1, "sort_field": "modified", @@ -413,4 +414,4 @@ } ], "track_changes": 1 -} \ No newline at end of file +} diff --git a/casecentral/case_central/doctype/missing_file_items/__init__.py b/casecentral/case_central/doctype/missing_file_items/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/casecentral/case_central/doctype/missing_file_items/missing_file_items.json b/casecentral/case_central/doctype/missing_file_items/missing_file_items.json new file mode 100644 index 0000000..fbc4103 --- /dev/null +++ b/casecentral/case_central/doctype/missing_file_items/missing_file_items.json @@ -0,0 +1,63 @@ +{ + "actions": [], + "allow_bulk_edit": 1, + "allow_rename": 1, + "creation": "2026-09-05 16:02:37.054143", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "barcode", + "matter", + "case", + "returned" + ], + "fields": [ + { + "columns": 3, + "fieldname": "barcode", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Barcode" + }, + { + "columns": 3, + "fieldname": "matter", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Matter", + "options": "Matter" + }, + { + "columns": 3, + "fieldname": "case", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Case", + "options": "Case" + }, + { + "columns": 1, + "default": "0", + "fieldname": "returned", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Returned" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-09-05 16:04:00.786893", + "modified_by": "Administrator", + "module": "Case Central", + "name": "Missing File Items", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/casecentral/case_central/doctype/missing_file_items/missing_file_items.py b/casecentral/case_central/doctype/missing_file_items/missing_file_items.py new file mode 100644 index 0000000..7d0b017 --- /dev/null +++ b/casecentral/case_central/doctype/missing_file_items/missing_file_items.py @@ -0,0 +1,9 @@ +# Copyright (c) 2026, 4C Solutions and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class MissingFileItems(Document): + pass From c8ab6e31b60177de80cd08f3b7ac73d4b2415a76 Mon Sep 17 00:00:00 2001 From: Abdullah Syed Date: Wed, 16 Sep 2026 09:02:36 +0000 Subject: [PATCH 02/12] fix(scanner): use Html5Qrcode for cross-browser camera barcode scanning --- .../doctype/file_movement/file_movement.js | 140 +++++++++++------- 1 file changed, 83 insertions(+), 57 deletions(-) diff --git a/casecentral/case_central/doctype/file_movement/file_movement.js b/casecentral/case_central/doctype/file_movement/file_movement.js index 65ccbc0..5d24e6f 100644 --- a/casecentral/case_central/doctype/file_movement/file_movement.js +++ b/casecentral/case_central/doctype/file_movement/file_movement.js @@ -182,6 +182,9 @@ function setup_page_scan_button(frm) { // ========================================================= function open_scan_dialog(frm) { + var scanner_id = "scanner-reader-" + frappe.dom.set_unique_id(); + var html5_scanner = null; + var dialog = new frappe.ui.Dialog({ title: __("Scan Barcode / Matter ID"), fields: [ @@ -233,41 +236,79 @@ function open_scan_dialog(frm) { '' + __("Start Camera Scanner") + '' + '' + - '' + + '' + '
' + ''; dialog.fields_dict.camera_area.$wrapper.html(html); var wrapper = dialog.fields_dict.camera_area.$wrapper; - var video = wrapper.find(".barcode-video")[0]; var start_button = wrapper.find(".start-camera"); var stop_button = wrapper.find(".stop-camera"); var camera_message = wrapper.find(".camera-message"); + var scanner_container = wrapper.find("#" + scanner_id); - var stream = null; - var scanning = false; - var scan_interval = null; - - start_button.on("click", function () { - if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) { - navigator.mediaDevices.getUserMedia({ video: { facingMode: "environment" } }) - .then(function (s) { - stream = s; - video.srcObject = s; - video.style.display = "block"; - start_button.hide(); - stop_button.show(); - camera_message.html("" + __("Point camera at barcode...") + ""); - scanning = true; - start_camera_detection(); - }) - .catch(function (err) { - camera_message.html("" + __("Camera error: {0}", [err.message]) + ""); + function stop_camera() { + if (html5_scanner) { + try { + html5_scanner.stop().then(function () { + try { html5_scanner.clear(); } catch (e) {} + html5_scanner = null; + }).catch(function () { + html5_scanner = null; }); - } else { - camera_message.html("" + __("Camera not supported on this device.") + ""); + } catch (e) { + html5_scanner = null; + } } + scanner_container.hide(); + start_button.show(); + stop_button.hide(); + camera_message.html(""); + } + + start_button.on("click", function () { + camera_message.html("" + __("Starting camera...") + ""); + + load_html5_qrcode().then(function () { + scanner_container.show(); + start_button.hide(); + stop_button.show(); + camera_message.html("" + __("Point camera at barcode or QR code...") + ""); + + html5_scanner = new Html5Qrcode(scanner_id); + var config = { + fps: 15, + qrbox: function(viewfinderWidth, viewfinderHeight) { + var minEdgePercentage = 0.85; + var minEdgeSize = Math.min(viewfinderWidth, viewfinderHeight); + var qrboxSize = Math.floor(minEdgeSize * minEdgePercentage); + return { + width: qrboxSize, + height: Math.max(120, Math.floor(qrboxSize * 0.5)) + }; + } + }; + + html5_scanner.start( + { facingMode: "environment" }, + config, + function onScanSuccess(decodedText) { + if (decodedText) { + stop_camera(); + process_scan(frm, decodedText, dialog); + } + }, + function onScanFailure() { + // Frame scan attempt without match - continue scanning + } + ).catch(function (err) { + camera_message.html("" + __("Camera error: {0}", [err.message || err]) + ""); + stop_camera(); + }); + }).catch(function (err) { + camera_message.html("" + __("Could not load camera library: {0}", [err.message || err]) + ""); + }); }); stop_button.on("click", function () { @@ -281,42 +322,27 @@ function open_scan_dialog(frm) { dialog.$wrapper.on("hidden.bs.modal", function () { stop_camera(); }); +} - function stop_camera() { - scanning = false; - if (scan_interval) { - clearInterval(scan_interval); - scan_interval = null; - } - if (stream) { - stream.getTracks().forEach(function (track) { track.stop(); }); - stream = null; - } - video.style.display = "none"; - start_button.show(); - stop_button.hide(); - camera_message.html(""); - } - - function start_camera_detection() { - if ("BarcodeDetector" in window) { - var barcodeDetector = new BarcodeDetector(); - scan_interval = setInterval(function () { - if (!scanning || !video.videoWidth) return; - barcodeDetector.detect(video) - .then(function (barcodes) { - if (barcodes.length > 0 && scanning) { - var detected_code = barcodes[0].rawValue; - stop_camera(); - process_scan(frm, detected_code, dialog); - } - }) - .catch(function () {}); - }, 300); - } else { - camera_message.html("" + __("Live camera detection not supported by browser. Please type or scan using barcode gun.") + ""); - } +function load_html5_qrcode() { + if (window.Html5Qrcode) { + return Promise.resolve(); } + return new Promise(function (resolve, reject) { + frappe.require("/assets/frappe/node_modules/html5-qrcode/html5-qrcode.min.js", function () { + if (window.Html5Qrcode) { + resolve(); + } else { + frappe.require("https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js", function () { + if (window.Html5Qrcode) { + resolve(); + } else { + reject(new Error("Failed to load Html5Qrcode library")); + } + }); + } + }); + }); } From 5f40c61b5f0c8909c113fc1bd758809c65ad82c5 Mon Sep 17 00:00:00 2001 From: Abdullah Syed Date: Wed, 16 Sep 2026 09:06:18 +0000 Subject: [PATCH 03/12] feat(workspace): add File Movement shortcut to Case Central workspace --- .../workspace/case_central/case_central.json | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/casecentral/case_central/workspace/case_central/case_central.json b/casecentral/case_central/workspace/case_central/case_central.json index 3ee4750..5a6e616 100644 --- a/casecentral/case_central/workspace/case_central/case_central.json +++ b/casecentral/case_central/workspace/case_central/case_central.json @@ -1,6 +1,7 @@ { + "app": "casecentral", "charts": [], - "content": "[{\"id\":\"header_1\",\"type\":\"header\",\"data\":{\"text\":\"Case Central\",\"col\":12}},{\"id\":\"sh_customer\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Customer\",\"col\":3}},{\"id\":\"sh_matter\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Matter\",\"col\":3}},{\"id\":\"sh_case\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Case\",\"col\":3}},{\"id\":\"sh_caveat\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Caveat\",\"col\":3}},{\"id\":\"sh_task\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Task\",\"col\":3}},{\"id\":\"sh_appointment\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Customer Appointment\",\"col\":3}},{\"id\":\"sh_timesheet\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Timesheet\",\"col\":3}},{\"id\":\"sh_schedule\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Lawyer Schedule\",\"col\":3}},{\"id\":\"sh_book\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Book\",\"col\":4}},{\"id\":\"sh_settings\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Case Central Settings\",\"col\":4}},{\"id\":\"sh_dashboard\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Dashboard\",\"col\":4}},{\"id\":\"spacer_1\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"card_matter_mgt\",\"type\":\"card\",\"data\":{\"card_name\":\"Matter Management\",\"col\":4}},{\"id\":\"card_case_mgt\",\"type\":\"card\",\"data\":{\"card_name\":\"Case Management\",\"col\":4}},{\"id\":\"card_appointments\",\"type\":\"card\",\"data\":{\"card_name\":\"Appointments\",\"col\":4}},{\"id\":\"card_operations\",\"type\":\"card\",\"data\":{\"card_name\":\"Operations\",\"col\":4}},{\"id\":\"card_books_journals\",\"type\":\"card\",\"data\":{\"card_name\":\"Books and Journals\",\"col\":4}},{\"id\":\"card_quality_mgt\",\"type\":\"card\",\"data\":{\"card_name\":\"Quality Management\",\"col\":4}},{\"id\":\"card_masters\",\"type\":\"card\",\"data\":{\"card_name\":\"Masters\",\"col\":4}},{\"id\":\"card_setup\",\"type\":\"card\",\"data\":{\"card_name\":\"Setup\",\"col\":4}},{\"id\":\"card_reports\",\"type\":\"card\",\"data\":{\"card_name\":\"Reports\",\"col\":4}}]", + "content": "[{\"id\":\"header_1\",\"type\":\"header\",\"data\":{\"text\":\"Case Central\",\"col\":12}},{\"id\":\"sh_customer\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Customer\",\"col\":3}},{\"id\":\"sh_matter\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Matter\",\"col\":3}},{\"id\":\"sh_case\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Case\",\"col\":3}},{\"id\":\"sh_caveat\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Caveat\",\"col\":3}},{\"id\":\"sh_task\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Task\",\"col\":3}},{\"id\":\"sh_appointment\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Customer Appointment\",\"col\":3}},{\"id\":\"sh_timesheet\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Timesheet\",\"col\":3}},{\"id\":\"sh_schedule\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Lawyer Schedule\",\"col\":3}},{\"id\":\"sh_book\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Book\",\"col\":4}},{\"id\":\"sh_settings\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Case Central Settings\",\"col\":4}},{\"id\":\"sh_dashboard\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Dashboard\",\"col\":4}},{\"id\":\"sh_file_movement\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"File Movement\",\"col\":4}},{\"id\":\"spacer_1\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"card_matter_mgt\",\"type\":\"card\",\"data\":{\"card_name\":\"Matter Management\",\"col\":4}},{\"id\":\"card_case_mgt\",\"type\":\"card\",\"data\":{\"card_name\":\"Case Management\",\"col\":4}},{\"id\":\"card_appointments\",\"type\":\"card\",\"data\":{\"card_name\":\"Appointments\",\"col\":4}},{\"id\":\"card_operations\",\"type\":\"card\",\"data\":{\"card_name\":\"Operations\",\"col\":4}},{\"id\":\"card_books_journals\",\"type\":\"card\",\"data\":{\"card_name\":\"Books and Journals\",\"col\":4}},{\"id\":\"card_quality_mgt\",\"type\":\"card\",\"data\":{\"card_name\":\"Quality Management\",\"col\":4}},{\"id\":\"card_masters\",\"type\":\"card\",\"data\":{\"card_name\":\"Masters\",\"col\":4}},{\"id\":\"card_setup\",\"type\":\"card\",\"data\":{\"card_name\":\"Setup\",\"col\":4}},{\"id\":\"card_reports\",\"type\":\"card\",\"data\":{\"card_name\":\"Reports\",\"col\":4}}]", "creation": "2022-12-27 17:17:22.857757", "custom_blocks": [], "docstatus": 0, @@ -365,7 +366,7 @@ "type": "Link" } ], - "modified": "2026-06-15 21:05:37.815545", + "modified": "2026-09-16 13:23:58.276004", "modified_by": "Administrator", "module": "Case Central", "name": "Case Central", @@ -386,6 +387,14 @@ "stats_filter": "{\"disabled\":[\"=\",0]}", "type": "DocType" }, + { + "color": "Grey", + "doc_view": "List", + "label": "File Movement", + "link_to": "File Movement", + "stats_filter": "[]", + "type": "DocType" + }, { "color": "Green", "doc_view": "List", @@ -461,5 +470,6 @@ "type": "Dashboard" } ], - "title": "Case Central" -} \ No newline at end of file + "title": "Case Central", + "type": "Workspace" +} From 03dfe2078595d975d9f126bca732ef1f1a1f5de9 Mon Sep 17 00:00:00 2001 From: Abdullah Syed Date: Mon, 28 Sep 2026 07:05:18 +0000 Subject: [PATCH 04/12] fix(file-movement): automatically return matter status to Open when all linked files/cases are returned --- .../doctype/file_movement/file_movement.py | 64 +++++++++++++++++-- 1 file changed, 57 insertions(+), 7 deletions(-) diff --git a/casecentral/case_central/doctype/file_movement/file_movement.py b/casecentral/case_central/doctype/file_movement/file_movement.py index a3d75cd..08d0a34 100644 --- a/casecentral/case_central/doctype/file_movement/file_movement.py +++ b/casecentral/case_central/doctype/file_movement/file_movement.py @@ -154,14 +154,15 @@ def update_outgoing_linked_statuses(self): if row.matter: matters.add(row.matter) for matter_name in matters: - if frappe.db.exists("Matter", matter_name): - frappe.db.set_value("Matter", matter_name, "status", "Working") + recalculate_matter_status_from_movements(matter_name) def update_incoming_linked_statuses(self): + matters = set() for row in self.get("barcode_files") or []: - if row.matter and row.returned: - if frappe.db.exists("Matter", row.matter): - frappe.db.set_value("Matter", row.matter, "status", "Open") + if row.matter: + matters.add(row.matter) + for matter_name in matters: + recalculate_matter_status_from_movements(matter_name) def restore_outgoing_linked_statuses(self): matters = set() @@ -169,8 +170,57 @@ def restore_outgoing_linked_statuses(self): if row.matter: matters.add(row.matter) for matter_name in matters: - if frappe.db.exists("Matter", matter_name): - frappe.db.set_value("Matter", matter_name, "status", "Open") + recalculate_matter_status_from_movements(matter_name) + + +def recalculate_matter_status_from_movements(matter_name): + """ + Checks whether all cases and files for a Matter have been returned. + If there are active unreturned outgoing movements, status is 'Working'. + Once all cases and matter files are returned, status is set to 'Open'. + """ + if not matter_name or not frappe.db.exists("Matter", matter_name): + return + + # Find all submitted outgoing file movements containing this matter + outgoing_rows = frappe.db.sql(""" + SELECT bfi.name, bfi.parent, bfi.case, bfi.barcode + FROM `tabBarcode File Item` bfi + INNER JOIN `tabFile Movement` fm ON fm.name = bfi.parent + WHERE fm.docstatus = 1 AND fm.movement_type = 'Outgoing' AND bfi.matter = %s + """, (matter_name,), as_dict=True) + + if not outgoing_rows: + # No active outgoing movements for this matter -> it is Open + frappe.db.set_value("Matter", matter_name, "status", "Open") + return + + # Check for unreturned items + unreturned_count = 0 + for out_row in outgoing_rows: + # Check if there is a submitted incoming movement for this outgoing movement where this item is returned + returned = frappe.db.sql(""" + SELECT bfi.name + FROM `tabBarcode File Item` bfi + INNER JOIN `tabFile Movement` fm ON fm.name = bfi.parent + WHERE fm.docstatus = 1 + AND fm.movement_type = 'Incoming' + AND fm.original_outgoing_movement = %s + AND ( + (bfi.case = %s AND %s != '') OR + (bfi.barcode = %s AND %s != '') OR + (bfi.matter = %s) + ) + AND bfi.returned = 1 + """, (out_row.parent, out_row.get("case") or "", out_row.get("case") or "", out_row.get("barcode") or "", out_row.get("barcode") or "", matter_name)) + + if not returned: + unreturned_count += 1 + + if unreturned_count == 0: + frappe.db.set_value("Matter", matter_name, "status", "Open") + else: + frappe.db.set_value("Matter", matter_name, "status", "Working") # ========================================================= From 05aa24c98746f99cc43e8b13358467891b87085a Mon Sep 17 00:00:00 2001 From: Abdullah Syed Date: Mon, 5 Oct 2026 12:28:09 +0000 Subject: [PATCH 05/12] fix: barcode input field with scanner icon and ensure Outgoing status is strictly Open --- .../doctype/file_movement/file_movement.js | 60 ++++++++++++------- .../doctype/file_movement/file_movement.json | 11 ++-- .../doctype/file_movement/file_movement.py | 9 ++- 3 files changed, 53 insertions(+), 27 deletions(-) diff --git a/casecentral/case_central/doctype/file_movement/file_movement.js b/casecentral/case_central/doctype/file_movement/file_movement.js index 5d24e6f..d3f6a11 100644 --- a/casecentral/case_central/doctype/file_movement/file_movement.js +++ b/casecentral/case_central/doctype/file_movement/file_movement.js @@ -4,16 +4,21 @@ frappe.ui.form.on("File Movement", { onload: function (frm) { - setup_page_scan_button(frm); + setup_scan_barcode_input(frm); }, refresh: function (frm) { - setup_page_scan_button(frm); + setup_scan_barcode_input(frm); frm.remove_custom_button("Create Incoming"); frm.remove_custom_button("Return Missing Files"); frm.remove_custom_button("Scan Barcode"); + // Force Outgoing movement status to "Open" + if (frm.doc.movement_type === "Outgoing" && frm.doc.status !== "Open") { + frm.set_value("status", "Open"); + } + // Direct standalone "Create Incoming" button on submitted Outgoing movement (not inside Actions) if (frm.doc.docstatus === 1 && frm.doc.movement_type === "Outgoing") { add_create_incoming_button(frm); @@ -28,9 +33,19 @@ frappe.ui.form.on("File Movement", { set_form_read_only_states(frm); }, + scan_barcode: function (frm) { + var val = (frm.doc.scan_barcode || "").trim(); + if (!val) return; + frm.set_value("scan_barcode", ""); + process_scan(frm, val); + }, + movement_type: function (frm) { set_form_read_only_states(frm); - if (frm.doc.movement_type === "Incoming" && frm.doc.docstatus === 0) { + if (frm.doc.movement_type === "Outgoing") { + frm.set_value("missing_file_items", []); + frm.set_value("status", "Open"); + } else if (frm.doc.movement_type === "Incoming" && frm.doc.docstatus === 0) { update_missing_files_table_and_status_client(frm); } } @@ -155,25 +170,27 @@ function set_form_read_only_states(frm) { // ========================================================= -// SCAN BARCODE BUTTON (ONLY IN PAGE BELOW RECEIVER) +// SCAN BARCODE INPUT FIELD SETUP // ========================================================= -function setup_page_scan_button(frm) { - if (!frm.fields_dict.scan_barcode_btn) return; - - var btn_html = - '
' + - '' + - '
'; - - frm.fields_dict.scan_barcode_btn.$wrapper.html(btn_html); - - frm.fields_dict.scan_barcode_btn.$wrapper.find(".btn-page-scan-barcode").off("click").on("click", function (e) { - e.preventDefault(); - open_scan_dialog(frm); - }); +function setup_scan_barcode_input(frm) { + if (!frm.fields_dict.scan_barcode) return; + + var field = frm.fields_dict.scan_barcode; + if (field.$input) { + field.$input.attr("placeholder", __("Scan Barcode / Matter ID / Case...")); + field.$input.off("keydown.fm_scan").on("keydown.fm_scan", function (e) { + if (e.which === 13) { + e.preventDefault(); + var val = $(this).val().trim(); + if (val) { + $(this).val(""); + frm.set_value("scan_barcode", ""); + process_scan(frm, val); + } + } + }); + } } @@ -531,6 +548,9 @@ function handle_incoming_scan(frm, data, original_value) { function update_missing_files_table_and_status_client(frm) { if (frm.doc.movement_type !== "Incoming") { frm.set_value("missing_file_items", []); + if (frm.doc.status !== "Open") { + frm.set_value("status", "Open"); + } return; } diff --git a/casecentral/case_central/doctype/file_movement/file_movement.json b/casecentral/case_central/doctype/file_movement/file_movement.json index be217ff..cd7a44e 100644 --- a/casecentral/case_central/doctype/file_movement/file_movement.json +++ b/casecentral/case_central/doctype/file_movement/file_movement.json @@ -8,7 +8,7 @@ "field_order": [ "giver", "receiver", - "scan_barcode_btn", + "scan_barcode", "column_break_1", "movement_date", "status", @@ -38,9 +38,10 @@ "reqd": 1 }, { - "fieldname": "scan_barcode_btn", - "fieldtype": "HTML", - "label": "Scan Barcode Button" + "fieldname": "scan_barcode", + "fieldtype": "Data", + "label": "Scan Barcode", + "options": "Barcode" }, { "fieldname": "column_break_1", @@ -123,7 +124,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2026-09-15 14:00:00.000000", + "modified": "2026-10-05 14:00:00.000000", "modified_by": "Administrator", "module": "Case Central", "name": "File Movement", diff --git a/casecentral/case_central/doctype/file_movement/file_movement.py b/casecentral/case_central/doctype/file_movement/file_movement.py index 08d0a34..161a3f5 100644 --- a/casecentral/case_central/doctype/file_movement/file_movement.py +++ b/casecentral/case_central/doctype/file_movement/file_movement.py @@ -37,6 +37,9 @@ def autoname(self): self.name = frappe.model.naming.make_autoname("FMO-.#####") def validate(self): + if self.movement_type == "Outgoing": + self.status = "Open" + self.set("missing_file_items", []) self.validate_movement() self.validate_rows() self.validate_duplicate_barcodes() @@ -110,8 +113,7 @@ def validate_duplicate_barcodes(self): def update_missing_files_and_status(self): if self.movement_type == "Outgoing": - if not self.status: - self.status = "Open" + self.status = "Open" self.set("missing_file_items", []) return @@ -137,11 +139,14 @@ def set_missing_file_table(self, missing): def on_submit(self): if self.movement_type == "Outgoing": + self.status = "Open" + self.db_set("status", "Open") self.update_outgoing_linked_statuses() elif self.movement_type == "Incoming": missing = calculate_missing_files_from_doc(self) self.set_missing_file_table(missing) self.status = "Missing Files" if missing else "Received" + self.db_set("status", self.status) self.update_incoming_linked_statuses() def on_cancel(self): From e924627aca1865d9dfd713d9a78312f51be6e1f4 Mon Sep 17 00:00:00 2001 From: Abdullah Syed Date: Tue, 6 Oct 2026 06:58:13 +0000 Subject: [PATCH 06/12] feat(file-movement): incoming FM starts with empty table, compares scanned items against outgoing, and fixes list view status indicators --- .../doctype/file_movement/file_movement.js | 259 +++++++++++++++--- .../doctype/file_movement/file_movement.py | 158 +++++++++-- .../file_movement/file_movement_list.js | 9 +- .../file_movement/test_file_movement.py | 27 +- 4 files changed, 367 insertions(+), 86 deletions(-) diff --git a/casecentral/case_central/doctype/file_movement/file_movement.js b/casecentral/case_central/doctype/file_movement/file_movement.js index d3f6a11..e01b357 100644 --- a/casecentral/case_central/doctype/file_movement/file_movement.js +++ b/casecentral/case_central/doctype/file_movement/file_movement.js @@ -5,6 +5,9 @@ frappe.ui.form.on("File Movement", { onload: function (frm) { setup_scan_barcode_input(frm); + if (frm.doc.movement_type === "Incoming" && frm.doc.original_outgoing_movement) { + fetch_outgoing_items_and_update(frm); + } }, refresh: function (frm) { @@ -31,6 +34,10 @@ frappe.ui.form.on("File Movement", { // Set field read-only states depending on movement type set_form_read_only_states(frm); + + if (frm.doc.movement_type === "Incoming" && frm.doc.original_outgoing_movement) { + fetch_outgoing_items_and_update(frm); + } }, scan_barcode: function (frm) { @@ -40,13 +47,19 @@ frappe.ui.form.on("File Movement", { process_scan(frm, val); }, + original_outgoing_movement: function (frm) { + if (frm.doc.movement_type === "Incoming") { + fetch_outgoing_items_and_update(frm); + } + }, + movement_type: function (frm) { set_form_read_only_states(frm); if (frm.doc.movement_type === "Outgoing") { frm.set_value("missing_file_items", []); frm.set_value("status", "Open"); - } else if (frm.doc.movement_type === "Incoming" && frm.doc.docstatus === 0) { - update_missing_files_table_and_status_client(frm); + } else if (frm.doc.movement_type === "Incoming") { + fetch_outgoing_items_and_update(frm); } } }); @@ -57,10 +70,27 @@ frappe.ui.form.on("File Movement", { // ========================================================= frappe.ui.form.on("Barcode File Item", { + barcode_files_add: function (frm, cdt, cdn) { + if (frm.doc.movement_type === "Incoming") { + frappe.model.set_value(cdt, cdn, "returned", 1); + update_missing_files_table_and_status_client(frm); + } + }, + + barcode_files_remove: function (frm) { + if (frm.doc.movement_type === "Incoming") { + update_missing_files_table_and_status_client(frm); + } + }, + matter: function (frm, cdt, cdn) { var row = frappe.get_doc(cdt, cdn); if (!row.matter) return; + if (frm.doc.movement_type === "Incoming") { + frappe.model.set_value(cdt, cdn, "returned", 1); + } + frappe.call({ method: "casecentral.case_central.doctype.file_movement.file_movement.get_cases_for_matter", args: { matter: row.matter }, @@ -107,7 +137,7 @@ frappe.ui.form.on("Barcode File Item", { new_row.matter = c.matter || row.matter; new_row.case = c.name || ""; new_row.barcode = c.barcode || ""; - new_row.returned = 0; + new_row.returned = frm.doc.movement_type === "Incoming" ? 1 : 0; } } frm.refresh_field("barcode_files"); @@ -134,11 +164,18 @@ frappe.ui.form.on("Barcode File Item", { var row = frappe.get_doc(cdt, cdn); if (!row.case) return; + if (frm.doc.movement_type === "Incoming") { + frappe.model.set_value(cdt, cdn, "returned", 1); + } + frappe.db.get_value("Case", row.case, ["matter", "name"]).then(r => { if (r && r.message) { if (r.message.matter && !row.matter) { frappe.model.set_value(cdt, cdn, "matter", r.message.matter); } + if (frm.doc.movement_type === "Incoming") { + update_missing_files_table_and_status_client(frm); + } } else { frappe.msgprint({ title: __("Case Not Found"), @@ -508,35 +545,111 @@ function handle_outgoing_scan(frm, data, original_value) { } function handle_incoming_scan(frm, data, original_value) { - var matched_rows = []; + if (!frm._outgoing_items && frm.doc.original_outgoing_movement) { + fetch_outgoing_items_and_update(frm, function () { + handle_incoming_scan_process(frm, data, original_value); + }); + } else { + handle_incoming_scan_process(frm, data, original_value); + } +} + +function handle_incoming_scan_process(frm, data, original_value) { + var added_count = 0; + + if (data.type === "matter") { + var cases = data.cases || []; + if (cases.length > 0) { + cases.forEach(function (c) { + var exists = (frm.doc.barcode_files || []).some(function (item) { + return item.matter === (c.matter || data.matter) && item.case === c.name; + }); - (frm.doc.barcode_files || []).forEach(function (row) { - if (data.type === "matter" && row.matter === data.matter) { - matched_rows.push(row); - } else if (data.type === "case" && row.case === data.case_name) { - matched_rows.push(row); - } else if (row.barcode === original_value || (data.barcode && row.barcode === data.barcode)) { - matched_rows.push(row); + if (!exists) { + var row = frm.add_child("barcode_files"); + row.matter = c.matter || data.matter; + row.case = c.name || ""; + row.barcode = c.barcode || ""; + row.returned = 1; + added_count++; + } + }); + } else { + var exists = (frm.doc.barcode_files || []).some(function (item) { + return item.matter === data.matter; + }); + if (!exists) { + var row = frm.add_child("barcode_files"); + row.matter = data.matter; + row.case = ""; + row.barcode = ""; + row.returned = 1; + added_count++; + } } - }); - if (matched_rows.length > 0) { - matched_rows.forEach(function (row) { - row.returned = 1; - }); frm.refresh_field("barcode_files"); update_missing_files_table_and_status_client(frm); - frappe.show_alert({ - message: __("Marked returned: {0} ({1} item(s)).", [original_value, matched_rows.length]), - indicator: "green" + if (added_count > 0) { + frappe.show_alert({ + message: __("Scanned Matter {0} ({1} Case(s) returned).", [data.matter, added_count]), + indicator: "green" + }); + } else { + frappe.show_alert({ + message: __("Matter {0} is already scanned.", [data.matter]), + indicator: "orange" + }); + } + } else if (data.type === "case") { + var exists = (frm.doc.barcode_files || []).some(function (item) { + return item.case === data.case_name; }); - } else { - frappe.msgprint({ - title: __("Not in Movement"), - indicator: "orange", - message: __("Scanned item '{0}' was not found in this Incoming movement.", [original_value]) + + if (!exists) { + var row = frm.add_child("barcode_files"); + row.matter = data.matter || ""; + row.case = data.case_name || ""; + row.barcode = data.barcode || original_value; + row.returned = 1; + frm.refresh_field("barcode_files"); + update_missing_files_table_and_status_client(frm); + + frappe.show_alert({ + message: __("Scanned & returned Case {0}.", [data.case_name]), + indicator: "green" + }); + } else { + frappe.show_alert({ + message: __("Case {0} is already scanned.", [data.case_name]), + indicator: "orange" + }); + } + } else if (data.type === "case_file") { + var exists = (frm.doc.barcode_files || []).some(function (item) { + return item.barcode === data.barcode; }); + + if (!exists) { + var row = frm.add_child("barcode_files"); + row.matter = data.matter || ""; + row.case = data.case || ""; + row.barcode = data.barcode || original_value; + row.returned = 1; + frm.refresh_field("barcode_files"); + update_missing_files_table_and_status_client(frm); + + frappe.show_alert({ + message: __("Scanned & returned Barcode {0}.", [data.barcode]), + indicator: "green" + }); + } else { + frappe.show_alert({ + message: __("Barcode {0} is already scanned.", [data.barcode]), + indicator: "orange" + }); + } } } @@ -545,6 +658,27 @@ function handle_incoming_scan(frm, data, original_value) { // REAL-TIME MISSING FILES TABLE CALCULATION (CLIENT-SIDE) // ========================================================= +function fetch_outgoing_items_and_update(frm, callback) { + if (frm.doc.movement_type !== "Incoming" || !frm.doc.original_outgoing_movement) { + frm._outgoing_items = []; + update_missing_files_table_and_status_client(frm); + if (callback) callback(); + return; + } + + frappe.call({ + method: "casecentral.case_central.doctype.file_movement.file_movement.get_outgoing_movement_items", + args: { + outgoing_name: frm.doc.original_outgoing_movement + }, + callback: function (r) { + frm._outgoing_items = r.message || []; + update_missing_files_table_and_status_client(frm); + if (callback) callback(); + } + }); +} + function update_missing_files_table_and_status_client(frm) { if (frm.doc.movement_type !== "Incoming") { frm.set_value("missing_file_items", []); @@ -554,24 +688,73 @@ function update_missing_files_table_and_status_client(frm) { return; } - var missing = []; + var incoming_rows = frm.doc.barcode_files || []; - (frm.doc.barcode_files || []).forEach(function (row) { - if (!row.returned) { - missing.push({ - barcode: row.barcode || "", - matter: row.matter || "", - case: row.case || "", - returned: 0 + if (frm.doc.original_outgoing_movement && frm._outgoing_items && frm._outgoing_items.length > 0) { + var missing = []; + frm._outgoing_items.forEach(function (out_row) { + var is_returned = incoming_rows.some(function (inc_row) { + if (!inc_row.returned && inc_row.returned !== 1 && inc_row.returned !== true) { + return false; + } + if (out_row.case && inc_row.case && out_row.case === inc_row.case) { + return true; + } + if (out_row.barcode && inc_row.barcode && out_row.barcode === inc_row.barcode) { + return true; + } + if (out_row.matter && inc_row.matter && out_row.matter === inc_row.matter) { + if (!out_row.case && !inc_row.case) return true; + if (out_row.case && inc_row.case && out_row.case === inc_row.case) return true; + } + return false; }); - } - }); - frm.set_value("missing_file_items", missing); + if (!is_returned) { + missing.push({ + barcode: out_row.barcode || "", + matter: out_row.matter || "", + case: out_row.case || "", + returned: 0 + }); + } + }); + + frm.set_value("missing_file_items", missing); + + var new_status; + if (missing.length === 0) { + new_status = "Received"; + } else if (incoming_rows.length === 0 && frm.doc.docstatus === 0) { + new_status = "Open"; + } else { + new_status = "Missing Files"; + } - var new_status = missing.length > 0 ? "Missing Files" : "Received"; - if (frm.doc.status !== new_status) { - frm.set_value("status", new_status); + if (frm.doc.status !== new_status) { + frm.set_value("status", new_status); + } + } else { + // Fallback if not created from outgoing movement + var missing = []; + incoming_rows.forEach(function (row) { + if (!row.returned) { + missing.push({ + barcode: row.barcode || "", + matter: row.matter || "", + case: row.case || "", + returned: 0 + }); + } + }); + frm.set_value("missing_file_items", missing); + var new_status = missing.length > 0 ? "Missing Files" : "Received"; + if (incoming_rows.length === 0 && frm.doc.docstatus === 0) { + new_status = "Open"; + } + if (frm.doc.status !== new_status) { + frm.set_value("status", new_status); + } } } diff --git a/casecentral/case_central/doctype/file_movement/file_movement.py b/casecentral/case_central/doctype/file_movement/file_movement.py index 161a3f5..bcda8e3 100644 --- a/casecentral/case_central/doctype/file_movement/file_movement.py +++ b/casecentral/case_central/doctype/file_movement/file_movement.py @@ -64,7 +64,7 @@ def validate_movement(self): if not frappe.db.exists("Employee", self.receiver): frappe.throw(f"Receiver employee '{self.receiver}' does not exist.") - if not self.get("barcode_files"): + if self.movement_type == "Outgoing" and not self.get("barcode_files"): frappe.throw("Please add at least one row in the Matter Files table.") def validate_rows(self): @@ -123,9 +123,11 @@ def update_missing_files_and_status(self): if self.docstatus == 1: self.status = "Missing Files" if missing else "Received" else: - if not self.status: + if not self.get("barcode_files") and not self.status: self.status = "Open" - elif self.status != "Open": + elif not self.get("barcode_files"): + self.status = "Open" + else: self.status = "Missing Files" if missing else "Received" def set_missing_file_table(self, missing): @@ -166,6 +168,13 @@ def update_incoming_linked_statuses(self): for row in self.get("barcode_files") or []: if row.matter: matters.add(row.matter) + # Also check matters from original outgoing movement + outgoing_name = (self.original_outgoing_movement or self.get("outgoing_movement") or "").strip() + if outgoing_name and frappe.db.exists("File Movement", outgoing_name): + out_doc = frappe.get_doc("File Movement", outgoing_name) + for row in out_doc.get("barcode_files") or []: + if row.matter: + matters.add(row.matter) for matter_name in matters: recalculate_matter_status_from_movements(matter_name) @@ -363,17 +372,10 @@ def create_incoming(outgoing_name): incoming.receiver = outgoing.giver incoming.original_outgoing_movement = outgoing.name - # Copy Matter Files + # Matter Files table starts EMPTY - user/giver will scan to populate! incoming.set("barcode_files", []) - for row in outgoing.barcode_files or []: - incoming.append("barcode_files", { - "barcode": row.barcode or "", - "matter": row.matter or "", - "case": row.case or "", - "returned": 0 - }) - # Initially all copied files are missing + # Initially all outgoing files are missing until scanned missing = calculate_missing_files_from_doc(incoming) incoming.set_missing_file_table(missing) @@ -381,7 +383,80 @@ def create_incoming(outgoing_name): return incoming.name +@frappe.whitelist() +def get_outgoing_movement_items(outgoing_name): + outgoing_name = str(outgoing_name or "").strip() + if not outgoing_name or not frappe.db.exists("File Movement", outgoing_name): + return [] + + outgoing = frappe.get_doc("File Movement", outgoing_name) + result = [] + for row in outgoing.get("barcode_files") or []: + result.append({ + "barcode": row.barcode or "", + "matter": row.matter or "", + "case": row.case or "" + }) + return result + + +def is_item_returned(out_row, incoming_rows): + """ + Checks if an item from outgoing movement is present and marked returned in incoming_rows. + """ + out_case = (out_row.get("case") or "").strip() + out_barcode = (out_row.get("barcode") or "").strip() + out_matter = (out_row.get("matter") or "").strip() + + for inc_row in incoming_rows: + inc_returned = inc_row.get("returned") + if not inc_returned or inc_returned in [0, "0", False]: + continue + + inc_case = (inc_row.get("case") or "").strip() + inc_barcode = (inc_row.get("barcode") or "").strip() + inc_matter = (inc_row.get("matter") or "").strip() + + # 1. Match by case if both have case + if out_case and inc_case: + if out_case == inc_case: + return True + + # 2. Match by barcode if both have barcode + if out_barcode and inc_barcode: + if out_barcode == inc_barcode: + return True + + # 3. Match by matter + if out_matter and inc_matter: + if out_matter == inc_matter: + if not out_case and not inc_case: + return True + if out_case and inc_case and out_case == inc_case: + return True + + return False + + def calculate_missing_files_from_doc(doc): + if doc.movement_type == "Outgoing": + return [] + + outgoing_name = (doc.original_outgoing_movement or doc.get("outgoing_movement") or "").strip() + if outgoing_name and frappe.db.exists("File Movement", outgoing_name): + outgoing = frappe.get_doc("File Movement", outgoing_name) + incoming_rows = doc.get("barcode_files") or [] + missing = [] + for out_row in outgoing.get("barcode_files") or []: + if not is_item_returned(out_row, incoming_rows): + missing.append({ + "barcode": out_row.barcode or "", + "matter": out_row.matter or "", + "case": out_row.case or "" + }) + return missing + + # Fallback if no outgoing movement linked missing = [] for row in doc.get("barcode_files") or []: if not row.returned: @@ -422,7 +497,7 @@ def return_missing_files( if doc.movement_type != "Incoming": frappe.throw("Only Incoming movements can return missing files.") - items_to_return = set() + items_to_return = [] # Parse files arg if passed as json or list if isinstance(files, str): @@ -433,35 +508,58 @@ def return_missing_files( if isinstance(files, list): for f in files: if isinstance(f, dict): - if f.get("barcode"): - items_to_return.add(str(f["barcode"]).strip()) - if f.get("case"): - items_to_return.add(str(f["case"]).strip()) - if f.get("matter"): - items_to_return.add(str(f["matter"]).strip()) + items_to_return.append(f) elif str(f).strip(): - items_to_return.add(str(f).strip()) + items_to_return.append({"case": str(f).strip()}) for b in selected_barcodes or []: if str(b).strip(): - items_to_return.add(str(b).strip()) + items_to_return.append({"barcode": str(b).strip()}) for m in selected_matters or []: if str(m).strip(): - items_to_return.add(str(m).strip()) + items_to_return.append({"matter": str(m).strip()}) for c in selected_cases or []: if str(c).strip(): - items_to_return.add(str(c).strip()) + items_to_return.append({"case": str(c).strip()}) if not items_to_return: frappe.throw("Please select at least one file or matter to return.") - # Mark returned in barcode_files (Matter Files table) - for row in doc.barcode_files or []: - if (row.barcode and row.barcode in items_to_return) or \ - (row.case and row.case in items_to_return) or \ - (row.matter and row.matter in items_to_return): - row.returned = 1 - frappe.db.set_value("Barcode File Item", row.name, "returned", 1) + # Mark returned or append to barcode_files + for item in items_to_return: + target_case = item.get("case") or "" + target_barcode = item.get("barcode") or "" + target_matter = item.get("matter") or "" + + found = False + for row in doc.get("barcode_files") or []: + if (target_case and row.case == target_case) or \ + (target_barcode and row.barcode == target_barcode) or \ + (target_matter and row.matter == target_matter and not row.case and not target_case): + row.returned = 1 + frappe.db.set_value("Barcode File Item", row.name, "returned", 1) + found = True + break + + if not found: + # Look up case/matter if partial + if not target_matter and target_case: + target_matter = frappe.db.get_value("Case", target_case, "matter") or "" + if not target_case and target_barcode: + target_case = find_case_by_barcode(target_barcode) or "" + if target_case and not target_matter: + target_matter = frappe.db.get_value("Case", target_case, "matter") or "" + + new_row = doc.append("barcode_files", { + "barcode": target_barcode, + "matter": target_matter, + "case": target_case, + "returned": 1 + }) + new_row.parent = doc.name + new_row.parenttype = "File Movement" + new_row.parentfield = "barcode_files" + new_row.db_insert() # Recalculate remaining missing remaining_missing = calculate_missing_files_from_doc(doc) diff --git a/casecentral/case_central/doctype/file_movement/file_movement_list.js b/casecentral/case_central/doctype/file_movement/file_movement_list.js index 506f719..833df27 100644 --- a/casecentral/case_central/doctype/file_movement/file_movement_list.js +++ b/casecentral/case_central/doctype/file_movement/file_movement_list.js @@ -1,4 +1,5 @@ frappe.listview_settings["File Movement"] = { + has_indicator_for_draft: true, add_fields: ["status", "movement_type"], get_indicator: function (doc) { if (doc.status === "Open") { @@ -8,13 +9,5 @@ frappe.listview_settings["File Movement"] = { } else if (doc.status === "Received") { return [__("Received"), "green", "status,=,Received"]; } - }, - formatters: { - name: function (value, df, doc) { - if (doc.status) { - return `${value} — ${doc.status}`; - } - return value; - } } }; \ No newline at end of file diff --git a/casecentral/case_central/doctype/file_movement/test_file_movement.py b/casecentral/case_central/doctype/file_movement/test_file_movement.py index 1ac3619..f2a7cf7 100644 --- a/casecentral/case_central/doctype/file_movement/test_file_movement.py +++ b/casecentral/case_central/doctype/file_movement/test_file_movement.py @@ -213,8 +213,8 @@ def test_full_incoming_workflow_partial_and_full_returns(self): """ Complete lifecycle test with single Matter Files table: 1. Create Outgoing with 2 items -> Submit - 2. Create Incoming via create_incoming -> Check giver/receiver reversal & copy - 3. Partial return (1 returned, 1 unreturned) -> Submit -> Status "Missing Files" & missing table populated + 2. Create Incoming via create_incoming -> Verify barcode_files is EMPTY and missing_file_items has 2 items + 3. Partial scan / return (1 item scanned into barcode_files) -> Submit -> Status "Missing Files" & 1 missing item 4. Late return of remaining missing file -> Status becomes "Received" """ # Step 1: Outgoing @@ -237,7 +237,7 @@ def test_full_incoming_workflow_partial_and_full_returns(self): outgoing.insert(ignore_permissions=True) outgoing.submit() - # Step 2: Create Incoming + # Step 2: Create Incoming - should start with EMPTY barcode_files incoming_name = create_incoming(outgoing.name) incoming = frappe.get_doc("File Movement", incoming_name) self.assertEqual(incoming.movement_type, "Incoming") @@ -247,13 +247,19 @@ def test_full_incoming_workflow_partial_and_full_returns(self): self.assertEqual(incoming.giver, self.receiver) self.assertEqual(incoming.receiver, self.giver) self.assertEqual(incoming.original_outgoing_movement, outgoing.name) - self.assertEqual(len(incoming.barcode_files), 2) - self.assertEqual(incoming.barcode_files[0].returned, 0) - self.assertEqual(incoming.barcode_files[1].returned, 0) - - # Step 3: Partial return - mark row 0 as returned, row 1 unreturned - incoming.barcode_files[0].returned = 1 - incoming.barcode_files[1].returned = 0 + + # BARCODE_FILES MUST BE EMPTY INITIALLY + self.assertEqual(len(incoming.barcode_files), 0, "Incoming barcode_files should start empty") + # All 2 outgoing files should be listed in missing_file_items initially + self.assertEqual(len(incoming.missing_file_items), 2, "Incoming missing_file_items should list all 2 outgoing items") + + # Step 3: Partial return - scan 1 item into barcode_files + incoming.append("barcode_files", { + "barcode": "TEST-BC-101", + "matter": self.matter_1, + "case": self.case_1, + "returned": 1 + }) incoming.save(ignore_permissions=True) incoming.submit() @@ -275,6 +281,7 @@ def test_full_incoming_workflow_partial_and_full_returns(self): incoming.reload() self.assertEqual(incoming.status, "Received") self.assertEqual(len(incoming.missing_file_items), 0) + self.assertEqual(len(incoming.barcode_files), 2) def test_fmo_and_fmi_matching_id_number(self): """Test that Outgoing movement creates FMO-##### and Incoming creates matching FMI-#####.""" From 6f47c407a7b0a9434607217e435208e41775bf0f Mon Sep 17 00:00:00 2001 From: Abdullah Syed Date: Tue, 6 Oct 2026 07:02:52 +0000 Subject: [PATCH 07/12] fix(file-movement): prevent form dirty state loop on save in file_movement.js --- .../doctype/file_movement/file_movement.js | 79 ++++++++----------- 1 file changed, 34 insertions(+), 45 deletions(-) diff --git a/casecentral/case_central/doctype/file_movement/file_movement.js b/casecentral/case_central/doctype/file_movement/file_movement.js index e01b357..b9c4f0b 100644 --- a/casecentral/case_central/doctype/file_movement/file_movement.js +++ b/casecentral/case_central/doctype/file_movement/file_movement.js @@ -6,7 +6,7 @@ frappe.ui.form.on("File Movement", { onload: function (frm) { setup_scan_barcode_input(frm); if (frm.doc.movement_type === "Incoming" && frm.doc.original_outgoing_movement) { - fetch_outgoing_items_and_update(frm); + load_outgoing_items(frm); } }, @@ -17,11 +17,6 @@ frappe.ui.form.on("File Movement", { frm.remove_custom_button("Return Missing Files"); frm.remove_custom_button("Scan Barcode"); - // Force Outgoing movement status to "Open" - if (frm.doc.movement_type === "Outgoing" && frm.doc.status !== "Open") { - frm.set_value("status", "Open"); - } - // Direct standalone "Create Incoming" button on submitted Outgoing movement (not inside Actions) if (frm.doc.docstatus === 1 && frm.doc.movement_type === "Outgoing") { add_create_incoming_button(frm); @@ -36,7 +31,7 @@ frappe.ui.form.on("File Movement", { set_form_read_only_states(frm); if (frm.doc.movement_type === "Incoming" && frm.doc.original_outgoing_movement) { - fetch_outgoing_items_and_update(frm); + load_outgoing_items(frm); } }, @@ -49,17 +44,23 @@ frappe.ui.form.on("File Movement", { original_outgoing_movement: function (frm) { if (frm.doc.movement_type === "Incoming") { - fetch_outgoing_items_and_update(frm); + load_outgoing_items(frm, function () { + update_missing_files_table_and_status_client(frm); + }); } }, movement_type: function (frm) { set_form_read_only_states(frm); if (frm.doc.movement_type === "Outgoing") { - frm.set_value("missing_file_items", []); - frm.set_value("status", "Open"); + frm.doc.missing_file_items = []; + frm.doc.status = "Open"; + frm.refresh_field("missing_file_items"); + frm.refresh_field("status"); } else if (frm.doc.movement_type === "Incoming") { - fetch_outgoing_items_and_update(frm); + load_outgoing_items(frm, function () { + update_missing_files_table_and_status_client(frm); + }); } } }); @@ -546,7 +547,7 @@ function handle_outgoing_scan(frm, data, original_value) { function handle_incoming_scan(frm, data, original_value) { if (!frm._outgoing_items && frm.doc.original_outgoing_movement) { - fetch_outgoing_items_and_update(frm, function () { + load_outgoing_items(frm, function () { handle_incoming_scan_process(frm, data, original_value); }); } else { @@ -658,10 +659,9 @@ function handle_incoming_scan_process(frm, data, original_value) { // REAL-TIME MISSING FILES TABLE CALCULATION (CLIENT-SIDE) // ========================================================= -function fetch_outgoing_items_and_update(frm, callback) { +function load_outgoing_items(frm, callback) { if (frm.doc.movement_type !== "Incoming" || !frm.doc.original_outgoing_movement) { frm._outgoing_items = []; - update_missing_files_table_and_status_client(frm); if (callback) callback(); return; } @@ -673,7 +673,6 @@ function fetch_outgoing_items_and_update(frm, callback) { }, callback: function (r) { frm._outgoing_items = r.message || []; - update_missing_files_table_and_status_client(frm); if (callback) callback(); } }); @@ -681,17 +680,13 @@ function fetch_outgoing_items_and_update(frm, callback) { function update_missing_files_table_and_status_client(frm) { if (frm.doc.movement_type !== "Incoming") { - frm.set_value("missing_file_items", []); - if (frm.doc.status !== "Open") { - frm.set_value("status", "Open"); - } return; } var incoming_rows = frm.doc.barcode_files || []; + var missing = []; if (frm.doc.original_outgoing_movement && frm._outgoing_items && frm._outgoing_items.length > 0) { - var missing = []; frm._outgoing_items.forEach(function (out_row) { var is_returned = incoming_rows.some(function (inc_row) { if (!inc_row.returned && inc_row.returned !== 1 && inc_row.returned !== true) { @@ -719,24 +714,7 @@ function update_missing_files_table_and_status_client(frm) { }); } }); - - frm.set_value("missing_file_items", missing); - - var new_status; - if (missing.length === 0) { - new_status = "Received"; - } else if (incoming_rows.length === 0 && frm.doc.docstatus === 0) { - new_status = "Open"; - } else { - new_status = "Missing Files"; - } - - if (frm.doc.status !== new_status) { - frm.set_value("status", new_status); - } } else { - // Fallback if not created from outgoing movement - var missing = []; incoming_rows.forEach(function (row) { if (!row.returned) { missing.push({ @@ -747,18 +725,29 @@ function update_missing_files_table_and_status_client(frm) { }); } }); - frm.set_value("missing_file_items", missing); - var new_status = missing.length > 0 ? "Missing Files" : "Received"; - if (incoming_rows.length === 0 && frm.doc.docstatus === 0) { - new_status = "Open"; - } - if (frm.doc.status !== new_status) { - frm.set_value("status", new_status); - } + } + + // Direct memory update and UI refresh (no frm.set_value loops) + frm.doc.missing_file_items = missing; + frm.refresh_field("missing_file_items"); + + var new_status; + if (missing.length === 0 && incoming_rows.length > 0) { + new_status = "Received"; + } else if (incoming_rows.length === 0 && frm.doc.docstatus === 0) { + new_status = "Open"; + } else { + new_status = "Missing Files"; + } + + if (frm.doc.status !== new_status) { + frm.doc.status = new_status; + frm.refresh_field("status"); } } + // ========================================================= // ACTION: CREATE INCOMING (STANDALONE PRIMARY BUTTON) // ========================================================= From f1e0ad4c6590a76c3585acc7eb9124717cc2caf5 Mon Sep 17 00:00:00 2001 From: Abdullah Syed Date: Tue, 6 Oct 2026 07:18:59 +0000 Subject: [PATCH 08/12] feat(file-movement): enable barcode scanner icon and auto-check in Return Missing Files modal --- .../doctype/file_movement/file_movement.js | 105 ++++++++++++++---- 1 file changed, 82 insertions(+), 23 deletions(-) diff --git a/casecentral/case_central/doctype/file_movement/file_movement.js b/casecentral/case_central/doctype/file_movement/file_movement.js index b9c4f0b..991e544 100644 --- a/casecentral/case_central/doctype/file_movement/file_movement.js +++ b/casecentral/case_central/doctype/file_movement/file_movement.js @@ -815,7 +815,8 @@ function show_return_missing_modal(frm, missing_files) { fieldname: "quick_scan", label: __("Scan Barcode / Matter to Return"), fieldtype: "Data", - description: __("Scan barcode gun or type code to check off returned items") + options: "Barcode", + description: __("Scan using barcode gun, click scanner icon for camera scanner, or enter Matter ID / Case ID") }, { fieldname: "missing_items_section", @@ -834,7 +835,7 @@ function show_return_missing_modal(frm, missing_files) { fieldname: "item_" + idx, label: label_parts.join(" | ") || __("Item {0}", [idx + 1]), fieldtype: "Check", - default: 1 + default: 0 }); }); @@ -852,7 +853,7 @@ function show_return_missing_modal(frm, missing_files) { }); if (!selected_items.length) { - frappe.msgprint(__("Please select at least one file to return.")); + frappe.msgprint(__("Please select or scan at least one file to return.")); return; } @@ -883,28 +884,56 @@ function show_return_missing_modal(frm, missing_files) { dialog.show(); - // Bind quick scanner in modal - setTimeout(function () { - var $input = dialog.fields_dict.quick_scan?.$input; - if ($input) { - $input.focus(); - $input.on("keydown", function (e) { - if (e.which === 13) { - e.preventDefault(); - var code = $(this).val().trim(); - if (!code) return; + function match_and_check(code) { + if (!code) return; + code = code.trim(); - var matched = false; - missing_files.forEach(function (item, idx) { - if (item.barcode === code || item.case === code || item.matter === code) { - dialog.set_value("item_" + idx, 1); - matched = true; - } - }); + var matched_indices = []; + missing_files.forEach(function (item, idx) { + if (item.barcode === code || item.case === code || item.matter === code) { + matched_indices.push(idx); + } + }); + + if (matched_indices.length > 0) { + matched_indices.forEach(function (idx) { + dialog.set_value("item_" + idx, 1); + }); + frappe.show_alert({ + message: __("Checked {0} missing item(s) for: {1}", [matched_indices.length, code]), + indicator: "green" + }); + dialog.set_value("quick_scan", ""); + setTimeout(function () { + dialog.fields_dict.quick_scan?.$input?.focus(); + }, 100); + } else { + // Check via get_scan_information API in case barcode maps to a case/matter + frappe.call({ + method: "casecentral.case_central.doctype.file_movement.file_movement.get_scan_information", + args: { value: code }, + callback: function (r) { + var data = r.message; + var api_matched = []; + + if (data && data.type !== "not_found") { + missing_files.forEach(function (item, idx) { + if (data.type === "matter" && item.matter === data.matter) { + api_matched.push(idx); + } else if (data.type === "case" && item.case === data.case_name) { + api_matched.push(idx); + } else if (data.barcode && item.barcode === data.barcode) { + api_matched.push(idx); + } + }); + } - if (matched) { + if (api_matched.length > 0) { + api_matched.forEach(function (idx) { + dialog.set_value("item_" + idx, 1); + }); frappe.show_alert({ - message: __("Checked item: {0}", [code]), + message: __("Checked {0} missing item(s) for: {1}", [api_matched.length, code]), indicator: "green" }); } else { @@ -914,7 +943,37 @@ function show_return_missing_modal(frm, missing_files) { }); } dialog.set_value("quick_scan", ""); - setTimeout(function () { $input.focus(); }, 50); + setTimeout(function () { + dialog.fields_dict.quick_scan?.$input?.focus(); + }, 100); + } + }); + } + } + + // Bind quick scanner in modal + setTimeout(function () { + var field = dialog.fields_dict.quick_scan; + var $input = field?.$input; + if ($input) { + $input.focus(); + + $input.off("keydown.return_scan").on("keydown.return_scan", function (e) { + if (e.which === 13) { + e.preventDefault(); + var code = $(this).val().trim(); + if (code) { + $(this).val(""); + match_and_check(code); + } + } + }); + + $input.off("change.return_scan").on("change.return_scan", function () { + var code = $(this).val().trim(); + if (code) { + $(this).val(""); + match_and_check(code); } }); } From 9f5bd99ed81ebb74c89530d1736b781d9099a541 Mon Sep 17 00:00:00 2001 From: Abdullah Syed Date: Tue, 6 Oct 2026 07:27:35 +0000 Subject: [PATCH 09/12] feat(file-movement): remove checkboxes from return missing files modal and require barcode or ID scanning --- .../doctype/file_movement/file_movement.js | 167 +++++++++++------- 1 file changed, 105 insertions(+), 62 deletions(-) diff --git a/casecentral/case_central/doctype/file_movement/file_movement.js b/casecentral/case_central/doctype/file_movement/file_movement.js index 991e544..1b0e10f 100644 --- a/casecentral/case_central/doctype/file_movement/file_movement.js +++ b/casecentral/case_central/doctype/file_movement/file_movement.js @@ -810,50 +810,42 @@ function add_return_missing_button(frm) { } function show_return_missing_modal(frm, missing_files) { - var fields = [ - { - fieldname: "quick_scan", - label: __("Scan Barcode / Matter to Return"), - fieldtype: "Data", - options: "Barcode", - description: __("Scan using barcode gun, click scanner icon for camera scanner, or enter Matter ID / Case ID") - }, - { - fieldname: "missing_items_section", - fieldtype: "Section Break", - label: __("Select Missing Items to Return ({0})", [missing_files.length]) - } - ]; - - missing_files.forEach(function (item, idx) { - var label_parts = []; - if (item.matter) label_parts.push(__("Matter: {0}", [item.matter])); - if (item.case) label_parts.push(__("Case: {0}", [item.case])); - if (item.barcode) label_parts.push(__("Barcode: {0}", [item.barcode])); - - fields.push({ - fieldname: "item_" + idx, - label: label_parts.join(" | ") || __("Item {0}", [idx + 1]), - fieldtype: "Check", - default: 0 - }); + var items_state = missing_files.map(function (item) { + return { + barcode: item.barcode || "", + matter: item.matter || "", + case: item.case || "", + scanned: false + }; }); var dialog = new frappe.ui.Dialog({ title: __("Return Missing Files"), - fields: fields, - primary_action_label: __("Return Selected Files"), - primary_action: function (values) { - var selected_items = []; - - missing_files.forEach(function (item, idx) { - if (values["item_" + idx]) { - selected_items.push(item); - } + fields: [ + { + fieldname: "quick_scan", + label: __("Scan Barcode / Matter ID / Case to Return"), + fieldtype: "Data", + options: "Barcode", + description: __("Scan barcode gun, click scanner icon for camera scanner, or enter Matter ID / Case ID") + }, + { + fieldname: "missing_table_area", + fieldtype: "HTML" + } + ], + primary_action_label: __("Return Scanned Files"), + primary_action: function () { + var selected_items = items_state.filter(function (item) { + return item.scanned; }); if (!selected_items.length) { - frappe.msgprint(__("Please select or scan at least one file to return.")); + frappe.msgprint({ + title: __("No Files Scanned"), + indicator: "orange", + message: __("Please scan or enter at least one missing Matter ID, Case, or Barcode before submitting return.") + }); return; } @@ -864,14 +856,14 @@ function show_return_missing_modal(frm, missing_files) { files: JSON.stringify(selected_items) }, freeze: true, - freeze_message: __("Returning files..."), + freeze_message: __("Returning scanned files..."), callback: function (response) { if (response.message) { var res = response.message; frappe.show_alert({ message: res.status === "Received" ? __("All missing files returned successfully!") - : __("Files returned. Some files remain missing."), + : __("{0} file(s) returned. Some files remain missing.", [selected_items.length]), indicator: res.status === "Received" ? "green" : "orange" }); dialog.hide(); @@ -882,25 +874,30 @@ function show_return_missing_modal(frm, missing_files) { } }); + function update_table_view() { + var html = render_missing_table_html(items_state); + dialog.fields_dict.missing_table_area.$wrapper.html(html); + } + dialog.show(); + update_table_view(); - function match_and_check(code) { + function match_and_mark_scanned(code) { if (!code) return; code = code.trim(); - var matched_indices = []; - missing_files.forEach(function (item, idx) { + var matched_count = 0; + items_state.forEach(function (item) { if (item.barcode === code || item.case === code || item.matter === code) { - matched_indices.push(idx); + item.scanned = true; + matched_count++; } }); - if (matched_indices.length > 0) { - matched_indices.forEach(function (idx) { - dialog.set_value("item_" + idx, 1); - }); + if (matched_count > 0) { + update_table_view(); frappe.show_alert({ - message: __("Checked {0} missing item(s) for: {1}", [matched_indices.length, code]), + message: __("Scanned & verified: {0} ({1} item(s)).", [code, matched_count]), indicator: "green" }); dialog.set_value("quick_scan", ""); @@ -914,31 +911,32 @@ function show_return_missing_modal(frm, missing_files) { args: { value: code }, callback: function (r) { var data = r.message; - var api_matched = []; + var api_matched = 0; if (data && data.type !== "not_found") { - missing_files.forEach(function (item, idx) { + items_state.forEach(function (item) { if (data.type === "matter" && item.matter === data.matter) { - api_matched.push(idx); + item.scanned = true; + api_matched++; } else if (data.type === "case" && item.case === data.case_name) { - api_matched.push(idx); + item.scanned = true; + api_matched++; } else if (data.barcode && item.barcode === data.barcode) { - api_matched.push(idx); + item.scanned = true; + api_matched++; } }); } - if (api_matched.length > 0) { - api_matched.forEach(function (idx) { - dialog.set_value("item_" + idx, 1); - }); + if (api_matched > 0) { + update_table_view(); frappe.show_alert({ - message: __("Checked {0} missing item(s) for: {1}", [api_matched.length, code]), + message: __("Scanned & verified: {0} ({1} item(s)).", [code, api_matched]), indicator: "green" }); } else { frappe.show_alert({ - message: __("Code {0} is not in the missing list.", [code]), + message: __("Code {0} is not in the missing files list.", [code]), indicator: "orange" }); } @@ -951,7 +949,7 @@ function show_return_missing_modal(frm, missing_files) { } } - // Bind quick scanner in modal + // Bind scanner input handlers setTimeout(function () { var field = dialog.fields_dict.quick_scan; var $input = field?.$input; @@ -964,7 +962,7 @@ function show_return_missing_modal(frm, missing_files) { var code = $(this).val().trim(); if (code) { $(this).val(""); - match_and_check(code); + match_and_mark_scanned(code); } } }); @@ -973,9 +971,54 @@ function show_return_missing_modal(frm, missing_files) { var code = $(this).val().trim(); if (code) { $(this).val(""); - match_and_check(code); + match_and_mark_scanned(code); } }); } }, 200); +} + +function render_missing_table_html(items) { + var scanned_count = items.filter(function (it) { return it.scanned; }).length; + var total_count = items.length; + + var rows_html = items.map(function (item, idx) { + var row_bg = item.scanned ? "background-color: #f0fdf4;" : ""; + var badge_html = item.scanned + ? '' + __("Scanned to Return") + '' + : '' + __("Pending Scan") + ''; + + return '' + + '' + (idx + 1) + '' + + '' + (item.matter || "-") + '' + + '' + (item.case || "-") + '' + + '' + (item.barcode || "-") + '' + + '' + badge_html + '' + + ''; + }).join(""); + + return '
' + + '
' + + '' + __("Missing Files List ({0})", [total_count]) + '' + + '' + + __("{0} of {1} Scanned", [scanned_count, total_count]) + + '' + + '
' + + '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + rows_html + + '' + + '
#' + __("Matter") + '' + __("Case") + '' + __("Barcode") + '' + __("Status") + '
' + + '
' + + '
'; } \ No newline at end of file From b41d6f36a9a332e2c8489c231af1dba1e6055903 Mon Sep 17 00:00:00 2001 From: Abdullah Syed Date: Mon, 12 Oct 2026 10:15:09 +0000 Subject: [PATCH 10/12] feat(file-movement): unified single document lifecycle with outgoing and incoming files, scan inputs, and giver-receiver exclusion --- .../barcode_file_item/barcode_file_item.json | 14 +- .../doctype/file_movement/file_movement.js | 817 +++++------------- .../doctype/file_movement/file_movement.json | 94 +- .../doctype/file_movement/file_movement.py | 597 +++++++------ .../file_movement/file_movement_list.js | 2 +- .../file_movement/test_file_movement.py | 165 ++-- .../doctype/file_movement_items/__init__.py | 0 .../file_movement_items.js | 11 - .../file_movement_items.json | 56 -- .../file_movement_items.py | 5 - .../missing_file_items.json | 19 +- 11 files changed, 718 insertions(+), 1062 deletions(-) delete mode 100644 casecentral/case_central/doctype/file_movement_items/__init__.py delete mode 100644 casecentral/case_central/doctype/file_movement_items/file_movement_items.js delete mode 100644 casecentral/case_central/doctype/file_movement_items/file_movement_items.json delete mode 100644 casecentral/case_central/doctype/file_movement_items/file_movement_items.py diff --git a/casecentral/case_central/doctype/barcode_file_item/barcode_file_item.json b/casecentral/case_central/doctype/barcode_file_item/barcode_file_item.json index e8ee7ee..7649cb8 100644 --- a/casecentral/case_central/doctype/barcode_file_item/barcode_file_item.json +++ b/casecentral/case_central/doctype/barcode_file_item/barcode_file_item.json @@ -14,7 +14,7 @@ ], "fields": [ { - "columns": 3, + "columns": 4, "fieldname": "matter", "fieldtype": "Link", "in_list_view": 1, @@ -22,7 +22,7 @@ "options": "Matter" }, { - "columns": 3, + "columns": 4, "fieldname": "case", "fieldtype": "Link", "in_list_view": 1, @@ -30,26 +30,26 @@ "options": "Case" }, { - "columns": 3, + "columns": 4, "fieldname": "barcode", "fieldtype": "Data", "in_list_view": 1, "label": "Barcode" }, { - "columns": 1, "default": "0", "fieldname": "returned", "fieldtype": "Check", - "in_list_view": 1, - "label": "Returned" + "hidden": 1, + "label": "Returned", + "read_only": 1 } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2026-09-15 14:00:00.000000", + "modified": "2026-10-06 14:00:00.000000", "modified_by": "Administrator", "module": "Case Central", "name": "Barcode File Item", diff --git a/casecentral/case_central/doctype/file_movement/file_movement.js b/casecentral/case_central/doctype/file_movement/file_movement.js index 1b0e10f..bcfb876 100644 --- a/casecentral/case_central/doctype/file_movement/file_movement.js +++ b/casecentral/case_central/doctype/file_movement/file_movement.js @@ -4,93 +4,108 @@ frappe.ui.form.on("File Movement", { onload: function (frm) { - setup_scan_barcode_input(frm); - if (frm.doc.movement_type === "Incoming" && frm.doc.original_outgoing_movement) { - load_outgoing_items(frm); - } + setup_scan_outgoing_barcode_input(frm); + setup_scan_incoming_barcode_input(frm); + setup_giver_receiver_filter(frm); }, refresh: function (frm) { - setup_scan_barcode_input(frm); + setup_scan_outgoing_barcode_input(frm); + setup_scan_incoming_barcode_input(frm); + setup_giver_receiver_filter(frm); frm.remove_custom_button("Create Incoming"); frm.remove_custom_button("Return Missing Files"); - frm.remove_custom_button("Scan Barcode"); - // Direct standalone "Create Incoming" button on submitted Outgoing movement (not inside Actions) - if (frm.doc.docstatus === 1 && frm.doc.movement_type === "Outgoing") { + // Single Document Lifecycle: + // 1. When Outgoing is submitted and Incoming has NOT yet been activated + if (frm.doc.docstatus === 1 && !frm.doc.has_incoming && frm.doc.status !== "Received") { add_create_incoming_button(frm); } - // Direct standalone "Return Missing Files" button on submitted Incoming movement with Missing Files (not inside Actions) - if (frm.doc.docstatus === 1 && frm.doc.movement_type === "Incoming" && frm.doc.status === "Missing Files") { + // 2. When Incoming is active and some files are missing + if (frm.doc.docstatus === 1 && frm.doc.has_incoming && frm.doc.status === "Missing Files") { add_return_missing_button(frm); } - - // Set field read-only states depending on movement type - set_form_read_only_states(frm); - - if (frm.doc.movement_type === "Incoming" && frm.doc.original_outgoing_movement) { - load_outgoing_items(frm); - } }, - scan_barcode: function (frm) { - var val = (frm.doc.scan_barcode || "").trim(); - if (!val) return; - frm.set_value("scan_barcode", ""); - process_scan(frm, val); - }, - - original_outgoing_movement: function (frm) { - if (frm.doc.movement_type === "Incoming") { - load_outgoing_items(frm, function () { - update_missing_files_table_and_status_client(frm); + giver: function (frm) { + if (frm.doc.giver && frm.doc.receiver && frm.doc.giver === frm.doc.receiver) { + frappe.msgprint({ + title: __("Invalid Selection"), + indicator: "red", + message: __("Giver and Receiver cannot be the same employee. Please choose a different Receiver.") }); + frm.set_value("receiver", ""); } }, - movement_type: function (frm) { - set_form_read_only_states(frm); - if (frm.doc.movement_type === "Outgoing") { - frm.doc.missing_file_items = []; - frm.doc.status = "Open"; - frm.refresh_field("missing_file_items"); - frm.refresh_field("status"); - } else if (frm.doc.movement_type === "Incoming") { - load_outgoing_items(frm, function () { - update_missing_files_table_and_status_client(frm); + receiver: function (frm) { + if (frm.doc.giver && frm.doc.receiver && frm.doc.giver === frm.doc.receiver) { + frappe.msgprint({ + title: __("Invalid Selection"), + indicator: "red", + message: __("Giver and Receiver cannot be the same employee. Please choose a different Receiver.") }); + frm.set_value("receiver", ""); } + }, + + scan_outgoing_barcode: function (frm) { + var val = (frm.doc.scan_outgoing_barcode || "").trim(); + if (!val) return; + frm.set_value("scan_outgoing_barcode", ""); + process_outgoing_scan(frm, val); + }, + + scan_incoming_barcode: function (frm) { + var val = (frm.doc.scan_incoming_barcode || "").trim(); + if (!val) return; + frm.set_value("scan_incoming_barcode", ""); + process_incoming_scan(frm, val); } }); // ========================================================= -// CHILD TABLE: BARCODE FILE ITEM (MATTER FILES) +// GIVER & RECEIVER FILTER (EXCLUDE SAME EMPLOYEE) // ========================================================= -frappe.ui.form.on("Barcode File Item", { - barcode_files_add: function (frm, cdt, cdn) { - if (frm.doc.movement_type === "Incoming") { - frappe.model.set_value(cdt, cdn, "returned", 1); - update_missing_files_table_and_status_client(frm); +function setup_giver_receiver_filter(frm) { + frm.set_query("receiver", function () { + var filters = []; + if (frm.doc.giver) { + filters.push(["Employee", "name", "!=", frm.doc.giver]); } - }, + return { + filters: filters + }; + }); - barcode_files_remove: function (frm) { - if (frm.doc.movement_type === "Incoming") { - update_missing_files_table_and_status_client(frm); + frm.set_query("giver", function () { + var filters = []; + if (frm.doc.receiver) { + filters.push(["Employee", "name", "!=", frm.doc.receiver]); } - }, + return { + filters: filters + }; + }); +} + + +// ========================================================= +// CHILD TABLE: OUTGOING FILES +// ========================================================= + +frappe.ui.form.on("Barcode File Item", { matter: function (frm, cdt, cdn) { var row = frappe.get_doc(cdt, cdn); if (!row.matter) return; - if (frm.doc.movement_type === "Incoming") { - frappe.model.set_value(cdt, cdn, "returned", 1); - } + // Only auto-expand in outgoing_files when in draft + if (row.parentfield !== "outgoing_files") return; frappe.call({ method: "casecentral.case_central.doctype.file_movement.file_movement.get_cases_for_matter", @@ -100,7 +115,6 @@ frappe.ui.form.on("Barcode File Item", { callback: function (response) { var cases = response.message || []; if (!cases.length) { - // Check if matter itself exists frappe.db.get_value("Matter", row.matter, "name").then(res => { if (!res || !res.message || !res.message.name) { frappe.msgprint({ @@ -125,33 +139,28 @@ frappe.ui.form.on("Barcode File Item", { frappe.model.set_value(cdt, cdn, "case", cases[0].name); frappe.model.set_value(cdt, cdn, "barcode", cases[0].barcode || ""); - // If multiple cases exist for this matter, add additional rows for the remaining cases + // If multiple cases exist, add additional rows if (cases.length > 1) { for (var i = 1; i < cases.length; i++) { var c = cases[i]; - var exists = (frm.doc.barcode_files || []).some(function (item) { + var exists = (frm.doc.outgoing_files || []).some(function (item) { return item.matter === (c.matter || row.matter) && item.case === c.name; }); if (!exists) { - var new_row = frm.add_child("barcode_files"); + var new_row = frm.add_child("outgoing_files"); new_row.matter = c.matter || row.matter; new_row.case = c.name || ""; new_row.barcode = c.barcode || ""; - new_row.returned = frm.doc.movement_type === "Incoming" ? 1 : 0; } } - frm.refresh_field("barcode_files"); + frm.refresh_field("outgoing_files"); } frappe.show_alert({ message: __("{0} Case(s) added for Matter {1}.", [cases.length, row.matter]), indicator: "green" }); - - if (frm.doc.movement_type === "Incoming") { - update_missing_files_table_and_status_client(frm); - } }, error: function () { frappe.model.set_value(cdt, cdn, "matter", ""); @@ -165,18 +174,13 @@ frappe.ui.form.on("Barcode File Item", { var row = frappe.get_doc(cdt, cdn); if (!row.case) return; - if (frm.doc.movement_type === "Incoming") { - frappe.model.set_value(cdt, cdn, "returned", 1); - } + if (row.parentfield !== "outgoing_files") return; frappe.db.get_value("Case", row.case, ["matter", "name"]).then(r => { if (r && r.message) { if (r.message.matter && !row.matter) { frappe.model.set_value(cdt, cdn, "matter", r.message.matter); } - if (frm.doc.movement_type === "Incoming") { - update_missing_files_table_and_status_client(frm); - } } else { frappe.msgprint({ title: __("Case Not Found"), @@ -186,591 +190,235 @@ frappe.ui.form.on("Barcode File Item", { frappe.model.set_value(cdt, cdn, "case", ""); } }); - }, - - returned: function (frm) { - if (frm.doc.movement_type === "Incoming") { - update_missing_files_table_and_status_client(frm); - } } }); // ========================================================= -// FORM FIELD READ-ONLY STATES -// ========================================================= - -function set_form_read_only_states(frm) { - var is_incoming = frm.doc.movement_type === "Incoming"; - frm.set_df_property("original_outgoing_movement", "read_only", is_incoming && frm.doc.docstatus === 1 ? 1 : 0); - frm.refresh_fields(); -} - - -// ========================================================= -// SCAN BARCODE INPUT FIELD SETUP +// SCAN OUTGOING BARCODE (DRAFT OUTGOING SECTION) // ========================================================= -function setup_scan_barcode_input(frm) { - if (!frm.fields_dict.scan_barcode) return; +function setup_scan_outgoing_barcode_input(frm) { + if (!frm.fields_dict.scan_outgoing_barcode) return; - var field = frm.fields_dict.scan_barcode; + var field = frm.fields_dict.scan_outgoing_barcode; if (field.$input) { - field.$input.attr("placeholder", __("Scan Barcode / Matter ID / Case...")); - field.$input.off("keydown.fm_scan").on("keydown.fm_scan", function (e) { + field.$input.attr("placeholder", __("Scan Barcode / Matter ID / Case to Add to Outgoing Files...")); + field.$input.off("keydown.fm_out_scan").on("keydown.fm_out_scan", function (e) { if (e.which === 13) { e.preventDefault(); var val = $(this).val().trim(); if (val) { $(this).val(""); - frm.set_value("scan_barcode", ""); - process_scan(frm, val); - } - } - }); - } -} - - -// ========================================================= -// SCAN BARCODE DIALOG (SCANNER GUN & CAMERA) -// ========================================================= - -function open_scan_dialog(frm) { - var scanner_id = "scanner-reader-" + frappe.dom.set_unique_id(); - var html5_scanner = null; - - var dialog = new frappe.ui.Dialog({ - title: __("Scan Barcode / Matter ID"), - fields: [ - { - fieldname: "barcode", - label: __("Barcode / Matter ID / Case"), - fieldtype: "Data", - description: __("Scan using barcode gun, enter Matter ID (e.g. MID-26-001), or type barcode and press Enter") - }, - { - fieldname: "camera_area", - fieldtype: "HTML" - } - ], - primary_action_label: __("Add / Process"), - primary_action: function () { - var value = (dialog.get_value("barcode") || "").trim(); - if (!value) { - frappe.msgprint(__("Please scan or enter a value.")); - return; - } - process_scan(frm, value, dialog); - } - }); - - dialog.show(); - - // Auto focus barcode input field - setTimeout(function () { - var $input = dialog.fields_dict.barcode.$input; - if ($input) { - $input.focus(); - $input.on("keydown", function (e) { - if (e.which === 13) { // Enter key from scanner gun - e.preventDefault(); - var val = $(this).val().trim(); - if (val) { - process_scan(frm, val, dialog); - } + frm.set_value("scan_outgoing_barcode", ""); + process_outgoing_scan(frm, val); } - }); - } - }, 200); - - // Camera scanner area - var html = - '
' + - '' + - '' + - '' + - '
' + - '
'; - - dialog.fields_dict.camera_area.$wrapper.html(html); - - var wrapper = dialog.fields_dict.camera_area.$wrapper; - var start_button = wrapper.find(".start-camera"); - var stop_button = wrapper.find(".stop-camera"); - var camera_message = wrapper.find(".camera-message"); - var scanner_container = wrapper.find("#" + scanner_id); - - function stop_camera() { - if (html5_scanner) { - try { - html5_scanner.stop().then(function () { - try { html5_scanner.clear(); } catch (e) {} - html5_scanner = null; - }).catch(function () { - html5_scanner = null; - }); - } catch (e) { - html5_scanner = null; } - } - scanner_container.hide(); - start_button.show(); - stop_button.hide(); - camera_message.html(""); - } - - start_button.on("click", function () { - camera_message.html("" + __("Starting camera...") + ""); - - load_html5_qrcode().then(function () { - scanner_container.show(); - start_button.hide(); - stop_button.show(); - camera_message.html("" + __("Point camera at barcode or QR code...") + ""); - - html5_scanner = new Html5Qrcode(scanner_id); - var config = { - fps: 15, - qrbox: function(viewfinderWidth, viewfinderHeight) { - var minEdgePercentage = 0.85; - var minEdgeSize = Math.min(viewfinderWidth, viewfinderHeight); - var qrboxSize = Math.floor(minEdgeSize * minEdgePercentage); - return { - width: qrboxSize, - height: Math.max(120, Math.floor(qrboxSize * 0.5)) - }; - } - }; - - html5_scanner.start( - { facingMode: "environment" }, - config, - function onScanSuccess(decodedText) { - if (decodedText) { - stop_camera(); - process_scan(frm, decodedText, dialog); - } - }, - function onScanFailure() { - // Frame scan attempt without match - continue scanning - } - ).catch(function (err) { - camera_message.html("" + __("Camera error: {0}", [err.message || err]) + ""); - stop_camera(); - }); - }).catch(function (err) { - camera_message.html("" + __("Could not load camera library: {0}", [err.message || err]) + ""); }); - }); - - stop_button.on("click", function () { - stop_camera(); - }); - - dialog.on_page_show = function () { - dialog.fields_dict.barcode?.$input?.focus(); - }; - - dialog.$wrapper.on("hidden.bs.modal", function () { - stop_camera(); - }); -} - -function load_html5_qrcode() { - if (window.Html5Qrcode) { - return Promise.resolve(); } - return new Promise(function (resolve, reject) { - frappe.require("/assets/frappe/node_modules/html5-qrcode/html5-qrcode.min.js", function () { - if (window.Html5Qrcode) { - resolve(); - } else { - frappe.require("https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js", function () { - if (window.Html5Qrcode) { - resolve(); - } else { - reject(new Error("Failed to load Html5Qrcode library")); - } - }); - } - }); - }); } - -// ========================================================= -// PROCESS SCANNED VALUE -// ========================================================= - -function process_scan(frm, value, dialog) { - if (!value) return; - value = value.trim(); +function process_outgoing_scan(frm, code) { + if (!code) return; frappe.call({ method: "casecentral.case_central.doctype.file_movement.file_movement.get_scan_information", - args: { value: value }, + args: { value: code }, freeze: true, - freeze_message: __("Validating scanned code..."), + freeze_message: __("Resolving scan '{0}'...", [code]), callback: function (r) { var data = r.message; - - // STRICT REJECTION IF VALUE DOES NOT MATCH ANYTHING if (!data || data.type === "not_found") { frappe.msgprint({ title: __("Not Found"), indicator: "red", - message: __("Scanned value '{0}' does not match any existing Matter ID, Case, or Barcode.

The table was not populated.", [value]) + message: (data && data.message) || __("Scanned value '{0}' does not exist in Case Central.", [code]) }); - - if (dialog) { - dialog.set_value("barcode", ""); - dialog.fields_dict.barcode?.$input?.focus(); - } return; } - // OUTGOING MOVEMENT SCAN HANDLING - if (frm.doc.movement_type === "Outgoing") { - handle_outgoing_scan(frm, data, value); - } - // INCOMING MOVEMENT SCAN HANDLING - else if (frm.doc.movement_type === "Incoming") { - handle_incoming_scan(frm, data, value); - } + if (data.type === "matter") { + var added_count = 0; + var cases = data.cases || []; - if (dialog) { - dialog.set_value("barcode", ""); - dialog.fields_dict.barcode?.$input?.focus(); - } - } - }); -} + if (cases.length === 0) { + var exists = (frm.doc.outgoing_files || []).some(function (item) { + return item.matter === data.matter && !item.case; + }); + if (!exists) { + var row = frm.add_child("outgoing_files"); + row.matter = data.matter; + added_count++; + } + } else { + cases.forEach(function (c) { + var exists = (frm.doc.outgoing_files || []).some(function (item) { + return item.matter === (c.matter || data.matter) && item.case === c.name; + }); + if (!exists) { + var row = frm.add_child("outgoing_files"); + row.matter = c.matter || data.matter; + row.case = c.name || ""; + row.barcode = c.barcode || ""; + added_count++; + } + }); + } -function handle_outgoing_scan(frm, data, original_value) { - var added_count = 0; + frm.refresh_field("outgoing_files"); + if (added_count > 0) { + frappe.show_alert({ + message: __("Added Matter {0} ({1} Case(s)).", [data.matter, added_count]), + indicator: "green" + }); + } else { + frappe.show_alert({ + message: __("Matter {0} is already in Outgoing Files.", [data.matter]), + indicator: "orange" + }); + } - if (data.type === "matter") { - var cases = data.cases || []; - if (cases.length > 0) { - cases.forEach(function (c) { - var exists = (frm.doc.barcode_files || []).some(function (item) { - return item.matter === (c.matter || data.matter) && item.case === c.name; + } else if (data.type === "case") { + var exists = (frm.doc.outgoing_files || []).some(function (item) { + return item.case === data.case_name; }); - - if (!exists) { - var row = frm.add_child("barcode_files"); - row.matter = c.matter || data.matter; - row.case = c.name || ""; - row.barcode = c.barcode || ""; - row.returned = 0; - added_count++; + if (exists) { + frappe.show_alert({ + message: __("Case {0} is already in Outgoing Files.", [data.case_name]), + indicator: "orange" + }); + return; } - }); - } else { - var exists = (frm.doc.barcode_files || []).some(function (item) { - return item.matter === data.matter; - }); - if (!exists) { - var row = frm.add_child("barcode_files"); - row.matter = data.matter; - row.case = ""; - row.barcode = ""; - row.returned = 0; - added_count++; - } - } - frm.refresh_field("barcode_files"); + var row = frm.add_child("outgoing_files"); + row.matter = data.matter || ""; + row.case = data.case_name; + row.barcode = data.barcode || ""; + frm.refresh_field("outgoing_files"); - if (added_count > 0) { - frappe.show_alert({ - message: __("Added Matter {0} ({1} Case(s)).", [data.matter, added_count]), - indicator: "green" - }); - } else { - frappe.show_alert({ - message: __("Matter {0} is already in the table.", [data.matter]), - indicator: "orange" - }); - } - } else if (data.type === "case") { - var exists = (frm.doc.barcode_files || []).some(function (item) { - return item.case === data.case_name; - }); - - if (!exists) { - var row = frm.add_child("barcode_files"); - row.matter = data.matter || ""; - row.case = data.case_name || ""; - row.barcode = data.barcode || original_value; - row.returned = 0; - frm.refresh_field("barcode_files"); + frappe.show_alert({ + message: __("Added Case {0} to Outgoing Files.", [data.case_name]), + indicator: "green" + }); - frappe.show_alert({ - message: __("Added Case {0} (Matter: {1}).", [data.case_name, data.matter || "N/A"]), - indicator: "green" - }); - } else { - frappe.show_alert({ - message: __("Case {0} is already in the table.", [data.case_name]), - indicator: "orange" - }); - } - } else if (data.type === "case_file") { - var exists = (frm.doc.barcode_files || []).some(function (item) { - return item.barcode === data.barcode; - }); + } else if (data.type === "case_file") { + var exists = (frm.doc.outgoing_files || []).some(function (item) { + return item.barcode === data.barcode; + }); + if (exists) { + frappe.show_alert({ + message: __("Barcode {0} is already in Outgoing Files.", [data.barcode]), + indicator: "orange" + }); + return; + } - if (!exists) { - var row = frm.add_child("barcode_files"); - row.matter = data.matter || ""; - row.case = data.case || ""; - row.barcode = data.barcode || original_value; - row.returned = 0; - frm.refresh_field("barcode_files"); + var row = frm.add_child("outgoing_files"); + row.matter = data.matter || ""; + row.case = data.case || ""; + row.barcode = data.barcode; + frm.refresh_field("outgoing_files"); - frappe.show_alert({ - message: __("Added Barcode {0}.", [data.barcode]), - indicator: "green" - }); - } else { - frappe.show_alert({ - message: __("Barcode {0} is already in the table.", [data.barcode]), - indicator: "orange" - }); + frappe.show_alert({ + message: __("Added Barcode {0} to Outgoing Files.", [data.barcode]), + indicator: "green" + }); + } } - } + }); } -function handle_incoming_scan(frm, data, original_value) { - if (!frm._outgoing_items && frm.doc.original_outgoing_movement) { - load_outgoing_items(frm, function () { - handle_incoming_scan_process(frm, data, original_value); - }); - } else { - handle_incoming_scan_process(frm, data, original_value); - } -} -function handle_incoming_scan_process(frm, data, original_value) { - var added_count = 0; +// ========================================================= +// SCAN INCOMING BARCODE (SUBMITTED INCOMING SECTION) +// ========================================================= - if (data.type === "matter") { - var cases = data.cases || []; - if (cases.length > 0) { - cases.forEach(function (c) { - var exists = (frm.doc.barcode_files || []).some(function (item) { - return item.matter === (c.matter || data.matter) && item.case === c.name; - }); +function setup_scan_incoming_barcode_input(frm) { + if (!frm.fields_dict.scan_incoming_barcode) return; - if (!exists) { - var row = frm.add_child("barcode_files"); - row.matter = c.matter || data.matter; - row.case = c.name || ""; - row.barcode = c.barcode || ""; - row.returned = 1; - added_count++; + var field = frm.fields_dict.scan_incoming_barcode; + if (field.$input) { + field.$input.attr("placeholder", __("Scan Barcode / Matter ID / Case to Receive into Incoming Files...")); + field.$input.off("keydown.fm_inc_scan").on("keydown.fm_inc_scan", function (e) { + if (e.which === 13) { + e.preventDefault(); + var val = $(this).val().trim(); + if (val) { + $(this).val(""); + frm.set_value("scan_incoming_barcode", ""); + process_incoming_scan(frm, val); } - }); - } else { - var exists = (frm.doc.barcode_files || []).some(function (item) { - return item.matter === data.matter; - }); - if (!exists) { - var row = frm.add_child("barcode_files"); - row.matter = data.matter; - row.case = ""; - row.barcode = ""; - row.returned = 1; - added_count++; } - } - - frm.refresh_field("barcode_files"); - update_missing_files_table_and_status_client(frm); - - if (added_count > 0) { - frappe.show_alert({ - message: __("Scanned Matter {0} ({1} Case(s) returned).", [data.matter, added_count]), - indicator: "green" - }); - } else { - frappe.show_alert({ - message: __("Matter {0} is already scanned.", [data.matter]), - indicator: "orange" - }); - } - } else if (data.type === "case") { - var exists = (frm.doc.barcode_files || []).some(function (item) { - return item.case === data.case_name; - }); - - if (!exists) { - var row = frm.add_child("barcode_files"); - row.matter = data.matter || ""; - row.case = data.case_name || ""; - row.barcode = data.barcode || original_value; - row.returned = 1; - frm.refresh_field("barcode_files"); - update_missing_files_table_and_status_client(frm); - - frappe.show_alert({ - message: __("Scanned & returned Case {0}.", [data.case_name]), - indicator: "green" - }); - } else { - frappe.show_alert({ - message: __("Case {0} is already scanned.", [data.case_name]), - indicator: "orange" - }); - } - } else if (data.type === "case_file") { - var exists = (frm.doc.barcode_files || []).some(function (item) { - return item.barcode === data.barcode; }); - - if (!exists) { - var row = frm.add_child("barcode_files"); - row.matter = data.matter || ""; - row.case = data.case || ""; - row.barcode = data.barcode || original_value; - row.returned = 1; - frm.refresh_field("barcode_files"); - update_missing_files_table_and_status_client(frm); - - frappe.show_alert({ - message: __("Scanned & returned Barcode {0}.", [data.barcode]), - indicator: "green" - }); - } else { - frappe.show_alert({ - message: __("Barcode {0} is already scanned.", [data.barcode]), - indicator: "orange" - }); - } } } - -// ========================================================= -// REAL-TIME MISSING FILES TABLE CALCULATION (CLIENT-SIDE) -// ========================================================= - -function load_outgoing_items(frm, callback) { - if (frm.doc.movement_type !== "Incoming" || !frm.doc.original_outgoing_movement) { - frm._outgoing_items = []; - if (callback) callback(); - return; - } +function process_incoming_scan(frm, code) { + if (!code) return; frappe.call({ - method: "casecentral.case_central.doctype.file_movement.file_movement.get_outgoing_movement_items", + method: "casecentral.case_central.doctype.file_movement.file_movement.scan_incoming_file", args: { - outgoing_name: frm.doc.original_outgoing_movement + movement_name: frm.doc.name, + value: code }, + freeze: true, + freeze_message: __("Recording incoming scan '{0}'...", [code]), callback: function (r) { - frm._outgoing_items = r.message || []; - if (callback) callback(); - } - }); -} - -function update_missing_files_table_and_status_client(frm) { - if (frm.doc.movement_type !== "Incoming") { - return; - } - - var incoming_rows = frm.doc.barcode_files || []; - var missing = []; - - if (frm.doc.original_outgoing_movement && frm._outgoing_items && frm._outgoing_items.length > 0) { - frm._outgoing_items.forEach(function (out_row) { - var is_returned = incoming_rows.some(function (inc_row) { - if (!inc_row.returned && inc_row.returned !== 1 && inc_row.returned !== true) { - return false; - } - if (out_row.case && inc_row.case && out_row.case === inc_row.case) { - return true; - } - if (out_row.barcode && inc_row.barcode && out_row.barcode === inc_row.barcode) { - return true; - } - if (out_row.matter && inc_row.matter && out_row.matter === inc_row.matter) { - if (!out_row.case && !inc_row.case) return true; - if (out_row.case && inc_row.case && out_row.case === inc_row.case) return true; - } - return false; - }); + if (!r.message) return; + var res = r.message; - if (!is_returned) { - missing.push({ - barcode: out_row.barcode || "", - matter: out_row.matter || "", - case: out_row.case || "", - returned: 0 + if (res.status === "error") { + frappe.msgprint({ + title: __("Not Found in Outgoing Files"), + indicator: "red", + message: res.message }); - } - }); - } else { - incoming_rows.forEach(function (row) { - if (!row.returned) { - missing.push({ - barcode: row.barcode || "", - matter: row.matter || "", - case: row.case || "", - returned: 0 + } else if (res.status === "warning") { + frappe.show_alert({ + message: res.message, + indicator: "orange" + }); + } else if (res.status === "success") { + frappe.show_alert({ + message: res.message, + indicator: res.movement_status === "Received" ? "green" : "blue" }); + frm.reload_doc(); } - }); - } - - // Direct memory update and UI refresh (no frm.set_value loops) - frm.doc.missing_file_items = missing; - frm.refresh_field("missing_file_items"); - - var new_status; - if (missing.length === 0 && incoming_rows.length > 0) { - new_status = "Received"; - } else if (incoming_rows.length === 0 && frm.doc.docstatus === 0) { - new_status = "Open"; - } else { - new_status = "Missing Files"; - } - - if (frm.doc.status !== new_status) { - frm.doc.status = new_status; - frm.refresh_field("status"); - } + } + }); } - // ========================================================= // ACTION: CREATE INCOMING (STANDALONE PRIMARY BUTTON) // ========================================================= function add_create_incoming_button(frm) { var btn = frm.add_custom_button(__("Create Incoming"), function () { - frappe.call({ - method: "casecentral.case_central.doctype.file_movement.file_movement.create_incoming", - args: { - outgoing_name: frm.doc.name - }, - freeze: true, - freeze_message: __("Creating Incoming File Movement..."), - callback: function (r) { - if (r.message) { - frappe.show_alert({ - message: __("Incoming File Movement created: {0}", [r.message]), - indicator: "green" - }); - frappe.set_route("Form", "File Movement", r.message); - } + frappe.confirm( + __("Do you want to initialize the Incoming Files workflow for this movement?"), + function () { + frappe.call({ + method: "casecentral.case_central.doctype.file_movement.file_movement.create_incoming", + args: { + movement_name: frm.doc.name + }, + freeze: true, + freeze_message: __("Initializing Incoming Files..."), + callback: function (r) { + if (r.message) { + frappe.show_alert({ + message: __("Incoming Files table activated. You can now scan returned files."), + indicator: "green" + }); + frm.reload_doc(); + } + } + }); } - }); + ); }); if (btn) { @@ -780,7 +428,7 @@ function add_create_incoming_button(frm) { // ========================================================= -// ACTION: RETURN MISSING FILES (STANDALONE PRIMARY BUTTON) +// ACTION: RETURN MISSING FILES (SCANNER MODAL) // ========================================================= function add_return_missing_button(frm) { @@ -798,7 +446,6 @@ function add_return_missing_button(frm) { frappe.msgprint(__("There are no missing files in this movement.")); return; } - show_return_missing_modal(frm, missing_files); } }); @@ -827,7 +474,7 @@ function show_return_missing_modal(frm, missing_files) { label: __("Scan Barcode / Matter ID / Case to Return"), fieldtype: "Data", options: "Barcode", - description: __("Scan barcode gun, click scanner icon for camera scanner, or enter Matter ID / Case ID") + description: __("Scan using barcode gun, click scanner icon for camera, or enter Matter ID / Case ID") }, { fieldname: "missing_table_area", @@ -856,13 +503,13 @@ function show_return_missing_modal(frm, missing_files) { files: JSON.stringify(selected_items) }, freeze: true, - freeze_message: __("Returning scanned files..."), + freeze_message: __("Returning scanned files into Incoming Files..."), callback: function (response) { if (response.message) { var res = response.message; frappe.show_alert({ message: res.status === "Received" - ? __("All missing files returned successfully!") + ? __("All missing files returned successfully! Movement marked as Received.") : __("{0} file(s) returned. Some files remain missing.", [selected_items.length]), indicator: res.status === "Received" ? "green" : "orange" }); @@ -905,7 +552,6 @@ function show_return_missing_modal(frm, missing_files) { dialog.fields_dict.quick_scan?.$input?.focus(); }, 100); } else { - // Check via get_scan_information API in case barcode maps to a case/matter frappe.call({ method: "casecentral.case_central.doctype.file_movement.file_movement.get_scan_information", args: { value: code }, @@ -936,7 +582,7 @@ function show_return_missing_modal(frm, missing_files) { }); } else { frappe.show_alert({ - message: __("Code {0} is not in the missing files list.", [code]), + message: __("Code {0} is not in the missing files list for this movement.", [code]), indicator: "orange" }); } @@ -949,7 +595,6 @@ function show_return_missing_modal(frm, missing_files) { } } - // Bind scanner input handlers setTimeout(function () { var field = dialog.fields_dict.quick_scan; var $input = field?.$input; diff --git a/casecentral/case_central/doctype/file_movement/file_movement.json b/casecentral/case_central/doctype/file_movement/file_movement.json index cd7a44e..b27a4f8 100644 --- a/casecentral/case_central/doctype/file_movement/file_movement.json +++ b/casecentral/case_central/doctype/file_movement/file_movement.json @@ -8,15 +8,18 @@ "field_order": [ "giver", "receiver", - "scan_barcode", + "has_incoming", "column_break_1", "movement_date", + "incoming_date", "status", - "movement_type", - "original_outgoing_movement", "amended_from", - "section_break_matter_files", - "barcode_files", + "section_break_outgoing_files", + "scan_outgoing_barcode", + "outgoing_files", + "section_break_incoming_files", + "scan_incoming_barcode", + "incoming_files", "section_break_missing_files", "missing_file_items" ], @@ -38,10 +41,11 @@ "reqd": 1 }, { - "fieldname": "scan_barcode", - "fieldtype": "Data", - "label": "Scan Barcode", - "options": "Barcode" + "default": "0", + "fieldname": "has_incoming", + "fieldtype": "Check", + "hidden": 1, + "label": "Has Incoming" }, { "fieldname": "column_break_1", @@ -55,6 +59,14 @@ "label": "Movement Date", "reqd": 1 }, + { + "allow_on_submit": 1, + "depends_on": "eval:doc.has_incoming || (doc.incoming_files && doc.incoming_files.length > 0)", + "fieldname": "incoming_date", + "fieldtype": "Datetime", + "label": "Incoming Date", + "read_only": 1 + }, { "allow_on_submit": 1, "default": "Open", @@ -63,24 +75,8 @@ "in_list_view": 1, "in_standard_filter": 1, "label": "Status", - "options": "Open\nMissing Files\nReceived" - }, - { - "fieldname": "movement_type", - "fieldtype": "Select", - "in_list_view": 1, - "in_standard_filter": 1, - "label": "Movement Type", - "options": "Outgoing\nIncoming", - "reqd": 1 - }, - { - "allow_on_submit": 1, - "depends_on": "eval:doc.movement_type=='Incoming'", - "fieldname": "original_outgoing_movement", - "fieldtype": "Link", - "label": "Original Outgoing Movement", - "options": "File Movement" + "options": "Open\nMissing Files\nReceived", + "read_only": 1 }, { "fieldname": "amended_from", @@ -93,20 +89,52 @@ "search_index": 1 }, { - "fieldname": "section_break_matter_files", + "fieldname": "section_break_outgoing_files", + "fieldtype": "Section Break", + "label": "Outgoing Files" + }, + { + "fieldname": "scan_outgoing_barcode", + "fieldtype": "Data", + "label": "Scan Outgoing Barcode", + "options": "Barcode", + "read_only_depends_on": "eval:doc.docstatus == 1" + }, + { + "allow_on_submit": 0, + "fieldname": "outgoing_files", + "fieldtype": "Table", + "label": "Outgoing Files", + "options": "Barcode File Item", + "read_only_depends_on": "eval:doc.docstatus == 1", + "reqd": 1 + }, + { + "collapsible": 0, + "depends_on": "eval:doc.has_incoming || (doc.incoming_files && doc.incoming_files.length > 0)", + "fieldname": "section_break_incoming_files", "fieldtype": "Section Break", - "label": "Matter Files" + "label": "Incoming Files" + }, + { + "allow_on_submit": 1, + "depends_on": "eval:doc.has_incoming || (doc.incoming_files && doc.incoming_files.length > 0)", + "fieldname": "scan_incoming_barcode", + "fieldtype": "Data", + "label": "Scan Incoming Barcode", + "options": "Barcode" }, { "allow_on_submit": 1, - "fieldname": "barcode_files", + "depends_on": "eval:doc.has_incoming || (doc.incoming_files && doc.incoming_files.length > 0)", + "fieldname": "incoming_files", "fieldtype": "Table", - "label": "Matter Files", + "label": "Incoming Files", "options": "Barcode File Item" }, { "collapsible": 1, - "depends_on": "eval:doc.status=='Missing Files' || (doc.missing_file_items && doc.missing_file_items.length > 0)", + "depends_on": "eval:doc.status == 'Missing Files' || (doc.missing_file_items && doc.missing_file_items.length > 0)", "fieldname": "section_break_missing_files", "fieldtype": "Section Break", "label": "Missing Files" @@ -124,7 +152,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2026-10-05 14:00:00.000000", + "modified": "2026-10-12 15:00:00.000000", "modified_by": "Administrator", "module": "Case Central", "name": "File Movement", diff --git a/casecentral/case_central/doctype/file_movement/file_movement.py b/casecentral/case_central/doctype/file_movement/file_movement.py index bcda8e3..7fdf4e8 100644 --- a/casecentral/case_central/doctype/file_movement/file_movement.py +++ b/casecentral/case_central/doctype/file_movement/file_movement.py @@ -11,44 +11,15 @@ class FileMovement(Document): def autoname(self): if self.name: return - - if self.movement_type == "Outgoing": - self.name = frappe.model.naming.make_autoname("FMO-.#####") - elif self.movement_type == "Incoming": - outgoing_name = (self.original_outgoing_movement or self.get("outgoing_movement") or "").strip() - if outgoing_name: - if outgoing_name.startswith("FMO-"): - candidate = "FMI-" + outgoing_name[4:] - elif outgoing_name.startswith("FM-"): - candidate = "FMI-" + outgoing_name[3:] - else: - candidate = "FMI-" + outgoing_name - - if frappe.db.exists("File Movement", candidate): - idx = 1 - while frappe.db.exists("File Movement", f"{candidate}-{idx}"): - idx += 1 - self.name = f"{candidate}-{idx}" - else: - self.name = candidate - else: - self.name = frappe.model.naming.make_autoname("FMI-.#####") - else: - self.name = frappe.model.naming.make_autoname("FMO-.#####") + self.name = frappe.model.naming.make_autoname("FM-.#####") def validate(self): - if self.movement_type == "Outgoing": - self.status = "Open" - self.set("missing_file_items", []) self.validate_movement() self.validate_rows() self.validate_duplicate_barcodes() self.update_missing_files_and_status() def validate_movement(self): - if self.movement_type not in ["Outgoing", "Incoming"]: - frappe.throw("Movement Type must be Outgoing or Incoming.") - if not self.giver: frappe.throw("Giver is required.") @@ -64,71 +35,72 @@ def validate_movement(self): if not frappe.db.exists("Employee", self.receiver): frappe.throw(f"Receiver employee '{self.receiver}' does not exist.") - if self.movement_type == "Outgoing" and not self.get("barcode_files"): - frappe.throw("Please add at least one row in the Matter Files table.") + if not self.get("outgoing_files"): + frappe.throw("Please add at least one row in the Outgoing Files table.") def validate_rows(self): - for idx, row in enumerate(self.get("barcode_files") or [], start=1): - # If matter is provided, verify it exists + for idx, row in enumerate(self.get("outgoing_files") or [], start=1): if row.matter: if not frappe.db.exists("Matter", row.matter): frappe.throw(f"Matter ID '{row.matter}' in row {idx} does not exist.") - - # Auto-populate case if not set and case exists if not row.case: case_name = frappe.db.get_value("Case", {"matter": row.matter}, "name") if case_name: row.case = case_name - # If case is provided, verify it exists if row.case: if not frappe.db.exists("Case", row.case): frappe.throw(f"Case '{row.case}' in row {idx} does not exist.") - - # Auto-populate matter if not set if not row.matter: case_matter = frappe.db.get_value("Case", row.case, "matter") if case_matter: row.matter = case_matter - # If neither matter nor case nor barcode is provided if not row.matter and not row.case and not row.barcode: - frappe.throw(f"Row {idx} in Matter Files must have a Matter ID, Case, or Barcode.") + frappe.throw(f"Row {idx} in Outgoing Files must have a Matter ID, Case, or Barcode.") def validate_duplicate_barcodes(self): + # Validate uniqueness inside outgoing_files seen_barcodes = set() seen_cases = set() - for idx, row in enumerate(self.get("barcode_files") or [], start=1): + for idx, row in enumerate(self.get("outgoing_files") or [], start=1): barcode = (row.barcode or "").strip() if barcode: if barcode in seen_barcodes: - frappe.throw(f"Duplicate barcode '{barcode}' in row {idx}.") + frappe.throw(f"Duplicate barcode '{barcode}' in Outgoing Files row {idx}.") seen_barcodes.add(barcode) case_name = (row.case or "").strip() if case_name: if case_name in seen_cases: - frappe.throw(f"Duplicate Case '{case_name}' in row {idx}.") + frappe.throw(f"Duplicate Case '{case_name}' in Outgoing Files row {idx}.") seen_cases.add(case_name) + # Validate uniqueness inside incoming_files + seen_inc_barcodes = set() + seen_inc_cases = set() + for idx, row in enumerate(self.get("incoming_files") or [], start=1): + barcode = (row.barcode or "").strip() + if barcode: + if barcode in seen_inc_barcodes: + frappe.throw(f"Duplicate barcode '{barcode}' in Incoming Files row {idx}.") + seen_inc_barcodes.add(barcode) + + case_name = (row.case or "").strip() + if case_name: + if case_name in seen_inc_cases: + frappe.throw(f"Duplicate Case '{case_name}' in Incoming Files row {idx}.") + seen_inc_cases.add(case_name) + def update_missing_files_and_status(self): - if self.movement_type == "Outgoing": + if not self.has_incoming and not self.get("incoming_files"): self.status = "Open" self.set("missing_file_items", []) return missing = calculate_missing_files_from_doc(self) self.set_missing_file_table(missing) - - if self.docstatus == 1: - self.status = "Missing Files" if missing else "Received" - else: - if not self.get("barcode_files") and not self.status: - self.status = "Open" - elif not self.get("barcode_files"): - self.status = "Open" - else: - self.status = "Missing Files" if missing else "Received" + self.status = "Received" if len(missing) == 0 else "Missing Files" def set_missing_file_table(self, missing): self.set("missing_file_items", []) @@ -137,50 +109,26 @@ def set_missing_file_table(self, missing): row.barcode = item.get("barcode") or "" row.matter = item.get("matter") or "" row.case = item.get("case") or "" - row.returned = 0 def on_submit(self): - if self.movement_type == "Outgoing": - self.status = "Open" - self.db_set("status", "Open") - self.update_outgoing_linked_statuses() - elif self.movement_type == "Incoming": - missing = calculate_missing_files_from_doc(self) - self.set_missing_file_table(missing) - self.status = "Missing Files" if missing else "Received" - self.db_set("status", self.status) - self.update_incoming_linked_statuses() + self.status = "Open" + self.db_set("status", "Open") + self.update_outgoing_linked_statuses() def on_cancel(self): - if self.movement_type == "Outgoing": - self.restore_outgoing_linked_statuses() + self.restore_outgoing_linked_statuses() def update_outgoing_linked_statuses(self): matters = set() - for row in self.get("barcode_files") or []: - if row.matter: - matters.add(row.matter) - for matter_name in matters: - recalculate_matter_status_from_movements(matter_name) - - def update_incoming_linked_statuses(self): - matters = set() - for row in self.get("barcode_files") or []: + for row in self.get("outgoing_files") or []: if row.matter: matters.add(row.matter) - # Also check matters from original outgoing movement - outgoing_name = (self.original_outgoing_movement or self.get("outgoing_movement") or "").strip() - if outgoing_name and frappe.db.exists("File Movement", outgoing_name): - out_doc = frappe.get_doc("File Movement", outgoing_name) - for row in out_doc.get("barcode_files") or []: - if row.matter: - matters.add(row.matter) for matter_name in matters: recalculate_matter_status_from_movements(matter_name) def restore_outgoing_linked_statuses(self): matters = set() - for row in self.get("barcode_files") or []: + for row in self.get("outgoing_files") or []: if row.matter: matters.add(row.matter) for matter_name in matters: @@ -190,51 +138,49 @@ def restore_outgoing_linked_statuses(self): def recalculate_matter_status_from_movements(matter_name): """ Checks whether all cases and files for a Matter have been returned. - If there are active unreturned outgoing movements, status is 'Working'. - Once all cases and matter files are returned, status is set to 'Open'. + If there are active unreturned movements (docstatus=1, status!='Received'), + Matter status is set to 'Working'. Once all files are returned, status is 'Open'. """ if not matter_name or not frappe.db.exists("Matter", matter_name): return - # Find all submitted outgoing file movements containing this matter - outgoing_rows = frappe.db.sql(""" - SELECT bfi.name, bfi.parent, bfi.case, bfi.barcode + # Find unreturned movements containing this matter in outgoing_files + unreturned = frappe.db.sql(""" + SELECT bfi.name, bfi.`case`, bfi.barcode, fm.name as movement_name FROM `tabBarcode File Item` bfi INNER JOIN `tabFile Movement` fm ON fm.name = bfi.parent - WHERE fm.docstatus = 1 AND fm.movement_type = 'Outgoing' AND bfi.matter = %s + WHERE fm.docstatus = 1 + AND fm.status != 'Received' + AND bfi.parentfield = 'outgoing_files' + AND bfi.matter = %s """, (matter_name,), as_dict=True) - if not outgoing_rows: - # No active outgoing movements for this matter -> it is Open + if not unreturned: frappe.db.set_value("Matter", matter_name, "status", "Open") return - # Check for unreturned items - unreturned_count = 0 - for out_row in outgoing_rows: - # Check if there is a submitted incoming movement for this outgoing movement where this item is returned - returned = frappe.db.sql(""" - SELECT bfi.name - FROM `tabBarcode File Item` bfi - INNER JOIN `tabFile Movement` fm ON fm.name = bfi.parent - WHERE fm.docstatus = 1 - AND fm.movement_type = 'Incoming' - AND fm.original_outgoing_movement = %s + has_unreturned = False + for out_row in unreturned: + # Check if this outgoing item has been added into incoming_files of this movement + inc = frappe.db.sql(""" + SELECT name FROM `tabBarcode File Item` + WHERE parent = %s + AND parentfield = 'incoming_files' AND ( - (bfi.case = %s AND %s != '') OR - (bfi.barcode = %s AND %s != '') OR - (bfi.matter = %s) + (`case` = %s AND %s != '') OR + (barcode = %s AND %s != '') OR + (matter = %s) ) - AND bfi.returned = 1 - """, (out_row.parent, out_row.get("case") or "", out_row.get("case") or "", out_row.get("barcode") or "", out_row.get("barcode") or "", matter_name)) + """, (out_row.movement_name, out_row.get("case") or "", out_row.get("case") or "", out_row.get("barcode") or "", out_row.get("barcode") or "", matter_name)) - if not returned: - unreturned_count += 1 + if not inc: + has_unreturned = True + break - if unreturned_count == 0: - frappe.db.set_value("Matter", matter_name, "status", "Open") - else: + if has_unreturned: frappe.db.set_value("Matter", matter_name, "status", "Working") + else: + frappe.db.set_value("Matter", matter_name, "status", "Open") # ========================================================= @@ -348,126 +294,268 @@ def find_case_by_barcode(barcode): # ========================================================= -# INCOMING & MISSING FILES WORKFLOW +# SINGLE DOCUMENT INCOMING & RETURN WORKFLOW # ========================================================= -@frappe.whitelist() -def create_incoming(outgoing_name): - outgoing_name = str(outgoing_name or "").strip() - if not outgoing_name: - frappe.throw("Outgoing movement is required.") - - outgoing = frappe.get_doc("File Movement", outgoing_name) - - if outgoing.docstatus != 1: - frappe.throw("Outgoing movement must be submitted first.") - - if outgoing.movement_type != "Outgoing": - frappe.throw("The selected movement is not Outgoing.") - - incoming = frappe.new_doc("File Movement") - incoming.movement_type = "Incoming" - incoming.status = "Open" - incoming.giver = outgoing.receiver - incoming.receiver = outgoing.giver - incoming.original_outgoing_movement = outgoing.name - - # Matter Files table starts EMPTY - user/giver will scan to populate! - incoming.set("barcode_files", []) - - # Initially all outgoing files are missing until scanned - missing = calculate_missing_files_from_doc(incoming) - incoming.set_missing_file_table(missing) - - incoming.insert(ignore_permissions=True) - return incoming.name - - -@frappe.whitelist() -def get_outgoing_movement_items(outgoing_name): - outgoing_name = str(outgoing_name or "").strip() - if not outgoing_name or not frappe.db.exists("File Movement", outgoing_name): - return [] - - outgoing = frappe.get_doc("File Movement", outgoing_name) - result = [] - for row in outgoing.get("barcode_files") or []: - result.append({ - "barcode": row.barcode or "", - "matter": row.matter or "", - "case": row.case or "" - }) - return result - - def is_item_returned(out_row, incoming_rows): """ - Checks if an item from outgoing movement is present and marked returned in incoming_rows. + Checks if an item from outgoing_files is present in incoming_rows. """ out_case = (out_row.get("case") or "").strip() out_barcode = (out_row.get("barcode") or "").strip() out_matter = (out_row.get("matter") or "").strip() for inc_row in incoming_rows: - inc_returned = inc_row.get("returned") - if not inc_returned or inc_returned in [0, "0", False]: - continue - inc_case = (inc_row.get("case") or "").strip() inc_barcode = (inc_row.get("barcode") or "").strip() inc_matter = (inc_row.get("matter") or "").strip() # 1. Match by case if both have case - if out_case and inc_case: - if out_case == inc_case: - return True + if out_case and inc_case and out_case == inc_case: + return True # 2. Match by barcode if both have barcode - if out_barcode and inc_barcode: - if out_barcode == inc_barcode: - return True + if out_barcode and inc_barcode and out_barcode == inc_barcode: + return True # 3. Match by matter - if out_matter and inc_matter: - if out_matter == inc_matter: - if not out_case and not inc_case: - return True - if out_case and inc_case and out_case == inc_case: - return True + if out_matter and inc_matter and out_matter == inc_matter: + if not out_case and not inc_case: + return True + if out_case and inc_case and out_case == inc_case: + return True return False def calculate_missing_files_from_doc(doc): - if doc.movement_type == "Outgoing": - return [] - - outgoing_name = (doc.original_outgoing_movement or doc.get("outgoing_movement") or "").strip() - if outgoing_name and frappe.db.exists("File Movement", outgoing_name): - outgoing = frappe.get_doc("File Movement", outgoing_name) - incoming_rows = doc.get("barcode_files") or [] - missing = [] - for out_row in outgoing.get("barcode_files") or []: - if not is_item_returned(out_row, incoming_rows): - missing.append({ - "barcode": out_row.barcode or "", - "matter": out_row.matter or "", - "case": out_row.case or "" - }) - return missing + """ + Compares doc.outgoing_files against doc.incoming_files. + Any outgoing item not present in incoming_files is missing. + """ + outgoing = doc.get("outgoing_files") or [] + incoming = doc.get("incoming_files") or [] - # Fallback if no outgoing movement linked missing = [] - for row in doc.get("barcode_files") or []: - if not row.returned: + for out_row in outgoing: + if not is_item_returned(out_row, incoming): missing.append({ - "barcode": row.barcode or "", - "matter": row.matter or "", - "case": row.case or "" + "barcode": out_row.barcode or "", + "matter": out_row.matter or "", + "case": out_row.case or "" }) return missing +@frappe.whitelist() +def create_incoming(movement_name): + """ + Activates incoming workflow on the SAME File Movement document: + - Sets has_incoming = 1 + - Initializes incoming_date + - Computes missing files (all outgoing items not in incoming_files) + - Updates status and persists missing_file_items + """ + movement_name = str(movement_name or "").strip() + if not movement_name or not frappe.db.exists("File Movement", movement_name): + frappe.throw("File Movement not found.") + + doc = frappe.get_doc("File Movement", movement_name) + + if doc.docstatus != 1: + frappe.throw("File Movement must be submitted before creating incoming.") + + doc.has_incoming = 1 + if not doc.incoming_date: + doc.incoming_date = frappe.utils.now_datetime() + + missing = calculate_missing_files_from_doc(doc) + doc.set_missing_file_table(missing) + doc.status = "Missing Files" if missing else "Received" + + frappe.db.set_value("File Movement", doc.name, { + "has_incoming": 1, + "incoming_date": doc.incoming_date, + "status": doc.status + }) + + frappe.db.delete("Missing File Items", {"parent": doc.name}) + for row in doc.missing_file_items: + row.parent = doc.name + row.parenttype = "File Movement" + row.parentfield = "missing_file_items" + row.db_insert() + + return { + "name": doc.name, + "has_incoming": 1, + "status": doc.status, + "missing_count": len(missing) + } + + +@frappe.whitelist() +def scan_incoming_file(movement_name, value): + """ + Scans a barcode, Case ID, or Matter ID into incoming_files for this movement: + - Verifies the item belongs to outgoing_files + - Appends to incoming_files + - Recalculates missing files and status (Received / Missing Files) + - Updates DB and Matter status in real time + """ + movement_name = str(movement_name or "").strip() + value = str(value or "").strip() + + if not movement_name or not frappe.db.exists("File Movement", movement_name): + frappe.throw("File Movement not found.") + + if not value: + frappe.throw("Barcode or ID is required.") + + doc = frappe.get_doc("File Movement", movement_name) + if doc.docstatus != 1: + frappe.throw("Movement must be submitted before receiving files.") + + outgoing = doc.get("outgoing_files") or [] + items_to_add = [] + + # 1. Check direct matches in outgoing_files by barcode, case, or matter + direct_bc = [r for r in outgoing if (r.barcode or "").strip() == value] + direct_case = [r for r in outgoing if (r.case or "").strip() == value] + direct_matter = [r for r in outgoing if (r.matter or "").strip() == value] + + if direct_bc: + for r in direct_bc: + items_to_add.append({ + "matter": r.matter or "", + "case": r.case or "", + "barcode": r.barcode or value + }) + elif direct_case: + for r in direct_case: + items_to_add.append({ + "matter": r.matter or "", + "case": r.case, + "barcode": r.barcode or "" + }) + elif direct_matter: + for r in direct_matter: + items_to_add.append({ + "matter": r.matter, + "case": r.case or "", + "barcode": r.barcode or "" + }) + else: + # 2. Fallback to global resolver + info = get_scan_information(value) + if not info or info.get("type") == "not_found": + return { + "status": "error", + "message": f"Scanned value '{value}' does not match any existing Matter, Case, or Barcode." + } + + if info["type"] == "matter": + matter_val = info["matter"] + matching_out = [r for r in outgoing if r.matter == matter_val] + if not matching_out: + return { + "status": "error", + "message": f"Matter '{matter_val}' is not part of Outgoing Files in this movement." + } + for out_r in matching_out: + items_to_add.append({ + "matter": out_r.matter, + "case": out_r.case or "", + "barcode": out_r.barcode or "" + }) + + elif info["type"] == "case": + case_val = info["case_name"] + matching_out = [r for r in outgoing if r.case == case_val] + if not matching_out: + return { + "status": "error", + "message": f"Case '{case_val}' is not part of Outgoing Files in this movement." + } + items_to_add.append({ + "matter": matching_out[0].matter, + "case": case_val, + "barcode": matching_out[0].barcode or info.get("barcode") or "" + }) + + elif info["type"] == "case_file": + barcode_val = info["barcode"] + matching_out = [r for r in outgoing if r.barcode == barcode_val] + if not matching_out: + return { + "status": "error", + "message": f"Barcode '{barcode_val}' is not part of Outgoing Files in this movement." + } + items_to_add.append({ + "matter": matching_out[0].matter, + "case": matching_out[0].case or "", + "barcode": barcode_val + }) + + # Filter out items already in incoming_files + incoming = doc.get("incoming_files") or [] + new_items = [] + already_scanned = 0 + + for item in items_to_add: + if is_item_returned(item, incoming): + already_scanned += 1 + else: + new_items.append(item) + + if not new_items: + return { + "status": "warning", + "message": f"All matching items for '{value}' are already scanned in Incoming Files." + } + + # Insert new items into incoming_files + for item in new_items: + new_row = doc.append("incoming_files", item) + new_row.parent = doc.name + new_row.parenttype = "File Movement" + new_row.parentfield = "incoming_files" + new_row.db_insert() + + # Recalculate remaining missing + remaining_missing = calculate_missing_files_from_doc(doc) + doc.set_missing_file_table(remaining_missing) + doc.status = "Received" if len(remaining_missing) == 0 else "Missing Files" + doc.has_incoming = 1 + if not doc.incoming_date: + doc.incoming_date = frappe.utils.now_datetime() + + frappe.db.set_value("File Movement", doc.name, { + "status": doc.status, + "has_incoming": 1, + "incoming_date": doc.incoming_date + }) + + # Update missing file items in DB + frappe.db.delete("Missing File Items", {"parent": doc.name}) + for row in doc.missing_file_items: + row.parent = doc.name + row.parenttype = "File Movement" + row.parentfield = "missing_file_items" + row.db_insert() + + # Update matter statuses + doc.update_outgoing_linked_statuses() + + return { + "status": "success", + "movement_status": doc.status, + "added_count": len(new_items), + "missing_count": len(remaining_missing), + "message": f"Scanned & received {len(new_items)} file(s). Status: {doc.status}." + } + + @frappe.whitelist() def get_missing_files(movement_name): movement_name = str(movement_name or "").strip() @@ -475,9 +563,6 @@ def get_missing_files(movement_name): return [] doc = frappe.get_doc("File Movement", movement_name) - if doc.movement_type != "Incoming": - frappe.throw("Only Incoming movements can have missing files.") - return calculate_missing_files_from_doc(doc) @@ -489,17 +574,18 @@ def return_missing_files( selected_matters=None, selected_cases=None ): + """ + Returns missing files directly into incoming_files of this single document. + """ movement_name = str(movement_name or "").strip() if not movement_name: frappe.throw("Movement name is required.") doc = frappe.get_doc("File Movement", movement_name) - if doc.movement_type != "Incoming": - frappe.throw("Only Incoming movements can return missing files.") + if doc.docstatus != 1: + frappe.throw("Movement must be submitted first.") items_to_return = [] - - # Parse files arg if passed as json or list if isinstance(files, str): try: files = json.loads(files) @@ -523,52 +609,66 @@ def return_missing_files( items_to_return.append({"case": str(c).strip()}) if not items_to_return: - frappe.throw("Please select at least one file or matter to return.") + frappe.throw("Please select or scan at least one file to return.") + + incoming = doc.get("incoming_files") or [] + outgoing = doc.get("outgoing_files") or [] + added_count = 0 - # Mark returned or append to barcode_files for item in items_to_return: - target_case = item.get("case") or "" - target_barcode = item.get("barcode") or "" - target_matter = item.get("matter") or "" - - found = False - for row in doc.get("barcode_files") or []: - if (target_case and row.case == target_case) or \ - (target_barcode and row.barcode == target_barcode) or \ - (target_matter and row.matter == target_matter and not row.case and not target_case): - row.returned = 1 - frappe.db.set_value("Barcode File Item", row.name, "returned", 1) - found = True + t_case = item.get("case") or "" + t_barcode = item.get("barcode") or "" + t_matter = item.get("matter") or "" + + # Match against outgoing files to get full record + matched_out = None + for out_r in outgoing: + if (t_case and out_r.case == t_case) or \ + (t_barcode and out_r.barcode == t_barcode) or \ + (t_matter and out_r.matter == t_matter and not t_case and not out_r.case): + matched_out = out_r break - if not found: - # Look up case/matter if partial - if not target_matter and target_case: - target_matter = frappe.db.get_value("Case", target_case, "matter") or "" - if not target_case and target_barcode: - target_case = find_case_by_barcode(target_barcode) or "" - if target_case and not target_matter: - target_matter = frappe.db.get_value("Case", target_case, "matter") or "" - - new_row = doc.append("barcode_files", { - "barcode": target_barcode, - "matter": target_matter, - "case": target_case, - "returned": 1 - }) - new_row.parent = doc.name - new_row.parenttype = "File Movement" - new_row.parentfield = "barcode_files" - new_row.db_insert() + if matched_out: + if not is_item_returned(matched_out, incoming): + new_row = doc.append("incoming_files", { + "barcode": matched_out.barcode or "", + "matter": matched_out.matter or "", + "case": matched_out.case or "" + }) + new_row.parent = doc.name + new_row.parenttype = "File Movement" + new_row.parentfield = "incoming_files" + new_row.db_insert() + added_count += 1 + else: + # Direct insert if not strictly matched + if not t_matter and t_case: + t_matter = frappe.db.get_value("Case", t_case, "matter") or "" + if not t_case and t_barcode: + t_case = find_case_by_barcode(t_barcode) or "" + if t_case and not t_matter: + t_matter = frappe.db.get_value("Case", t_case, "matter") or "" + + entry = {"barcode": t_barcode, "matter": t_matter, "case": t_case} + if not is_item_returned(entry, incoming): + new_row = doc.append("incoming_files", entry) + new_row.parent = doc.name + new_row.parenttype = "File Movement" + new_row.parentfield = "incoming_files" + new_row.db_insert() + added_count += 1 - # Recalculate remaining missing remaining_missing = calculate_missing_files_from_doc(doc) doc.set_missing_file_table(remaining_missing) + doc.status = "Received" if len(remaining_missing) == 0 else "Missing Files" + doc.has_incoming = 1 - doc.status = "Missing Files" if remaining_missing else "Received" - frappe.db.set_value("File Movement", doc.name, "status", doc.status) + frappe.db.set_value("File Movement", doc.name, { + "status": doc.status, + "has_incoming": 1 + }) - # Update missing table in DB frappe.db.delete("Missing File Items", {"parent": doc.name}) for row in doc.missing_file_items: row.parent = doc.name @@ -576,8 +676,7 @@ def return_missing_files( row.parentfield = "missing_file_items" row.db_insert() - # Update matter status - doc.update_incoming_linked_statuses() + doc.update_outgoing_linked_statuses() return { "status": doc.status, diff --git a/casecentral/case_central/doctype/file_movement/file_movement_list.js b/casecentral/case_central/doctype/file_movement/file_movement_list.js index 833df27..1db1b34 100644 --- a/casecentral/case_central/doctype/file_movement/file_movement_list.js +++ b/casecentral/case_central/doctype/file_movement/file_movement_list.js @@ -1,6 +1,6 @@ frappe.listview_settings["File Movement"] = { has_indicator_for_draft: true, - add_fields: ["status", "movement_type"], + add_fields: ["status", "has_incoming"], get_indicator: function (doc) { if (doc.status === "Open") { return [__("Open"), "orange", "status,=,Open"]; diff --git a/casecentral/case_central/doctype/file_movement/test_file_movement.py b/casecentral/case_central/doctype/file_movement/test_file_movement.py index f2a7cf7..dabe647 100644 --- a/casecentral/case_central/doctype/file_movement/test_file_movement.py +++ b/casecentral/case_central/doctype/file_movement/test_file_movement.py @@ -5,6 +5,7 @@ from frappe.tests.utils import FrappeTestCase from casecentral.case_central.doctype.file_movement.file_movement import ( create_incoming, + scan_incoming_file, get_missing_files, return_missing_files, get_scan_information, @@ -141,9 +142,8 @@ def get_or_create_file_type(self, name): # ========================================================= def test_empty_movement_validation(self): - """Must fail if barcode_files is empty.""" + """Must fail if outgoing_files is empty.""" fm = frappe.new_doc("File Movement") - fm.movement_type = "Outgoing" fm.giver = self.giver fm.receiver = self.receiver self.assertRaises(frappe.ValidationError, fm.insert) @@ -151,20 +151,18 @@ def test_empty_movement_validation(self): def test_same_giver_receiver_validation(self): """Same giver and receiver must throw validation error.""" fm = frappe.new_doc("File Movement") - fm.movement_type = "Outgoing" fm.giver = self.giver fm.receiver = self.giver - fm.append("barcode_files", {"barcode": "BC-001", "matter": self.matter_1, "case": self.case_1}) + fm.append("outgoing_files", {"barcode": "BC-001", "matter": self.matter_1, "case": self.case_1}) self.assertRaises(frappe.ValidationError, fm.insert) def test_duplicate_barcode_validation(self): - """Duplicate barcode in the same movement must throw validation error.""" + """Duplicate barcode in the same movement outgoing files must throw validation error.""" fm = frappe.new_doc("File Movement") - fm.movement_type = "Outgoing" fm.giver = self.giver fm.receiver = self.receiver - fm.append("barcode_files", {"barcode": "BC-DUP-01", "matter": self.matter_1, "case": self.case_1}) - fm.append("barcode_files", {"barcode": "BC-DUP-01", "matter": self.matter_2, "case": self.case_2}) + fm.append("outgoing_files", {"barcode": "BC-DUP-01", "matter": self.matter_1, "case": self.case_1}) + fm.append("outgoing_files", {"barcode": "BC-DUP-01", "matter": self.matter_2, "case": self.case_2}) self.assertRaises(frappe.ValidationError, fm.insert) def test_nonexistent_matter_scan_rejection(self): @@ -173,18 +171,18 @@ def test_nonexistent_matter_scan_rejection(self): self.assertIsNotNone(res) self.assertEqual(res["type"], "not_found") - def test_outgoing_with_matter_files(self): - """Outgoing movement with matter files in barcode_files table.""" + def test_outgoing_with_files_and_autoname(self): + """Outgoing movement creates FM-.#####, sets status to Open and Matter to Working.""" fm = frappe.new_doc("File Movement") - fm.movement_type = "Outgoing" fm.giver = self.giver fm.receiver = self.receiver - fm.append("barcode_files", { + fm.append("outgoing_files", { "barcode": "BC-001", "matter": self.matter_1, "case": self.case_1 }) fm.insert(ignore_permissions=True) + self.assertTrue(fm.name.startswith("FM-"), f"Expected FM- prefix, got {fm.name}") self.assertEqual(fm.status, "Open") fm.submit() self.assertEqual(frappe.db.get_value("Matter", self.matter_1, "status"), "Working") @@ -209,103 +207,70 @@ def test_scan_and_query_apis(self): cases = get_cases_for_matter(self.matter_1) self.assertTrue(any(c["name"] == self.case_1 for c in cases)) - def test_full_incoming_workflow_partial_and_full_returns(self): + def test_single_doc_incoming_workflow_partial_and_full_returns(self): """ - Complete lifecycle test with single Matter Files table: - 1. Create Outgoing with 2 items -> Submit - 2. Create Incoming via create_incoming -> Verify barcode_files is EMPTY and missing_file_items has 2 items - 3. Partial scan / return (1 item scanned into barcode_files) -> Submit -> Status "Missing Files" & 1 missing item - 4. Late return of remaining missing file -> Status becomes "Received" + Complete Single Document Lifecycle test: + 1. Create Outgoing with 2 items in outgoing_files -> Submit (status: Open, Matter: Working) + 2. Call create_incoming on SAME doc -> has_incoming=1, incoming_files empty, 2 missing items + 3. Scan 1 item into incoming_files via scan_incoming_file -> Status 'Missing Files', 1 missing + 4. Late return of remaining missing file via return_missing_files -> Status becomes 'Received', Matter: Open """ - # Step 1: Outgoing - outgoing = frappe.new_doc("File Movement") - outgoing.movement_type = "Outgoing" - outgoing.giver = self.giver - outgoing.receiver = self.receiver - outgoing.append("barcode_files", { + # Step 1: Create Outgoing + doc = frappe.new_doc("File Movement") + doc.giver = self.giver + doc.receiver = self.receiver + doc.append("outgoing_files", { "barcode": "TEST-BC-101", "matter": self.matter_1, - "case": self.case_1, - "returned": 0 + "case": self.case_1 }) - outgoing.append("barcode_files", { + doc.append("outgoing_files", { "barcode": "TEST-BC-102", "matter": self.matter_2, - "case": self.case_2, - "returned": 0 - }) - outgoing.insert(ignore_permissions=True) - outgoing.submit() - - # Step 2: Create Incoming - should start with EMPTY barcode_files - incoming_name = create_incoming(outgoing.name) - incoming = frappe.get_doc("File Movement", incoming_name) - self.assertEqual(incoming.movement_type, "Incoming") - self.assertTrue(outgoing.name.startswith("FMO-"), f"Outgoing name should start with FMO-, got {outgoing.name}") - self.assertTrue(incoming.name.startswith("FMI-"), f"Incoming name should start with FMI-, got {incoming.name}") - self.assertEqual(incoming.name[4:], outgoing.name[4:], f"FMI suffix {incoming.name} should match FMO suffix {outgoing.name}") - self.assertEqual(incoming.giver, self.receiver) - self.assertEqual(incoming.receiver, self.giver) - self.assertEqual(incoming.original_outgoing_movement, outgoing.name) - - # BARCODE_FILES MUST BE EMPTY INITIALLY - self.assertEqual(len(incoming.barcode_files), 0, "Incoming barcode_files should start empty") - # All 2 outgoing files should be listed in missing_file_items initially - self.assertEqual(len(incoming.missing_file_items), 2, "Incoming missing_file_items should list all 2 outgoing items") - - # Step 3: Partial return - scan 1 item into barcode_files - incoming.append("barcode_files", { - "barcode": "TEST-BC-101", - "matter": self.matter_1, - "case": self.case_1, - "returned": 1 + "case": self.case_2 }) - incoming.save(ignore_permissions=True) - incoming.submit() - - self.assertEqual(incoming.status, "Missing Files") - self.assertEqual(len(incoming.missing_file_items), 1) - self.assertEqual(incoming.missing_file_items[0].case, self.case_2) - - # Step 4: Check get_missing_files - missing = get_missing_files(incoming.name) - self.assertEqual(len(missing), 1) - self.assertEqual(missing[0]["case"], self.case_2) - - # Step 5: Late return of remaining missing file - res = return_missing_files(incoming.name, files=[self.case_2]) - self.assertEqual(res["status"], "Received") - self.assertEqual(len(res["missing"]), 0) - - # Verify incoming doc status updated in database - incoming.reload() - self.assertEqual(incoming.status, "Received") - self.assertEqual(len(incoming.missing_file_items), 0) - self.assertEqual(len(incoming.barcode_files), 2) - - def test_fmo_and_fmi_matching_id_number(self): - """Test that Outgoing movement creates FMO-##### and Incoming creates matching FMI-#####.""" - outgoing = frappe.new_doc("File Movement") - outgoing.movement_type = "Outgoing" - outgoing.giver = self.giver - outgoing.receiver = self.receiver - outgoing.append("barcode_files", { - "barcode": "TEST-BC-FMO-01", - "matter": self.matter_1, - "case": self.case_1 - }) - outgoing.insert(ignore_permissions=True) - outgoing.submit() - - self.assertTrue(outgoing.name.startswith("FMO-"), f"Expected FMO- prefix, got {outgoing.name}") - - # Extract number part - num_part = outgoing.name.replace("FMO-", "") - - # Create incoming - incoming_name = create_incoming(outgoing.name) - self.assertEqual(incoming_name, f"FMI-{num_part}", f"Expected FMI-{num_part}, got {incoming_name}") + doc.insert(ignore_permissions=True) + doc.submit() + self.assertEqual(doc.status, "Open") + self.assertEqual(frappe.db.get_value("Matter", self.matter_1, "status"), "Working") + self.assertEqual(frappe.db.get_value("Matter", self.matter_2, "status"), "Working") + + # Step 2: Initialize Incoming on the SAME document + res_inc = create_incoming(doc.name) + self.assertEqual(res_inc["name"], doc.name) + self.assertEqual(res_inc["has_incoming"], 1) + self.assertEqual(res_inc["status"], "Missing Files") + self.assertEqual(res_inc["missing_count"], 2) + + doc.reload() + self.assertEqual(len(doc.incoming_files), 0, "Incoming files should start empty") + self.assertEqual(len(doc.missing_file_items), 2, "All 2 outgoing items should initially be missing") + + # Step 3: Scan 1 item into incoming_files + scan_res = scan_incoming_file(doc.name, "TEST-BC-101") + self.assertEqual(scan_res["status"], "success") + self.assertEqual(scan_res["movement_status"], "Missing Files") + self.assertEqual(scan_res["added_count"], 1) + self.assertEqual(scan_res["missing_count"], 1) + + doc.reload() + self.assertEqual(len(doc.incoming_files), 1) + self.assertEqual(len(doc.missing_file_items), 1) + self.assertEqual(doc.missing_file_items[0].case, self.case_2) + + # Step 4: Return remaining item + ret_res = return_missing_files(doc.name, files=[self.case_2]) + self.assertEqual(ret_res["status"], "Received") + self.assertEqual(len(ret_res["missing"]), 0) + + # Step 5: Verify document is now Received and Matters are Open + doc.reload() + self.assertEqual(doc.status, "Received") + self.assertEqual(len(doc.missing_file_items), 0) + self.assertEqual(len(doc.incoming_files), 2) + self.assertEqual(frappe.db.get_value("Matter", self.matter_1, "status"), "Open") + self.assertEqual(frappe.db.get_value("Matter", self.matter_2, "status"), "Open") def run_test_suite(): diff --git a/casecentral/case_central/doctype/file_movement_items/__init__.py b/casecentral/case_central/doctype/file_movement_items/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/casecentral/case_central/doctype/file_movement_items/file_movement_items.js b/casecentral/case_central/doctype/file_movement_items/file_movement_items.js deleted file mode 100644 index 8242e31..0000000 --- a/casecentral/case_central/doctype/file_movement_items/file_movement_items.js +++ /dev/null @@ -1,11 +0,0 @@ -// ============================================================= -// FILE MOVEMENT ITEMS -// ============================================================= -// -// Matter -> Case population is handled by file_movement.js. -// -// This file is intentionally kept empty so that the child -// table does not have duplicate event handlers. -// - -frappe.ui.form.on("File_Movement_Items", {}); \ No newline at end of file diff --git a/casecentral/case_central/doctype/file_movement_items/file_movement_items.json b/casecentral/case_central/doctype/file_movement_items/file_movement_items.json deleted file mode 100644 index 44e8f9f..0000000 --- a/casecentral/case_central/doctype/file_movement_items/file_movement_items.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "actions": [], - "allow_bulk_edit": 1, - "allow_rename": 1, - "creation": "2026-08-24 16:32:19.094791", - "doctype": "DocType", - "editable_grid": 1, - "engine": "InnoDB", - "field_order": [ - "matter", - "case", - "returned" - ], - "fields": [ - { - "columns": 3, - "fieldname": "matter", - "fieldtype": "Link", - "in_list_view": 1, - "label": "Matter", - "options": "Matter", - "reqd": 1 - }, - { - "columns": 3, - "fieldname": "case", - "fieldtype": "Link", - "in_list_view": 1, - "label": "Case", - "options": "Case" - }, - { - "columns": 1, - "default": "0", - "fieldname": "returned", - "fieldtype": "Check", - "in_list_view": 1, - "label": "Returned" - } - ], - "grid_page_length": 50, - "index_web_pages_for_search": 1, - "istable": 1, - "links": [], - "modified": "2026-09-05 14:40:00.000000", - "modified_by": "Administrator", - "module": "Case Central", - "name": "File_Movement_Items", - "owner": "Administrator", - "permissions": [], - "row_format": "Dynamic", - "rows_threshold_for_grid_search": 20, - "sort_field": "creation", - "sort_order": "DESC", - "states": [] -} diff --git a/casecentral/case_central/doctype/file_movement_items/file_movement_items.py b/casecentral/case_central/doctype/file_movement_items/file_movement_items.py deleted file mode 100644 index 4fe6cb0..0000000 --- a/casecentral/case_central/doctype/file_movement_items/file_movement_items.py +++ /dev/null @@ -1,5 +0,0 @@ -from frappe.model.document import Document - - -class File_Movement_Items(Document): - pass diff --git a/casecentral/case_central/doctype/missing_file_items/missing_file_items.json b/casecentral/case_central/doctype/missing_file_items/missing_file_items.json index fbc4103..c04c939 100644 --- a/casecentral/case_central/doctype/missing_file_items/missing_file_items.json +++ b/casecentral/case_central/doctype/missing_file_items/missing_file_items.json @@ -9,19 +9,18 @@ "field_order": [ "barcode", "matter", - "case", - "returned" + "case" ], "fields": [ { - "columns": 3, + "columns": 4, "fieldname": "barcode", "fieldtype": "Data", "in_list_view": 1, "label": "Barcode" }, { - "columns": 3, + "columns": 4, "fieldname": "matter", "fieldtype": "Link", "in_list_view": 1, @@ -29,27 +28,19 @@ "options": "Matter" }, { - "columns": 3, + "columns": 4, "fieldname": "case", "fieldtype": "Link", "in_list_view": 1, "label": "Case", "options": "Case" - }, - { - "columns": 1, - "default": "0", - "fieldname": "returned", - "fieldtype": "Check", - "in_list_view": 1, - "label": "Returned" } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2026-09-05 16:04:00.786893", + "modified": "2026-10-06 14:00:00.000000", "modified_by": "Administrator", "module": "Case Central", "name": "Missing File Items", From b6a8ce96cde6a6ca5f3d765cd5fa9051b68c8fef Mon Sep 17 00:00:00 2001 From: Abdullah Syed Date: Tue, 13 Oct 2026 12:09:46 +0000 Subject: [PATCH 11/12] revert: remove modifications to Matter and Case Central workspace --- .../case_central/doctype/matter/matter.json | 7 +++---- .../workspace/case_central/case_central.json | 18 ++++-------------- 2 files changed, 7 insertions(+), 18 deletions(-) diff --git a/casecentral/case_central/doctype/matter/matter.json b/casecentral/case_central/doctype/matter/matter.json index 7234701..c4c52c1 100644 --- a/casecentral/case_central/doctype/matter/matter.json +++ b/casecentral/case_central/doctype/matter/matter.json @@ -91,7 +91,6 @@ "fieldtype": "Column Break" }, { - "allow_on_submit": 1, "fieldname": "status", "fieldtype": "Select", "in_list_view": 1, @@ -339,10 +338,11 @@ "link_fieldname": "custom_matter" } ], - "modified": "2026-09-01 14:51:14.805232", + "modified": "2023-10-30 23:01:24.808994", "modified_by": "Administrator", "module": "Case Central", "name": "Matter", + "name_case": "Title Case", "naming_rule": "By \"Naming Series\" field", "owner": "Administrator", "permissions": [ @@ -386,7 +386,6 @@ "write": 1 } ], - "row_format": "Dynamic", "show_name_in_global_search": 1, "show_preview_popup": 1, "sort_field": "modified", @@ -414,4 +413,4 @@ } ], "track_changes": 1 -} +} \ No newline at end of file diff --git a/casecentral/case_central/workspace/case_central/case_central.json b/casecentral/case_central/workspace/case_central/case_central.json index 5a6e616..3ee4750 100644 --- a/casecentral/case_central/workspace/case_central/case_central.json +++ b/casecentral/case_central/workspace/case_central/case_central.json @@ -1,7 +1,6 @@ { - "app": "casecentral", "charts": [], - "content": "[{\"id\":\"header_1\",\"type\":\"header\",\"data\":{\"text\":\"Case Central\",\"col\":12}},{\"id\":\"sh_customer\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Customer\",\"col\":3}},{\"id\":\"sh_matter\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Matter\",\"col\":3}},{\"id\":\"sh_case\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Case\",\"col\":3}},{\"id\":\"sh_caveat\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Caveat\",\"col\":3}},{\"id\":\"sh_task\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Task\",\"col\":3}},{\"id\":\"sh_appointment\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Customer Appointment\",\"col\":3}},{\"id\":\"sh_timesheet\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Timesheet\",\"col\":3}},{\"id\":\"sh_schedule\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Lawyer Schedule\",\"col\":3}},{\"id\":\"sh_book\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Book\",\"col\":4}},{\"id\":\"sh_settings\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Case Central Settings\",\"col\":4}},{\"id\":\"sh_dashboard\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Dashboard\",\"col\":4}},{\"id\":\"sh_file_movement\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"File Movement\",\"col\":4}},{\"id\":\"spacer_1\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"card_matter_mgt\",\"type\":\"card\",\"data\":{\"card_name\":\"Matter Management\",\"col\":4}},{\"id\":\"card_case_mgt\",\"type\":\"card\",\"data\":{\"card_name\":\"Case Management\",\"col\":4}},{\"id\":\"card_appointments\",\"type\":\"card\",\"data\":{\"card_name\":\"Appointments\",\"col\":4}},{\"id\":\"card_operations\",\"type\":\"card\",\"data\":{\"card_name\":\"Operations\",\"col\":4}},{\"id\":\"card_books_journals\",\"type\":\"card\",\"data\":{\"card_name\":\"Books and Journals\",\"col\":4}},{\"id\":\"card_quality_mgt\",\"type\":\"card\",\"data\":{\"card_name\":\"Quality Management\",\"col\":4}},{\"id\":\"card_masters\",\"type\":\"card\",\"data\":{\"card_name\":\"Masters\",\"col\":4}},{\"id\":\"card_setup\",\"type\":\"card\",\"data\":{\"card_name\":\"Setup\",\"col\":4}},{\"id\":\"card_reports\",\"type\":\"card\",\"data\":{\"card_name\":\"Reports\",\"col\":4}}]", + "content": "[{\"id\":\"header_1\",\"type\":\"header\",\"data\":{\"text\":\"Case Central\",\"col\":12}},{\"id\":\"sh_customer\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Customer\",\"col\":3}},{\"id\":\"sh_matter\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Matter\",\"col\":3}},{\"id\":\"sh_case\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Case\",\"col\":3}},{\"id\":\"sh_caveat\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Caveat\",\"col\":3}},{\"id\":\"sh_task\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Task\",\"col\":3}},{\"id\":\"sh_appointment\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Customer Appointment\",\"col\":3}},{\"id\":\"sh_timesheet\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Timesheet\",\"col\":3}},{\"id\":\"sh_schedule\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Lawyer Schedule\",\"col\":3}},{\"id\":\"sh_book\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Book\",\"col\":4}},{\"id\":\"sh_settings\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Case Central Settings\",\"col\":4}},{\"id\":\"sh_dashboard\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Dashboard\",\"col\":4}},{\"id\":\"spacer_1\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"card_matter_mgt\",\"type\":\"card\",\"data\":{\"card_name\":\"Matter Management\",\"col\":4}},{\"id\":\"card_case_mgt\",\"type\":\"card\",\"data\":{\"card_name\":\"Case Management\",\"col\":4}},{\"id\":\"card_appointments\",\"type\":\"card\",\"data\":{\"card_name\":\"Appointments\",\"col\":4}},{\"id\":\"card_operations\",\"type\":\"card\",\"data\":{\"card_name\":\"Operations\",\"col\":4}},{\"id\":\"card_books_journals\",\"type\":\"card\",\"data\":{\"card_name\":\"Books and Journals\",\"col\":4}},{\"id\":\"card_quality_mgt\",\"type\":\"card\",\"data\":{\"card_name\":\"Quality Management\",\"col\":4}},{\"id\":\"card_masters\",\"type\":\"card\",\"data\":{\"card_name\":\"Masters\",\"col\":4}},{\"id\":\"card_setup\",\"type\":\"card\",\"data\":{\"card_name\":\"Setup\",\"col\":4}},{\"id\":\"card_reports\",\"type\":\"card\",\"data\":{\"card_name\":\"Reports\",\"col\":4}}]", "creation": "2022-12-27 17:17:22.857757", "custom_blocks": [], "docstatus": 0, @@ -366,7 +365,7 @@ "type": "Link" } ], - "modified": "2026-09-16 13:23:58.276004", + "modified": "2026-06-15 21:05:37.815545", "modified_by": "Administrator", "module": "Case Central", "name": "Case Central", @@ -387,14 +386,6 @@ "stats_filter": "{\"disabled\":[\"=\",0]}", "type": "DocType" }, - { - "color": "Grey", - "doc_view": "List", - "label": "File Movement", - "link_to": "File Movement", - "stats_filter": "[]", - "type": "DocType" - }, { "color": "Green", "doc_view": "List", @@ -470,6 +461,5 @@ "type": "Dashboard" } ], - "title": "Case Central", - "type": "Workspace" -} + "title": "Case Central" +} \ No newline at end of file From e1841c93522180dda02fcacc85686eadb323dfd2 Mon Sep 17 00:00:00 2001 From: Abdullah Syed Date: Thu, 15 Oct 2026 09:51:40 +0000 Subject: [PATCH 12/12] feat(file-movement): prompt case selection dialog when matter has multiple cases --- .../doctype/file_movement/file_movement.js | 324 +++++++++++++++--- .../doctype/file_movement/file_movement.py | 4 +- 2 files changed, 277 insertions(+), 51 deletions(-) diff --git a/casecentral/case_central/doctype/file_movement/file_movement.js b/casecentral/case_central/doctype/file_movement/file_movement.js index bcfb876..2d62c9f 100644 --- a/casecentral/case_central/doctype/file_movement/file_movement.js +++ b/casecentral/case_central/doctype/file_movement/file_movement.js @@ -107,6 +107,9 @@ frappe.ui.form.on("Barcode File Item", { // Only auto-expand in outgoing_files when in draft if (row.parentfield !== "outgoing_files") return; + // If a specific case is already selected in this row, don't re-prompt + if (row.case) return; + frappe.call({ method: "casecentral.case_central.doctype.file_movement.file_movement.get_cases_for_matter", args: { matter: row.matter }, @@ -135,32 +138,23 @@ frappe.ui.form.on("Barcode File Item", { return; } - // Populate first case in current row - frappe.model.set_value(cdt, cdn, "case", cases[0].name); - frappe.model.set_value(cdt, cdn, "barcode", cases[0].barcode || ""); - - // If multiple cases exist, add additional rows - if (cases.length > 1) { - for (var i = 1; i < cases.length; i++) { - var c = cases[i]; - var exists = (frm.doc.outgoing_files || []).some(function (item) { - return item.matter === (c.matter || row.matter) && item.case === c.name; - }); - - if (!exists) { - var new_row = frm.add_child("outgoing_files"); - new_row.matter = c.matter || row.matter; - new_row.case = c.name || ""; - new_row.barcode = c.barcode || ""; + if (cases.length === 1) { + // Populate single case directly + frappe.model.set_value(cdt, cdn, "case", cases[0].name); + frappe.model.set_value(cdt, cdn, "barcode", cases[0].barcode || ""); + frappe.show_alert({ + message: __("Added Case {0} for Matter {1}.", [cases[0].name, row.matter]), + indicator: "green" + }); + } else { + // Multiple cases exist -> Show selection dialog so user can pick which cases to take + show_case_selection_dialog(frm, row.matter, cases, row, function () { + if (!row.case) { + frappe.model.set_value(cdt, cdn, "matter", ""); + frappe.model.set_value(cdt, cdn, "barcode", ""); } - } - frm.refresh_field("outgoing_files"); + }); } - - frappe.show_alert({ - message: __("{0} Case(s) added for Matter {1}.", [cases.length, row.matter]), - indicator: "green" - }); }, error: function () { frappe.model.set_value(cdt, cdn, "matter", ""); @@ -238,7 +232,6 @@ function process_outgoing_scan(frm, code) { } if (data.type === "matter") { - var added_count = 0; var cases = data.cases || []; if (cases.length === 0) { @@ -248,34 +241,41 @@ function process_outgoing_scan(frm, code) { if (!exists) { var row = frm.add_child("outgoing_files"); row.matter = data.matter; - added_count++; - } - } else { - cases.forEach(function (c) { - var exists = (frm.doc.outgoing_files || []).some(function (item) { - return item.matter === (c.matter || data.matter) && item.case === c.name; + frm.refresh_field("outgoing_files"); + frappe.show_alert({ + message: __("Added Matter {0} (No linked Cases found).", [data.matter]), + indicator: "blue" }); - if (!exists) { - var row = frm.add_child("outgoing_files"); - row.matter = c.matter || data.matter; - row.case = c.name || ""; - row.barcode = c.barcode || ""; - added_count++; - } - }); - } - - frm.refresh_field("outgoing_files"); - if (added_count > 0) { - frappe.show_alert({ - message: __("Added Matter {0} ({1} Case(s)).", [data.matter, added_count]), - indicator: "green" + } else { + frappe.show_alert({ + message: __("Matter {0} is already in Outgoing Files.", [data.matter]), + indicator: "orange" + }); + } + } else if (cases.length === 1) { + var c = cases[0]; + var exists = (frm.doc.outgoing_files || []).some(function (item) { + return item.case === c.name; }); + if (!exists) { + var row = frm.add_child("outgoing_files"); + row.matter = c.matter || data.matter; + row.case = c.name || ""; + row.barcode = c.barcode || ""; + frm.refresh_field("outgoing_files"); + frappe.show_alert({ + message: __("Added Case {0} for Matter {1}.", [c.name, data.matter]), + indicator: "green" + }); + } else { + frappe.show_alert({ + message: __("Case {0} is already in Outgoing Files.", [c.name]), + indicator: "orange" + }); + } } else { - frappe.show_alert({ - message: __("Matter {0} is already in Outgoing Files.", [data.matter]), - indicator: "orange" - }); + // Multiple cases exist -> Show selection modal + show_case_selection_dialog(frm, data.matter, cases, null, null); } } else if (data.type === "case") { @@ -329,6 +329,230 @@ function process_outgoing_scan(frm, code) { } +// ========================================================= +// MULTI-CASE SELECTION DIALOG (WHEN MATTER HAS MULTIPLE CASES) +// ========================================================= + +function show_case_selection_dialog(frm, matter, cases, target_row, on_cancel) { + cases = cases || []; + var existing_cases = new Set( + (frm.doc.outgoing_files || []) + .map(function (item) { return (item.case || "").trim(); }) + .filter(Boolean) + ); + + if (target_row && target_row.case) { + existing_cases.delete(target_row.case.trim()); + } + + var available_count = cases.filter(function (c) { + return !existing_cases.has(c.name); + }).length; + + if (available_count === 0) { + frappe.msgprint({ + title: __("Cases Already Added"), + indicator: "orange", + message: __("All {0} case(s) for Matter {1} are already in Outgoing Files.", [cases.length, frappe.utils.escape_html(matter)]) + }); + if (on_cancel) on_cancel(); + return; + } + + var rows_html = ""; + cases.forEach(function (c) { + var is_added = existing_cases.has(c.name); + var barcode_label = c.barcode ? `${frappe.utils.escape_html(c.barcode)}` : ""; + var status_badge = c.status ? `${frappe.utils.escape_html(c.status)}` : ""; + var disabled_attr = is_added ? "disabled" : ""; + var row_style = is_added + ? "display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; margin-bottom: 6px; border: 1px solid var(--border-color); border-radius: 6px; opacity: 0.6; background: var(--bg-light-gray, #f9f9f9);" + : "display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; margin-bottom: 6px; border: 1px solid var(--border-color); border-radius: 6px; cursor: pointer; background: var(--card-bg, #fff); transition: background 0.15s ease;"; + var already_added_note = is_added ? `${__("Already in Outgoing Files")}` : ""; + var title_html = c.case_title ? `
${frappe.utils.escape_html(c.case_title)}
` : ""; + var search_meta = ((c.name || "") + " " + (c.barcode || "") + " " + (c.case_title || "") + " " + (c.status || "")).toLowerCase(); + + rows_html += ` +
+
+ +
+
+ ${frappe.utils.escape_html(c.name)} + ${barcode_label} + ${already_added_note} +
+ ${title_html} +
+
+
+ ${status_badge} +
+
+ `; + }); + + var dialog_content = ` +
+
+
+ Matter ${frappe.utils.escape_html(matter)} has ${cases.length} cases. Select the case(s) you want to take: +
+
+ + + 0 of ${available_count} selected +
+
+ +
+ +
+ +
+ ${rows_html} +
+
+ `; + + var dialog = new frappe.ui.Dialog({ + title: __("Select Cases to Take — Matter {0}", [matter]), + fields: [ + { + fieldtype: "HTML", + fieldname: "cases_html", + options: dialog_content + } + ], + primary_action_label: __("Add Selected Cases"), + primary_action: function () { + var selected = []; + dialog.$wrapper.find(".case-select-checkbox:checked").each(function () { + var c_name = $(this).data("case-name"); + var match = cases.find(function (c) { return c.name === c_name; }); + if (match) { + selected.push(match); + } + }); + + if (!selected.length) { + frappe.msgprint({ + title: __("No Cases Selected"), + indicator: "orange", + message: __("Please select at least one case to take, or click Cancel.") + }); + return; + } + + dialog._completed = true; + add_selected_cases_to_outgoing(frm, matter, selected, target_row); + dialog.hide(); + } + }); + + function update_counter() { + var count = dialog.$wrapper.find(".case-select-checkbox:checked").length; + dialog.$wrapper.find(".case-selected-counter").text(`${count} of ${available_count} selected`); + if (count > 0) { + dialog.set_primary_action_label(__("Add Selected ({0})", [count])); + } else { + dialog.set_primary_action_label(__("Add Selected Cases")); + } + } + + dialog.on_page_show = function () { + var $wrapper = dialog.$wrapper; + + $wrapper.on("click", ".case-item-card", function (e) { + if ($(e.target).is("input[type='checkbox']")) { + update_counter(); + return; + } + var $cb = $(this).find(".case-select-checkbox"); + if (!$cb.prop("disabled")) { + $cb.prop("checked", !$cb.prop("checked")); + update_counter(); + } + }); + + $wrapper.on("input", ".case-filter-input", function () { + var q = ($(this).val() || "").toLowerCase().trim(); + $wrapper.find(".case-item-card").each(function () { + var text = $(this).data("search") || ""; + if (!q || text.indexOf(q) !== -1) { + $(this).show(); + } else { + $(this).hide(); + } + }); + }); + + $wrapper.on("click", ".btn-select-all", function () { + $wrapper.find(".case-item-card:visible .case-select-checkbox:not(:disabled)").prop("checked", true); + update_counter(); + }); + + $wrapper.on("click", ".btn-clear-all", function () { + $wrapper.find(".case-select-checkbox:not(:disabled)").prop("checked", false); + update_counter(); + }); + + update_counter(); + }; + + dialog.onhide = function () { + if (!dialog._completed && on_cancel) { + on_cancel(); + } + }; + + dialog.show(); +} + +function add_selected_cases_to_outgoing(frm, matter, selected_cases, target_row) { + if (!selected_cases || !selected_cases.length) return; + + var start_idx = 0; + if (target_row) { + var first = selected_cases[0]; + frappe.model.set_value(target_row.doctype, target_row.name, "matter", matter); + frappe.model.set_value(target_row.doctype, target_row.name, "case", first.name); + frappe.model.set_value(target_row.doctype, target_row.name, "barcode", first.barcode || ""); + start_idx = 1; + } else { + var blank_row = (frm.doc.outgoing_files || []).find(function (r) { + return !r.matter && !r.case && !r.barcode; + }); + if (blank_row) { + var first = selected_cases[0]; + blank_row.matter = matter; + blank_row.case = first.name; + blank_row.barcode = first.barcode || ""; + start_idx = 1; + } + } + + for (var i = start_idx; i < selected_cases.length; i++) { + var c = selected_cases[i]; + var exists = (frm.doc.outgoing_files || []).some(function (item) { + return item.case === c.name; + }); + if (!exists) { + var new_row = frm.add_child("outgoing_files"); + new_row.matter = matter; + new_row.case = c.name; + new_row.barcode = c.barcode || ""; + } + } + + frm.refresh_field("outgoing_files"); + frappe.show_alert({ + message: __("Added {0} Case(s) for Matter {1} to Outgoing Files.", [selected_cases.length, frappe.utils.escape_html(matter)]), + indicator: "green" + }); +} + + // ========================================================= // SCAN INCOMING BARCODE (SUBMITTED INCOMING SECTION) // ========================================================= diff --git a/casecentral/case_central/doctype/file_movement/file_movement.py b/casecentral/case_central/doctype/file_movement/file_movement.py index 7fdf4e8..fcd35bf 100644 --- a/casecentral/case_central/doctype/file_movement/file_movement.py +++ b/casecentral/case_central/doctype/file_movement/file_movement.py @@ -253,7 +253,7 @@ def get_cases_for_matter(matter): cases = frappe.get_all( "Case", filters={"matter": matter}, - fields=["name", "matter"], + fields=["name", "matter", "case_title", "status"], order_by="name asc" ) @@ -263,6 +263,8 @@ def get_cases_for_matter(matter): result.append({ "name": c.name, "matter": c.matter or matter, + "case_title": c.get("case_title") or "", + "status": c.get("status") or "", "barcode": get_case_barcode(case_doc) })