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..bcfb876 --- /dev/null +++ b/casecentral/case_central/doctype/file_movement/file_movement.js @@ -0,0 +1,669 @@ +// 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; + + 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; + } + + // Populate first case in current row + frappe.model.set_value(cdt, cdn, "case", cases[0].name); + frappe.model.set_value(cdt, cdn, "barcode", cases[0].barcode || ""); + + // If multiple cases exist, add additional rows + if (cases.length > 1) { + for (var i = 1; i < cases.length; i++) { + var c = cases[i]; + var exists = (frm.doc.outgoing_files || []).some(function (item) { + return item.matter === (c.matter || row.matter) && item.case === c.name; + }); + + if (!exists) { + var new_row = frm.add_child("outgoing_files"); + new_row.matter = c.matter || row.matter; + new_row.case = c.name || ""; + new_row.barcode = c.barcode || ""; + } + } + frm.refresh_field("outgoing_files"); + } + + frappe.show_alert({ + message: __("{0} Case(s) added for Matter {1}.", [cases.length, row.matter]), + indicator: "green" + }); + }, + error: function () { + frappe.model.set_value(cdt, cdn, "matter", ""); + 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 added_count = 0; + 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; + added_count++; + } + } else { + cases.forEach(function (c) { + var exists = (frm.doc.outgoing_files || []).some(function (item) { + return item.matter === (c.matter || data.matter) && item.case === c.name; + }); + if (!exists) { + var row = frm.add_child("outgoing_files"); + row.matter = c.matter || data.matter; + row.case = c.name || ""; + row.barcode = c.barcode || ""; + added_count++; + } + }); + } + + frm.refresh_field("outgoing_files"); + if (added_count > 0) { + frappe.show_alert({ + message: __("Added Matter {0} ({1} Case(s)).", [data.matter, added_count]), + indicator: "green" + }); + } else { + frappe.show_alert({ + message: __("Matter {0} is already in Outgoing Files.", [data.matter]), + indicator: "orange" + }); + } + + } else if (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" + }); + } + } + }); +} + + +// ========================================================= +// 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]) + + '' + + '
' + + '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + rows_html + + '' + + '
#' + __("Matter") + '' + __("Case") + '' + __("Barcode") + '' + __("Status") + '
' + + '
' + + '
'; +} \ 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..7fdf4e8 --- /dev/null +++ b/casecentral/case_central/doctype/file_movement/file_movement.py @@ -0,0 +1,687 @@ +# 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"], + 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 + + +# ========================================================= +# 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/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..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 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" +} diff --git a/casecentral/desktop_icon/case_central.json b/casecentral/desktop_icon/case_central.json new file mode 100644 index 0000000..65b1bba --- /dev/null +++ b/casecentral/desktop_icon/case_central.json @@ -0,0 +1,22 @@ +{ + "app": "casecentral", + "bg_color": "gray", + "creation": "2026-09-28 11:58:21.809265", + "docstatus": 0, + "doctype": "Desktop Icon", + "hidden": 0, + "icon": "briefcase", + "icon_type": "App", + "idx": 0, + "label": "Case Central", + "link_to": "Case Central", + "link_type": "Workspace Sidebar", + "logo_url": "/assets/casecentral/images/logo.png", + "modified": "2026-09-28 11:58:21.809265", + "modified_by": "Administrator", + "name": "Case Central", + "owner": "Administrator", + "restrict_removal": 0, + "roles": [], + "standard": 1 +} diff --git a/casecentral/workspace_sidebar/case_central.json b/casecentral/workspace_sidebar/case_central.json new file mode 100644 index 0000000..199b92d --- /dev/null +++ b/casecentral/workspace_sidebar/case_central.json @@ -0,0 +1,40 @@ +{ + "app": "casecentral", + "creation": "2026-09-28 11:58:21.760974", + "docstatus": 0, + "doctype": "Workspace Sidebar", + "header_icon": "briefcase", + "idx": 0, + "items": [ + { + "child": 0, + "collapsible": 1, + "icon": "briefcase", + "indent": 0, + "keep_closed": 0, + "label": "Case Central", + "link_to": "Case Central", + "link_type": "Workspace", + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "icon": "file", + "indent": 0, + "keep_closed": 0, + "label": "Legal Documents", + "link_to": "Legal Documents", + "link_type": "Workspace", + "show_arrow": 0, + "type": "Link" + } + ], + "modified": "2026-09-28 11:58:21.760974", + "modified_by": "Administrator", + "name": "Case Central", + "owner": "Administrator", + "standard": 1, + "title": "Case Central" +}