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]) + + '' + + '
' + + '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + 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..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