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..5d24e6f
--- /dev/null
+++ b/casecentral/case_central/doctype/file_movement/file_movement.js
@@ -0,0 +1,730 @@
+// 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 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);
+ }
+ }
+ });
+ }
+ }, 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();
+
+ 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
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"
+}