diff --git a/erpnext/healthcare/doctype/clinical_procedure/test_clinical_procedure.py b/erpnext/healthcare/doctype/clinical_procedure/test_clinical_procedure.py index 81a3982c4bf8..0326e5e9da7d 100644 --- a/erpnext/healthcare/doctype/clinical_procedure/test_clinical_procedure.py +++ b/erpnext/healthcare/doctype/clinical_procedure/test_clinical_procedure.py @@ -11,7 +11,7 @@ class TestClinicalProcedure(unittest.TestCase): def test_procedure_template_item(self): - patient, medical_department, practitioner = create_healthcare_docs() + patient, practitioner = create_healthcare_docs() procedure_template = create_clinical_procedure_template() self.assertTrue(frappe.db.exists('Item', procedure_template.item)) @@ -20,7 +20,7 @@ def test_procedure_template_item(self): self.assertEqual(frappe.db.get_value('Item', procedure_template.item, 'disabled'), 1) def test_consumables(self): - patient, medical_department, practitioner = create_healthcare_docs() + patient, practitioner = create_healthcare_docs() procedure_template = create_clinical_procedure_template() procedure_template.allow_stock_consumption = 1 consumable = create_consumable() diff --git a/erpnext/healthcare/doctype/fee_validity/test_fee_validity.py b/erpnext/healthcare/doctype/fee_validity/test_fee_validity.py index 4a178723a0e6..957f85211de5 100644 --- a/erpnext/healthcare/doctype/fee_validity/test_fee_validity.py +++ b/erpnext/healthcare/doctype/fee_validity/test_fee_validity.py @@ -27,7 +27,7 @@ def test_fee_validity(self): healthcare_settings.automate_appointment_invoicing = 1 healthcare_settings.op_consulting_charge_item = item healthcare_settings.save(ignore_permissions=True) - patient, medical_department, practitioner = create_healthcare_docs() + patient, practitioner = create_healthcare_docs() # For first appointment, invoice is generated. First appointment not considered in fee validity appointment = create_appointment(patient, practitioner, nowdate()) diff --git a/erpnext/healthcare/doctype/healthcare_service_unit/healthcare_service_unit.js b/erpnext/healthcare/doctype/healthcare_service_unit/healthcare_service_unit.js index 2cdd5506565d..2d1caf7efc72 100644 --- a/erpnext/healthcare/doctype/healthcare_service_unit/healthcare_service_unit.js +++ b/erpnext/healthcare/doctype/healthcare_service_unit/healthcare_service_unit.js @@ -7,8 +7,8 @@ frappe.ui.form.on('Healthcare Service Unit', { // get query select healthcare service unit frm.fields_dict['parent_healthcare_service_unit'].get_query = function(doc) { - return{ - filters:[ + return { + filters: [ ['Healthcare Service Unit', 'is_group', '=', 1], ['Healthcare Service Unit', 'name', '!=', doc.healthcare_service_unit_name] ] @@ -21,6 +21,14 @@ frappe.ui.form.on('Healthcare Service Unit', { frm.add_custom_button(__('Healthcare Service Unit Tree'), function() { frappe.set_route('Tree', 'Healthcare Service Unit'); }); + + frm.set_query('warehouse', function() { + return { + filters: { + 'company': frm.doc.company + } + }; + }); }, set_root_readonly: function(frm) { // read-only for root healthcare service unit @@ -43,5 +51,10 @@ frappe.ui.form.on('Healthcare Service Unit', { else { frm.set_df_property('service_unit_type', 'reqd', 1); } + }, + overlap_appointments: function(frm) { + if (frm.doc.overlap_appointments == 0) { + frm.set_value('service_unit_capacity', ''); + } } }); diff --git a/erpnext/healthcare/doctype/healthcare_service_unit/healthcare_service_unit.json b/erpnext/healthcare/doctype/healthcare_service_unit/healthcare_service_unit.json index 9ee865a62a45..8935ec7d3c98 100644 --- a/erpnext/healthcare/doctype/healthcare_service_unit/healthcare_service_unit.json +++ b/erpnext/healthcare/doctype/healthcare_service_unit/healthcare_service_unit.json @@ -16,6 +16,7 @@ "service_unit_type", "allow_appointments", "overlap_appointments", + "service_unit_capacity", "inpatient_occupancy", "occupancy_status", "column_break_9", @@ -31,6 +32,8 @@ { "fieldname": "healthcare_service_unit_name", "fieldtype": "Data", + "hide_days": 1, + "hide_seconds": 1, "in_global_search": 1, "in_list_view": 1, "label": "Service Unit", @@ -41,6 +44,8 @@ "bold": 1, "fieldname": "parent_healthcare_service_unit", "fieldtype": "Link", + "hide_days": 1, + "hide_seconds": 1, "ignore_user_permissions": 1, "in_list_view": 1, "label": "Parent Service Unit", @@ -52,6 +57,8 @@ "depends_on": "eval:doc.inpatient_occupancy != 1 && doc.allow_appointments != 1", "fieldname": "is_group", "fieldtype": "Check", + "hide_days": 1, + "hide_seconds": 1, "label": "Is Group" }, { @@ -59,6 +66,8 @@ "depends_on": "eval:doc.is_group != 1", "fieldname": "service_unit_type", "fieldtype": "Link", + "hide_days": 1, + "hide_seconds": 1, "label": "Service Unit Type", "options": "Healthcare Service Unit Type" }, @@ -68,6 +77,8 @@ "fetch_from": "service_unit_type.allow_appointments", "fieldname": "allow_appointments", "fieldtype": "Check", + "hide_days": 1, + "hide_seconds": 1, "in_list_view": 1, "label": "Allow Appointments", "no_copy": 1, @@ -79,6 +90,8 @@ "fetch_from": "service_unit_type.overlap_appointments", "fieldname": "overlap_appointments", "fieldtype": "Check", + "hide_days": 1, + "hide_seconds": 1, "label": "Allow Overlap", "no_copy": 1, "read_only": 1 @@ -90,6 +103,8 @@ "fetch_from": "service_unit_type.inpatient_occupancy", "fieldname": "inpatient_occupancy", "fieldtype": "Check", + "hide_days": 1, + "hide_seconds": 1, "in_list_view": 1, "label": "Inpatient Occupancy", "no_copy": 1, @@ -100,6 +115,8 @@ "depends_on": "eval:doc.inpatient_occupancy == 1", "fieldname": "occupancy_status", "fieldtype": "Select", + "hide_days": 1, + "hide_seconds": 1, "label": "Occupancy Status", "no_copy": 1, "options": "Vacant\nOccupied", @@ -107,13 +124,17 @@ }, { "fieldname": "column_break_9", - "fieldtype": "Column Break" + "fieldtype": "Column Break", + "hide_days": 1, + "hide_seconds": 1 }, { "bold": 1, "depends_on": "eval:doc.is_group != 1", "fieldname": "warehouse", "fieldtype": "Link", + "hide_days": 1, + "hide_seconds": 1, "label": "Warehouse", "no_copy": 1, "options": "Warehouse" @@ -121,6 +142,8 @@ { "fieldname": "company", "fieldtype": "Link", + "hide_days": 1, + "hide_seconds": 1, "ignore_user_permissions": 1, "in_list_view": 1, "in_standard_filter": 1, @@ -134,6 +157,8 @@ "fieldname": "lft", "fieldtype": "Int", "hidden": 1, + "hide_days": 1, + "hide_seconds": 1, "label": "lft", "no_copy": 1, "print_hide": 1, @@ -143,6 +168,8 @@ "fieldname": "rgt", "fieldtype": "Int", "hidden": 1, + "hide_days": 1, + "hide_seconds": 1, "label": "rgt", "no_copy": 1, "print_hide": 1, @@ -152,6 +179,8 @@ "fieldname": "old_parent", "fieldtype": "Link", "hidden": 1, + "hide_days": 1, + "hide_seconds": 1, "ignore_user_permissions": 1, "label": "Old Parent", "no_copy": 1, @@ -163,14 +192,26 @@ "collapsible": 1, "fieldname": "tree_details_section", "fieldtype": "Section Break", + "hide_days": 1, + "hide_seconds": 1, "label": "Tree Details" + }, + { + "depends_on": "eval:doc.overlap_appointments == 1", + "fieldname": "service_unit_capacity", + "fieldtype": "Int", + "label": "Service Unit Capacity", + "mandatory_depends_on": "eval:doc.overlap_appointments == 1", + "non_negative": 1 } ], + "is_tree": 1, "links": [], - "modified": "2020-05-20 18:26:56.065543", + "modified": "2021-08-19 14:09:11.643464", "modified_by": "Administrator", "module": "Healthcare", "name": "Healthcare Service Unit", + "nsm_parent_field": "parent_healthcare_service_unit", "owner": "Administrator", "permissions": [ { diff --git a/erpnext/healthcare/doctype/healthcare_service_unit/healthcare_service_unit.py b/erpnext/healthcare/doctype/healthcare_service_unit/healthcare_service_unit.py index 9e0417a2bef2..989d42678974 100644 --- a/erpnext/healthcare/doctype/healthcare_service_unit/healthcare_service_unit.py +++ b/erpnext/healthcare/doctype/healthcare_service_unit/healthcare_service_unit.py @@ -5,14 +5,21 @@ from __future__ import unicode_literals from frappe.utils.nestedset import NestedSet +from frappe.utils import cint, cstr import frappe +from frappe import _ +import json + class HealthcareServiceUnit(NestedSet): nsm_parent_field = 'parent_healthcare_service_unit' + def validate(self): + self.set_service_unit_properties() + def autoname(self): if self.company: - suffix = " - " + frappe.get_cached_value('Company', self.company, "abbr") + suffix = " - " + frappe.get_cached_value('Company', self.company, 'abbr') if not self.healthcare_service_unit_name.endswith(suffix): self.name = self.healthcare_service_unit_name + suffix else: @@ -22,16 +29,86 @@ def on_update(self): super(HealthcareServiceUnit, self).on_update() self.validate_one_root() - def after_insert(self): + def set_service_unit_properties(self): if self.is_group: - self.allow_appointments = 0 - self.overlap_appointments = 0 - self.inpatient_occupancy = 0 - elif self.service_unit_type: + self.allow_appointments = False + self.overlap_appointments = False + self.inpatient_occupancy = False + self.service_unit_capacity = 0 + self.occupancy_status = '' + self.service_unit_type = '' + elif self.service_unit_type != '': service_unit_type = frappe.get_doc('Healthcare Service Unit Type', self.service_unit_type) self.allow_appointments = service_unit_type.allow_appointments - self.overlap_appointments = service_unit_type.overlap_appointments self.inpatient_occupancy = service_unit_type.inpatient_occupancy - if self.inpatient_occupancy: + + if self.inpatient_occupancy and self.occupancy_status != '': self.occupancy_status = 'Vacant' - self.overlap_appointments = 0 + + if service_unit_type.overlap_appointments: + self.overlap_appointments = True + else: + self.overlap_appointments = False + self.service_unit_capacity = 0 + + if self.overlap_appointments: + if not self.service_unit_capacity: + frappe.throw(_('Please set a valid Service Unit Capacity to enable Overlapping Appointments'), + title=_('Mandatory')) + + +@frappe.whitelist() +def add_multiple_service_units(parent, data): + ''' + parent - parent service unit under which the service units are to be created + data (dict) - company, healthcare_service_unit_name, count, service_unit_type, warehouse, service_unit_capacity + ''' + if not parent or not data: + return + + data = json.loads(data) + company = data.get('company') or \ + frappe.defaults.get_defaults().get('company') or \ + frappe.db.get_single_value('Global Defaults', 'default_company') + + if not data.get('healthcare_service_unit_name') or not company: + frappe.throw(_('Service Unit Name and Company are mandatory to create Healthcare Service Units'), + title=_('Missing Required Fields')) + + count = cint(data.get('count') or 0) + if count <= 0: + frappe.throw(_('Number of Service Units to be created should at least be 1'), + title=_('Invalid Number of Service Units')) + + capacity = cint(data.get('service_unit_capacity') or 1) + + service_unit = { + 'doctype': 'Healthcare Service Unit', + 'parent_healthcare_service_unit': parent, + 'service_unit_type': data.get('service_unit_type') or None, + 'service_unit_capacity': capacity if capacity > 0 else 1, + 'warehouse': data.get('warehouse') or None, + 'company': company + } + + service_unit_name = '{}'.format(data.get('healthcare_service_unit_name').strip(' -')) + + last_suffix = frappe.db.sql("""SELECT + IFNULL(MAX(CAST(SUBSTRING(name FROM %(start)s FOR 4) AS UNSIGNED)), 0) + FROM `tabHealthcare Service Unit` + WHERE name like %(prefix)s AND company=%(company)s""", + {'start': len(service_unit_name)+2, 'prefix': '{}-%'.format(service_unit_name), 'company': company}, + as_list=1)[0][0] + start_suffix = cint(last_suffix) + 1 + + failed_list = [] + for i in range(start_suffix, count + start_suffix): + # name to be in the form WARD-#### + service_unit['healthcare_service_unit_name'] = '{}-{}'.format(service_unit_name, cstr('%0*d' % (4, i))) + service_unit_doc = frappe.get_doc(service_unit) + try: + service_unit_doc.insert() + except Exception: + failed_list.append(service_unit['healthcare_service_unit_name']) + + return failed_list diff --git a/erpnext/healthcare/doctype/healthcare_service_unit/healthcare_service_unit_tree.js b/erpnext/healthcare/doctype/healthcare_service_unit/healthcare_service_unit_tree.js index b75f2718271f..ea3fea6b7a5b 100644 --- a/erpnext/healthcare/doctype/healthcare_service_unit/healthcare_service_unit_tree.js +++ b/erpnext/healthcare/doctype/healthcare_service_unit/healthcare_service_unit_tree.js @@ -1,35 +1,185 @@ -frappe.treeview_settings["Healthcare Service Unit"] = { - breadcrumbs: "Healthcare Service Unit", - title: __("Healthcare Service Unit"), +frappe.provide("frappe.treeview_settings"); + +frappe.treeview_settings['Healthcare Service Unit'] = { + breadcrumbs: 'Healthcare Service Unit', + title: __('Service Unit Tree'), get_tree_root: false, + get_tree_nodes: 'erpnext.healthcare.utils.get_children', filters: [{ - fieldname: "company", - fieldtype: "Select", - options: erpnext.utils.get_tree_options("company"), - label: __("Company"), - default: erpnext.utils.get_tree_default("company") + fieldname: 'company', + fieldtype: 'Select', + options: erpnext.utils.get_tree_options('company'), + label: __('Company'), + default: erpnext.utils.get_tree_default('company') }], - get_tree_nodes: 'erpnext.healthcare.utils.get_children', - ignore_fields:["parent_healthcare_service_unit"], - onrender: function(node) { - if (node.data.occupied_out_of_vacant!==undefined) { - $('' - + " " + node.data.occupied_out_of_vacant + fields: [ + { + fieldtype: 'Data', fieldname: 'healthcare_service_unit_name', label: __('New Service Unit Name'), + reqd: true + }, + { + fieldtype: 'Check', fieldname: 'is_group', label: __('Is Group'), + description: __("Child nodes can be only created under 'Group' type nodes") + }, + { + fieldtype: 'Link', fieldname: 'service_unit_type', label: __('Service Unit Type'), + options: 'Healthcare Service Unit Type', description: __('Type of the new Service Unit'), + depends_on: 'eval:!doc.is_group', default: '', + onchange: () => { + if (cur_dialog) { + if (cur_dialog.fields_dict.service_unit_type.value) { + frappe.db.get_value('Healthcare Service Unit Type', + cur_dialog.fields_dict.service_unit_type.value, 'overlap_appointments') + .then(r => { + if (r.message.overlap_appointments) { + cur_dialog.set_df_property('service_unit_capacity', 'hidden', false); + cur_dialog.set_df_property('service_unit_capacity', 'reqd', true); + } else { + cur_dialog.set_df_property('service_unit_capacity', 'hidden', true); + cur_dialog.set_df_property('service_unit_capacity', 'reqd', false); + } + }); + } else { + cur_dialog.set_df_property('service_unit_capacity', 'hidden', true); + cur_dialog.set_df_property('service_unit_capacity', 'reqd', false); + } + } + } + }, + { + fieldtype: 'Int', fieldname: 'service_unit_capacity', label: __('Service Unit Capacity'), + description: __('Sets the number of concurrent appointments allowed'), reqd: false, + depends_on: "eval:!doc.is_group && doc.service_unit_type != ''", hidden: true + }, + { + fieldtype: 'Link', fieldname: 'warehouse', label: __('Warehouse'), options: 'Warehouse', + description: __('Optional, if you want to manage stock separately for this Service Unit'), + depends_on: 'eval:!doc.is_group' + }, + { + fieldtype: 'Link', fieldname: 'company', label: __('Company'), options: 'Company', reqd: true, + default: () => { + return cur_page.page.page.fields_dict.company.value; + } + } + ], + ignore_fields: ['parent_healthcare_service_unit'], + onrender: function (node) { + if (node.data.occupied_of_available !== undefined) { + $("" + + ' ' + node.data.occupied_of_available + '').insertBefore(node.$ul); } - if (node.data && node.data.inpatient_occupancy!==undefined) { + if (node.data && node.data.inpatient_occupancy !== undefined) { if (node.data.inpatient_occupancy == 1) { - if (node.data.occupancy_status == "Occupied") { - $('' - + " " + node.data.occupancy_status + if (node.data.occupancy_status == 'Occupied') { + $("" + + ' ' + node.data.occupancy_status + '').insertBefore(node.$ul); } - if (node.data.occupancy_status == "Vacant") { - $('' - + " " + node.data.occupancy_status + if (node.data.occupancy_status == 'Vacant') { + $("" + + ' ' + node.data.occupancy_status + '').insertBefore(node.$ul); } } } }, + post_render: function (treeview) { + frappe.treeview_settings['Healthcare Service Unit'].treeview = {}; + $.extend(frappe.treeview_settings['Healthcare Service Unit'].treeview, treeview); + }, + toolbar: [ + { + label: __('Add Multiple'), + condition: function (node) { + return node.expandable; + }, + click: function (node) { + const dialog = new frappe.ui.Dialog({ + title: __('Add Multiple Service Units'), + fields: [ + { + fieldtype: 'Data', fieldname: 'healthcare_service_unit_name', label: __('Service Unit Name'), + reqd: true, description: __("Will be serially suffixed to maintain uniquness. Example: 'Ward' will be named as 'Ward-####'"), + }, + { + fieldtype: 'Int', fieldname: 'count', label: __('Number of Service Units'), + reqd: true + }, + { + fieldtype: 'Link', fieldname: 'service_unit_type', label: __('Service Unit Type'), + options: 'Healthcare Service Unit Type', description: __('Type of the new Service Unit'), + depends_on: 'eval:!doc.is_group', default: '', reqd: true, + onchange: () => { + if (cur_dialog) { + if (cur_dialog.fields_dict.service_unit_type.value) { + frappe.db.get_value('Healthcare Service Unit Type', + cur_dialog.fields_dict.service_unit_type.value, 'overlap_appointments') + .then(r => { + if (r.message.overlap_appointments) { + cur_dialog.set_df_property('service_unit_capacity', 'hidden', false); + cur_dialog.set_df_property('service_unit_capacity', 'reqd', true); + } else { + cur_dialog.set_df_property('service_unit_capacity', 'hidden', true); + cur_dialog.set_df_property('service_unit_capacity', 'reqd', false); + } + }); + } else { + cur_dialog.set_df_property('service_unit_capacity', 'hidden', true); + cur_dialog.set_df_property('service_unit_capacity', 'reqd', false); + } + } + } + }, + { + fieldtype: 'Int', fieldname: 'service_unit_capacity', label: __('Service Unit Capacity'), + description: __('Sets the number of concurrent appointments allowed'), reqd: false, + depends_on: "eval:!doc.is_group && doc.service_unit_type != ''", hidden: true + }, + { + fieldtype: 'Link', fieldname: 'warehouse', label: __('Warehouse'), options: 'Warehouse', + description: __('Optional, if you want to manage stock separately for this Service Unit'), + }, + { + fieldtype: 'Link', fieldname: 'company', label: __('Company'), options: 'Company', reqd: true, + default: () => { + return cur_page.page.page.fields_dict.company.get_value(); + } + } + ], + primary_action: () => { + dialog.hide(); + let vals = dialog.get_values(); + if (!vals) return; + + return frappe.call({ + method: 'erpnext.healthcare.doctype.healthcare_service_unit.healthcare_service_unit.add_multiple_service_units', + args: { + parent: node.data.value, + data: vals + }, + callback: function (r) { + if (!r.exc && r.message) { + frappe.treeview_settings['Healthcare Service Unit'].treeview.tree.load_children(node, true); + + frappe.show_alert({ + message: __('{0} Service Units created', [vals.count - r.message.length]), + indicator: 'green' + }); + } else { + frappe.msgprint(__('Could not create Service Units')); + } + }, + freeze: true, + freeze_message: __('Creating {0} Service Units', [vals.count]) + }); + }, + primary_action_label: __('Create') + }); + dialog.show(); + } + } + ], + extend_toolbar: true }; diff --git a/erpnext/healthcare/doctype/healthcare_service_unit_type/healthcare_service_unit_type.js b/erpnext/healthcare/doctype/healthcare_service_unit_type/healthcare_service_unit_type.js index eb33ab68c0d5..ecf4aa1a4bfe 100644 --- a/erpnext/healthcare/doctype/healthcare_service_unit_type/healthcare_service_unit_type.js +++ b/erpnext/healthcare/doctype/healthcare_service_unit_type/healthcare_service_unit_type.js @@ -68,8 +68,8 @@ let change_item_code = function(frm, doc) { if (values) { frappe.call({ "method": "erpnext.healthcare.doctype.healthcare_service_unit_type.healthcare_service_unit_type.change_item_code", - "args": {item: doc.item, item_code: values['item_code'], doc_name: doc.name}, - callback: function () { + "args": { item: doc.item, item_code: values['item_code'], doc_name: doc.name }, + callback: function() { frm.reload_doc(); } }); diff --git a/erpnext/healthcare/doctype/healthcare_service_unit_type/healthcare_service_unit_type.json b/erpnext/healthcare/doctype/healthcare_service_unit_type/healthcare_service_unit_type.json index 4b8503d02869..9c81c65f6b8d 100644 --- a/erpnext/healthcare/doctype/healthcare_service_unit_type/healthcare_service_unit_type.json +++ b/erpnext/healthcare/doctype/healthcare_service_unit_type/healthcare_service_unit_type.json @@ -29,6 +29,8 @@ { "fieldname": "service_unit_type", "fieldtype": "Data", + "hide_days": 1, + "hide_seconds": 1, "in_list_view": 1, "label": "Service Unit Type", "no_copy": 1, @@ -41,6 +43,8 @@ "depends_on": "eval:doc.inpatient_occupancy != 1", "fieldname": "allow_appointments", "fieldtype": "Check", + "hide_days": 1, + "hide_seconds": 1, "label": "Allow Appointments" }, { @@ -49,6 +53,8 @@ "depends_on": "eval:doc.allow_appointments == 1 && doc.inpatient_occupany != 1", "fieldname": "overlap_appointments", "fieldtype": "Check", + "hide_days": 1, + "hide_seconds": 1, "label": "Allow Overlap" }, { @@ -57,6 +63,8 @@ "depends_on": "eval:doc.allow_appointments != 1", "fieldname": "inpatient_occupancy", "fieldtype": "Check", + "hide_days": 1, + "hide_seconds": 1, "label": "Inpatient Occupancy" }, { @@ -65,17 +73,23 @@ "depends_on": "eval:doc.inpatient_occupancy == 1 && doc.allow_appointments != 1", "fieldname": "is_billable", "fieldtype": "Check", + "hide_days": 1, + "hide_seconds": 1, "label": "Is Billable" }, { "depends_on": "is_billable", "fieldname": "item_details", "fieldtype": "Section Break", + "hide_days": 1, + "hide_seconds": 1, "label": "Item Details" }, { "fieldname": "item", "fieldtype": "Link", + "hide_days": 1, + "hide_seconds": 1, "label": "Item", "no_copy": 1, "options": "Item", @@ -84,6 +98,8 @@ { "fieldname": "item_code", "fieldtype": "Data", + "hide_days": 1, + "hide_seconds": 1, "label": "Item Code", "mandatory_depends_on": "eval: doc.is_billable == 1", "no_copy": 1 @@ -91,6 +107,8 @@ { "fieldname": "item_group", "fieldtype": "Link", + "hide_days": 1, + "hide_seconds": 1, "label": "Item Group", "mandatory_depends_on": "eval: doc.is_billable == 1", "options": "Item Group" @@ -98,6 +116,8 @@ { "fieldname": "uom", "fieldtype": "Link", + "hide_days": 1, + "hide_seconds": 1, "label": "UOM", "mandatory_depends_on": "eval: doc.is_billable == 1", "options": "UOM" @@ -105,28 +125,38 @@ { "fieldname": "no_of_hours", "fieldtype": "Int", + "hide_days": 1, + "hide_seconds": 1, "label": "UOM Conversion in Hours", "mandatory_depends_on": "eval: doc.is_billable == 1" }, { "fieldname": "column_break_11", - "fieldtype": "Column Break" + "fieldtype": "Column Break", + "hide_days": 1, + "hide_seconds": 1 }, { "fieldname": "rate", "fieldtype": "Currency", + "hide_days": 1, + "hide_seconds": 1, "label": "Rate / UOM" }, { "default": "0", "fieldname": "disabled", "fieldtype": "Check", + "hide_days": 1, + "hide_seconds": 1, "label": "Disabled", "no_copy": 1 }, { "fieldname": "description", "fieldtype": "Small Text", + "hide_days": 1, + "hide_seconds": 1, "label": "Description" }, { @@ -134,11 +164,13 @@ "fieldname": "change_in_item", "fieldtype": "Check", "hidden": 1, + "hide_days": 1, + "hide_seconds": 1, "label": "Change in Item" } ], "links": [], - "modified": "2020-05-20 15:31:09.627516", + "modified": "2021-08-19 17:52:30.266667", "modified_by": "Administrator", "module": "Healthcare", "name": "Healthcare Service Unit Type", diff --git a/erpnext/healthcare/doctype/inpatient_record/test_inpatient_record.py b/erpnext/healthcare/doctype/inpatient_record/test_inpatient_record.py index a8c7720a0a47..b4a961264f25 100644 --- a/erpnext/healthcare/doctype/inpatient_record/test_inpatient_record.py +++ b/erpnext/healthcare/doctype/inpatient_record/test_inpatient_record.py @@ -151,7 +151,7 @@ def get_healthcare_service_unit(unit_name=None): if not service_unit: service_unit = frappe.new_doc("Healthcare Service Unit") - service_unit.healthcare_service_unit_name = unit_name or "Test Service Unit Ip Occupancy" + service_unit.healthcare_service_unit_name = unit_name or "_Test Service Unit Ip Occupancy" service_unit.company = "_Test Company" service_unit.service_unit_type = get_service_unit_type() service_unit.inpatient_occupancy = 1 @@ -159,12 +159,12 @@ def get_healthcare_service_unit(unit_name=None): service_unit.is_group = 0 service_unit_parent_name = frappe.db.exists({ "doctype": "Healthcare Service Unit", - "healthcare_service_unit_name": "All Healthcare Service Units", + "healthcare_service_unit_name": "_Test All Healthcare Service Units", "is_group": 1 }) if not service_unit_parent_name: parent_service_unit = frappe.new_doc("Healthcare Service Unit") - parent_service_unit.healthcare_service_unit_name = "All Healthcare Service Units" + parent_service_unit.healthcare_service_unit_name = "_Test All Healthcare Service Units" parent_service_unit.is_group = 1 parent_service_unit.save(ignore_permissions = True) service_unit.parent_healthcare_service_unit = parent_service_unit.name @@ -180,7 +180,7 @@ def get_service_unit_type(): if not service_unit_type: service_unit_type = frappe.new_doc("Healthcare Service Unit Type") - service_unit_type.service_unit_type = "Test Service Unit Type Ip Occupancy" + service_unit_type.service_unit_type = "_Test Service Unit Type Ip Occupancy" service_unit_type.inpatient_occupancy = 1 service_unit_type.save(ignore_permissions = True) return service_unit_type.name diff --git a/erpnext/healthcare/doctype/patient/patient.js b/erpnext/healthcare/doctype/patient/patient.js index bce42e51d07d..9266467155f9 100644 --- a/erpnext/healthcare/doctype/patient/patient.js +++ b/erpnext/healthcare/doctype/patient/patient.js @@ -26,31 +26,39 @@ frappe.ui.form.on('Patient', { } if (frm.doc.patient_name && frappe.user.has_role('Physician')) { + frm.add_custom_button(__('Patient Progress'), function() { + frappe.route_options = {'patient': frm.doc.name}; + frappe.set_route('patient-progress'); + }, __('View')); + frm.add_custom_button(__('Patient History'), function() { frappe.route_options = {'patient': frm.doc.name}; frappe.set_route('patient_history'); - },'View'); + }, __('View')); } - if (!frm.doc.__islocal && (frappe.user.has_role('Nursing User') || frappe.user.has_role('Physician'))) { - frm.add_custom_button(__('Vital Signs'), function () { - create_vital_signs(frm); - }, 'Create'); - frm.add_custom_button(__('Medical Record'), function () { - create_medical_record(frm); - }, 'Create'); - frm.add_custom_button(__('Patient Encounter'), function () { - create_encounter(frm); - }, 'Create'); - frm.toggle_enable(['customer'], 0); // ToDo, allow change only if no transactions booked or better, add merge option + frappe.dynamic_link = {doc: frm.doc, fieldname: 'name', doctype: 'Patient'}; + frm.toggle_display(['address_html', 'contact_html'], !frm.is_new()); + + if (!frm.is_new()) { + if ((frappe.user.has_role('Nursing User') || frappe.user.has_role('Physician'))) { + frm.add_custom_button(__('Medical Record'), function () { + create_medical_record(frm); + }, 'Create'); + frm.toggle_enable(['customer'], 0); + } + frappe.contacts.render_address_and_contact(frm); + erpnext.utils.set_party_dashboard_indicators(frm); + } else { + frappe.contacts.clear_address_and_contact(frm); } }, + onload: function (frm) { - if (!frm.doc.dob) { - $(frm.fields_dict['age_html'].wrapper).html(''); - } if (frm.doc.dob) { $(frm.fields_dict['age_html'].wrapper).html(`${__('AGE')} : ${get_age(frm.doc.dob)}`); + } else { + $(frm.fields_dict['age_html'].wrapper).html(''); } } }); @@ -59,16 +67,14 @@ frappe.ui.form.on('Patient', 'dob', function(frm) { if (frm.doc.dob) { let today = new Date(); let birthDate = new Date(frm.doc.dob); - if (today < birthDate){ + if (today < birthDate) { frappe.msgprint(__('Please select a valid Date')); frappe.model.set_value(frm.doctype,frm.docname, 'dob', ''); - } - else { + } else { let age_str = get_age(frm.doc.dob); $(frm.fields_dict['age_html'].wrapper).html(`${__('AGE')} : ${age_str}`); } - } - else { + } else { $(frm.fields_dict['age_html'].wrapper).html(''); } }); diff --git a/erpnext/healthcare/doctype/patient/patient.json b/erpnext/healthcare/doctype/patient/patient.json index 8af1a9ccd75a..4092a6a76819 100644 --- a/erpnext/healthcare/doctype/patient/patient.json +++ b/erpnext/healthcare/doctype/patient/patient.json @@ -1,6 +1,6 @@ { "actions": [], - "allow_copy": 1, + "allow_events_in_timeline": 1, "allow_import": 1, "allow_rename": 1, "autoname": "naming_series:", @@ -24,12 +24,19 @@ "image", "column_break_14", "status", + "uid", "inpatient_record", "inpatient_status", "report_preference", "mobile", - "email", "phone", + "email", + "invite_user", + "user_id", + "address_contacts", + "address_html", + "column_break_22", + "contact_html", "customer_details_section", "customer", "customer_group", @@ -74,6 +81,7 @@ "fieldtype": "Select", "in_preview": 1, "label": "Inpatient Status", + "no_copy": 1, "options": "\nAdmission Scheduled\nAdmitted\nDischarge Scheduled", "read_only": 1 }, @@ -81,6 +89,7 @@ "fieldname": "inpatient_record", "fieldtype": "Link", "label": "Inpatient Record", + "no_copy": 1, "options": "Inpatient Record", "read_only": 1 }, @@ -101,6 +110,7 @@ "in_list_view": 1, "in_standard_filter": 1, "label": "Full Name", + "no_copy": 1, "read_only": 1, "search_index": 1 }, @@ -118,6 +128,7 @@ "fieldtype": "Select", "in_preview": 1, "label": "Blood Group", + "no_copy": 1, "options": "\nA Positive\nA Negative\nAB Positive\nAB Negative\nB Positive\nB Negative\nO Positive\nO Negative" }, { @@ -125,7 +136,8 @@ "fieldname": "dob", "fieldtype": "Date", "in_preview": 1, - "label": "Date of birth" + "label": "Date of birth", + "no_copy": 1 }, { "fieldname": "age_html", @@ -167,6 +179,7 @@ "fieldtype": "Link", "ignore_user_permissions": 1, "label": "Customer", + "no_copy": 1, "options": "Customer", "set_only_once": 1 }, @@ -183,6 +196,7 @@ "in_list_view": 1, "in_standard_filter": 1, "label": "Mobile", + "no_copy": 1, "options": "Phone" }, { @@ -192,6 +206,7 @@ "in_list_view": 1, "in_standard_filter": 1, "label": "Email", + "no_copy": 1, "options": "Email" }, { @@ -199,6 +214,7 @@ "fieldtype": "Data", "in_filter": 1, "label": "Phone", + "no_copy": 1, "options": "Phone" }, { @@ -230,7 +246,8 @@ "fieldname": "medication", "fieldtype": "Small Text", "ignore_xss_filter": 1, - "label": "Medication" + "label": "Medication", + "no_copy": 1 }, { "fieldname": "column_break_20", @@ -240,13 +257,15 @@ "fieldname": "medical_history", "fieldtype": "Small Text", "ignore_xss_filter": 1, - "label": "Medical History" + "label": "Medical History", + "no_copy": 1 }, { "fieldname": "surgical_history", "fieldtype": "Small Text", "ignore_xss_filter": 1, - "label": "Surgical History" + "label": "Surgical History", + "no_copy": 1 }, { "collapsible": 1, @@ -258,8 +277,8 @@ "fieldname": "occupation", "fieldtype": "Data", "ignore_xss_filter": 1, - "in_standard_filter": 1, - "label": "Occupation" + "label": "Occupation", + "no_copy": 1 }, { "fieldname": "column_break_25", @@ -269,6 +288,7 @@ "fieldname": "marital_status", "fieldtype": "Select", "label": "Marital Status", + "no_copy": 1, "options": "\nSingle\nMarried\nDivorced\nWidow" }, { @@ -281,25 +301,29 @@ "fieldname": "tobacco_past_use", "fieldtype": "Data", "ignore_xss_filter": 1, - "label": "Tobacco Consumption (Past)" + "label": "Tobacco Consumption (Past)", + "no_copy": 1 }, { "fieldname": "tobacco_current_use", "fieldtype": "Data", "ignore_xss_filter": 1, - "label": "Tobacco Consumption (Present)" + "label": "Tobacco Consumption (Present)", + "no_copy": 1 }, { "fieldname": "alcohol_past_use", "fieldtype": "Data", "ignore_xss_filter": 1, - "label": "Alcohol Consumption (Past)" + "label": "Alcohol Consumption (Past)", + "no_copy": 1 }, { "fieldname": "alcohol_current_use", "fieldtype": "Data", "ignore_user_permissions": 1, - "label": "Alcohol Consumption (Present)" + "label": "Alcohol Consumption (Present)", + "no_copy": 1 }, { "fieldname": "column_break_32", @@ -309,13 +333,15 @@ "fieldname": "surrounding_factors", "fieldtype": "Small Text", "ignore_xss_filter": 1, - "label": "Occupational Hazards and Environmental Factors" + "label": "Occupational Hazards and Environmental Factors", + "no_copy": 1 }, { "fieldname": "other_risk_factors", "fieldtype": "Small Text", "ignore_xss_filter": 1, - "label": "Other Risk Factors" + "label": "Other Risk Factors", + "no_copy": 1 }, { "collapsible": 1, @@ -331,7 +357,8 @@ "fieldname": "patient_details", "fieldtype": "Text", "ignore_xss_filter": 1, - "label": "Patient Details" + "label": "Patient Details", + "no_copy": 1 }, { "fieldname": "default_currency", @@ -342,19 +369,22 @@ { "fieldname": "last_name", "fieldtype": "Data", - "label": "Last Name" + "label": "Last Name", + "no_copy": 1 }, { "fieldname": "first_name", "fieldtype": "Data", "label": "First Name", + "no_copy": 1, "oldfieldtype": "Data", "reqd": 1 }, { "fieldname": "middle_name", "fieldtype": "Data", - "label": "Middle Name (optional)" + "label": "Middle Name (optional)", + "no_copy": 1 }, { "collapsible": 1, @@ -389,13 +419,63 @@ "fieldtype": "Link", "label": "Print Language", "options": "Language" + }, + { + "depends_on": "eval:!doc.__islocal", + "fieldname": "address_contacts", + "fieldtype": "Section Break", + "label": "Address and Contact", + "options": "fa fa-map-marker" + }, + { + "fieldname": "address_html", + "fieldtype": "HTML", + "label": "Address HTML", + "no_copy": 1, + "read_only": 1 + }, + { + "fieldname": "column_break_22", + "fieldtype": "Column Break" + }, + { + "fieldname": "contact_html", + "fieldtype": "HTML", + "label": "Contact HTML", + "no_copy": 1, + "read_only": 1 + }, + { + "allow_in_quick_entry": 1, + "default": "1", + "fieldname": "invite_user", + "fieldtype": "Check", + "label": "Invite as User", + "no_copy": 1, + "read_only_depends_on": "eval: doc.user_id" + }, + { + "fieldname": "user_id", + "fieldtype": "Read Only", + "label": "User ID", + "no_copy": 1, + "options": "User" + }, + { + "allow_in_quick_entry": 1, + "bold": 1, + "fieldname": "uid", + "fieldtype": "Data", + "in_standard_filter": 1, + "label": "Identification Number (UID)", + "unique": 1 } ], "icon": "fa fa-user", "image_field": "image", "links": [], "max_attachments": 50, - "modified": "2020-04-25 17:24:32.146415", + "modified": "2021-03-14 13:21:09.759906", "modified_by": "Administrator", "module": "Healthcare", "name": "Patient", @@ -453,7 +533,7 @@ ], "quick_entry": 1, "restrict_to_domain": "Healthcare", - "search_fields": "patient_name,mobile,email,phone", + "search_fields": "patient_name,mobile,email,phone,uid", "show_name_in_global_search": 1, "sort_field": "modified", "sort_order": "ASC", diff --git a/erpnext/healthcare/doctype/patient/patient.py b/erpnext/healthcare/doctype/patient/patient.py index f77ad70633df..9dae1f68b061 100644 --- a/erpnext/healthcare/doctype/patient/patient.py +++ b/erpnext/healthcare/doctype/patient/patient.py @@ -8,24 +8,27 @@ from frappe.model.document import Document from frappe.utils import cint, cstr, getdate import dateutil +from frappe.contacts.address_and_contact import load_address_and_contact +from frappe.contacts.doctype.contact.contact import get_default_contact from frappe.model.naming import set_name_by_naming_series from frappe.utils.nestedset import get_root_of from erpnext import get_default_currency from erpnext.healthcare.doctype.healthcare_settings.healthcare_settings import get_receivable_account, get_income_account, send_registration_sms +from erpnext.accounts.party import get_dashboard_info class Patient(Document): + def onload(self): + '''Load address and contacts in `__onload`''' + load_address_and_contact(self) + self.load_dashboard_info() + def validate(self): self.set_full_name() - self.add_as_website_user() def before_insert(self): self.set_missing_customer_details() def after_insert(self): - self.add_as_website_user() - self.reload() - if frappe.db.get_single_value('Healthcare Settings', 'link_customer_to_patient') and not self.customer: - create_customer(self) if frappe.db.get_single_value('Healthcare Settings', 'collect_registration_fee'): frappe.db.set_value('Patient', self.name, 'status', 'Disabled') else: @@ -49,6 +52,16 @@ def on_update(self): else: create_customer(self) + self.set_contact() # add or update contact + + if not self.user_id and self.email and self.invite_user: + self.create_website_user() + + def load_dashboard_info(self): + if self.customer: + info = get_dashboard_info('Customer', self.customer, None) + self.set_onload('dashboard_info', info) + def set_full_name(self): if self.last_name: self.patient_name = ' '.join(filter(None, [self.first_name, self.last_name])) @@ -71,18 +84,24 @@ def set_missing_customer_details(self): if not self.language: self.language = frappe.db.get_single_value('System Settings', 'language') - def add_as_website_user(self): - if self.email: - if not frappe.db.exists ('User', self.email): - user = frappe.get_doc({ - 'doctype': 'User', - 'first_name': self.first_name, - 'last_name': self.last_name, - 'email': self.email, - 'user_type': 'Website User' - }) - user.flags.ignore_permissions = True - user.add_roles('Patient') + def create_website_user(self): + if self.email and not frappe.db.exists('User', self.email): + user = frappe.get_doc({ + 'doctype': 'User', + 'first_name': self.first_name, + 'last_name': self.last_name, + 'email': self.email, + 'user_type': 'Website User', + 'gender': self.sex, + 'phone': self.phone, + 'mobile_no': self.mobile, + 'birth_date': self.dob + }) + user.flags.ignore_permissions = True + user.enabled = True + user.send_welcome_email = True + user.add_roles('Patient') + frappe.db.set_value(self.doctype, self.name, 'user_id', user.name) def autoname(self): patient_name_by = frappe.db.get_single_value('Healthcare Settings', 'patient_name_by') @@ -114,7 +133,7 @@ def get_age(self): age = self.age if not age: return - age_str = str(age.years) + ' ' + _("Years(s)") + ' ' + str(age.months) + ' ' + _("Month(s)") + ' ' + str(age.days) + ' ' + _("Day(s)") + age_str = f'{str(age.years)} {_("Years(s)")} {str(age.months)} {_("Month(s)")} {str(age.days)} {_("Day(s)")}' return age_str @frappe.whitelist() @@ -131,6 +150,58 @@ def invoice_patient_registration(self): return {'invoice': sales_invoice.name} + def set_contact(self): + if frappe.db.exists('Dynamic Link', {'parenttype':'Contact', 'link_doctype':'Patient', 'link_name':self.name}): + old_doc = self.get_doc_before_save() + if old_doc.email != self.email or old_doc.mobile != self.mobile or old_doc.phone != self.phone: + self.update_contact() + else: + self.reload() + if self.email or self.mobile or self.phone: + contact = frappe.get_doc({ + 'doctype': 'Contact', + 'first_name': self.first_name, + 'middle_name': self.middle_name, + 'last_name': self.last_name, + 'gender': self.sex, + 'is_primary_contact': 1 + }) + contact.append('links', dict(link_doctype='Patient', link_name=self.name)) + if self.customer: + contact.append('links', dict(link_doctype='Customer', link_name=self.customer)) + + contact.insert(ignore_permissions=True) + self.update_contact(contact) # update email, mobile and phone + + def update_contact(self, contact=None): + if not contact: + contact_name = get_default_contact(self.doctype, self.name) + if contact_name: + contact = frappe.get_doc('Contact', contact_name) + + if contact: + if self.email and self.email != contact.email_id: + for email in contact.email_ids: + email.is_primary = True if email.email_id == self.email else False + contact.add_email(self.email, is_primary=True) + contact.set_primary_email() + + if self.mobile and self.mobile != contact.mobile_no: + for mobile in contact.phone_nos: + mobile.is_primary_mobile_no = True if mobile.phone == self.mobile else False + contact.add_phone(self.mobile, is_primary_mobile_no=True) + contact.set_primary('mobile_no') + + if self.phone and self.phone != contact.phone: + for phone in contact.phone_nos: + phone.is_primary_phone = True if phone.phone == self.phone else False + contact.add_phone(self.phone, is_primary_phone=True) + contact.set_primary('phone') + + contact.flags.ignore_validate = True # disable hook TODO: safe? + contact.save(ignore_permissions=True) + + def create_customer(doc): customer = frappe.get_doc({ 'doctype': 'Customer', @@ -156,8 +227,8 @@ def make_invoice(patient, company): sales_invoice.debit_to = get_receivable_account(company) item_line = sales_invoice.append('items') - item_line.item_name = 'Registeration Fee' - item_line.description = 'Registeration Fee' + item_line.item_name = 'Registration Fee' + item_line.description = 'Registration Fee' item_line.qty = 1 item_line.uom = uom item_line.conversion_factor = 1 @@ -181,8 +252,11 @@ def get_patient_detail(patient): return details def get_timeline_data(doctype, name): - """Return timeline data from medical records""" - return dict(frappe.db.sql(''' + ''' + Return Patient's timeline data from medical records + Also include the associated Customer timeline data + ''' + patient_timeline_data = dict(frappe.db.sql(''' SELECT unix_timestamp(communication_date), count(*) FROM @@ -191,3 +265,11 @@ def get_timeline_data(doctype, name): patient=%s and `communication_date` > date_sub(curdate(), interval 1 year) GROUP BY communication_date''', name)) + + customer = frappe.db.get_value(doctype, name, 'customer') + if customer: + from erpnext.accounts.party import get_timeline_data + customer_timeline_data = get_timeline_data('Customer', customer) + patient_timeline_data.update(customer_timeline_data) + + return patient_timeline_data diff --git a/erpnext/healthcare/doctype/patient/patient_dashboard.py b/erpnext/healthcare/doctype/patient/patient_dashboard.py index 39603f77a065..7f7cfa8e5b97 100644 --- a/erpnext/healthcare/doctype/patient/patient_dashboard.py +++ b/erpnext/healthcare/doctype/patient/patient_dashboard.py @@ -6,22 +6,33 @@ def get_data(): 'heatmap': True, 'heatmap_message': _('This is based on transactions against this Patient. See timeline below for details'), 'fieldname': 'patient', + 'non_standard_fieldnames': { + 'Payment Entry': 'party' + }, 'transactions': [ { - 'label': _('Appointments and Patient Encounters'), - 'items': ['Patient Appointment', 'Patient Encounter'] + 'label': _('Appointments and Encounters'), + 'items': ['Patient Appointment', 'Vital Signs', 'Patient Encounter'] }, { 'label': _('Lab Tests and Vital Signs'), - 'items': ['Lab Test', 'Sample Collection', 'Vital Signs'] + 'items': ['Lab Test', 'Sample Collection'] }, { - 'label': _('Billing'), - 'items': ['Sales Invoice'] + 'label': _('Rehab and Physiotherapy'), + 'items': ['Patient Assessment', 'Therapy Session', 'Therapy Plan'] }, { - 'label': _('Orders'), - 'items': ['Inpatient Medication Order'] + 'label': _('Surgery'), + 'items': ['Clinical Procedure'] + }, + { + 'label': _('Admissions'), + 'items': ['Inpatient Record', 'Inpatient Medication Order'] + }, + { + 'label': _('Billing and Payments'), + 'items': ['Sales Invoice', 'Payment Entry'] } ] } diff --git a/erpnext/healthcare/doctype/patient_appointment/patient_appointment.js b/erpnext/healthcare/doctype/patient_appointment/patient_appointment.js index c6e489ec17cf..49847d5bc8a1 100644 --- a/erpnext/healthcare/doctype/patient_appointment/patient_appointment.js +++ b/erpnext/healthcare/doctype/patient_appointment/patient_appointment.js @@ -17,9 +17,9 @@ frappe.ui.form.on('Patient Appointment', { }, refresh: function(frm) { - frm.set_query('patient', function () { + frm.set_query('patient', function() { return { - filters: {'status': 'Active'} + filters: { 'status': 'Active' } }; }); @@ -64,7 +64,7 @@ frappe.ui.form.on('Patient Appointment', { } else { frappe.call({ method: 'erpnext.healthcare.doctype.patient_appointment.patient_appointment.check_payment_fields_reqd', - args: {'patient': frm.doc.patient}, + args: { 'patient': frm.doc.patient }, callback: function(data) { if (data.message == true) { if (frm.doc.mode_of_payment && frm.doc.paid_amount) { @@ -97,7 +97,7 @@ frappe.ui.form.on('Patient Appointment', { if (frm.doc.patient) { frm.add_custom_button(__('Patient History'), function() { - frappe.route_options = {'patient': frm.doc.patient}; + frappe.route_options = { 'patient': frm.doc.patient }; frappe.set_route('patient_history'); }, __('View')); } @@ -111,14 +111,14 @@ frappe.ui.form.on('Patient Appointment', { }); if (frm.doc.procedure_template) { - frm.add_custom_button(__('Clinical Procedure'), function(){ + frm.add_custom_button(__('Clinical Procedure'), function() { frappe.model.open_mapped_doc({ method: 'erpnext.healthcare.doctype.clinical_procedure.clinical_procedure.make_procedure', frm: frm, }); }, __('Create')); } else if (frm.doc.therapy_type) { - frm.add_custom_button(__('Therapy Session'),function(){ + frm.add_custom_button(__('Therapy Session'), function() { frappe.model.open_mapped_doc({ method: 'erpnext.healthcare.doctype.therapy_session.therapy_session.create_therapy_session', frm: frm, @@ -148,7 +148,7 @@ frappe.ui.form.on('Patient Appointment', { doctype: 'Patient', name: frm.doc.patient }, - callback: function (data) { + callback: function(data) { let age = null; if (data.message.dob) { age = calculate_age(data.message.dob); @@ -165,7 +165,7 @@ frappe.ui.form.on('Patient Appointment', { }, practitioner: function(frm) { - if (frm.doc.practitioner ) { + if (frm.doc.practitioner) { frm.events.set_payment_details(frm); } }, @@ -230,7 +230,7 @@ frappe.ui.form.on('Patient Appointment', { toggle_payment_fields: function(frm) { frappe.call({ method: 'erpnext.healthcare.doctype.patient_appointment.patient_appointment.check_payment_fields_reqd', - args: {'patient': frm.doc.patient}, + args: { 'patient': frm.doc.patient }, callback: function(data) { if (data.message.fee_validity) { // if fee validity exists and automated appointment invoicing is enabled, @@ -254,7 +254,7 @@ frappe.ui.form.on('Patient Appointment', { frm.toggle_display('paid_amount', data.message ? 1 : 0); frm.toggle_display('billing_item', data.message ? 1 : 0); frm.toggle_reqd('mode_of_payment', data.message ? 1 : 0); - frm.toggle_reqd('paid_amount', data.message ? 1 :0); + frm.toggle_reqd('paid_amount', data.message ? 1 : 0); frm.toggle_reqd('billing_item', data.message ? 1 : 0); } } @@ -265,7 +265,7 @@ frappe.ui.form.on('Patient Appointment', { if (frm.doc.patient) { frappe.call({ method: "erpnext.healthcare.doctype.patient_appointment.patient_appointment.get_prescribed_therapies", - args: {patient: frm.doc.patient}, + args: { patient: frm.doc.patient }, callback: function(r) { if (r.message) { show_therapy_types(frm, r.message); @@ -302,13 +302,13 @@ let check_and_set_availability = function(frm) { let d = new frappe.ui.Dialog({ title: __('Available slots'), fields: [ - { fieldtype: 'Link', options: 'Medical Department', reqd: 1, fieldname: 'department', label: 'Medical Department'}, - { fieldtype: 'Column Break'}, - { fieldtype: 'Link', options: 'Healthcare Practitioner', reqd: 1, fieldname: 'practitioner', label: 'Healthcare Practitioner'}, - { fieldtype: 'Column Break'}, - { fieldtype: 'Date', reqd: 1, fieldname: 'appointment_date', label: 'Date'}, - { fieldtype: 'Section Break'}, - { fieldtype: 'HTML', fieldname: 'available_slots'} + { fieldtype: 'Link', options: 'Medical Department', reqd: 1, fieldname: 'department', label: 'Medical Department' }, + { fieldtype: 'Column Break' }, + { fieldtype: 'Link', options: 'Healthcare Practitioner', reqd: 1, fieldname: 'practitioner', label: 'Healthcare Practitioner' }, + { fieldtype: 'Column Break' }, + { fieldtype: 'Date', reqd: 1, fieldname: 'appointment_date', label: 'Date' }, + { fieldtype: 'Section Break' }, + { fieldtype: 'HTML', fieldname: 'available_slots' } ], primary_action_label: __('Book'), @@ -386,59 +386,22 @@ let check_and_set_availability = function(frm) { let $wrapper = d.fields_dict.available_slots.$wrapper; // make buttons for each slot - let slot_details = data.slot_details; - let slot_html = ''; - for (let i = 0; i < slot_details.length; i++) { - slot_html = slot_html + ``; - slot_html = slot_html + `
` + slot_details[i].avail_slot.map(slot => { - let disabled = ''; - let start_str = slot.from_time; - let slot_start_time = moment(slot.from_time, 'HH:mm:ss'); - let slot_to_time = moment(slot.to_time, 'HH:mm:ss'); - let interval = (slot_to_time - slot_start_time)/60000 | 0; - // iterate in all booked appointments, update the start time and duration - slot_details[i].appointments.forEach(function(booked) { - let booked_moment = moment(booked.appointment_time, 'HH:mm:ss'); - let end_time = booked_moment.clone().add(booked.duration, 'minutes'); - // Deal with 0 duration appointments - if (booked_moment.isSame(slot_start_time) || booked_moment.isBetween(slot_start_time, slot_to_time)) { - if(booked.duration == 0){ - disabled = 'disabled="disabled"'; - return false; - } - } - // Check for overlaps considering appointment duration - if (slot_start_time.isBefore(end_time) && slot_to_time.isAfter(booked_moment)) { - // There is an overlap - disabled = 'disabled="disabled"'; - return false; - } - }); - return ``; - }).join(""); - slot_html = slot_html + `
`; - } + let slot_html = get_slots(data.slot_details); $wrapper .css('margin-bottom', 0) .addClass('text-center') .html(slot_html); - // blue button when clicked + // highlight button when clicked $wrapper.on('click', 'button', function() { let $btn = $(this); - $wrapper.find('button').removeClass('btn-primary'); - $btn.addClass('btn-primary'); + $wrapper.find('button').removeClass('btn-outline-primary'); + $btn.addClass('btn-outline-primary'); selected_slot = $btn.attr('data-name'); service_unit = $btn.attr('data-service-unit'); duration = $btn.attr('data-duration'); - // enable dialog action + // enable primary action 'Book' d.get_primary_btn().attr('disabled', null); }); @@ -448,19 +411,102 @@ let check_and_set_availability = function(frm) { } }, freeze: true, - freeze_message: __('Fetching records......') + freeze_message: __('Fetching Schedule...') }); } else { fd.available_slots.html(__('Appointment date and Healthcare Practitioner are Mandatory').bold()); } } + + function get_slots(slot_details) { + let slot_html = ''; + let appointment_count = 0; + let disabled = false; + let start_str, slot_start_time, slot_end_time, interval, count, count_class, tool_tip, available_slots; + + slot_details.forEach((slot_info) => { + slot_html += `
+ ${__('Practitioner Schedule:')} ${slot_info.slot_name}
+ ${__('Service Unit:')} ${slot_info.service_unit} `; + + if (slot_info.service_unit_capacity) { + slot_html += `
${__('Maximum Capacity:')} ${slot_info.service_unit_capacity} `; + } + + slot_html += '


'; + + slot_html += slot_info.avail_slot.map(slot => { + appointment_count = 0; + disabled = false; + start_str = slot.from_time; + slot_start_time = moment(slot.from_time, 'HH:mm:ss'); + slot_end_time = moment(slot.to_time, 'HH:mm:ss'); + interval = (slot_end_time - slot_start_time) / 60000 | 0; + + // iterate in all booked appointments, update the start time and duration + slot_info.appointments.forEach((booked) => { + let booked_moment = moment(booked.appointment_time, 'HH:mm:ss'); + let end_time = booked_moment.clone().add(booked.duration, 'minutes'); + + // Deal with 0 duration appointments + if (booked_moment.isSame(slot_start_time) || booked_moment.isBetween(slot_start_time, slot_end_time)) { + if (booked.duration == 0) { + disabled = true; + return false; + } + } + + // Check for overlaps considering appointment duration + if (slot_info.allow_overlap != 1) { + if (slot_start_time.isBefore(end_time) && slot_end_time.isAfter(booked_moment)) { + // There is an overlap + disabled = true; + return false; + } + } else { + if (slot_start_time.isBefore(end_time) && slot_end_time.isAfter(booked_moment)) { + appointment_count++; + } + if (appointment_count >= slot_info.service_unit_capacity) { + // There is an overlap + disabled = true; + return false; + } + } + }); + + if (slot_info.allow_overlap == 1 && slot_info.service_unit_capacity > 1) { + available_slots = slot_info.service_unit_capacity - appointment_count; + count = `${(available_slots > 0 ? available_slots : __('Full'))}`; + count_class = `${(available_slots > 0 ? 'badge-success' : 'badge-danger')}`; + tool_tip =`${available_slots} ${__('slots available for booking')}`; + } + return ` + `; + }).join(""); + + if (slot_info.service_unit_capacity) { + slot_html += `
${__('Each slot indicates the capacity currently available for booking')}`; + } + slot_html += `

