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..7649cb8
--- /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": 4,
+ "fieldname": "matter",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Matter",
+ "options": "Matter"
+ },
+ {
+ "columns": 4,
+ "fieldname": "case",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Case",
+ "options": "Case"
+ },
+ {
+ "columns": 4,
+ "fieldname": "barcode",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Barcode"
+ },
+ {
+ "default": "0",
+ "fieldname": "returned",
+ "fieldtype": "Check",
+ "hidden": 1,
+ "label": "Returned",
+ "read_only": 1
+ }
+ ],
+ "grid_page_length": 50,
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2026-10-06 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..2d62c9f
--- /dev/null
+++ b/casecentral/case_central/doctype/file_movement/file_movement.js
@@ -0,0 +1,893 @@
+// Copyright (c) 2026, 4C Solutions and Contributors
+// See license.txt
+
+frappe.ui.form.on("File Movement", {
+
+ onload: function (frm) {
+ setup_scan_outgoing_barcode_input(frm);
+ setup_scan_incoming_barcode_input(frm);
+ setup_giver_receiver_filter(frm);
+ },
+
+ refresh: function (frm) {
+ setup_scan_outgoing_barcode_input(frm);
+ setup_scan_incoming_barcode_input(frm);
+ setup_giver_receiver_filter(frm);
+
+ frm.remove_custom_button("Create Incoming");
+ frm.remove_custom_button("Return Missing Files");
+
+ // Single Document Lifecycle:
+ // 1. When Outgoing is submitted and Incoming has NOT yet been activated
+ if (frm.doc.docstatus === 1 && !frm.doc.has_incoming && frm.doc.status !== "Received") {
+ add_create_incoming_button(frm);
+ }
+
+ // 2. When Incoming is active and some files are missing
+ if (frm.doc.docstatus === 1 && frm.doc.has_incoming && frm.doc.status === "Missing Files") {
+ add_return_missing_button(frm);
+ }
+ },
+
+ giver: function (frm) {
+ if (frm.doc.giver && frm.doc.receiver && frm.doc.giver === frm.doc.receiver) {
+ frappe.msgprint({
+ title: __("Invalid Selection"),
+ indicator: "red",
+ message: __("Giver and Receiver cannot be the same employee. Please choose a different Receiver.")
+ });
+ frm.set_value("receiver", "");
+ }
+ },
+
+ receiver: function (frm) {
+ if (frm.doc.giver && frm.doc.receiver && frm.doc.giver === frm.doc.receiver) {
+ frappe.msgprint({
+ title: __("Invalid Selection"),
+ indicator: "red",
+ message: __("Giver and Receiver cannot be the same employee. Please choose a different Receiver.")
+ });
+ frm.set_value("receiver", "");
+ }
+ },
+
+ scan_outgoing_barcode: function (frm) {
+ var val = (frm.doc.scan_outgoing_barcode || "").trim();
+ if (!val) return;
+ frm.set_value("scan_outgoing_barcode", "");
+ process_outgoing_scan(frm, val);
+ },
+
+ scan_incoming_barcode: function (frm) {
+ var val = (frm.doc.scan_incoming_barcode || "").trim();
+ if (!val) return;
+ frm.set_value("scan_incoming_barcode", "");
+ process_incoming_scan(frm, val);
+ }
+});
+
+
+// =========================================================
+// GIVER & RECEIVER FILTER (EXCLUDE SAME EMPLOYEE)
+// =========================================================
+
+function setup_giver_receiver_filter(frm) {
+ frm.set_query("receiver", function () {
+ var filters = [];
+ if (frm.doc.giver) {
+ filters.push(["Employee", "name", "!=", frm.doc.giver]);
+ }
+ return {
+ filters: filters
+ };
+ });
+
+ frm.set_query("giver", function () {
+ var filters = [];
+ if (frm.doc.receiver) {
+ filters.push(["Employee", "name", "!=", frm.doc.receiver]);
+ }
+ return {
+ filters: filters
+ };
+ });
+}
+
+
+
+// =========================================================
+// CHILD TABLE: OUTGOING FILES
+// =========================================================
+
+frappe.ui.form.on("Barcode File Item", {
+ matter: function (frm, cdt, cdn) {
+ var row = frappe.get_doc(cdt, cdn);
+ if (!row.matter) return;
+
+ // Only auto-expand in outgoing_files when in draft
+ if (row.parentfield !== "outgoing_files") return;
+
+ // If a specific case is already selected in this row, don't re-prompt
+ if (row.case) return;
+
+ frappe.call({
+ method: "casecentral.case_central.doctype.file_movement.file_movement.get_cases_for_matter",
+ args: { matter: row.matter },
+ freeze: true,
+ freeze_message: __("Validating Matter ID & finding Cases..."),
+ callback: function (response) {
+ var cases = response.message || [];
+ if (!cases.length) {
+ 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;
+ }
+
+ if (cases.length === 1) {
+ // Populate single case directly
+ frappe.model.set_value(cdt, cdn, "case", cases[0].name);
+ frappe.model.set_value(cdt, cdn, "barcode", cases[0].barcode || "");
+ frappe.show_alert({
+ message: __("Added Case {0} for Matter {1}.", [cases[0].name, row.matter]),
+ indicator: "green"
+ });
+ } else {
+ // Multiple cases exist -> Show selection dialog so user can pick which cases to take
+ show_case_selection_dialog(frm, row.matter, cases, row, function () {
+ if (!row.case) {
+ frappe.model.set_value(cdt, cdn, "matter", "");
+ frappe.model.set_value(cdt, cdn, "barcode", "");
+ }
+ });
+ }
+ },
+ 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;
+
+ if (row.parentfield !== "outgoing_files") return;
+
+ frappe.db.get_value("Case", row.case, ["matter", "name"]).then(r => {
+ if (r && r.message) {
+ if (r.message.matter && !row.matter) {
+ frappe.model.set_value(cdt, cdn, "matter", r.message.matter);
+ }
+ } else {
+ frappe.msgprint({
+ title: __("Case Not Found"),
+ indicator: "red",
+ message: __("Case '{0}' does not exist.", [row.case])
+ });
+ frappe.model.set_value(cdt, cdn, "case", "");
+ }
+ });
+ }
+});
+
+
+// =========================================================
+// SCAN OUTGOING BARCODE (DRAFT OUTGOING SECTION)
+// =========================================================
+
+function setup_scan_outgoing_barcode_input(frm) {
+ if (!frm.fields_dict.scan_outgoing_barcode) return;
+
+ var field = frm.fields_dict.scan_outgoing_barcode;
+ if (field.$input) {
+ field.$input.attr("placeholder", __("Scan Barcode / Matter ID / Case to Add to Outgoing Files..."));
+ field.$input.off("keydown.fm_out_scan").on("keydown.fm_out_scan", function (e) {
+ if (e.which === 13) {
+ e.preventDefault();
+ var val = $(this).val().trim();
+ if (val) {
+ $(this).val("");
+ frm.set_value("scan_outgoing_barcode", "");
+ process_outgoing_scan(frm, val);
+ }
+ }
+ });
+ }
+}
+
+function process_outgoing_scan(frm, code) {
+ if (!code) return;
+
+ frappe.call({
+ method: "casecentral.case_central.doctype.file_movement.file_movement.get_scan_information",
+ args: { value: code },
+ freeze: true,
+ freeze_message: __("Resolving scan '{0}'...", [code]),
+ callback: function (r) {
+ var data = r.message;
+ if (!data || data.type === "not_found") {
+ frappe.msgprint({
+ title: __("Not Found"),
+ indicator: "red",
+ message: (data && data.message) || __("Scanned value '{0}' does not exist in Case Central.", [code])
+ });
+ return;
+ }
+
+ if (data.type === "matter") {
+ var cases = data.cases || [];
+
+ if (cases.length === 0) {
+ var exists = (frm.doc.outgoing_files || []).some(function (item) {
+ return item.matter === data.matter && !item.case;
+ });
+ if (!exists) {
+ var row = frm.add_child("outgoing_files");
+ row.matter = data.matter;
+ frm.refresh_field("outgoing_files");
+ frappe.show_alert({
+ message: __("Added Matter {0} (No linked Cases found).", [data.matter]),
+ indicator: "blue"
+ });
+ } else {
+ frappe.show_alert({
+ message: __("Matter {0} is already in Outgoing Files.", [data.matter]),
+ indicator: "orange"
+ });
+ }
+ } else if (cases.length === 1) {
+ var c = cases[0];
+ var exists = (frm.doc.outgoing_files || []).some(function (item) {
+ return item.case === c.name;
+ });
+ if (!exists) {
+ var row = frm.add_child("outgoing_files");
+ row.matter = c.matter || data.matter;
+ row.case = c.name || "";
+ row.barcode = c.barcode || "";
+ frm.refresh_field("outgoing_files");
+ frappe.show_alert({
+ message: __("Added Case {0} for Matter {1}.", [c.name, data.matter]),
+ indicator: "green"
+ });
+ } else {
+ frappe.show_alert({
+ message: __("Case {0} is already in Outgoing Files.", [c.name]),
+ indicator: "orange"
+ });
+ }
+ } else {
+ // Multiple cases exist -> Show selection modal
+ show_case_selection_dialog(frm, data.matter, cases, null, null);
+ }
+
+ } else if (data.type === "case") {
+ var exists = (frm.doc.outgoing_files || []).some(function (item) {
+ return item.case === data.case_name;
+ });
+ if (exists) {
+ frappe.show_alert({
+ message: __("Case {0} is already in Outgoing Files.", [data.case_name]),
+ indicator: "orange"
+ });
+ return;
+ }
+
+ var row = frm.add_child("outgoing_files");
+ row.matter = data.matter || "";
+ row.case = data.case_name;
+ row.barcode = data.barcode || "";
+ frm.refresh_field("outgoing_files");
+
+ frappe.show_alert({
+ message: __("Added Case {0} to Outgoing Files.", [data.case_name]),
+ indicator: "green"
+ });
+
+ } else if (data.type === "case_file") {
+ var exists = (frm.doc.outgoing_files || []).some(function (item) {
+ return item.barcode === data.barcode;
+ });
+ if (exists) {
+ frappe.show_alert({
+ message: __("Barcode {0} is already in Outgoing Files.", [data.barcode]),
+ indicator: "orange"
+ });
+ return;
+ }
+
+ var row = frm.add_child("outgoing_files");
+ row.matter = data.matter || "";
+ row.case = data.case || "";
+ row.barcode = data.barcode;
+ frm.refresh_field("outgoing_files");
+
+ frappe.show_alert({
+ message: __("Added Barcode {0} to Outgoing Files.", [data.barcode]),
+ indicator: "green"
+ });
+ }
+ }
+ });
+}
+
+
+// =========================================================
+// MULTI-CASE SELECTION DIALOG (WHEN MATTER HAS MULTIPLE CASES)
+// =========================================================
+
+function show_case_selection_dialog(frm, matter, cases, target_row, on_cancel) {
+ cases = cases || [];
+ var existing_cases = new Set(
+ (frm.doc.outgoing_files || [])
+ .map(function (item) { return (item.case || "").trim(); })
+ .filter(Boolean)
+ );
+
+ if (target_row && target_row.case) {
+ existing_cases.delete(target_row.case.trim());
+ }
+
+ var available_count = cases.filter(function (c) {
+ return !existing_cases.has(c.name);
+ }).length;
+
+ if (available_count === 0) {
+ frappe.msgprint({
+ title: __("Cases Already Added"),
+ indicator: "orange",
+ message: __("All {0} case(s) for Matter {1} are already in Outgoing Files.", [cases.length, frappe.utils.escape_html(matter)])
+ });
+ if (on_cancel) on_cancel();
+ return;
+ }
+
+ var rows_html = "";
+ cases.forEach(function (c) {
+ var is_added = existing_cases.has(c.name);
+ var barcode_label = c.barcode ? `${frappe.utils.escape_html(c.barcode)}` : "";
+ var status_badge = c.status ? `${frappe.utils.escape_html(c.status)}` : "";
+ var disabled_attr = is_added ? "disabled" : "";
+ var row_style = is_added
+ ? "display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; margin-bottom: 6px; border: 1px solid var(--border-color); border-radius: 6px; opacity: 0.6; background: var(--bg-light-gray, #f9f9f9);"
+ : "display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; margin-bottom: 6px; border: 1px solid var(--border-color); border-radius: 6px; cursor: pointer; background: var(--card-bg, #fff); transition: background 0.15s ease;";
+ var already_added_note = is_added ? `${__("Already in Outgoing Files")}` : "";
+ var title_html = c.case_title ? `
${frappe.utils.escape_html(c.case_title)}
` : "";
+ var search_meta = ((c.name || "") + " " + (c.barcode || "") + " " + (c.case_title || "") + " " + (c.status || "")).toLowerCase();
+
+ rows_html += `
+
+
+
+
+
+ ${frappe.utils.escape_html(c.name)}
+ ${barcode_label}
+ ${already_added_note}
+
+ ${title_html}
+
+
+
+ ${status_badge}
+
+
+ `;
+ });
+
+ var dialog_content = `
+
+
+
+ Matter ${frappe.utils.escape_html(matter)} has ${cases.length} cases. Select the case(s) you want to take:
+
+
+
+
+ 0 of ${available_count} selected
+
+
+
+
+
+
+
+
+ ${rows_html}
+
+
+ `;
+
+ var dialog = new frappe.ui.Dialog({
+ title: __("Select Cases to Take — Matter {0}", [matter]),
+ fields: [
+ {
+ fieldtype: "HTML",
+ fieldname: "cases_html",
+ options: dialog_content
+ }
+ ],
+ primary_action_label: __("Add Selected Cases"),
+ primary_action: function () {
+ var selected = [];
+ dialog.$wrapper.find(".case-select-checkbox:checked").each(function () {
+ var c_name = $(this).data("case-name");
+ var match = cases.find(function (c) { return c.name === c_name; });
+ if (match) {
+ selected.push(match);
+ }
+ });
+
+ if (!selected.length) {
+ frappe.msgprint({
+ title: __("No Cases Selected"),
+ indicator: "orange",
+ message: __("Please select at least one case to take, or click Cancel.")
+ });
+ return;
+ }
+
+ dialog._completed = true;
+ add_selected_cases_to_outgoing(frm, matter, selected, target_row);
+ dialog.hide();
+ }
+ });
+
+ function update_counter() {
+ var count = dialog.$wrapper.find(".case-select-checkbox:checked").length;
+ dialog.$wrapper.find(".case-selected-counter").text(`${count} of ${available_count} selected`);
+ if (count > 0) {
+ dialog.set_primary_action_label(__("Add Selected ({0})", [count]));
+ } else {
+ dialog.set_primary_action_label(__("Add Selected Cases"));
+ }
+ }
+
+ dialog.on_page_show = function () {
+ var $wrapper = dialog.$wrapper;
+
+ $wrapper.on("click", ".case-item-card", function (e) {
+ if ($(e.target).is("input[type='checkbox']")) {
+ update_counter();
+ return;
+ }
+ var $cb = $(this).find(".case-select-checkbox");
+ if (!$cb.prop("disabled")) {
+ $cb.prop("checked", !$cb.prop("checked"));
+ update_counter();
+ }
+ });
+
+ $wrapper.on("input", ".case-filter-input", function () {
+ var q = ($(this).val() || "").toLowerCase().trim();
+ $wrapper.find(".case-item-card").each(function () {
+ var text = $(this).data("search") || "";
+ if (!q || text.indexOf(q) !== -1) {
+ $(this).show();
+ } else {
+ $(this).hide();
+ }
+ });
+ });
+
+ $wrapper.on("click", ".btn-select-all", function () {
+ $wrapper.find(".case-item-card:visible .case-select-checkbox:not(:disabled)").prop("checked", true);
+ update_counter();
+ });
+
+ $wrapper.on("click", ".btn-clear-all", function () {
+ $wrapper.find(".case-select-checkbox:not(:disabled)").prop("checked", false);
+ update_counter();
+ });
+
+ update_counter();
+ };
+
+ dialog.onhide = function () {
+ if (!dialog._completed && on_cancel) {
+ on_cancel();
+ }
+ };
+
+ dialog.show();
+}
+
+function add_selected_cases_to_outgoing(frm, matter, selected_cases, target_row) {
+ if (!selected_cases || !selected_cases.length) return;
+
+ var start_idx = 0;
+ if (target_row) {
+ var first = selected_cases[0];
+ frappe.model.set_value(target_row.doctype, target_row.name, "matter", matter);
+ frappe.model.set_value(target_row.doctype, target_row.name, "case", first.name);
+ frappe.model.set_value(target_row.doctype, target_row.name, "barcode", first.barcode || "");
+ start_idx = 1;
+ } else {
+ var blank_row = (frm.doc.outgoing_files || []).find(function (r) {
+ return !r.matter && !r.case && !r.barcode;
+ });
+ if (blank_row) {
+ var first = selected_cases[0];
+ blank_row.matter = matter;
+ blank_row.case = first.name;
+ blank_row.barcode = first.barcode || "";
+ start_idx = 1;
+ }
+ }
+
+ for (var i = start_idx; i < selected_cases.length; i++) {
+ var c = selected_cases[i];
+ var exists = (frm.doc.outgoing_files || []).some(function (item) {
+ return item.case === c.name;
+ });
+ if (!exists) {
+ var new_row = frm.add_child("outgoing_files");
+ new_row.matter = matter;
+ new_row.case = c.name;
+ new_row.barcode = c.barcode || "";
+ }
+ }
+
+ frm.refresh_field("outgoing_files");
+ frappe.show_alert({
+ message: __("Added {0} Case(s) for Matter {1} to Outgoing Files.", [selected_cases.length, frappe.utils.escape_html(matter)]),
+ indicator: "green"
+ });
+}
+
+
+// =========================================================
+// SCAN INCOMING BARCODE (SUBMITTED INCOMING SECTION)
+// =========================================================
+
+function setup_scan_incoming_barcode_input(frm) {
+ if (!frm.fields_dict.scan_incoming_barcode) return;
+
+ var field = frm.fields_dict.scan_incoming_barcode;
+ if (field.$input) {
+ field.$input.attr("placeholder", __("Scan Barcode / Matter ID / Case to Receive into Incoming Files..."));
+ field.$input.off("keydown.fm_inc_scan").on("keydown.fm_inc_scan", function (e) {
+ if (e.which === 13) {
+ e.preventDefault();
+ var val = $(this).val().trim();
+ if (val) {
+ $(this).val("");
+ frm.set_value("scan_incoming_barcode", "");
+ process_incoming_scan(frm, val);
+ }
+ }
+ });
+ }
+}
+
+function process_incoming_scan(frm, code) {
+ if (!code) return;
+
+ frappe.call({
+ method: "casecentral.case_central.doctype.file_movement.file_movement.scan_incoming_file",
+ args: {
+ movement_name: frm.doc.name,
+ value: code
+ },
+ freeze: true,
+ freeze_message: __("Recording incoming scan '{0}'...", [code]),
+ callback: function (r) {
+ if (!r.message) return;
+ var res = r.message;
+
+ if (res.status === "error") {
+ frappe.msgprint({
+ title: __("Not Found in Outgoing Files"),
+ indicator: "red",
+ message: res.message
+ });
+ } else if (res.status === "warning") {
+ frappe.show_alert({
+ message: res.message,
+ indicator: "orange"
+ });
+ } else if (res.status === "success") {
+ frappe.show_alert({
+ message: res.message,
+ indicator: res.movement_status === "Received" ? "green" : "blue"
+ });
+ frm.reload_doc();
+ }
+ }
+ });
+}
+
+
+// =========================================================
+// ACTION: CREATE INCOMING (STANDALONE PRIMARY BUTTON)
+// =========================================================
+
+function add_create_incoming_button(frm) {
+ var btn = frm.add_custom_button(__("Create Incoming"), function () {
+ frappe.confirm(
+ __("Do you want to initialize the Incoming Files workflow for this movement?"),
+ function () {
+ frappe.call({
+ method: "casecentral.case_central.doctype.file_movement.file_movement.create_incoming",
+ args: {
+ movement_name: frm.doc.name
+ },
+ freeze: true,
+ freeze_message: __("Initializing Incoming Files..."),
+ callback: function (r) {
+ if (r.message) {
+ frappe.show_alert({
+ message: __("Incoming Files table activated. You can now scan returned files."),
+ indicator: "green"
+ });
+ frm.reload_doc();
+ }
+ }
+ });
+ }
+ );
+ });
+
+ if (btn) {
+ btn.addClass("btn-primary");
+ }
+}
+
+
+// =========================================================
+// ACTION: RETURN MISSING FILES (SCANNER MODAL)
+// =========================================================
+
+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 items_state = missing_files.map(function (item) {
+ return {
+ barcode: item.barcode || "",
+ matter: item.matter || "",
+ case: item.case || "",
+ scanned: false
+ };
+ });
+
+ var dialog = new frappe.ui.Dialog({
+ title: __("Return Missing Files"),
+ fields: [
+ {
+ fieldname: "quick_scan",
+ label: __("Scan Barcode / Matter ID / Case to Return"),
+ fieldtype: "Data",
+ options: "Barcode",
+ description: __("Scan using barcode gun, click scanner icon for camera, or enter Matter ID / Case ID")
+ },
+ {
+ fieldname: "missing_table_area",
+ fieldtype: "HTML"
+ }
+ ],
+ primary_action_label: __("Return Scanned Files"),
+ primary_action: function () {
+ var selected_items = items_state.filter(function (item) {
+ return item.scanned;
+ });
+
+ if (!selected_items.length) {
+ frappe.msgprint({
+ title: __("No Files Scanned"),
+ indicator: "orange",
+ message: __("Please scan or enter at least one missing Matter ID, Case, or Barcode before submitting 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 scanned files into Incoming Files..."),
+ callback: function (response) {
+ if (response.message) {
+ var res = response.message;
+ frappe.show_alert({
+ message: res.status === "Received"
+ ? __("All missing files returned successfully! Movement marked as Received.")
+ : __("{0} file(s) returned. Some files remain missing.", [selected_items.length]),
+ indicator: res.status === "Received" ? "green" : "orange"
+ });
+ dialog.hide();
+ frm.reload_doc();
+ }
+ }
+ });
+ }
+ });
+
+ function update_table_view() {
+ var html = render_missing_table_html(items_state);
+ dialog.fields_dict.missing_table_area.$wrapper.html(html);
+ }
+
+ dialog.show();
+ update_table_view();
+
+ function match_and_mark_scanned(code) {
+ if (!code) return;
+ code = code.trim();
+
+ var matched_count = 0;
+ items_state.forEach(function (item) {
+ if (item.barcode === code || item.case === code || item.matter === code) {
+ item.scanned = true;
+ matched_count++;
+ }
+ });
+
+ if (matched_count > 0) {
+ update_table_view();
+ frappe.show_alert({
+ message: __("Scanned & verified: {0} ({1} item(s)).", [code, matched_count]),
+ indicator: "green"
+ });
+ dialog.set_value("quick_scan", "");
+ setTimeout(function () {
+ dialog.fields_dict.quick_scan?.$input?.focus();
+ }, 100);
+ } else {
+ frappe.call({
+ method: "casecentral.case_central.doctype.file_movement.file_movement.get_scan_information",
+ args: { value: code },
+ callback: function (r) {
+ var data = r.message;
+ var api_matched = 0;
+
+ if (data && data.type !== "not_found") {
+ items_state.forEach(function (item) {
+ if (data.type === "matter" && item.matter === data.matter) {
+ item.scanned = true;
+ api_matched++;
+ } else if (data.type === "case" && item.case === data.case_name) {
+ item.scanned = true;
+ api_matched++;
+ } else if (data.barcode && item.barcode === data.barcode) {
+ item.scanned = true;
+ api_matched++;
+ }
+ });
+ }
+
+ if (api_matched > 0) {
+ update_table_view();
+ frappe.show_alert({
+ message: __("Scanned & verified: {0} ({1} item(s)).", [code, api_matched]),
+ indicator: "green"
+ });
+ } else {
+ frappe.show_alert({
+ message: __("Code {0} is not in the missing files list for this movement.", [code]),
+ indicator: "orange"
+ });
+ }
+ dialog.set_value("quick_scan", "");
+ setTimeout(function () {
+ dialog.fields_dict.quick_scan?.$input?.focus();
+ }, 100);
+ }
+ });
+ }
+ }
+
+ setTimeout(function () {
+ var field = dialog.fields_dict.quick_scan;
+ var $input = field?.$input;
+ if ($input) {
+ $input.focus();
+
+ $input.off("keydown.return_scan").on("keydown.return_scan", function (e) {
+ if (e.which === 13) {
+ e.preventDefault();
+ var code = $(this).val().trim();
+ if (code) {
+ $(this).val("");
+ match_and_mark_scanned(code);
+ }
+ }
+ });
+
+ $input.off("change.return_scan").on("change.return_scan", function () {
+ var code = $(this).val().trim();
+ if (code) {
+ $(this).val("");
+ match_and_mark_scanned(code);
+ }
+ });
+ }
+ }, 200);
+}
+
+function render_missing_table_html(items) {
+ var scanned_count = items.filter(function (it) { return it.scanned; }).length;
+ var total_count = items.length;
+
+ var rows_html = items.map(function (item, idx) {
+ var row_bg = item.scanned ? "background-color: #f0fdf4;" : "";
+ var badge_html = item.scanned
+ ? '' + __("Scanned to Return") + ''
+ : '' + __("Pending Scan") + '';
+
+ return '' +
+ '| ' + (idx + 1) + ' | ' +
+ '' + (item.matter || "-") + ' | ' +
+ '' + (item.case || "-") + ' | ' +
+ '' + (item.barcode || "-") + ' | ' +
+ '' + badge_html + ' | ' +
+ '
';
+ }).join("");
+
+ return '' +
+ '
' +
+ '' + __("Missing Files List ({0})", [total_count]) + '' +
+ '' +
+ __("{0} of {1} Scanned", [scanned_count, total_count]) +
+ '' +
+ '
' +
+ '
' +
+ '
' +
+ '' +
+ '' +
+ '| # | ' +
+ '' + __("Matter") + ' | ' +
+ '' + __("Case") + ' | ' +
+ '' + __("Barcode") + ' | ' +
+ '' + __("Status") + ' | ' +
+ '
' +
+ '' +
+ '' +
+ rows_html +
+ '' +
+ '
' +
+ '
' +
+ '
';
+}
\ 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..b27a4f8
--- /dev/null
+++ b/casecentral/case_central/doctype/file_movement/file_movement.json
@@ -0,0 +1,180 @@
+{
+ "actions": [],
+ "allow_bulk_edit": 1,
+ "autoname": "autoname",
+ "creation": "2026-08-20 13:14:58.767666",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "giver",
+ "receiver",
+ "has_incoming",
+ "column_break_1",
+ "movement_date",
+ "incoming_date",
+ "status",
+ "amended_from",
+ "section_break_outgoing_files",
+ "scan_outgoing_barcode",
+ "outgoing_files",
+ "section_break_incoming_files",
+ "scan_incoming_barcode",
+ "incoming_files",
+ "section_break_missing_files",
+ "missing_file_items"
+ ],
+ "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
+ },
+ {
+ "default": "0",
+ "fieldname": "has_incoming",
+ "fieldtype": "Check",
+ "hidden": 1,
+ "label": "Has Incoming"
+ },
+ {
+ "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,
+ "depends_on": "eval:doc.has_incoming || (doc.incoming_files && doc.incoming_files.length > 0)",
+ "fieldname": "incoming_date",
+ "fieldtype": "Datetime",
+ "label": "Incoming Date",
+ "read_only": 1
+ },
+ {
+ "allow_on_submit": 1,
+ "default": "Open",
+ "fieldname": "status",
+ "fieldtype": "Select",
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "Status",
+ "options": "Open\nMissing Files\nReceived",
+ "read_only": 1
+ },
+ {
+ "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_outgoing_files",
+ "fieldtype": "Section Break",
+ "label": "Outgoing Files"
+ },
+ {
+ "fieldname": "scan_outgoing_barcode",
+ "fieldtype": "Data",
+ "label": "Scan Outgoing Barcode",
+ "options": "Barcode",
+ "read_only_depends_on": "eval:doc.docstatus == 1"
+ },
+ {
+ "allow_on_submit": 0,
+ "fieldname": "outgoing_files",
+ "fieldtype": "Table",
+ "label": "Outgoing Files",
+ "options": "Barcode File Item",
+ "read_only_depends_on": "eval:doc.docstatus == 1",
+ "reqd": 1
+ },
+ {
+ "collapsible": 0,
+ "depends_on": "eval:doc.has_incoming || (doc.incoming_files && doc.incoming_files.length > 0)",
+ "fieldname": "section_break_incoming_files",
+ "fieldtype": "Section Break",
+ "label": "Incoming Files"
+ },
+ {
+ "allow_on_submit": 1,
+ "depends_on": "eval:doc.has_incoming || (doc.incoming_files && doc.incoming_files.length > 0)",
+ "fieldname": "scan_incoming_barcode",
+ "fieldtype": "Data",
+ "label": "Scan Incoming Barcode",
+ "options": "Barcode"
+ },
+ {
+ "allow_on_submit": 1,
+ "depends_on": "eval:doc.has_incoming || (doc.incoming_files && doc.incoming_files.length > 0)",
+ "fieldname": "incoming_files",
+ "fieldtype": "Table",
+ "label": "Incoming Files",
+ "options": "Barcode File Item"
+ },
+ {
+ "collapsible": 1,
+ "depends_on": "eval:doc.status == 'Missing Files' || (doc.missing_file_items && doc.missing_file_items.length > 0)",
+ "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-10-12 15: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..fcd35bf
--- /dev/null
+++ b/casecentral/case_central/doctype/file_movement/file_movement.py
@@ -0,0 +1,689 @@
+# 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
+ self.name = frappe.model.naming.make_autoname("FM-.#####")
+
+ def validate(self):
+ self.validate_movement()
+ self.validate_rows()
+ self.validate_duplicate_barcodes()
+ self.update_missing_files_and_status()
+
+ def validate_movement(self):
+ 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("outgoing_files"):
+ frappe.throw("Please add at least one row in the Outgoing Files table.")
+
+ def validate_rows(self):
+ for idx, row in enumerate(self.get("outgoing_files") or [], start=1):
+ if row.matter:
+ if not frappe.db.exists("Matter", row.matter):
+ frappe.throw(f"Matter ID '{row.matter}' in row {idx} does not exist.")
+ if not row.case:
+ case_name = frappe.db.get_value("Case", {"matter": row.matter}, "name")
+ if case_name:
+ row.case = case_name
+
+ if row.case:
+ if not frappe.db.exists("Case", row.case):
+ frappe.throw(f"Case '{row.case}' in row {idx} does not exist.")
+ if not row.matter:
+ case_matter = frappe.db.get_value("Case", row.case, "matter")
+ if case_matter:
+ row.matter = case_matter
+
+ if not row.matter and not row.case and not row.barcode:
+ frappe.throw(f"Row {idx} in Outgoing Files must have a Matter ID, Case, or Barcode.")
+
+ def validate_duplicate_barcodes(self):
+ # Validate uniqueness inside outgoing_files
+ seen_barcodes = set()
+ seen_cases = set()
+ for idx, row in enumerate(self.get("outgoing_files") or [], start=1):
+ barcode = (row.barcode or "").strip()
+ if barcode:
+ if barcode in seen_barcodes:
+ frappe.throw(f"Duplicate barcode '{barcode}' in Outgoing Files row {idx}.")
+ seen_barcodes.add(barcode)
+
+ case_name = (row.case or "").strip()
+ if case_name:
+ if case_name in seen_cases:
+ frappe.throw(f"Duplicate Case '{case_name}' in Outgoing Files row {idx}.")
+ seen_cases.add(case_name)
+
+ # Validate uniqueness inside incoming_files
+ seen_inc_barcodes = set()
+ seen_inc_cases = set()
+ for idx, row in enumerate(self.get("incoming_files") or [], start=1):
+ barcode = (row.barcode or "").strip()
+ if barcode:
+ if barcode in seen_inc_barcodes:
+ frappe.throw(f"Duplicate barcode '{barcode}' in Incoming Files row {idx}.")
+ seen_inc_barcodes.add(barcode)
+
+ case_name = (row.case or "").strip()
+ if case_name:
+ if case_name in seen_inc_cases:
+ frappe.throw(f"Duplicate Case '{case_name}' in Incoming Files row {idx}.")
+ seen_inc_cases.add(case_name)
+
+ def update_missing_files_and_status(self):
+ if not self.has_incoming and not self.get("incoming_files"):
+ self.status = "Open"
+ self.set("missing_file_items", [])
+ return
+
+ missing = calculate_missing_files_from_doc(self)
+ self.set_missing_file_table(missing)
+ self.status = "Received" if len(missing) == 0 else "Missing Files"
+
+ 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 ""
+
+ def on_submit(self):
+ self.status = "Open"
+ self.db_set("status", "Open")
+ self.update_outgoing_linked_statuses()
+
+ def on_cancel(self):
+ self.restore_outgoing_linked_statuses()
+
+ def update_outgoing_linked_statuses(self):
+ matters = set()
+ for row in self.get("outgoing_files") or []:
+ if row.matter:
+ matters.add(row.matter)
+ for matter_name in matters:
+ recalculate_matter_status_from_movements(matter_name)
+
+ def restore_outgoing_linked_statuses(self):
+ matters = set()
+ for row in self.get("outgoing_files") or []:
+ if row.matter:
+ matters.add(row.matter)
+ for matter_name in matters:
+ recalculate_matter_status_from_movements(matter_name)
+
+
+def recalculate_matter_status_from_movements(matter_name):
+ """
+ Checks whether all cases and files for a Matter have been returned.
+ If there are active unreturned movements (docstatus=1, status!='Received'),
+ Matter status is set to 'Working'. Once all files are returned, status is 'Open'.
+ """
+ if not matter_name or not frappe.db.exists("Matter", matter_name):
+ return
+
+ # Find unreturned movements containing this matter in outgoing_files
+ unreturned = frappe.db.sql("""
+ SELECT bfi.name, bfi.`case`, bfi.barcode, fm.name as movement_name
+ FROM `tabBarcode File Item` bfi
+ INNER JOIN `tabFile Movement` fm ON fm.name = bfi.parent
+ WHERE fm.docstatus = 1
+ AND fm.status != 'Received'
+ AND bfi.parentfield = 'outgoing_files'
+ AND bfi.matter = %s
+ """, (matter_name,), as_dict=True)
+
+ if not unreturned:
+ frappe.db.set_value("Matter", matter_name, "status", "Open")
+ return
+
+ has_unreturned = False
+ for out_row in unreturned:
+ # Check if this outgoing item has been added into incoming_files of this movement
+ inc = frappe.db.sql("""
+ SELECT name FROM `tabBarcode File Item`
+ WHERE parent = %s
+ AND parentfield = 'incoming_files'
+ AND (
+ (`case` = %s AND %s != '') OR
+ (barcode = %s AND %s != '') OR
+ (matter = %s)
+ )
+ """, (out_row.movement_name, out_row.get("case") or "", out_row.get("case") or "", out_row.get("barcode") or "", out_row.get("barcode") or "", matter_name))
+
+ if not inc:
+ has_unreturned = True
+ break
+
+ if has_unreturned:
+ frappe.db.set_value("Matter", matter_name, "status", "Working")
+ else:
+ 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", "case_title", "status"],
+ 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,
+ "case_title": c.get("case_title") or "",
+ "status": c.get("status") or "",
+ "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
+
+
+# =========================================================
+# SINGLE DOCUMENT INCOMING & RETURN WORKFLOW
+# =========================================================
+
+def is_item_returned(out_row, incoming_rows):
+ """
+ Checks if an item from outgoing_files is present in incoming_rows.
+ """
+ out_case = (out_row.get("case") or "").strip()
+ out_barcode = (out_row.get("barcode") or "").strip()
+ out_matter = (out_row.get("matter") or "").strip()
+
+ for inc_row in incoming_rows:
+ inc_case = (inc_row.get("case") or "").strip()
+ inc_barcode = (inc_row.get("barcode") or "").strip()
+ inc_matter = (inc_row.get("matter") or "").strip()
+
+ # 1. Match by case if both have case
+ if out_case and inc_case and out_case == inc_case:
+ return True
+
+ # 2. Match by barcode if both have barcode
+ if out_barcode and inc_barcode and out_barcode == inc_barcode:
+ return True
+
+ # 3. Match by matter
+ if out_matter and inc_matter and out_matter == inc_matter:
+ if not out_case and not inc_case:
+ return True
+ if out_case and inc_case and out_case == inc_case:
+ return True
+
+ return False
+
+
+def calculate_missing_files_from_doc(doc):
+ """
+ Compares doc.outgoing_files against doc.incoming_files.
+ Any outgoing item not present in incoming_files is missing.
+ """
+ outgoing = doc.get("outgoing_files") or []
+ incoming = doc.get("incoming_files") or []
+
+ missing = []
+ for out_row in outgoing:
+ if not is_item_returned(out_row, incoming):
+ missing.append({
+ "barcode": out_row.barcode or "",
+ "matter": out_row.matter or "",
+ "case": out_row.case or ""
+ })
+ return missing
+
+
+@frappe.whitelist()
+def create_incoming(movement_name):
+ """
+ Activates incoming workflow on the SAME File Movement document:
+ - Sets has_incoming = 1
+ - Initializes incoming_date
+ - Computes missing files (all outgoing items not in incoming_files)
+ - Updates status and persists missing_file_items
+ """
+ movement_name = str(movement_name or "").strip()
+ if not movement_name or not frappe.db.exists("File Movement", movement_name):
+ frappe.throw("File Movement not found.")
+
+ doc = frappe.get_doc("File Movement", movement_name)
+
+ if doc.docstatus != 1:
+ frappe.throw("File Movement must be submitted before creating incoming.")
+
+ doc.has_incoming = 1
+ if not doc.incoming_date:
+ doc.incoming_date = frappe.utils.now_datetime()
+
+ missing = calculate_missing_files_from_doc(doc)
+ doc.set_missing_file_table(missing)
+ doc.status = "Missing Files" if missing else "Received"
+
+ frappe.db.set_value("File Movement", doc.name, {
+ "has_incoming": 1,
+ "incoming_date": doc.incoming_date,
+ "status": doc.status
+ })
+
+ frappe.db.delete("Missing File Items", {"parent": doc.name})
+ for row in doc.missing_file_items:
+ row.parent = doc.name
+ row.parenttype = "File Movement"
+ row.parentfield = "missing_file_items"
+ row.db_insert()
+
+ return {
+ "name": doc.name,
+ "has_incoming": 1,
+ "status": doc.status,
+ "missing_count": len(missing)
+ }
+
+
+@frappe.whitelist()
+def scan_incoming_file(movement_name, value):
+ """
+ Scans a barcode, Case ID, or Matter ID into incoming_files for this movement:
+ - Verifies the item belongs to outgoing_files
+ - Appends to incoming_files
+ - Recalculates missing files and status (Received / Missing Files)
+ - Updates DB and Matter status in real time
+ """
+ movement_name = str(movement_name or "").strip()
+ value = str(value or "").strip()
+
+ if not movement_name or not frappe.db.exists("File Movement", movement_name):
+ frappe.throw("File Movement not found.")
+
+ if not value:
+ frappe.throw("Barcode or ID is required.")
+
+ doc = frappe.get_doc("File Movement", movement_name)
+ if doc.docstatus != 1:
+ frappe.throw("Movement must be submitted before receiving files.")
+
+ outgoing = doc.get("outgoing_files") or []
+ items_to_add = []
+
+ # 1. Check direct matches in outgoing_files by barcode, case, or matter
+ direct_bc = [r for r in outgoing if (r.barcode or "").strip() == value]
+ direct_case = [r for r in outgoing if (r.case or "").strip() == value]
+ direct_matter = [r for r in outgoing if (r.matter or "").strip() == value]
+
+ if direct_bc:
+ for r in direct_bc:
+ items_to_add.append({
+ "matter": r.matter or "",
+ "case": r.case or "",
+ "barcode": r.barcode or value
+ })
+ elif direct_case:
+ for r in direct_case:
+ items_to_add.append({
+ "matter": r.matter or "",
+ "case": r.case,
+ "barcode": r.barcode or ""
+ })
+ elif direct_matter:
+ for r in direct_matter:
+ items_to_add.append({
+ "matter": r.matter,
+ "case": r.case or "",
+ "barcode": r.barcode or ""
+ })
+ else:
+ # 2. Fallback to global resolver
+ info = get_scan_information(value)
+ if not info or info.get("type") == "not_found":
+ return {
+ "status": "error",
+ "message": f"Scanned value '{value}' does not match any existing Matter, Case, or Barcode."
+ }
+
+ if info["type"] == "matter":
+ matter_val = info["matter"]
+ matching_out = [r for r in outgoing if r.matter == matter_val]
+ if not matching_out:
+ return {
+ "status": "error",
+ "message": f"Matter '{matter_val}' is not part of Outgoing Files in this movement."
+ }
+ for out_r in matching_out:
+ items_to_add.append({
+ "matter": out_r.matter,
+ "case": out_r.case or "",
+ "barcode": out_r.barcode or ""
+ })
+
+ elif info["type"] == "case":
+ case_val = info["case_name"]
+ matching_out = [r for r in outgoing if r.case == case_val]
+ if not matching_out:
+ return {
+ "status": "error",
+ "message": f"Case '{case_val}' is not part of Outgoing Files in this movement."
+ }
+ items_to_add.append({
+ "matter": matching_out[0].matter,
+ "case": case_val,
+ "barcode": matching_out[0].barcode or info.get("barcode") or ""
+ })
+
+ elif info["type"] == "case_file":
+ barcode_val = info["barcode"]
+ matching_out = [r for r in outgoing if r.barcode == barcode_val]
+ if not matching_out:
+ return {
+ "status": "error",
+ "message": f"Barcode '{barcode_val}' is not part of Outgoing Files in this movement."
+ }
+ items_to_add.append({
+ "matter": matching_out[0].matter,
+ "case": matching_out[0].case or "",
+ "barcode": barcode_val
+ })
+
+ # Filter out items already in incoming_files
+ incoming = doc.get("incoming_files") or []
+ new_items = []
+ already_scanned = 0
+
+ for item in items_to_add:
+ if is_item_returned(item, incoming):
+ already_scanned += 1
+ else:
+ new_items.append(item)
+
+ if not new_items:
+ return {
+ "status": "warning",
+ "message": f"All matching items for '{value}' are already scanned in Incoming Files."
+ }
+
+ # Insert new items into incoming_files
+ for item in new_items:
+ new_row = doc.append("incoming_files", item)
+ new_row.parent = doc.name
+ new_row.parenttype = "File Movement"
+ new_row.parentfield = "incoming_files"
+ new_row.db_insert()
+
+ # Recalculate remaining missing
+ remaining_missing = calculate_missing_files_from_doc(doc)
+ doc.set_missing_file_table(remaining_missing)
+ doc.status = "Received" if len(remaining_missing) == 0 else "Missing Files"
+ doc.has_incoming = 1
+ if not doc.incoming_date:
+ doc.incoming_date = frappe.utils.now_datetime()
+
+ frappe.db.set_value("File Movement", doc.name, {
+ "status": doc.status,
+ "has_incoming": 1,
+ "incoming_date": doc.incoming_date
+ })
+
+ # Update missing file items in DB
+ frappe.db.delete("Missing File Items", {"parent": doc.name})
+ for row in doc.missing_file_items:
+ row.parent = doc.name
+ row.parenttype = "File Movement"
+ row.parentfield = "missing_file_items"
+ row.db_insert()
+
+ # Update matter statuses
+ doc.update_outgoing_linked_statuses()
+
+ return {
+ "status": "success",
+ "movement_status": doc.status,
+ "added_count": len(new_items),
+ "missing_count": len(remaining_missing),
+ "message": f"Scanned & received {len(new_items)} file(s). Status: {doc.status}."
+ }
+
+
+@frappe.whitelist()
+def get_missing_files(movement_name):
+ movement_name = str(movement_name or "").strip()
+ if not movement_name:
+ return []
+
+ doc = frappe.get_doc("File Movement", movement_name)
+ 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
+):
+ """
+ Returns missing files directly into incoming_files of this single document.
+ """
+ movement_name = str(movement_name or "").strip()
+ if not movement_name:
+ frappe.throw("Movement name is required.")
+
+ doc = frappe.get_doc("File Movement", movement_name)
+ if doc.docstatus != 1:
+ frappe.throw("Movement must be submitted first.")
+
+ items_to_return = []
+ 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):
+ items_to_return.append(f)
+ elif str(f).strip():
+ items_to_return.append({"case": str(f).strip()})
+
+ for b in selected_barcodes or []:
+ if str(b).strip():
+ items_to_return.append({"barcode": str(b).strip()})
+ for m in selected_matters or []:
+ if str(m).strip():
+ items_to_return.append({"matter": str(m).strip()})
+ for c in selected_cases or []:
+ if str(c).strip():
+ items_to_return.append({"case": str(c).strip()})
+
+ if not items_to_return:
+ frappe.throw("Please select or scan at least one file to return.")
+
+ incoming = doc.get("incoming_files") or []
+ outgoing = doc.get("outgoing_files") or []
+ added_count = 0
+
+ for item in items_to_return:
+ t_case = item.get("case") or ""
+ t_barcode = item.get("barcode") or ""
+ t_matter = item.get("matter") or ""
+
+ # Match against outgoing files to get full record
+ matched_out = None
+ for out_r in outgoing:
+ if (t_case and out_r.case == t_case) or \
+ (t_barcode and out_r.barcode == t_barcode) or \
+ (t_matter and out_r.matter == t_matter and not t_case and not out_r.case):
+ matched_out = out_r
+ break
+
+ if matched_out:
+ if not is_item_returned(matched_out, incoming):
+ new_row = doc.append("incoming_files", {
+ "barcode": matched_out.barcode or "",
+ "matter": matched_out.matter or "",
+ "case": matched_out.case or ""
+ })
+ new_row.parent = doc.name
+ new_row.parenttype = "File Movement"
+ new_row.parentfield = "incoming_files"
+ new_row.db_insert()
+ added_count += 1
+ else:
+ # Direct insert if not strictly matched
+ if not t_matter and t_case:
+ t_matter = frappe.db.get_value("Case", t_case, "matter") or ""
+ if not t_case and t_barcode:
+ t_case = find_case_by_barcode(t_barcode) or ""
+ if t_case and not t_matter:
+ t_matter = frappe.db.get_value("Case", t_case, "matter") or ""
+
+ entry = {"barcode": t_barcode, "matter": t_matter, "case": t_case}
+ if not is_item_returned(entry, incoming):
+ new_row = doc.append("incoming_files", entry)
+ new_row.parent = doc.name
+ new_row.parenttype = "File Movement"
+ new_row.parentfield = "incoming_files"
+ new_row.db_insert()
+ added_count += 1
+
+ remaining_missing = calculate_missing_files_from_doc(doc)
+ doc.set_missing_file_table(remaining_missing)
+ doc.status = "Received" if len(remaining_missing) == 0 else "Missing Files"
+ doc.has_incoming = 1
+
+ frappe.db.set_value("File Movement", doc.name, {
+ "status": doc.status,
+ "has_incoming": 1
+ })
+
+ 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()
+
+ doc.update_outgoing_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..1db1b34
--- /dev/null
+++ b/casecentral/case_central/doctype/file_movement/file_movement_list.js
@@ -0,0 +1,13 @@
+frappe.listview_settings["File Movement"] = {
+ has_indicator_for_draft: true,
+ add_fields: ["status", "has_incoming"],
+ 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"];
+ }
+ }
+};
\ 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..dabe647
--- /dev/null
+++ b/casecentral/case_central/doctype/file_movement/test_file_movement.py
@@ -0,0 +1,283 @@
+# 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,
+ scan_incoming_file,
+ 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 outgoing_files is empty."""
+ fm = frappe.new_doc("File Movement")
+ 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.giver = self.giver
+ fm.receiver = self.giver
+ fm.append("outgoing_files", {"barcode": "BC-001", "matter": self.matter_1, "case": self.case_1})
+ self.assertRaises(frappe.ValidationError, fm.insert)
+
+ def test_duplicate_barcode_validation(self):
+ """Duplicate barcode in the same movement outgoing files must throw validation error."""
+ fm = frappe.new_doc("File Movement")
+ fm.giver = self.giver
+ fm.receiver = self.receiver
+ fm.append("outgoing_files", {"barcode": "BC-DUP-01", "matter": self.matter_1, "case": self.case_1})
+ fm.append("outgoing_files", {"barcode": "BC-DUP-01", "matter": self.matter_2, "case": self.case_2})
+ self.assertRaises(frappe.ValidationError, fm.insert)
+
+ def test_nonexistent_matter_scan_rejection(self):
+ """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_files_and_autoname(self):
+ """Outgoing movement creates FM-.#####, sets status to Open and Matter to Working."""
+ fm = frappe.new_doc("File Movement")
+ fm.giver = self.giver
+ fm.receiver = self.receiver
+ fm.append("outgoing_files", {
+ "barcode": "BC-001",
+ "matter": self.matter_1,
+ "case": self.case_1
+ })
+ fm.insert(ignore_permissions=True)
+ self.assertTrue(fm.name.startswith("FM-"), f"Expected FM- prefix, got {fm.name}")
+ self.assertEqual(fm.status, "Open")
+ fm.submit()
+ self.assertEqual(frappe.db.get_value("Matter", self.matter_1, "status"), "Working")
+
+ 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_single_doc_incoming_workflow_partial_and_full_returns(self):
+ """
+ Complete Single Document Lifecycle test:
+ 1. Create Outgoing with 2 items in outgoing_files -> Submit (status: Open, Matter: Working)
+ 2. Call create_incoming on SAME doc -> has_incoming=1, incoming_files empty, 2 missing items
+ 3. Scan 1 item into incoming_files via scan_incoming_file -> Status 'Missing Files', 1 missing
+ 4. Late return of remaining missing file via return_missing_files -> Status becomes 'Received', Matter: Open
+ """
+ # Step 1: Create Outgoing
+ doc = frappe.new_doc("File Movement")
+ doc.giver = self.giver
+ doc.receiver = self.receiver
+ doc.append("outgoing_files", {
+ "barcode": "TEST-BC-101",
+ "matter": self.matter_1,
+ "case": self.case_1
+ })
+ doc.append("outgoing_files", {
+ "barcode": "TEST-BC-102",
+ "matter": self.matter_2,
+ "case": self.case_2
+ })
+ doc.insert(ignore_permissions=True)
+ doc.submit()
+
+ self.assertEqual(doc.status, "Open")
+ self.assertEqual(frappe.db.get_value("Matter", self.matter_1, "status"), "Working")
+ self.assertEqual(frappe.db.get_value("Matter", self.matter_2, "status"), "Working")
+
+ # Step 2: Initialize Incoming on the SAME document
+ res_inc = create_incoming(doc.name)
+ self.assertEqual(res_inc["name"], doc.name)
+ self.assertEqual(res_inc["has_incoming"], 1)
+ self.assertEqual(res_inc["status"], "Missing Files")
+ self.assertEqual(res_inc["missing_count"], 2)
+
+ doc.reload()
+ self.assertEqual(len(doc.incoming_files), 0, "Incoming files should start empty")
+ self.assertEqual(len(doc.missing_file_items), 2, "All 2 outgoing items should initially be missing")
+
+ # Step 3: Scan 1 item into incoming_files
+ scan_res = scan_incoming_file(doc.name, "TEST-BC-101")
+ self.assertEqual(scan_res["status"], "success")
+ self.assertEqual(scan_res["movement_status"], "Missing Files")
+ self.assertEqual(scan_res["added_count"], 1)
+ self.assertEqual(scan_res["missing_count"], 1)
+
+ doc.reload()
+ self.assertEqual(len(doc.incoming_files), 1)
+ self.assertEqual(len(doc.missing_file_items), 1)
+ self.assertEqual(doc.missing_file_items[0].case, self.case_2)
+
+ # Step 4: Return remaining item
+ ret_res = return_missing_files(doc.name, files=[self.case_2])
+ self.assertEqual(ret_res["status"], "Received")
+ self.assertEqual(len(ret_res["missing"]), 0)
+
+ # Step 5: Verify document is now Received and Matters are Open
+ doc.reload()
+ self.assertEqual(doc.status, "Received")
+ self.assertEqual(len(doc.missing_file_items), 0)
+ self.assertEqual(len(doc.incoming_files), 2)
+ self.assertEqual(frappe.db.get_value("Matter", self.matter_1, "status"), "Open")
+ self.assertEqual(frappe.db.get_value("Matter", self.matter_2, "status"), "Open")
+
+
+def run_test_suite():
+ 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/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..c04c939
--- /dev/null
+++ b/casecentral/case_central/doctype/missing_file_items/missing_file_items.json
@@ -0,0 +1,54 @@
+{
+ "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"
+ ],
+ "fields": [
+ {
+ "columns": 4,
+ "fieldname": "barcode",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Barcode"
+ },
+ {
+ "columns": 4,
+ "fieldname": "matter",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Matter",
+ "options": "Matter"
+ },
+ {
+ "columns": 4,
+ "fieldname": "case",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Case",
+ "options": "Case"
+ }
+ ],
+ "grid_page_length": 50,
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2026-10-06 14:00:00.000000",
+ "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