From d4bfcca408f2c42c5d8406b77f5f616a2a513ccc Mon Sep 17 00:00:00 2001 From: Abdullah Syed Date: Wed, 16 Sep 2026 06:50:27 +0000 Subject: [PATCH 1/3] 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 de841fb..697bb7e 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 834a6074664f6485e7e1ed9feb9d8ce9988f5608 Mon Sep 17 00:00:00 2001 From: Abdullah Syed Date: Wed, 16 Sep 2026 09:02:36 +0000 Subject: [PATCH 2/3] 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 947976246158b1a2fb5b6c24638cfc3f042c30a5 Mon Sep 17 00:00:00 2001 From: Abdullah Syed Date: Wed, 16 Sep 2026 09:06:18 +0000 Subject: [PATCH 3/3] 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 b8296eb..149fc0f 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\":\"zkQlSSmwUA\",\"type\":\"header\",\"data\":{\"text\":\"Case Central\",\"col\":12}},{\"id\":\"DWgn1wYllm\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Customer\",\"col\":3}},{\"id\":\"PAyPv1xREA\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Matter\",\"col\":3}},{\"id\":\"Bf8EmZW4X1\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Case\",\"col\":3}},{\"id\":\"RfdbBJE5--\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Caveat\",\"col\":3}},{\"id\":\"Uk5zrabl7W\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Task\",\"col\":3}},{\"id\":\"8Gw3-JUC-s\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Dashboard\",\"col\":3}},{\"id\":\"CffZAne9zA\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"r84gKKGl3v\",\"type\":\"card\",\"data\":{\"card_name\":\"Matter Management\",\"col\":4}},{\"id\":\"IAqlYA_j7W\",\"type\":\"card\",\"data\":{\"card_name\":\"Case Management\",\"col\":4}},{\"id\":\"pGvcbAvNtp\",\"type\":\"card\",\"data\":{\"card_name\":\"Appointments\",\"col\":4}},{\"id\":\"vo9eEh5vWO\",\"type\":\"card\",\"data\":{\"card_name\":\"Operations\",\"col\":4}},{\"id\":\"CLmoYTPsPU\",\"type\":\"card\",\"data\":{\"card_name\":\"Quality Management\",\"col\":4}},{\"id\":\"RQvtolJSLV\",\"type\":\"card\",\"data\":{\"card_name\":\"Books and Journals\",\"col\":4}},{\"id\":\"k-bGe7ImE9\",\"type\":\"card\",\"data\":{\"card_name\":\"Masters\",\"col\":4}},{\"id\":\"RxQvZZH7ME\",\"type\":\"card\",\"data\":{\"card_name\":\"Setup\",\"col\":4}},{\"id\":\"2phxWUdck8\",\"type\":\"card\",\"data\":{\"card_name\":\"Reports\",\"col\":4}}]", + "content": "[{\"id\":\"zkQlSSmwUA\",\"type\":\"header\",\"data\":{\"text\":\"Case Central\",\"col\":12}},{\"id\":\"DWgn1wYllm\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Customer\",\"col\":3}},{\"id\":\"PAyPv1xREA\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Matter\",\"col\":3}},{\"id\":\"Bf8EmZW4X1\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Case\",\"col\":3}},{\"id\":\"RfdbBJE5--\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Caveat\",\"col\":3}},{\"id\":\"Uk5zrabl7W\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Task\",\"col\":3}},{\"id\":\"8Gw3-JUC-s\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Dashboard\",\"col\":3}},{\"id\":\"5KhggLX4wS\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"File Movement\",\"col\":3}},{\"id\":\"CffZAne9zA\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"r84gKKGl3v\",\"type\":\"card\",\"data\":{\"card_name\":\"Matter Management\",\"col\":4}},{\"id\":\"IAqlYA_j7W\",\"type\":\"card\",\"data\":{\"card_name\":\"Case Management\",\"col\":4}},{\"id\":\"pGvcbAvNtp\",\"type\":\"card\",\"data\":{\"card_name\":\"Appointments\",\"col\":4}},{\"id\":\"vo9eEh5vWO\",\"type\":\"card\",\"data\":{\"card_name\":\"Operations\",\"col\":4}},{\"id\":\"CLmoYTPsPU\",\"type\":\"card\",\"data\":{\"card_name\":\"Quality Management\",\"col\":4}},{\"id\":\"RQvtolJSLV\",\"type\":\"card\",\"data\":{\"card_name\":\"Books and Journals\",\"col\":4}},{\"id\":\"k-bGe7ImE9\",\"type\":\"card\",\"data\":{\"card_name\":\"Masters\",\"col\":4}},{\"id\":\"RxQvZZH7ME\",\"type\":\"card\",\"data\":{\"card_name\":\"Setup\",\"col\":4}},{\"id\":\"2phxWUdck8\",\"type\":\"card\",\"data\":{\"card_name\":\"Reports\",\"col\":4}}]", "creation": "2022-12-27 17:17:22.857757", "custom_blocks": [], "docstatus": 0, @@ -356,7 +357,7 @@ "type": "Link" } ], - "modified": "2024-08-25 15:49:54.034113", + "modified": "2026-09-16 13:23:58.276004", "modified_by": "Administrator", "module": "Case Central", "name": "Case Central", @@ -375,6 +376,14 @@ "link_to": "Caveat", "type": "DocType" }, + { + "color": "Grey", + "doc_view": "List", + "label": "File Movement", + "link_to": "File Movement", + "stats_filter": "[]", + "type": "DocType" + }, { "color": "Green", "doc_view": "List", @@ -419,5 +428,6 @@ "type": "DocType" } ], - "title": "Case Central" -} \ No newline at end of file + "title": "Case Central", + "type": "Workspace" +}