`; + }); + + return slot_html; + } }; let get_prescribed_procedure = function(frm) { if (frm.doc.patient) { frappe.call({ method: 'erpnext.healthcare.doctype.patient_appointment.patient_appointment.get_procedure_prescribed', - args: {patient: frm.doc.patient}, + args: { patient: frm.doc.patient }, callback: function(r) { if (r.message && r.message.length) { show_procedure_templates(frm, r.message); @@ -480,7 +526,7 @@ let get_prescribed_procedure = function(frm) { } }; -let show_procedure_templates = function(frm, result){ +let show_procedure_templates = function(frm, result) { let d = new frappe.ui.Dialog({ title: __('Prescribed Procedures'), fields: [ @@ -500,9 +546,11 @@ let show_procedure_templates = function(frm, result){ data-encounter="%(encounter)s" data-practitioner="%(practitioner)s"\ data-date="%(date)s" data-department="%(department)s">\

', {name:y[0], procedure_template: y[1], - encounter:y[2], consulting_practitioner:y[3], encounter_date:y[4], - practitioner:y[5]? y[5]:'', date: y[6]? y[6]:'', department: y[7]? y[7]:''})).appendTo(html_field); +

', { + name: y[0], procedure_template: y[1], + encounter: y[2], consulting_practitioner: y[3], encounter_date: y[4], + practitioner: y[5] ? y[5] : '', date: y[6] ? y[6] : '', department: y[7] ? y[7] : '' + })).appendTo(html_field); row.find("a").click(function() { frm.doc.procedure_template = $(this).attr('data-procedure-template'); frm.doc.procedure_prescription = $(this).attr('data-name'); @@ -520,7 +568,7 @@ let show_procedure_templates = function(frm, result){ }); if (!result) { let msg = __('There are no procedure prescribed for ') + frm.doc.patient; - $(repl('
%(msg)s
', {msg: msg})).appendTo(html_field); + $(repl('
%(msg)s
', { msg: msg })).appendTo(html_field); } d.show(); }; @@ -535,7 +583,7 @@ let show_therapy_types = function(frm, result) { ] }); var html_field = d.fields_dict.therapy_type.$wrapper; - $.each(result, function(x, y){ + $.each(result, function(x, y) { var row = $(repl('
\
%(encounter)s
%(practitioner)s
%(date)s
\
%(therapy)s
\ @@ -544,9 +592,11 @@ let show_therapy_types = function(frm, result) { data-encounter="%(encounter)s" data-practitioner="%(practitioner)s"\ data-date="%(date)s" data-department="%(department)s">\

', {therapy:y[0], - name: y[1], encounter:y[2], practitioner:y[3], date:y[4], - department:y[6]? y[6]:'', therapy_plan:y[5]})).appendTo(html_field); +

', { + therapy: y[0], + name: y[1], encounter: y[2], practitioner: y[3], date: y[4], + department: y[6] ? y[6] : '', therapy_plan: y[5] + })).appendTo(html_field); row.find("a").click(function() { frm.doc.therapy_type = $(this).attr("data-therapy"); @@ -581,13 +631,13 @@ let create_vital_signs = function(frm) { frappe.new_doc('Vital Signs'); }; -let update_status = function(frm, status){ +let update_status = function(frm, status) { let doc = frm.doc; frappe.confirm(__('Are you sure you want to cancel this appointment?'), function() { frappe.call({ method: 'erpnext.healthcare.doctype.patient_appointment.patient_appointment.update_status', - args: {appointment_id: doc.name, status:status}, + args: { appointment_id: doc.name, status: status }, callback: function(data) { if (!data.exc) { frm.reload_doc(); diff --git a/erpnext/healthcare/doctype/patient_appointment/patient_appointment.json b/erpnext/healthcare/doctype/patient_appointment/patient_appointment.json index 73ec3bc325f2..28d3a6dadf61 100644 --- a/erpnext/healthcare/doctype/patient_appointment/patient_appointment.json +++ b/erpnext/healthcare/doctype/patient_appointment/patient_appointment.json @@ -131,7 +131,7 @@ "fieldtype": "Link", "label": "Service Unit", "options": "Healthcare Service Unit", - "set_only_once": 1 + "read_only": 1 }, { "depends_on": "eval:doc.practitioner;", @@ -349,7 +349,7 @@ } ], "links": [], - "modified": "2021-06-16 00:40:26.841794", + "modified": "2021-08-30 09:00:41.329387", "modified_by": "Administrator", "module": "Healthcare", "name": "Patient Appointment", diff --git a/erpnext/healthcare/doctype/patient_appointment/patient_appointment.py b/erpnext/healthcare/doctype/patient_appointment/patient_appointment.py index 7db4fa616adc..36047c483816 100755 --- a/erpnext/healthcare/doctype/patient_appointment/patient_appointment.py +++ b/erpnext/healthcare/doctype/patient_appointment/patient_appointment.py @@ -15,6 +15,11 @@ from erpnext.healthcare.doctype.healthcare_settings.healthcare_settings import get_receivable_account, get_income_account from erpnext.healthcare.utils import check_fee_validity, get_service_item_and_practitioner_charge, manage_fee_validity +class MaximumCapacityError(frappe.ValidationError): + pass +class OverlapError(frappe.ValidationError): + pass + class PatientAppointment(Document): def validate(self): self.validate_overlaps() @@ -49,26 +54,49 @@ def validate_overlaps(self): end_time = datetime.datetime.combine(getdate(self.appointment_date), get_time(self.appointment_time)) \ + datetime.timedelta(minutes=flt(self.duration)) - overlaps = frappe.db.sql(""" - select - name, practitioner, patient, appointment_time, duration - from - `tabPatient Appointment` - where - appointment_date=%s and name!=%s and status NOT IN ("Closed", "Cancelled") - and (practitioner=%s or patient=%s) and - ((appointment_time<%s and appointment_time + INTERVAL duration MINUTE>%s) or - (appointment_time>%s and appointment_time<%s) or - (appointment_time=%s)) - """, (self.appointment_date, self.name, self.practitioner, self.patient, - self.appointment_time, end_time.time(), self.appointment_time, end_time.time(), self.appointment_time)) - - if overlaps: - overlapping_details = _('Appointment overlaps with ') - overlapping_details += "{0}
".format(overlaps[0][0]) - overlapping_details += _('{0} has appointment scheduled with {1} at {2} having {3} minute(s) duration.').format( - overlaps[0][1], overlaps[0][2], overlaps[0][3], overlaps[0][4]) - frappe.throw(overlapping_details, title=_('Appointments Overlapping')) + # all appointments for both patient and practitioner overlapping the duration of this appointment + overlapping_appointments = frappe.db.sql(""" + SELECT + name, practitioner, patient, appointment_time, duration, service_unit + FROM + `tabPatient Appointment` + WHERE + appointment_date=%(appointment_date)s AND name!=%(name)s AND status NOT IN ("Closed", "Cancelled") AND + (practitioner=%(practitioner)s OR patient=%(patient)s) AND + ((appointment_time<%(appointment_time)s AND appointment_time + INTERVAL duration MINUTE>%(appointment_time)s) OR + (appointment_time>%(appointment_time)s AND appointment_time<%(end_time)s) OR + (appointment_time=%(appointment_time)s)) + """, + { + 'appointment_date': self.appointment_date, + 'name': self.name, + 'practitioner': self.practitioner, + 'patient': self.patient, + 'appointment_time': self.appointment_time, + 'end_time':end_time.time() + }, + as_dict = True + ) + + if not overlapping_appointments: + return # No overlaps, nothing to validate! + + if self.service_unit: # validate service unit capacity if overlap enabled + allow_overlap, service_unit_capacity = frappe.get_value('Healthcare Service Unit', self.service_unit, + ['overlap_appointments', 'service_unit_capacity']) + if allow_overlap: + service_unit_appointments = list(filter(lambda appointment: appointment['service_unit'] == self.service_unit and + appointment['patient'] != self.patient, overlapping_appointments)) # if same patient already booked, it should be an overlap + if len(service_unit_appointments) >= (service_unit_capacity or 1): + frappe.throw(_("Not allowed, {} cannot exceed maximum capacity {}") + .format(frappe.bold(self.service_unit), frappe.bold(service_unit_capacity or 1)), MaximumCapacityError) + else: # service_unit_appointments within capacity, remove from overlapping_appointments + overlapping_appointments = [appointment for appointment in overlapping_appointments if appointment not in service_unit_appointments] + + if overlapping_appointments: + frappe.throw(_("Not allowed, cannot overlap appointment {}") + .format(frappe.bold(', '.join([appointment['name'] for appointment in overlapping_appointments]))), OverlapError) + def validate_service_unit(self): if self.inpatient_record and self.service_unit: @@ -325,6 +353,8 @@ def get_available_slots(practitioner_doc, date): if available_slots: appointments = [] + allow_overlap = 0 + service_unit_capacity = 0 # fetch all appointments to practitioner by service unit filters = { 'practitioner': practitioner, @@ -334,8 +364,8 @@ def get_available_slots(practitioner_doc, date): } if schedule_entry.service_unit: - slot_name = schedule_entry.schedule + ' - ' + schedule_entry.service_unit - allow_overlap = frappe.get_value('Healthcare Service Unit', schedule_entry.service_unit, 'overlap_appointments') + slot_name = f'{schedule_entry.schedule}' + allow_overlap, service_unit_capacity = frappe.get_value('Healthcare Service Unit', schedule_entry.service_unit, ['overlap_appointments', 'service_unit_capacity']) if not allow_overlap: # fetch all appointments to service unit filters.pop('practitioner') @@ -350,8 +380,8 @@ def get_available_slots(practitioner_doc, date): filters=filters, fields=['name', 'appointment_time', 'duration', 'status']) - slot_details.append({'slot_name':slot_name, 'service_unit':schedule_entry.service_unit, - 'avail_slot':available_slots, 'appointments': appointments}) + slot_details.append({'slot_name': slot_name, 'service_unit': schedule_entry.service_unit, 'avail_slot': available_slots, + 'appointments': appointments, 'allow_overlap': allow_overlap, 'service_unit_capacity': service_unit_capacity}) return slot_details diff --git a/erpnext/healthcare/doctype/patient_appointment/test_patient_appointment.py b/erpnext/healthcare/doctype/patient_appointment/test_patient_appointment.py index 157b3e116245..f5477c096aa3 100644 --- a/erpnext/healthcare/doctype/patient_appointment/test_patient_appointment.py +++ b/erpnext/healthcare/doctype/patient_appointment/test_patient_appointment.py @@ -16,9 +16,11 @@ def setUp(self): frappe.db.sql("""delete from `tabFee Validity`""") frappe.db.sql("""delete from `tabPatient Encounter`""") make_pos_profile() + frappe.db.sql("""delete from `tabHealthcare Service Unit` where name like '_Test %'""") + frappe.db.sql("""delete from `tabHealthcare Service Unit` where name like '_Test Service Unit Type%'""") def test_status(self): - patient, medical_department, practitioner = create_healthcare_docs() + patient, practitioner = create_healthcare_docs() frappe.db.set_value('Healthcare Settings', None, 'automate_appointment_invoicing', 0) appointment = create_appointment(patient, practitioner, nowdate()) self.assertEqual(appointment.status, 'Open') @@ -30,7 +32,7 @@ def test_status(self): self.assertEqual(frappe.db.get_value('Patient Appointment', appointment.name, 'status'), 'Open') def test_start_encounter(self): - patient, medical_department, practitioner = create_healthcare_docs() + patient, practitioner = create_healthcare_docs() frappe.db.set_value('Healthcare Settings', None, 'automate_appointment_invoicing', 1) appointment = create_appointment(patient, practitioner, add_days(nowdate(), 4), invoice = 1) appointment.reload() @@ -44,7 +46,7 @@ def test_start_encounter(self): self.assertEqual(encounter.invoiced, frappe.db.get_value('Patient Appointment', appointment.name, 'invoiced')) def test_auto_invoicing(self): - patient, medical_department, practitioner = create_healthcare_docs() + patient, practitioner = create_healthcare_docs() frappe.db.set_value('Healthcare Settings', None, 'enable_free_follow_ups', 0) frappe.db.set_value('Healthcare Settings', None, 'automate_appointment_invoicing', 0) appointment = create_appointment(patient, practitioner, nowdate()) @@ -60,13 +62,14 @@ def test_auto_invoicing(self): self.assertEqual(frappe.db.get_value('Sales Invoice', sales_invoice_name, 'paid_amount'), appointment.paid_amount) def test_auto_invoicing_based_on_department(self): - patient, medical_department, practitioner = create_healthcare_docs() + patient, practitioner = create_healthcare_docs() + medical_department = create_medical_department() frappe.db.set_value('Healthcare Settings', None, 'enable_free_follow_ups', 0) frappe.db.set_value('Healthcare Settings', None, 'automate_appointment_invoicing', 1) appointment_type = create_appointment_type() appointment = create_appointment(patient, practitioner, add_days(nowdate(), 2), - invoice=1, appointment_type=appointment_type.name, department='_Test Medical Department') + invoice=1, appointment_type=appointment_type.name, department=medical_department) appointment.reload() self.assertEqual(appointment.invoiced, 1) @@ -78,7 +81,7 @@ def test_auto_invoicing_based_on_department(self): self.assertEqual(frappe.db.get_value('Sales Invoice', sales_invoice_name, 'paid_amount'), appointment.paid_amount) def test_auto_invoicing_according_to_appointment_type_charge(self): - patient, medical_department, practitioner = create_healthcare_docs() + patient, practitioner = create_healthcare_docs() frappe.db.set_value('Healthcare Settings', None, 'enable_free_follow_ups', 0) frappe.db.set_value('Healthcare Settings', None, 'automate_appointment_invoicing', 1) @@ -104,7 +107,7 @@ def test_auto_invoicing_according_to_appointment_type_charge(self): self.assertTrue(sales_invoice_name) def test_appointment_cancel(self): - patient, medical_department, practitioner = create_healthcare_docs() + patient, practitioner = create_healthcare_docs() frappe.db.set_value('Healthcare Settings', None, 'enable_free_follow_ups', 1) appointment = create_appointment(patient, practitioner, nowdate()) fee_validity = frappe.db.get_value('Fee Validity', {'patient': patient, 'practitioner': practitioner}) @@ -112,7 +115,7 @@ def test_appointment_cancel(self): self.assertTrue(fee_validity) # first follow up appointment - appointment = create_appointment(patient, practitioner, nowdate()) + appointment = create_appointment(patient, practitioner, add_days(nowdate(), 1)) self.assertEqual(frappe.db.get_value('Fee Validity', fee_validity, 'visited'), 1) update_status(appointment.name, 'Cancelled') @@ -121,7 +124,7 @@ def test_appointment_cancel(self): frappe.db.set_value('Healthcare Settings', None, 'enable_free_follow_ups', 0) frappe.db.set_value('Healthcare Settings', None, 'automate_appointment_invoicing', 1) - appointment = create_appointment(patient, practitioner, nowdate(), invoice=1) + appointment = create_appointment(patient, practitioner, add_days(nowdate(), 1), invoice=1) update_status(appointment.name, 'Cancelled') # check invoice cancelled sales_invoice_name = frappe.db.get_value('Sales Invoice Item', {'reference_dn': appointment.name}, 'parent') @@ -133,7 +136,7 @@ def test_appointment_booking_for_admission_service_unit(self): create_inpatient, get_healthcare_service_unit, mark_invoiced_inpatient_occupancy frappe.db.sql("""delete from `tabInpatient Record`""") - patient, medical_department, practitioner = create_healthcare_docs() + patient, practitioner = create_healthcare_docs() patient = create_patient() # Schedule Admission ip_record = create_inpatient(patient) @@ -141,7 +144,7 @@ def test_appointment_booking_for_admission_service_unit(self): ip_record.save(ignore_permissions = True) # Admit - service_unit = get_healthcare_service_unit('Test Service Unit Ip Occupancy') + service_unit = get_healthcare_service_unit('_Test Service Unit Ip Occupancy') admit_patient(ip_record, service_unit, now_datetime()) appointment = create_appointment(patient, practitioner, nowdate(), service_unit=service_unit) @@ -159,7 +162,7 @@ def test_invalid_healthcare_service_unit_validation(self): create_inpatient, get_healthcare_service_unit, mark_invoiced_inpatient_occupancy frappe.db.sql("""delete from `tabInpatient Record`""") - patient, medical_department, practitioner = create_healthcare_docs() + patient, practitioner = create_healthcare_docs() patient = create_patient() # Schedule Admission ip_record = create_inpatient(patient) @@ -167,10 +170,10 @@ def test_invalid_healthcare_service_unit_validation(self): ip_record.save(ignore_permissions = True) # Admit - service_unit = get_healthcare_service_unit('Test Service Unit Ip Occupancy') + service_unit = get_healthcare_service_unit('_Test Service Unit Ip Occupancy') admit_patient(ip_record, service_unit, now_datetime()) - appointment_service_unit = get_healthcare_service_unit('Test Service Unit Ip Occupancy for Appointment') + appointment_service_unit = get_healthcare_service_unit('_Test Service Unit Ip Occupancy for Appointment') appointment = create_appointment(patient, practitioner, nowdate(), service_unit=appointment_service_unit, save=0) self.assertRaises(frappe.exceptions.ValidationError, appointment.save) @@ -192,7 +195,7 @@ def test_payment_should_be_mandatory_for_new_patient_appointment(self): assert payment_required is True def test_sales_invoice_should_be_generated_for_new_patient_appointment(self): - patient, medical_department, practitioner = create_healthcare_docs() + patient, practitioner = create_healthcare_docs() frappe.db.set_value('Healthcare Settings', None, 'automate_appointment_invoicing', 1) invoice_count = frappe.db.count('Sales Invoice') @@ -203,10 +206,10 @@ def test_sales_invoice_should_be_generated_for_new_patient_appointment(self): assert new_invoice_count == invoice_count + 1 def test_patient_appointment_should_consider_permissions_while_fetching_appointments(self): - patient, medical_department, practitioner = create_healthcare_docs() + patient, practitioner = create_healthcare_docs() create_appointment(patient, practitioner, nowdate()) - patient, medical_department, new_practitioner = create_healthcare_docs(practitioner_name='Dr. John') + patient, new_practitioner = create_healthcare_docs(id=5) create_appointment(patient, new_practitioner, nowdate()) roles = [{"doctype": "Has Role", "role": "Physician"}] @@ -223,41 +226,102 @@ def test_patient_appointment_should_consider_permissions_while_fetching_appointm appointments = frappe.get_list('Patient Appointment') assert len(appointments) == 2 -def create_healthcare_docs(practitioner_name=None): - if not practitioner_name: - practitioner_name = '_Test Healthcare Practitioner' - - patient = create_patient() - practitioner = frappe.db.exists('Healthcare Practitioner', practitioner_name) - medical_department = frappe.db.exists('Medical Department', '_Test Medical Department') - - if not medical_department: - medical_department = frappe.new_doc('Medical Department') - medical_department.department = '_Test Medical Department' - medical_department.save(ignore_permissions=True) - medical_department = medical_department.name - - if not practitioner: - practitioner = frappe.new_doc('Healthcare Practitioner') - practitioner.first_name = practitioner_name - practitioner.gender = 'Female' - practitioner.department = medical_department - practitioner.op_consulting_charge = 500 - practitioner.inpatient_visit_charge = 500 - practitioner.save(ignore_permissions=True) - practitioner = practitioner.name - - return patient, medical_department, practitioner - -def create_patient(): - patient = frappe.db.exists('Patient', '_Test Patient') - if not patient: - patient = frappe.new_doc('Patient') - patient.first_name = '_Test Patient' - patient.sex = 'Female' - patient.save(ignore_permissions=True) - patient = patient.name - return patient + def test_overlap_appointment(self): + from erpnext.healthcare.doctype.patient_appointment.patient_appointment import OverlapError + patient, practitioner = create_healthcare_docs(id=1) + patient_1, practitioner_1 = create_healthcare_docs(id=2) + service_unit = create_service_unit(id=0) + service_unit_1 = create_service_unit(id=1) + appointment = create_appointment(patient, practitioner, nowdate(), service_unit=service_unit) # valid + + # patient and practitioner cannot have overlapping appointments + appointment = create_appointment(patient, practitioner, nowdate(), service_unit=service_unit, save=0) + self.assertRaises(OverlapError, appointment.save) + appointment = create_appointment(patient, practitioner, nowdate(), service_unit=service_unit_1, save=0) # diff service unit + self.assertRaises(OverlapError, appointment.save) + appointment = create_appointment(patient, practitioner, nowdate(), save=0) # with no service unit link + self.assertRaises(OverlapError, appointment.save) + + # patient cannot have overlapping appointments with other practitioners + appointment = create_appointment(patient, practitioner_1, nowdate(), service_unit=service_unit, save=0) + self.assertRaises(OverlapError, appointment.save) + appointment = create_appointment(patient, practitioner_1, nowdate(), service_unit=service_unit_1, save=0) + self.assertRaises(OverlapError, appointment.save) + appointment = create_appointment(patient, practitioner_1, nowdate(), save=0) + self.assertRaises(OverlapError, appointment.save) + + # practitioner cannot have overlapping appointments with other patients + appointment = create_appointment(patient_1, practitioner, nowdate(), service_unit=service_unit, save=0) + self.assertRaises(OverlapError, appointment.save) + appointment = create_appointment(patient_1, practitioner, nowdate(), service_unit=service_unit_1, save=0) + self.assertRaises(OverlapError, appointment.save) + appointment = create_appointment(patient_1, practitioner, nowdate(), save=0) + self.assertRaises(OverlapError, appointment.save) + + def test_service_unit_capacity(self): + from erpnext.healthcare.doctype.patient_appointment.patient_appointment import MaximumCapacityError, OverlapError + practitioner = create_practitioner() + capacity = 3 + overlap_service_unit_type = create_service_unit_type(id=10, allow_appointments=1, overlap_appointments=1) + overlap_service_unit = create_service_unit(id=100, service_unit_type=overlap_service_unit_type, service_unit_capacity=capacity) + + for i in range(0, capacity): + patient = create_patient(id=i) + create_appointment(patient, practitioner, nowdate(), service_unit=overlap_service_unit) # valid + appointment = create_appointment(patient, practitioner, nowdate(), service_unit=overlap_service_unit, save=0) # overlap + self.assertRaises(OverlapError, appointment.save) + + patient = create_patient(id=capacity) + appointment = create_appointment(patient, practitioner, nowdate(), service_unit=overlap_service_unit, save=0) + self.assertRaises(MaximumCapacityError, appointment.save) + + +def create_healthcare_docs(id=0): + patient = create_patient(id) + practitioner = create_practitioner(id) + + return patient, practitioner + + +def create_patient(id=0): + if frappe.db.exists('Patient', {'firstname':f'_Test Patient {str(id)}'}): + patient = frappe.db.get_value('Patient', {'first_name': f'_Test Patient {str(id)}'}, ['name']) + return patient + + patient = frappe.new_doc('Patient') + patient.first_name = f'_Test Patient {str(id)}' + patient.sex = 'Female' + patient.save(ignore_permissions=True) + + return patient.name + + +def create_medical_department(id=0): + if frappe.db.exists('Medical Department', f'_Test Medical Department {str(id)}'): + return f'_Test Medical Department {str(id)}' + + medical_department = frappe.new_doc('Medical Department') + medical_department.department = f'_Test Medical Department {str(id)}' + medical_department.save(ignore_permissions=True) + + return medical_department.name + + +def create_practitioner(id=0, medical_department=None): + if frappe.db.exists('Healthcare Practitioner', {'firstname':f'_Test Healthcare Practitioner {str(id)}'}): + practitioner = frappe.db.get_value('Healthcare Practitioner', {'firstname':f'_Test Healthcare Practitioner {str(id)}'}, ['name']) + return practitioner + + practitioner = frappe.new_doc('Healthcare Practitioner') + practitioner.first_name = f'_Test Healthcare Practitioner {str(id)}' + practitioner.gender = 'Female' + practitioner.department = medical_department or create_medical_department(id) + practitioner.op_consulting_charge = 500 + practitioner.inpatient_visit_charge = 500 + practitioner.save(ignore_permissions=True) + + return practitioner.name + def create_encounter(appointment): if appointment: @@ -270,8 +334,10 @@ def create_encounter(appointment): encounter.company = appointment.company encounter.save() encounter.submit() + return encounter + def create_appointment(patient, practitioner, appointment_date, invoice=0, procedure_template=0, service_unit=None, appointment_type=None, save=1, department=None): item = create_healthcare_service_items() @@ -284,6 +350,7 @@ def create_appointment(patient, practitioner, appointment_date, invoice=0, proce appointment.appointment_date = appointment_date appointment.company = '_Test Company' appointment.duration = 15 + if service_unit: appointment.service_unit = service_unit if invoice: @@ -294,11 +361,14 @@ def create_appointment(patient, practitioner, appointment_date, invoice=0, proce appointment.procedure_template = create_clinical_procedure_template().get('name') if save: appointment.save(ignore_permissions=True) + return appointment + def create_healthcare_service_items(): if frappe.db.exists('Item', 'HLC-SI-001'): return 'HLC-SI-001' + item = frappe.new_doc('Item') item.item_code = 'HLC-SI-001' item.item_name = 'Consulting Charges' @@ -306,11 +376,14 @@ def create_healthcare_service_items(): item.is_stock_item = 0 item.stock_uom = 'Nos' item.save() + return item.name + def create_clinical_procedure_template(): if frappe.db.exists('Clinical Procedure Template', 'Knee Surgery and Rehab'): return frappe.get_doc('Clinical Procedure Template', 'Knee Surgery and Rehab') + template = frappe.new_doc('Clinical Procedure Template') template.template = 'Knee Surgery and Rehab' template.item_code = 'Knee Surgery and Rehab' @@ -319,8 +392,10 @@ def create_clinical_procedure_template(): template.description = 'Knee Surgery and Rehab' template.rate = 50000 template.save() + return template + def create_appointment_type(args=None): if not args: args = frappe.local.form_dict @@ -359,3 +434,30 @@ def create_user(email=None, roles=None): "roles": roles, }).insert() return user + + +def create_service_unit_type(id=0, allow_appointments=1, overlap_appointments=0): + if frappe.db.exists('Healthcare Service Unit Type', f'_Test Service Unit Type {str(id)}'): + return f'_Test Service Unit Type {str(id)}' + + service_unit_type = frappe.new_doc('Healthcare Service Unit Type') + service_unit_type.service_unit_type = f'_Test Service Unit Type {str(id)}' + service_unit_type.allow_appointments = allow_appointments + service_unit_type.overlap_appointments = overlap_appointments + service_unit_type.save(ignore_permissions=True) + + return service_unit_type.name + + +def create_service_unit(id=0, service_unit_type=None, service_unit_capacity=0): + if frappe.db.exists('Healthcare Service Unit', f'_Test Service Unit {str(id)}'): + return f'_Test service_unit {str(id)}' + + service_unit = frappe.new_doc('Healthcare Service Unit') + service_unit.is_group = 0 + service_unit.healthcare_service_unit_name= f'_Test Service Unit {str(id)}' + service_unit.service_unit_type = service_unit_type or create_service_unit_type(id) + service_unit.service_unit_capacity = service_unit_capacity + service_unit.save(ignore_permissions=True) + + return service_unit.name diff --git a/erpnext/healthcare/doctype/patient_medical_record/test_patient_medical_record.py b/erpnext/healthcare/doctype/patient_medical_record/test_patient_medical_record.py index f8ccc8a002ad..5b7d8d62c835 100644 --- a/erpnext/healthcare/doctype/patient_medical_record/test_patient_medical_record.py +++ b/erpnext/healthcare/doctype/patient_medical_record/test_patient_medical_record.py @@ -5,7 +5,7 @@ import unittest import frappe from frappe.utils import nowdate -from erpnext.healthcare.doctype.patient_appointment.test_patient_appointment import create_encounter, create_healthcare_docs, create_appointment +from erpnext.healthcare.doctype.patient_appointment.test_patient_appointment import create_encounter, create_healthcare_docs, create_appointment, create_medical_department from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile class TestPatientMedicalRecord(unittest.TestCase): @@ -15,7 +15,8 @@ def setUp(self): make_pos_profile() def test_medical_record(self): - patient, medical_department, practitioner = create_healthcare_docs() + patient, practitioner = create_healthcare_docs() + medical_department = create_medical_department() appointment = create_appointment(patient, practitioner, nowdate(), invoice=1) encounter = create_encounter(appointment) diff --git a/erpnext/healthcare/doctype/therapy_plan/test_therapy_plan.py b/erpnext/healthcare/doctype/therapy_plan/test_therapy_plan.py index 113fa513f981..983fba9f5ff9 100644 --- a/erpnext/healthcare/doctype/therapy_plan/test_therapy_plan.py +++ b/erpnext/healthcare/doctype/therapy_plan/test_therapy_plan.py @@ -8,11 +8,13 @@ from frappe.utils import getdate, flt, nowdate from erpnext.healthcare.doctype.therapy_type.test_therapy_type import create_therapy_type from erpnext.healthcare.doctype.therapy_plan.therapy_plan import make_therapy_session, make_sales_invoice -from erpnext.healthcare.doctype.patient_appointment.test_patient_appointment import create_healthcare_docs, create_patient, create_appointment +from erpnext.healthcare.doctype.patient_appointment.test_patient_appointment import \ + create_healthcare_docs, create_patient, create_appointment, create_medical_department class TestTherapyPlan(unittest.TestCase): def test_creation_on_encounter_submission(self): - patient, medical_department, practitioner = create_healthcare_docs() + patient, practitioner = create_healthcare_docs() + medical_department = create_medical_department() encounter = create_encounter(patient, medical_department, practitioner) self.assertTrue(frappe.db.exists('Therapy Plan', encounter.therapy_plan)) @@ -28,8 +30,9 @@ def test_status(self): frappe.get_doc(session).submit() self.assertEqual(frappe.db.get_value('Therapy Plan', plan.name, 'status'), 'Completed') - patient, medical_department, practitioner = create_healthcare_docs() - appointment = create_appointment(patient, practitioner, nowdate()) + patient, practitioner = create_healthcare_docs() + appointment = create_appointment(patient, practitioner, nowdate()) + session = make_therapy_session(plan.name, plan.patient, 'Basic Rehab', '_Test Company', appointment.name) session = frappe.get_doc(session) session.submit() diff --git a/erpnext/healthcare/doctype/therapy_type/test_therapy_type.py b/erpnext/healthcare/doctype/therapy_type/test_therapy_type.py index a5dad293e318..80fc83fd6ce4 100644 --- a/erpnext/healthcare/doctype/therapy_type/test_therapy_type.py +++ b/erpnext/healthcare/doctype/therapy_type/test_therapy_type.py @@ -34,7 +34,8 @@ def create_therapy_type(): }) therapy_type.save() else: - therapy_type = frappe.get_doc('Therapy Type', 'Basic Rehab') + therapy_type = frappe.get_doc('Therapy Type', therapy_type) + return therapy_type def create_exercise_type(): @@ -47,4 +48,7 @@ def create_exercise_type(): 'description': 'Squat and Rise' }) exercise_type.save() + else: + exercise_type = frappe.get_doc('Exercise Type', exercise_type) + return exercise_type diff --git a/erpnext/healthcare/report/inpatient_medication_orders/test_inpatient_medication_orders.py b/erpnext/healthcare/report/inpatient_medication_orders/test_inpatient_medication_orders.py index 4b461f1a97de..fae5ecef843d 100644 --- a/erpnext/healthcare/report/inpatient_medication_orders/test_inpatient_medication_orders.py +++ b/erpnext/healthcare/report/inpatient_medication_orders/test_inpatient_medication_orders.py @@ -25,7 +25,7 @@ def test_inpatient_medication_orders_report(self): 'from_date': getdate(), 'to_date': getdate(), 'patient': '_Test IPD Patient', - 'service_unit': 'Test Service Unit Ip Occupancy - _TC' + 'service_unit': '_Test Service Unit Ip Occupancy - _TC' } report = execute(filters) @@ -42,7 +42,7 @@ def test_inpatient_medication_orders_report(self): 'date': getdate(), 'time': datetime.timedelta(seconds=32400), 'is_completed': 0, - 'healthcare_service_unit': 'Test Service Unit Ip Occupancy - _TC' + 'healthcare_service_unit': '_Test Service Unit Ip Occupancy - _TC' }, { 'patient': '_Test IPD Patient', @@ -55,7 +55,7 @@ def test_inpatient_medication_orders_report(self): 'date': getdate(), 'time': datetime.timedelta(seconds=50400), 'is_completed': 0, - 'healthcare_service_unit': 'Test Service Unit Ip Occupancy - _TC' + 'healthcare_service_unit': '_Test Service Unit Ip Occupancy - _TC' }, { 'patient': '_Test IPD Patient', @@ -68,7 +68,7 @@ def test_inpatient_medication_orders_report(self): 'date': getdate(), 'time': datetime.timedelta(seconds=75600), 'is_completed': 0, - 'healthcare_service_unit': 'Test Service Unit Ip Occupancy - _TC' + 'healthcare_service_unit': '_Test Service Unit Ip Occupancy - _TC' } ] @@ -83,7 +83,7 @@ def test_inpatient_medication_orders_report(self): 'from_date': getdate(), 'to_date': getdate(), 'patient': '_Test IPD Patient', - 'service_unit': 'Test Service Unit Ip Occupancy - _TC', + 'service_unit': '_Test Service Unit Ip Occupancy - _TC', 'show_completed_orders': 0 } @@ -119,7 +119,7 @@ def create_records(patient): ip_record.expected_length_of_stay = 0 ip_record.save() ip_record.reload() - service_unit = get_healthcare_service_unit('Test Service Unit Ip Occupancy') + service_unit = get_healthcare_service_unit('_Test Service Unit Ip Occupancy') admit_patient(ip_record, service_unit, now_datetime()) ipmo = create_ipmo(patient) diff --git a/erpnext/healthcare/utils.py b/erpnext/healthcare/utils.py index d3d22c80b677..ffecf4dce294 100644 --- a/erpnext/healthcare/utils.py +++ b/erpnext/healthcare/utils.py @@ -543,58 +543,43 @@ def get_drugs_to_invoice(encounter): @frappe.whitelist() -def get_children(doctype, parent, company, is_root=False): - parent_fieldname = "parent_" + doctype.lower().replace(" ", "_") +def get_children(doctype, parent=None, company=None, is_root=False): + parent_fieldname = 'parent_' + doctype.lower().replace(' ', '_') fields = [ - "name as value", - "is_group as expandable", - "lft", - "rgt" + 'name as value', + 'is_group as expandable', + 'lft', + 'rgt' ] - # fields = [ "name", "is_group", "lft", "rgt" ] - filters = [["ifnull(`{0}`,'')".format(parent_fieldname), "=", "" if is_root else parent]] - if is_root: - fields += ["service_unit_type"] if doctype == "Healthcare Service Unit" else [] - filters.append(["company", "=", company]) + filters = [["ifnull(`{0}`,'')".format(parent_fieldname), + '=', '' if is_root else parent]] + if is_root: + fields += ['service_unit_type'] if doctype == 'Healthcare Service Unit' else [] + filters.append(['company', '=', company]) else: - fields += ["service_unit_type", "allow_appointments", "inpatient_occupancy", "occupancy_status"] if doctype == "Healthcare Service Unit" else [] - fields += [parent_fieldname + " as parent"] - - hc_service_units = frappe.get_list(doctype, fields=fields, filters=filters) - - if doctype == "Healthcare Service Unit": - for each in hc_service_units: - occupancy_msg = "" - if each["expandable"] == 1: - occupied = False - vacant = False - child_list = frappe.db.sql( - ''' - SELECT - name, occupancy_status - FROM - `tabHealthcare Service Unit` - WHERE - inpatient_occupancy = 1 - and lft > %s and rgt < %s - ''', (each['lft'], each['rgt'])) - - for child in child_list: - if not occupied: - occupied = 0 - if child[1] == "Occupied": - occupied += 1 - if not vacant: - vacant = 0 - if child[1] == "Vacant": - vacant += 1 - if vacant and occupied: - occupancy_total = vacant + occupied - occupancy_msg = str(occupied) + " Occupied out of " + str(occupancy_total) - each["occupied_out_of_vacant"] = occupancy_msg - return hc_service_units + fields += ['service_unit_type', 'allow_appointments', 'inpatient_occupancy', + 'occupancy_status'] if doctype == 'Healthcare Service Unit' else [] + fields += [parent_fieldname + ' as parent'] + + service_units = frappe.get_list(doctype, fields=fields, filters=filters) + for each in service_units: + if each['expandable'] == 1: # group node + available_count = frappe.db.count('Healthcare Service Unit', filters={ + 'parent_healthcare_service_unit': each['value'], + 'inpatient_occupancy': 1}) + + if available_count > 0: + occupied_count = frappe.db.count('Healthcare Service Unit', { + 'parent_healthcare_service_unit': each['value'], + 'inpatient_occupancy': 1, + 'occupancy_status': 'Occupied'}) + # set occupancy status of group node + each['occupied_of_available'] = str( + occupied_count) + ' Occupied of ' + str(available_count) + + return service_units @frappe.whitelist() @@ -717,3 +702,40 @@ def render_doc_as_html(doctype, docname, exclude_fields = []): doc_html = "
" %(doctype, docname) + doc_html + '
' return {'html': doc_html} + + +def update_address_links(address, method): + ''' + Hook validate Address + If Patient is linked in Address, also link the associated Customer + ''' + if 'Healthcare' not in frappe.get_active_domains(): + return + + patient_links = list(filter(lambda link: link.get('link_doctype') == 'Patient', address.links)) + + for link in patient_links: + customer = frappe.db.get_value('Patient', link.get('link_name'), 'customer') + if customer and not address.has_link('Customer', customer): + address.append('links', dict(link_doctype = 'Customer', link_name = customer)) + + +def update_patient_email_and_phone_numbers(contact, method): + ''' + Hook validate Contact + Update linked Patients' primary mobile and phone numbers + ''' + if 'Healthcare' not in frappe.get_active_domains(): + return + + if contact.is_primary_contact and (contact.email_id or contact.mobile_no or contact.phone): + patient_links = list(filter(lambda link: link.get('link_doctype') == 'Patient', contact.links)) + + for link in patient_links: + contact_details = frappe.db.get_value('Patient', link.get('link_name'), ['email', 'mobile', 'phone'], as_dict=1) + if contact.email_id and contact.email_id != contact_details.get('email'): + frappe.db.set_value('Patient', link.get('link_name'), 'email', contact.email_id) + if contact.mobile_no and contact.mobile_no != contact_details.get('mobile'): + frappe.db.set_value('Patient', link.get('link_name'), 'mobile', contact.mobile_no) + if contact.phone and contact.phone != contact_details.get('phone'): + frappe.db.set_value('Patient', link.get('link_name'), 'phone', contact.phone) diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 4854bfd1e16b..b1a64f95bf90 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -290,7 +290,12 @@ "on_trash": "erpnext.regional.check_deletion_permission" }, 'Address': { - 'validate': ['erpnext.regional.india.utils.validate_gstin_for_india', 'erpnext.regional.italy.utils.set_state_code', 'erpnext.regional.india.utils.update_gst_category'] + 'validate': [ + 'erpnext.regional.india.utils.validate_gstin_for_india', + 'erpnext.regional.italy.utils.set_state_code', + 'erpnext.regional.india.utils.update_gst_category', + 'erpnext.healthcare.utils.update_address_links' + ], }, 'Supplier': { 'validate': 'erpnext.regional.india.utils.validate_pan_for_india' @@ -301,7 +306,7 @@ "Contact": { "on_trash": "erpnext.support.doctype.issue.issue.update_issue", "after_insert": "erpnext.telephony.doctype.call_log.call_log.link_existing_conversations", - "validate": "erpnext.crm.utils.update_lead_phone_numbers" + "validate": ["erpnext.crm.utils.update_lead_phone_numbers", "erpnext.healthcare.utils.update_patient_email_and_phone_numbers"] }, "Email Unsubscribe": { "after_insert": "erpnext.crm.doctype.email_campaign.email_campaign.unsubscribe_recipient"