diff --git a/.claude/agents/lab-analyzer-integration.md b/.claude/agents/lab-analyzer-integration.md
new file mode 100644
index 00000000000..f608c8cd6f3
--- /dev/null
+++ b/.claude/agents/lab-analyzer-integration.md
@@ -0,0 +1,91 @@
+---
+name: lab-analyzer-integration
+description: "Use this agent when the user needs to develop, extend, or troubleshoot laboratory analyzer and LIS/LIMS middleware integrations — ASTM E1381/E1394 protocol handling, HL7 v2.5.1 lab messaging (ORU, OUL, QBP/RSP, ORM/OML), FHIR lab resources (Observation, DiagnosticReport, ServiceRequest, Specimen), POCT1-A device connectivity, or work on this repo's `com.divudi.ws.lims.*` package, `Analyzer`/`Machine`/`DepartmentMachine` entities, and `developer_docs/API_LIMS.md`.\n\nExamples:\n\n\nuser: \"We need to add support for a new Mindray BC-6800 hematology analyzer that talks ASTM over TCP\"\nassistant: \"I'm going to use the Task tool to launch the lab-analyzer-integration agent to design the ASTM frame handling and wire it into the existing Analyzer enum and MiddlewareController.\"\n\nAdding a new analyzer requires ASTM protocol knowledge plus understanding of how this repo's Analyzer enum, Machine/DepartmentMachine entities, and result-writing endpoints fit together — the lab-analyzer-integration agent specializes in exactly this.\n \n \n\n\nuser: \"The Sysmex XN-1000 is sending OUL^R22 messages but results aren't showing up against the right sample\"\nassistant: \"I'm going to use the Task tool to launch the lab-analyzer-integration agent to trace the HL7 OUL^R22 handling in LimsMiddlewareController and check the sample-ID matching logic.\"\n\nDebugging HL7 v2.5.1 unsolicited observation results requires segment-level HL7 knowledge (OBR/OBX/SPM) combined with familiarity with this codebase's HAPI-based parsing — use the lab-analyzer-integration agent.\n \n \n\n\nuser: \"A customer wants to expose lab results to an external EMR via FHIR instead of flat files\"\nassistant: \"I'm going to use the Task tool to launch the lab-analyzer-integration agent to design a FHIR DiagnosticReport/Observation mapping from PatientReport and PatientReportItemValue.\"\n\nMapping internal lab entities to FHIR lab resources needs both FHIR resource-model expertise and knowledge of this repo's PatientSample/PatientReport data model — the lab-analyzer-integration agent covers both.\n \n "
+model: sonnet
+color: teal
+---
+
+You are a senior laboratory systems integration engineer with deep, hands-on expertise in connecting IVD analyzers to LIS/LIMS/HIS platforms. You have implemented interfaces for hematology, chemistry, immunoassay, and coagulation analyzers across ASTM, HL7, and FHIR generations, and you are the go-to specialist for this HMIS codebase's `com.divudi.ws.lims` integration layer.
+
+## Ground Yourself in This Codebase First
+
+Before proposing changes, always check the current state of these files — they are the source of truth, not this prompt:
+
+- **`developer_docs/API_LIMS.md`** — the documented REST surface for all three LIMS resource groups (`/api/lims`, `/api/middleware`, `/api/limsmw`)
+- **`src/main/java/com/divudi/core/data/lab/Analyzer.java`** — enum of supported analyzers (name must exactly match, spaces → underscores, e.g. `Gallery_Indiko`). Adding a new analyzer means adding a constant here AND wiring its result-handling branch.
+- **`src/main/java/com/divudi/ws/lims/MiddlewareController.java`** (`/api/middleware`) — JSON `DataBundle`-based order/result exchange used by modern middleware (Data Innovations-style)
+- **`src/main/java/com/divudi/ws/lims/LimsMiddlewareController.java`** (`/api/limsmw`, ~2,200 lines) — HL7 v2 (via HAPI `ca.uhn.hl7v2`) and raw Sysmex ASTM handling; entry points for `OUL^R22`, `QBP^Q11`/`RSP^K11`, `/observation` (JSON), `/sysmex` (ASTM)
+- **`src/main/java/com/divudi/ws/lims/MachineApi.java`** and **`AnalyzerTestApi.java`** — machine/instrument CRUD and analyzer-test mapping
+- **`src/main/java/com/divudi/ws/lims/Lims.java`** — legacy barcode/login/middleware query endpoints
+- **`src/main/java/com/divudi/core/data/lab/SysMex.java` / `SysMexTypeA.java`** — ASTM frame parsing helpers specific to Sysmex instruments
+- **Entities**: `Machine`, `DepartmentMachine`, `PatientSample`, `PatientSampleComponant`, `PatientInvestigation`, `PatientReport`, `PatientReportItemValue`, `AnalyzerMessage`, `InvestigationItemValueFlag`
+
+If a user's request implies behavior that contradicts what's actually in these files, trust the code and flag the discrepancy rather than assuming the documented API is current.
+
+## Core Expertise
+
+### ASTM E1381 / E1394 (Analyzer-Side Protocol)
+- **Low-level (E1381) framing**: `ENQ`/`ACK`/`NAK`/`EOT` handshake, `STX ` frame structure, checksum calculation (mod-256 of bytes between STX and checksum field), frame numbering (1-7 cycling)
+- **High-level (E1394) record structure**: Header (H), Patient (P), Order (O), Result (R), Comment (C), Query (Q), Terminator (L) records, `|`-delimited fields with `^` component and `\` repeat separators
+- **Sysmex-specific ASTM variants**: differences between Type A and generic ASTM framing (see `SysMex`/`SysMexTypeA` in this repo) — Sysmex instruments often deviate slightly from strict E1394 field ordering
+- **Transport**: serial (RS-232) vs. ASTM-over-TCP (raw socket framing, no MLLP wrapper) vs. middleware-mediated (analyzer talks ASTM to middleware, middleware talks HL7/REST to HMIS — the pattern this repo actually uses for most instruments)
+- **Common pitfalls**: off-by-one frame numbering causing NAK loops, checksum mismatches from encoding issues (non-ASCII patient names), partial-frame timeouts, instruments that skip the P record when running QC
+
+### HL7 v2.5.1 Laboratory Messaging
+- **Message types**: `ORU^R01` (standard result), `OUL^R21`/`OUL^R22` (unsolicited lab observation, specimen-container-oriented — what `LimsMiddlewareController` handles), `ORM^O01`/`OML^O21`/`OML^O33` (orders), `QBP^Q11`/`RSP^K11` (query-by-parameter for pending orders — analyzer polls HMIS for what to run on a scanned sample), `ACK`/`NAK`
+- **Segment structure**: MSH (message header, encoding chars, message control ID), PID (patient), ORC (order common), OBR (observation request — links to the test/battery), OBX (observation/result — the actual value, units, reference range, abnormal flag), SPM (specimen), NTE (notes)
+- **MLLP framing**: `` (0x0B) message start, `` (0x1C 0x0D) message end, used for TCP-based HL7 (distinct from ASTM's raw framing)
+- **ACK handling**: `MSA-1` = `AA` (accept)/`AE` (error)/`AR` (reject); a message left un-ACK'd causes analyzer-side retry storms — always ACK deterministically even on internal processing failure, encoding the failure in `MSA-3`
+- **POCT1-A**: HL7-based point-of-care device connectivity standard (glucometers, blood gas analyzers, coag near-patient devices) — a specialization of HL7 v2 with device-specific observation identifiers (LOINC-coded) and battery/challenge concepts (e.g., QC lockout)
+
+### FHIR for Laboratory
+- **Core resources**: `Observation` (individual result — `code` via LOINC, `valueQuantity`/`valueString`, `referenceRange`, `interpretation` for H/L/HH/LL flags, `specimen` reference), `DiagnosticReport` (the report grouping — `result` array of Observation references, `status`, `conclusion`), `ServiceRequest` (the order, replaces old `ProcedureRequest`), `Specimen` (collection/container/type), `Device` (the analyzer itself, useful for provenance)
+- **Terminology bindings**: LOINC for observation codes, UCUM for units (this repo already uses `UCUM` as a coding-system value in the `/limsmw/observation` JSON payload), SNOMED CT for qualitative results/interpretations
+- **When to reach for FHIR here**: outbound integration to external EMRs/HIEs or modern middleware that speaks FHIR natively, not as a replacement for the existing ASTM/HL7 analyzer-facing endpoints — those stay because that's what the physical instruments and middleware boxes actually speak
+
+### LIS/LIMS Middleware Concepts
+- **Role of middleware** (Data Innovations Instrument Manager, Mediware, Sysmex WAM, or in-house — this repo's `/api/middleware` and `/api/limsmw` play this role directly for several analyzers): protocol translation, auto-verification rules, result routing to the correct department/machine, delta-check and reflex-test triggering, bidirectional order/result buffering when HMIS is briefly unreachable
+- **Sample ID matching**: the critical join key between analyzer output and HMIS records; this repo tries both `PatientSample.id` and `PatientSample.sampleId` — always confirm which one a given analyzer/message actually sends before debugging "sample not found" errors
+- **Auto-verification**: rules-based auto-release of results within reference range and without instrument flags, vs. manual technologist review — relevant when discussing result-writing endpoint design, not just protocol plumbing
+- **Bidirectional vs. unidirectional interfaces**: unidirectional (analyzer only pushes results, worklist entered manually on the instrument) vs. bidirectional (analyzer queries HMIS for pending orders via barcode scan — this repo's `QBP^Q11`/`test_orders_for_sample_requests` flows)
+
+## Implementation Approach
+
+1. **Confirm the analyzer's actual protocol before coding.** Manufacturers deviate from spec constantly — get the instrument's ASTM/HL7 conformance statement or interface spec sheet rather than assuming textbook framing. Sysmex, Mindray, and BioRad all have quirks already worked around in this repo's `SysMex`/`SysMexTypeA` classes and the analyzer-specific branches in `LimsMiddlewareController`.
+
+2. **Reuse the existing analyzer/message model.** New instruments should get a new `Analyzer` enum constant and a `Machine`/`DepartmentMachine` row, not a parallel ad hoc integration path. Follow the pattern of an existing similar analyzer (e.g., another hematology analyzer for a new CBC instrument) rather than inventing new message plumbing.
+
+3. **JPQL over native SQL** for any new queries against `PatientSample`, `PatientReport`, etc., per this repo's standing rule — native SQL is not justified by "ASTM/HL7 parsing is already imperative code."
+
+4. **Result-writing endpoints are high-blast-radius.** `/middleware/test_results`, `/limsmw/observation`, `/limsmw/sysmex`, and `/limsmw/limsProcessAnalyzerMessage` write patient lab results directly into the HMIS database. Per `developer_docs/API_LIMS.md`: never invoke these manually, in tests, or via ad hoc scripts unless you have a verified sample ID and are responding to genuine analyzer output — a stray call creates a spurious result record that can affect patient care. For non-production validation, use a QC/test sample explicitly marked as such, and verify the read paths (`test_orders_for_sample_requests`, `QBP^Q11`) before touching write paths.
+
+5. **ACK/NAK discipline.** Any HL7 handler must return a syntactically valid ACK for every received message, even on internal error — set `MSA-1=AE` and populate an error segment rather than dropping the connection, or the analyzer/middleware will retry indefinitely and can flood the interface.
+
+6. **Sample ID ambiguity.** Always check whether the analyzer/middleware is sending `PatientSample.id` (internal PK) or `PatientSample.sampleId` (business/barcode ID) — mismatches here are the most common cause of "sample not found" integration bugs.
+
+7. **Character encoding and delimiters.** ASTM and HL7 both use ASCII control characters and pipe/caret delimiters as protocol structure — never let free-text fields (patient name, comments) containing `|`, `^`, `\`, `~`, or `&` reach the wire unescaped, and watch for non-ASCII patient names breaking ASTM checksums.
+
+## Quality Assurance
+
+Before considering an analyzer/middleware integration change complete, verify:
+
+1. **Protocol conformance**: Does the frame/message structure match the instrument's actual interface spec, not just the general ASTM/HL7 standard?
+2. **Sample matching**: Does the code use the correct sample ID field for this analyzer's message format?
+3. **Existing pattern reuse**: Is the new analyzer wired through the `Analyzer` enum and existing `MiddlewareController`/`LimsMiddlewareController` dispatch, not a bespoke parallel path?
+4. **ACK correctness**: Does every HL7 message path return a well-formed ACK, including on error?
+5. **Write safety**: Have result-writing endpoints been exercised only against verified test/QC samples, never against arbitrary live sample IDs?
+6. **Documentation**: If a new endpoint, message type, or analyzer was added, is `developer_docs/API_LIMS.md` updated to match?
+
+## Communication Style
+
+1. **Be protocol-precise**: cite exact segment/field names (e.g., "OBX-5 result value", "the P record's 6th field"), not vague references to "the message"
+2. **Name the actual repo class/endpoint** you mean (`LimsMiddlewareController.receiveObservation`, `/api/middleware/test_results`) rather than describing it abstractly
+3. **Flag write-endpoint risk explicitly** any time a proposed test or debug step would call a result-writing endpoint
+4. **Explain instrument-specific quirks** when relevant ("Sysmex's ASTM framing here differs from strict E1394 because...")
+
+## When Uncertain
+
+1. Ask for the analyzer's actual interface/conformance specification sheet rather than guessing at message structure
+2. If unsure whether a new instrument should integrate via ASTM-direct, HL7, or through third-party middleware, ask which connectivity option the instrument vendor actually supports and what the customer site already has in place
+3. For FHIR-facing work, confirm whether the target consumer needs `Observation`-per-result or a single `DiagnosticReport` bundle before designing the mapping
+4. Never guess at whether a sample ID is internal PK or business ID — check `PatientSample` usage in the relevant endpoint first
diff --git a/developer_docs/testing/playwright-e2e-workflow.md b/developer_docs/testing/playwright-e2e-workflow.md
index 45a1f500768..e654a11ad21 100644
--- a/developer_docs/testing/playwright-e2e-workflow.md
+++ b/developer_docs/testing/playwright-e2e-workflow.md
@@ -457,6 +457,121 @@ test, try the token-based "Sale for Cashier" flow instead — its item-add/quant
inputs worked fine in this session, only the retail-native page's Settle button was
unreachable.
+## 20. A privilege-gated button that never renders may be a missing DB row, not a session issue
+
+If a `rendered="#{webUserController.hasPrivilege('SomePrivilege')}"` button never
+appears even after the §17 logout/relogin-and-reselect-department trick, the
+`webuserprivilege` row for that (user, department, privilege) triple may simply not
+exist in the local seed data — no amount of re-login fixes a privilege that was never
+granted for that department. Check first:
+
+```sql
+SELECT ID, PRIVILEGE, DEPARTMENT_ID, RETIRED
+FROM webuserprivilege
+WHERE WEBUSER_ID = AND PRIVILEGE = 'SomePrivilege';
+```
+
+If the row for the target department is absent, insert it (`RETIRED = 0`) for that
+`WEBUSER_ID`/`DEPARTMENT_ID`, then follow §17 (logout → login → reselect department)
+to force `SessionController.fillUserPrivileges()` to re-read it — the privilege list is
+cached per session at login and won't pick up a new row otherwise. This came up testing
+`BhtSummeryController.settle()` (`InwardSettleFinalBill`), where the local `buddhika`
+user had the privilege for `Store`/`Main Pharmacy` departments but not `Inward`.
+
+## 21. Inward "Add Services" item picker — the Filter box does not load other departments' items
+
+On `inward/inward_bill_service.xhtml` (and the surgery equivalent) the item selector shows
+a **department button row** (OPD, ETU, Inward, MRI, …) above an "Investigation or Service"
+list. That list is scoped to the **currently selected department button**, defaulting to the
+first (usually OPD). The "Filter" textbox only narrows the *already-loaded* department's list —
+typing an item name that belongs to another department returns nothing. To bill a service
+that lives in a different department (e.g. `CT SCANNING CHARGES` / `SUTURING & DRESSING
+CHARGES` under **ETU**), first click that department's button to load its items, *then* pick
+from the list. Symptom if you skip this: the filter shows "no match" even though the item
+exists and the DB confirms it. Refs churn after the department-button AJAX, so re-`snapshot`
+before clicking the option, and click the visible listbox row (the hidden native ``
+with the same text is not clickable). Verified while testing room-category service margins
+(issue #21977).
+
+## 22. Inward pharmacy margin lookup uses the *inpatient* department, not the issuing pharmacy
+
+When testing the inward price-adjustment (service-charge) margin for **pharmacy** issues to an
+inpatient, the matrix department is resolved by `PharmacySaleBhtController.determineMatrixDepartment()`,
+which is gated by config `"Price Matrix is calculated from Inpatient Department for "`
+(**default true**). When on, the lookup uses the patient's **current room's facility-charge
+department** — for A/C/Non-A/C rooms in the model DB that is **Inward**, *not* the pharmacy you
+are logged into (e.g. Main Pharmacy). Symptom if you create the matrix row against the pharmacy
+department: the margin resolves to 0 / the wrong row even though the row exists. Fix: create the
+`InwardPriceAdjustment` row for the **room facility charge's department**
+(`SELECT rfc.department_id FROM patientencounter pe JOIN patientroom pr ON pe.currentpatientroom_id=pr.id
+JOIN roomfacilitycharge rfc ON pr.roomfacilitycharge_id=rfc.id WHERE pe.id=`), and set the
+row's payment method to match the encounter's (`patientencounter.paymentMethod`). Also beware
+**pre-existing overlapping rows** for the same dept/category/price-range/payment-method — they make
+the wildcard-vs-specific comparison ambiguous; temporarily `retired=1` them for a clean A/B test,
+then restore. Fastest confirmation without the full multi-page issue flow:
+`GET /api/inward-price-adjustment/diagnose?itemId=&departmentId=&paymentMethod=&patientEncounterId=&price=`
+with a `Finance` API-key header — it runs the identical `fetchInwardMargin(...)` call the pharmacy
+controllers use and returns the matched row id + margin %. Verified while testing room-category
+pharmacy margins (issue #21981).
+
+## 23. Three authoring gotchas found via E2E on the role-template pages (issue #22023)
+
+Testing `admin/users/user_role_users.xhtml` / `user_role_bulk_operations.xhtml` surfaced
+three silent-failure patterns worth checking on any new admin page:
+
+1. **`p:selectManyCheckbox` over a `List` needs an explicit named converter.**
+ The `@FacesConverter(forClass = Department.class)` converter is *not* applied to
+ `UISelectMany` bound to a generic `List` (type erasure — JSF can't detect the element
+ type), so submitted values stay `String`s and the action later dies with
+ `ClassCastException: java.lang.String cannot be cast to ... Department` inside the EJB.
+ Fix: register a named converter (e.g. `userRoleDepartmentConverter`) and set
+ `converter="..."` on the component explicitly.
+2. **`process="cmbA cmbB"` without `@this` silently skips the button's own action.**
+ The AJAX request fires, inputs are applied, the `update` render runs — but the
+ `action` never executes because the button itself wasn't in the execute list.
+ Symptom: "No records found" with no error anywhere. Always write
+ `process="@this cmbA cmbB"`.
+3. **Multi-select checkbox column: this PrimeFaces version wants `selectionMode="multiple"`
+ on the `p:dataTable` + ` `** — a
+ ` ` (the pattern current PF docs show) renders an
+ *empty* cell. Copy the working pattern from `user_remove_multiple.xhtml`.
+
+Also (rendering): a `p:selectOneMenu` bound to `#{bean.current.field}` blows up the whole
+page with `PropertyNotFoundException: Target Unreachable` when `current` is null on first
+GET — unlike `p:inputText`, select components resolve the value expression's *type* during
+render. Guard with `rendered="#{bean.current ne null}"`.
+
+## 24. Granting a privilege that doesn't exist on the checked-out branch silently blanks ALL privileges for that department
+
+If you insert a `webuserprivilege` row for a `Privileges` enum value that exists on
+*another* branch (e.g. one you tested earlier today) but not on the branch currently
+checked out and deployed, the entire menu goes blank and every `hasPrivilege(...)` check
+returns `false` for that user **in that department** — not just the one bad privilege.
+`WebUserPrivilege.privilege` is `@Enumerated(EnumType.STRING)`; EclipseLink converts the
+DB string to the Java enum via `Enum.valueOf(...)` when it hydrates the full result list
+for `SessionController.fillUserPrivileges()`, and a single row whose string isn't a valid
+constant on the *currently running* code silently poisons that entire fetch — with no
+`SEVERE` entry in `server.log` and no visible page error, just empty menus / "not
+authorized" everywhere for that department, while other departments the row doesn't
+affect work fine (a strong tell if you compare departments). A domain restart or a full
+undeploy+redeploy does **not** fix this — it's a data/branch mismatch, not a cache.
+
+Diagnose fast: enable the MySQL general log to a table (`SET GLOBAL log_output='TABLE';
+SET GLOBAL general_log='ON';`) and check `mysql.general_log` for the exact
+`SELECT ... FROM WEBUSERPRIVILEGE WHERE ...` query, run it directly, then diff the
+distinct `PRIVILEGE` values for that user/department against
+`grep -oP '(?<=^ )[A-Za-z0-9_]+(?=\(")' src/main/java/com/divudi/core/data/Privileges.java`
+(note: some enum lines have a trailing `//` comment that breaks a naive end-of-line
+regex — verify any apparent mismatch with a direct `grep` before trusting the diff).
+
+This came up switching from the GRN privilege-guard branch (issue #22019, which added
+`PharmacyGrnCancel`/`PharmacyGrnReturnCancel`) to the PO privilege-guard branch (#22020,
+checked out fresh from `origin/development` since #22019 wasn't merged yet) — rows
+granted while testing #22019 were still sitting in the shared local DB and broke every
+privilege check for that department under the PO branch's code. Fix: delete (or retire)
+the rows for privileges that don't exist on the currently deployed branch, re-grant only
+what the current branch's `Privileges.java` actually declares, then re-login.
+
## Quick checklist
- [ ] Confirmed environment + URL with the developer; credentials kept out of the repo.
diff --git a/src/main/java/com/divudi/bean/common/DataAdministrationController.java b/src/main/java/com/divudi/bean/common/DataAdministrationController.java
index dea9667ba68..f93d7a82627 100644
--- a/src/main/java/com/divudi/bean/common/DataAdministrationController.java
+++ b/src/main/java/com/divudi/bean/common/DataAdministrationController.java
@@ -2391,7 +2391,14 @@ private void markSchemaAsCurrentSilently(boolean executionPerformed) {
}
public String getStoredDdlVersion() {
- return configOptionApplicationController.getShortTextValueByKey(CONFIG_KEY_DDL_VERSION);
+ try {
+ return configOptionApplicationController.getShortTextValueByKey(CONFIG_KEY_DDL_VERSION);
+ } catch (Exception e) {
+ // Reading/creating this config option can fail on a database that is itself
+ // mid-migration (e.g. config_option.id not yet AUTO_INCREMENT) — this page
+ // exists to fix that exact situation, so it must not crash rendering because of it.
+ return null;
+ }
}
public String getWikiDdlVersion() {
diff --git a/src/main/java/com/divudi/bean/common/DepartmentController.java b/src/main/java/com/divudi/bean/common/DepartmentController.java
index 27ff547f187..84e0b46834b 100644
--- a/src/main/java/com/divudi/bean/common/DepartmentController.java
+++ b/src/main/java/com/divudi/bean/common/DepartmentController.java
@@ -679,6 +679,19 @@ public List completeDept(String qry) {
return departmentList;
}
+ public List completeInwardDepartments(String qry) {
+ String sql;
+ HashMap hm = new HashMap();
+ sql = "select c from Department c "
+ + " where c.retired=false "
+ + " and c.inactive=false "
+ + " and c.departmentType = com.divudi.core.data.DepartmentType.Inward "
+ + " and upper(c.name) like :q "
+ + " order by c.name";
+ hm.put("q", "%" + qry.toUpperCase() + "%");
+ return getFacade().findByJpql(sql, hm);
+ }
+
public List completeDeptWithIns(String qry) {
FacesContext context = FacesContext.getCurrentInstance();
Institution selectedInstitution = (Institution) UIComponent.getCurrentComponent(context).getAttributes().get("selectedInstitution");
diff --git a/src/main/java/com/divudi/bean/common/PriceMatrixController.java b/src/main/java/com/divudi/bean/common/PriceMatrixController.java
index e1c673d7f70..dd3b481919a 100644
--- a/src/main/java/com/divudi/bean/common/PriceMatrixController.java
+++ b/src/main/java/com/divudi/bean/common/PriceMatrixController.java
@@ -220,6 +220,33 @@ private PriceMatrix fetchInwardMarginWithCreditCompany(Item item, double service
return result;
}
+ // =========================================================================
+ // Room-category-aware fetchInwardMargin overloads (issue #21977)
+ //
+ // Thread the patient's current room category through to the room-category-
+ // aware getInwardPriceAdjustment lookup. A null roomCategory restricts the
+ // lookup to wildcard (NULL) rows, so callers with no room context behave
+ // exactly as before. The category cascade (item category then parent
+ // category) and the config-gated payment-method dimension are preserved.
+ // =========================================================================
+
+ public PriceMatrix fetchInwardMargin(BillItem billItem, double serviceValue, Department department,
+ PaymentMethod paymentMethod, Institution creditCompany, AdmissionType admissionType, RoomCategory roomCategory) {
+ return fetchInwardMargin(billItem.getItem(), serviceValue, department, paymentMethod, creditCompany, admissionType, roomCategory);
+ }
+
+ public PriceMatrix fetchInwardMargin(Item item, double serviceValue, Department department,
+ PaymentMethod paymentMethod, Institution creditCompany, AdmissionType admissionType, RoomCategory roomCategory) {
+ boolean isPaymentMethodAllowedInInwardMatrix = configOptionApplicationController.getBooleanValueByKey("Inward Matrix - Allow PaymentMethod for Inward Matrix Calculation", false);
+ Category category = resolveInwardMatrixCategory(item);
+ PaymentMethod pm = isPaymentMethodAllowedInInwardMatrix ? paymentMethod : null;
+ PriceMatrix result = getInwardPriceAdjustment(department, serviceValue, category, pm, creditCompany, admissionType, roomCategory);
+ if (result == null && category != null) {
+ result = getInwardPriceAdjustment(department, serviceValue, category.getParentCategory(), pm, creditCompany, admissionType, roomCategory);
+ }
+ return result;
+ }
+
public double getItemWithInwardMargin(Item item) {
if (item == null) {
return 0.0;
@@ -466,6 +493,125 @@ public InwardPriceAdjustment getInwardPriceAdjustment(Department department, dou
return firstInwardPriceAdjustment(findInwardPriceAdjustments(sql, hm));
}
+ // =========================================================================
+ // Room-category-aware inward price-adjustment lookup (issue #21977)
+ //
+ // Adds the patient's current room category as an optional matrix dimension,
+ // additive on top of the existing paymentMethod / creditCompany /
+ // admissionType dimensions. Semantics mirror admissionType (issue #21551):
+ //
+ // - A row created WITH a roomCategory applies only to that room category.
+ // - A row created WITHOUT a roomCategory (NULL) is a wildcard and applies
+ // to every room category — this preserves the behaviour of all existing
+ // matrices, which have no room category set.
+ // - When both a matching room-category-specific row and a wildcard row
+ // exist for the same dept/category/price-range (and payment method /
+ // credit company / admission type), the specific row wins.
+ // - When roomCategory is null at the call site (patient not in a room yet),
+ // the lookup restricts to wildcard (NULL) rows only, identical to the
+ // legacy behaviour.
+ //
+ // Rather than doubling every existing overload, all of them are consolidated
+ // to delegate to one predicate-builder that appends a WHERE fragment per
+ // supplied dimension and orders wildcards last. The tie-break between
+ // admission-type specificity and room-category specificity is controlled by
+ // the config option below so deployments can choose which dimension is the
+ // dominant sort key.
+ // =========================================================================
+
+ private static final String CONFIG_ROOM_CATEGORY_OVER_ADMISSION_TYPE =
+ "Inward Matrix - Room Category takes priority over Admission Type";
+
+ /**
+ * Builds the ORDER BY that ranks the most specific matrix row first and the
+ * full wildcard row last, across the admissionType and roomCategory
+ * dimensions. Each dimension contributes 0 when specific and 1 when wildcard
+ * (NULL). The config flag decides which dimension is the primary sort key;
+ * a row that is specific on both always outranks any partially-specific row,
+ * which in turn outranks the full wildcard.
+ */
+ private String inwardMatrixOrderBy(boolean admissionTypeSupplied, boolean roomCategorySupplied) {
+ if (!admissionTypeSupplied && !roomCategorySupplied) {
+ return "";
+ }
+ String roomRank = roomCategorySupplied
+ ? "case when a.roomCategory is null then 1 else 0 end" : null;
+ String admRank = admissionTypeSupplied
+ ? "case when a.admissionType is null then 1 else 0 end" : null;
+ boolean roomFirst = configOptionApplicationController
+ .getBooleanValueByKey(CONFIG_ROOM_CATEGORY_OVER_ADMISSION_TYPE, false);
+ StringBuilder order = new StringBuilder(" order by ");
+ if (roomRank != null && admRank != null) {
+ order.append(roomFirst ? roomRank + ", " + admRank : admRank + ", " + roomRank);
+ } else {
+ order.append(roomRank != null ? roomRank : admRank);
+ }
+ return order.toString();
+ }
+
+ /**
+ * Appends the "(a.roomCategory = :rc or a.roomCategory is null)" fragment
+ * when a room category is supplied, else restricts to wildcard rows only —
+ * so a caller with no room context never picks up a room-specific row.
+ */
+ private String roomCategoryPredicate(RoomCategory roomCategory) {
+ if (roomCategory == null) {
+ return " and a.roomCategory is null";
+ }
+ return " and (a.roomCategory=:rc or a.roomCategory is null)";
+ }
+
+ /**
+ * Full inward price-adjustment lookup with every optional dimension threaded
+ * through. paymentMethod / creditCompany / admissionType / roomCategory are
+ * each optional (null = not filtered / wildcard-only, per the semantics
+ * documented above). This is the single method all the public overloads
+ * ultimately delegate to.
+ */
+ public InwardPriceAdjustment getInwardPriceAdjustment(Department department, double dbl, Category category,
+ PaymentMethod paymentMethod, Institution creditCompany, AdmissionType admissionType, RoomCategory roomCategory) {
+ StringBuilder sql = new StringBuilder("select a from InwardPriceAdjustment a "
+ + " where a.retired=false"
+ + " and a.category=:cat "
+ + " and a.department=:dep"
+ + " and (a.fromPrice< :frPrice and a.toPrice >:tPrice)");
+ HashMap hm = new HashMap();
+ hm.put("dep", department);
+ hm.put("frPrice", dbl);
+ hm.put("tPrice", dbl);
+ hm.put("cat", category);
+
+ if (paymentMethod == null) {
+ // No payment-method filter: match rows regardless of payment method.
+ } else {
+ sql.append(" and a.paymentMethod=:pm");
+ hm.put("pm", paymentMethod);
+ }
+
+ if (creditCompany == null) {
+ sql.append(" and a.creditCompany is null");
+ } else {
+ sql.append(" and a.creditCompany=:cc");
+ hm.put("cc", creditCompany);
+ }
+
+ if (admissionType == null) {
+ sql.append(" and a.admissionType is null");
+ } else {
+ sql.append(" and (a.admissionType=:at or a.admissionType is null)");
+ hm.put("at", admissionType);
+ }
+
+ sql.append(roomCategoryPredicate(roomCategory));
+ if (roomCategory != null) {
+ hm.put("rc", roomCategory);
+ }
+
+ sql.append(inwardMatrixOrderBy(admissionType != null, roomCategory != null));
+
+ return firstInwardPriceAdjustment(findInwardPriceAdjustments(sql.toString(), hm));
+ }
+
public InwardMemberShipDiscount getInwardMemberDisCount(PaymentMethod paymentMethod, MembershipScheme membershipScheme, Institution ins, InwardChargeType inwardChargeType, AdmissionType admissionType) {
String sql;
HashMap hm = new HashMap();
diff --git a/src/main/java/com/divudi/bean/common/SessionController.java b/src/main/java/com/divudi/bean/common/SessionController.java
index 5d571fb17b1..86c059841ee 100644
--- a/src/main/java/com/divudi/bean/common/SessionController.java
+++ b/src/main/java/com/divudi/bean/common/SessionController.java
@@ -19,6 +19,7 @@
import com.divudi.core.data.Icon;
import com.divudi.core.data.IconGroup;
import com.divudi.core.data.InstitutionType;
+import com.divudi.core.data.LoginPage;
import com.divudi.core.data.UserIconGroup;
import static com.divudi.core.data.LoginPage.CHANNELLING_QUEUE_PAGE;
import static com.divudi.core.data.LoginPage.CHANNELLING_TV_DISPLAY;
@@ -41,6 +42,7 @@
import com.divudi.core.entity.UserPreference;
import com.divudi.core.entity.WebUser;
import com.divudi.core.entity.WebUserDashboard;
+import com.divudi.core.entity.WebUserDefaultLoginPage;
import com.divudi.core.entity.WebUserDepartment;
import com.divudi.core.entity.WebUserPrivilege;
import com.divudi.core.entity.WebUserRole;
@@ -50,6 +52,7 @@
import com.divudi.core.facade.PersonFacade;
import com.divudi.core.facade.UserPreferenceFacade;
import com.divudi.core.facade.WebUserDashboardFacade;
+import com.divudi.core.facade.WebUserDefaultLoginPageFacade;
import com.divudi.core.facade.WebUserDepartmentFacade;
import com.divudi.core.facade.WebUserFacade;
import com.divudi.core.facade.WebUserPrivilegeFacade;
@@ -126,8 +129,10 @@ public class SessionController implements Serializable, HttpSessionListener {
WebUserRoleFacade rFacade;
@EJB
private WebUserPasswordHistoryFacade webUserPasswordHistoryFacade;
+ @EJB
+ private WebUserDefaultLoginPageFacade webUserDefaultLoginPageFacade;
- //
+ //
//
@Inject
private SecurityController securityController;
@@ -1843,14 +1848,38 @@ private boolean passwordRequirementsCorrect() {
return true;
}
+ /**
+ * Resolves the login page to navigate to: an active per-user-per-department
+ * {@link WebUserDefaultLoginPage} row takes priority, then the legacy
+ * {@link WebUser#getLoginPage()} value, then HOME.
+ */
+ private LoginPage resolveLoginPage() {
+ if (loggedUser == null) {
+ return LoginPage.HOME;
+ }
+ if (department != null) {
+ Map m = new HashMap();
+ m.put("user", loggedUser);
+ m.put("dept", department);
+ WebUserDefaultLoginPage wuLp = webUserDefaultLoginPageFacade.findFirstByJpql(
+ "select w from WebUserDefaultLoginPage w where w.webUser=:user and w.department=:dept and w.retired=false order by w.id desc",
+ m);
+ if (wuLp != null && wuLp.getLoginPage() != null) {
+ return wuLp.getLoginPage();
+ }
+ }
+ if (loggedUser.getLoginPage() != null) {
+ return loggedUser.getLoginPage();
+ }
+ return LoginPage.HOME;
+ }
+
public String navigateToLoginPageByUsersDefaultLoginPage() {
if (loggedUser == null) {
return null;
}
- if (loggedUser.getLoginPage() == null) {
- return "/home?faces-redirect=true";
- }
- switch (loggedUser.getLoginPage()) {
+ LoginPage resolvedLoginPage = resolveLoginPage();
+ switch (resolvedLoginPage) {
case CHANNELLING_QUEUE_PAGE:
return bookingController.navigateToChannelQueueFromMenu();
case CHANNELLING_TV_DISPLAY:
diff --git a/src/main/java/com/divudi/bean/common/UserPrivilageController.java b/src/main/java/com/divudi/bean/common/UserPrivilageController.java
index 31a5763b73f..d0b64c3ce58 100644
--- a/src/main/java/com/divudi/bean/common/UserPrivilageController.java
+++ b/src/main/java/com/divudi/bean/common/UserPrivilageController.java
@@ -206,6 +206,8 @@ private TreeNode createPrivilegeHolderTreeNodes() {
new DefaultTreeNode(new PrivilegeHolder(Privileges.InwardPharmacyIssueRequestSearch, "Pharmacy Issue Request Search"), inwardPharmacyNode);
new DefaultTreeNode(new PrivilegeHolder(Privileges.InwardPharmacyIssueRequestCancel, "Pharmacy Issue Request Cancel"), inwardPharmacyNode);
new DefaultTreeNode(new PrivilegeHolder(Privileges.InwardPharmacyReturnCancel, "Pharmacy Return Cancel"), inwardPharmacyNode);
+ new DefaultTreeNode(new PrivilegeHolder(Privileges.InwardPharmacyReturnSubmit, "Pharmacy Return Submit"), inwardPharmacyNode);
+ new DefaultTreeNode(new PrivilegeHolder(Privileges.InwardPharmacyBhtReceive, "Pharmacy BHT Receive"), inwardPharmacyNode);
TreeNode searchNode = new DefaultTreeNode(new PrivilegeHolder(null, "Search"), inwardNode);
new DefaultTreeNode(new PrivilegeHolder(Privileges.InwardSearch, "Search Menu"), searchNode);
@@ -752,8 +754,11 @@ private TreeNode createPrivilegeHolderTreeNodes() {
TreeNode issueForRequestApprove = new DefaultTreeNode(new PrivilegeHolder(Privileges.PharmacyIssueForRequestApprove, "Issue for Request Approve"), disbursementNode);
TreeNode disbursementDirectIssue = new DefaultTreeNode(new PrivilegeHolder(Privileges.PharmacyDisbursementDirectIssue, "Direct Issue"), disbursementNode);
TreeNode disbursementRecieve = new DefaultTreeNode(new PrivilegeHolder(Privileges.PharmacyDisbursementRecieve, "Recieve"), disbursementNode);
+ TreeNode receiveSave = new DefaultTreeNode(new PrivilegeHolder(Privileges.PharmacyReceiveSave, "Receive Save"), disbursementNode);
TreeNode receiveFinalize = new DefaultTreeNode(new PrivilegeHolder(Privileges.PharmacyReceiveFinalize, "Receive Finalize"), disbursementNode);
TreeNode receiveApprove = new DefaultTreeNode(new PrivilegeHolder(Privileges.PharmacyReceiveApprove, "Receive Approve"), disbursementNode);
+ TreeNode transferIssueCancel = new DefaultTreeNode(new PrivilegeHolder(Privileges.PharmacyTransferIssueCancel, "Transfer Issue Cancel"), disbursementNode);
+ TreeNode transferReceiveCancel = new DefaultTreeNode(new PrivilegeHolder(Privileges.PharmacyTransferReceiveCancel, "Transfer Receive Cancel"), disbursementNode);
TreeNode TransferReciveApproval = new DefaultTreeNode(new PrivilegeHolder(Privileges.TransferReciveApproval, "Recieve Approval"), disbursementNode);
TreeNode PharmacyDisbursementReports = new DefaultTreeNode(new PrivilegeHolder(Privileges.PharmacyDisbursementReports, "Pharmacy Disbursement Reports"), disbursementNode);
TreeNode PharmacyTransferViewRates = new DefaultTreeNode(new PrivilegeHolder(Privileges.PharmacyTransferViewRates, "Pharmacy Transfer View Rates"), disbursementNode);
@@ -787,10 +792,14 @@ private TreeNode createPrivilegeHolderTreeNodes() {
TreeNode pharmacyDirectPurchaseFinalize = new DefaultTreeNode(new PrivilegeHolder(Privileges.PharmacyDirectPurchaseFinalize, "Pharmacy Direct Purchase Finalize"), ProcumentNode);
TreeNode pharmacyDirectPurchaseApprove = new DefaultTreeNode(new PrivilegeHolder(Privileges.PharmacyDirectPurchaseApprove, "Pharmacy Direct Purchase Approve"), ProcumentNode);
TreeNode pharmacyPurchaseOrderApprovel = new DefaultTreeNode(new PrivilegeHolder(Privileges.PurchaseOrdersApprovel, "Purchase Orders Approvel"), ProcumentNode);
+ TreeNode pharmacyPurchaseOrderSave = new DefaultTreeNode(new PrivilegeHolder(Privileges.PurchaseOrderSave, "Purchase Order Save"), ProcumentNode);
+ TreeNode pharmacyPurchaseOrderFinalize = new DefaultTreeNode(new PrivilegeHolder(Privileges.PurchaseOrderFinalize, "Purchase Order Finalize"), ProcumentNode);
TreeNode pharmacyGoodRecipt = new DefaultTreeNode(new PrivilegeHolder(Privileges.GoodsRecipt, "Pharmacy Good Recipt"), ProcumentNode);
TreeNode pharmacyGrnSave = new DefaultTreeNode(new PrivilegeHolder(Privileges.PharmacyGrnSave, "Pharmacy GRN Save"), ProcumentNode);
TreeNode pharmacyGrnFinalize = new DefaultTreeNode(new PrivilegeHolder(Privileges.PharmacyGrnFinalize, "Pharmacy GRN Finalize"), ProcumentNode);
TreeNode pharmacyGrnApprove = new DefaultTreeNode(new PrivilegeHolder(Privileges.PharmacyGrnApprove, "Pharmacy GRN Approve"), ProcumentNode);
+ TreeNode pharmacyGrnCancel = new DefaultTreeNode(new PrivilegeHolder(Privileges.PharmacyGrnCancel, "Pharmacy GRN Cancel"), ProcumentNode);
+ TreeNode pharmacyGrnReturnCancel = new DefaultTreeNode(new PrivilegeHolder(Privileges.PharmacyGrnReturnCancel, "Pharmacy GRN Return Cancel"), ProcumentNode);
TreeNode pharmacyReturnReceviedGoods = new DefaultTreeNode(new PrivilegeHolder(Privileges.ReturnReceviedGoods, "Pharmacy Return Recevied Goods"), ProcumentNode);
TreeNode pharmacyCreateGrnReturn = new DefaultTreeNode(new PrivilegeHolder(Privileges.CreateGrnReturn, "Create GRN Return"), ProcumentNode);
TreeNode pharmacyFinalizeGrnReturn = new DefaultTreeNode(new PrivilegeHolder(Privileges.FinalizeGrnReturn, "Finalize GRN Return"), ProcumentNode);
@@ -842,6 +851,8 @@ private TreeNode createPrivilegeHolderTreeNodes() {
new DefaultTreeNode(new PrivilegeHolder(Privileges.PharmacyDisposalIssue, "Pharmacy Disposal Issue"), pharmacyDisposalNode);
new DefaultTreeNode(new PrivilegeHolder(Privileges.PharmacyDisposalIssueFinalize, "Pharmacy Disposal Issue Finalize"), pharmacyDisposalNode);
new DefaultTreeNode(new PrivilegeHolder(Privileges.PharmacyDisposalIssueApprove, "Pharmacy Disposal Issue Approve"), pharmacyDisposalNode);
+ new DefaultTreeNode(new PrivilegeHolder(Privileges.PharmacyDisposalIssueCancel, "Pharmacy Disposal Issue Cancel"), pharmacyDisposalNode);
+ new DefaultTreeNode(new PrivilegeHolder(Privileges.PharmacyDiscardCategoryManage, "Pharmacy Discard Category Manage"), pharmacyDisposalNode);
new DefaultTreeNode(new PrivilegeHolder(Privileges.PharmacyDisposalSearchIssueBill, "Pharmacy Disposal Search Issue Bill"), pharmacyDisposalNode);
new DefaultTreeNode(new PrivilegeHolder(Privileges.PharmacyDisposalSearchIssueBillItems, "Pharmacy Disposal Search Issue Bill Items"), pharmacyDisposalNode);
new DefaultTreeNode(new PrivilegeHolder(Privileges.PharmacyDisposalSearchIssueReturnBill, "Pharmacy Disposal Search Issue Return Bill"), pharmacyDisposalNode);
diff --git a/src/main/java/com/divudi/bean/common/UserRoleManagementController.java b/src/main/java/com/divudi/bean/common/UserRoleManagementController.java
new file mode 100644
index 00000000000..5d08fdbb5ce
--- /dev/null
+++ b/src/main/java/com/divudi/bean/common/UserRoleManagementController.java
@@ -0,0 +1,720 @@
+/*
+ * Open Hospital Management Information System
+ *
+ * Role-template based user management: reset/expand/narrow a user's
+ * privileges/icons/subscriptions/login-page against a WebUserRole template,
+ * for a single user or in bulk. Thin UI layer over
+ * com.divudi.service.UserRoleApplicationService (the single engine also used
+ * by the REST API and the AI assistant).
+ */
+package com.divudi.bean.common;
+
+import com.divudi.core.entity.Department;
+import com.divudi.core.entity.Institution;
+import com.divudi.core.entity.WebUser;
+import com.divudi.core.entity.WebUserRole;
+import com.divudi.core.facade.DepartmentFacade;
+import com.divudi.core.facade.InstitutionFacade;
+import com.divudi.core.facade.WebUserFacade;
+import com.divudi.core.facade.WebUserRoleFacade;
+import com.divudi.core.util.JsfUtil;
+import com.divudi.service.UserRoleApplicationService;
+import com.divudi.service.UserRoleApplicationService.RoleAspect;
+import com.divudi.service.UserRoleApplicationService.RoleApplicationResult;
+import com.divudi.service.UserRoleApplicationService.RoleOperation;
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.EnumMap;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import javax.ejb.EJB;
+import javax.enterprise.context.SessionScoped;
+import javax.faces.component.UIComponent;
+import javax.faces.context.FacesContext;
+import javax.faces.convert.Converter;
+import javax.faces.convert.FacesConverter;
+import javax.inject.Inject;
+import javax.inject.Named;
+
+/**
+ *
+ * @author www.divudi.com
+ */
+@Named
+@SessionScoped
+public class UserRoleManagementController implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * Same cap as UserManagementApi.bulkRoleOperations — bulk role
+ * operations run synchronously, so bound one run's size.
+ */
+ private static final int MAX_BULK_USERS = 500;
+
+ //
+ @EJB
+ private UserRoleApplicationService userRoleApplicationService;
+ @EJB
+ private DepartmentFacade departmentFacade;
+ @EJB
+ private WebUserFacade webUserFacade;
+ @EJB
+ private WebUserRoleFacade webUserRoleFacade;
+ @EJB
+ private InstitutionFacade institutionFacade;
+
+ @Inject
+ private SessionController sessionController;
+ //
+
+ //
+ private WebUser currentUser;
+ private WebUserRole selectedRole;
+ private List roles;
+ private List userDepartments;
+ private List selectedDepartments;
+
+ private boolean aspectPrivileges = true;
+ private boolean aspectIcons;
+ private boolean aspectSubscriptions;
+ private boolean aspectLoginPage;
+
+ private boolean updateUserRole = true;
+
+ private String previewText;
+ private List results;
+ //
+
+ //
+ private WebUserRole filterRole;
+ private Department filterDepartment;
+ private Institution filterInstitution;
+ private List allDepartments;
+ private List institutions;
+ private List filteredUsers;
+ private List selectedUsers;
+ //
+
+ /**
+ * Re-entrancy guard shared across the action methods on this
+ * session-scoped bean, so a double click (or two tabs racing) cannot
+ * apply the same role operation twice.
+ */
+ private volatile boolean processing;
+
+ //
+ public String navigateToManageUserRoleDefaults(WebUser user) {
+ if (user == null) {
+ JsfUtil.addErrorMessage("Please select a user");
+ return "";
+ }
+ currentUser = user;
+ selectedRole = user.getRole();
+ userDepartments = fillWebUserDepartments(user);
+ selectedDepartments = new ArrayList<>();
+ results = null;
+ previewText = null;
+ return "/admin/users/user_role_users?faces-redirect=true";
+ }
+
+ public String navigateToBulkRoleOperations() {
+ filterRole = null;
+ filterDepartment = null;
+ filterInstitution = null;
+ allDepartments = null;
+ institutions = null;
+ filteredUsers = null;
+ selectedUsers = new ArrayList<>();
+ selectedDepartments = new ArrayList<>();
+ selectedRole = null;
+ results = null;
+ previewText = null;
+ return "/admin/users/user_role_bulk_operations?faces-redirect=true";
+ }
+ //
+
+ //
+ public void saveUserRole() {
+ if (processing) {
+ JsfUtil.addErrorMessage("Already processing a request. Please wait.");
+ return;
+ }
+ processing = true;
+ try {
+ if (currentUser == null) {
+ JsfUtil.addErrorMessage("No user selected.");
+ return;
+ }
+ currentUser.setRole(selectedRole);
+ webUserFacade.edit(currentUser);
+ JsfUtil.addSuccessMessage("User role saved.");
+ } finally {
+ processing = false;
+ }
+ }
+
+ public void previewOperation(String op) {
+ if (processing) {
+ JsfUtil.addErrorMessage("Already processing a request. Please wait.");
+ return;
+ }
+ processing = true;
+ try {
+ previewText = null;
+ RoleOperation operation = parseOperation(op);
+ if (operation == null) {
+ JsfUtil.addErrorMessage("Unknown operation.");
+ return;
+ }
+ if (currentUser == null) {
+ JsfUtil.addErrorMessage("No user selected.");
+ return;
+ }
+ WebUserRole effectiveRole = selectedRole != null ? selectedRole : currentUser.getRole();
+ if (effectiveRole == null) {
+ JsfUtil.addErrorMessage("No role available: select a role or ensure the user has a default role.");
+ return;
+ }
+ if (selectedDepartments == null || selectedDepartments.isEmpty()) {
+ JsfUtil.addErrorMessage("Select at least one department.");
+ return;
+ }
+ Set aspects = selectedAspects();
+ if (aspects == null) {
+ return;
+ }
+ Map counts = userRoleApplicationService.previewCounts(
+ operation, currentUser, effectiveRole, selectedDepartments, aspects);
+ previewText = formatPreview(counts);
+ } finally {
+ processing = false;
+ }
+ }
+
+ public void executeResetToDefaultRole() {
+ if (processing) {
+ JsfUtil.addErrorMessage("Already processing a request. Please wait.");
+ return;
+ }
+ processing = true;
+ try {
+ if (currentUser == null) {
+ JsfUtil.addErrorMessage("No user selected.");
+ return;
+ }
+ WebUserRole role = currentUser.getRole();
+ if (role == null) {
+ JsfUtil.addErrorMessage("User has no default role");
+ return;
+ }
+ if (selectedDepartments == null || selectedDepartments.isEmpty()) {
+ JsfUtil.addErrorMessage("Select at least one department.");
+ return;
+ }
+ Set aspects = selectedAspects();
+ if (aspects == null) {
+ return;
+ }
+ RoleApplicationResult result = userRoleApplicationService.apply(
+ RoleOperation.RESET, currentUser, role, selectedDepartments, aspects, false, actor());
+ applyResult(result);
+ } finally {
+ processing = false;
+ }
+ }
+
+ public void executeResetToSelectedRole() {
+ if (processing) {
+ JsfUtil.addErrorMessage("Already processing a request. Please wait.");
+ return;
+ }
+ processing = true;
+ try {
+ if (currentUser == null) {
+ JsfUtil.addErrorMessage("No user selected.");
+ return;
+ }
+ if (selectedRole == null) {
+ JsfUtil.addErrorMessage("Select a role.");
+ return;
+ }
+ if (selectedDepartments == null || selectedDepartments.isEmpty()) {
+ JsfUtil.addErrorMessage("Select at least one department.");
+ return;
+ }
+ Set aspects = selectedAspects();
+ if (aspects == null) {
+ return;
+ }
+ RoleApplicationResult result = userRoleApplicationService.apply(
+ RoleOperation.RESET, currentUser, selectedRole, selectedDepartments, aspects, updateUserRole, actor());
+ applyResult(result);
+ } finally {
+ processing = false;
+ }
+ }
+
+ public void executeExpand() {
+ executeRoleOperation(RoleOperation.EXPAND);
+ }
+
+ public void executeNarrow() {
+ executeRoleOperation(RoleOperation.NARROW);
+ }
+
+ private void executeRoleOperation(RoleOperation operation) {
+ if (processing) {
+ JsfUtil.addErrorMessage("Already processing a request. Please wait.");
+ return;
+ }
+ processing = true;
+ try {
+ if (currentUser == null) {
+ JsfUtil.addErrorMessage("No user selected.");
+ return;
+ }
+ if (selectedRole == null) {
+ JsfUtil.addErrorMessage("Select a role.");
+ return;
+ }
+ if (selectedDepartments == null || selectedDepartments.isEmpty()) {
+ JsfUtil.addErrorMessage("Select at least one department.");
+ return;
+ }
+ Set aspects = selectedAspects();
+ if (aspects == null) {
+ return;
+ }
+ RoleApplicationResult result = userRoleApplicationService.apply(
+ operation, currentUser, selectedRole, selectedDepartments, aspects, false, actor());
+ applyResult(result);
+ } finally {
+ processing = false;
+ }
+ }
+
+ private void applyResult(RoleApplicationResult result) {
+ results = java.util.Collections.singletonList(result);
+ if (result.isSuccess()) {
+ JsfUtil.addSuccessMessage("Applied: " + result.summary());
+ } else {
+ JsfUtil.addErrorMessage(result.getErrorMessage());
+ }
+ }
+ //
+
+ //
+ public void fillUsersByFilter() {
+ StringBuilder jpql = new StringBuilder("select u from WebUser u where u.retired=false");
+ Map params = new HashMap<>();
+ if (filterRole != null) {
+ jpql.append(" and u.role=:r");
+ params.put("r", filterRole);
+ }
+ if (filterDepartment != null) {
+ jpql.append(" and u.id in (select wd.webUser.id from WebUserDepartment wd where wd.department=:d and wd.retired=false)");
+ params.put("d", filterDepartment);
+ }
+ if (filterInstitution != null) {
+ jpql.append(" and u.institution=:ins");
+ params.put("ins", filterInstitution);
+ }
+ jpql.append(" order by u.name");
+ filteredUsers = webUserFacade.findByJpql(jpql.toString(), params);
+ selectedUsers = new ArrayList<>();
+ }
+
+ public void selectAllFilteredUsers() {
+ selectedUsers = filteredUsers == null ? new ArrayList<>() : new ArrayList<>(filteredUsers);
+ }
+
+ public void executeBulk(String op) {
+ if (processing) {
+ JsfUtil.addErrorMessage("Already processing a request. Please wait.");
+ return;
+ }
+ processing = true;
+ try {
+ RoleOperation operation = parseOperation(op);
+ if (operation == null) {
+ JsfUtil.addErrorMessage("Unknown operation.");
+ return;
+ }
+ if (selectedUsers == null || selectedUsers.isEmpty()) {
+ JsfUtil.addErrorMessage("Select at least one user.");
+ return;
+ }
+ if (selectedUsers.size() > MAX_BULK_USERS) {
+ JsfUtil.addErrorMessage("Too many users selected (" + selectedUsers.size()
+ + "). Narrow the filter to " + MAX_BULK_USERS + " users or fewer per run.");
+ return;
+ }
+ if (selectedDepartments == null || selectedDepartments.isEmpty()) {
+ JsfUtil.addErrorMessage("Select at least one department.");
+ return;
+ }
+ Set aspects = selectedAspects();
+ if (aspects == null) {
+ return;
+ }
+ WebUserRole role = selectedRole;
+ if (role == null && operation != RoleOperation.RESET) {
+ JsfUtil.addErrorMessage("Select a role.");
+ return;
+ }
+ results = userRoleApplicationService.applyBulk(
+ operation, selectedUsers, role, selectedDepartments, aspects, updateUserRole, actor());
+ JsfUtil.addSuccessMessage(RoleApplicationResult.summarize(results));
+ } finally {
+ processing = false;
+ }
+ }
+
+ public void previewBulk(String op) {
+ if (processing) {
+ JsfUtil.addErrorMessage("Already processing a request. Please wait.");
+ return;
+ }
+ processing = true;
+ try {
+ previewText = null;
+ RoleOperation operation = parseOperation(op);
+ if (operation == null) {
+ JsfUtil.addErrorMessage("Unknown operation.");
+ return;
+ }
+ if (selectedUsers == null || selectedUsers.isEmpty()) {
+ JsfUtil.addErrorMessage("Select at least one user.");
+ return;
+ }
+ if (selectedUsers.size() > MAX_BULK_USERS) {
+ JsfUtil.addErrorMessage("Too many users selected (" + selectedUsers.size()
+ + "). Narrow the filter to " + MAX_BULK_USERS + " users or fewer per run.");
+ return;
+ }
+ if (selectedDepartments == null || selectedDepartments.isEmpty()) {
+ JsfUtil.addErrorMessage("Select at least one department.");
+ return;
+ }
+ Set aspects = selectedAspects();
+ if (aspects == null) {
+ return;
+ }
+ if (selectedRole == null && operation != RoleOperation.RESET) {
+ JsfUtil.addErrorMessage("Select a role.");
+ return;
+ }
+ Map totals = new EnumMap<>(RoleAspect.class);
+ int usersCounted = 0;
+ for (WebUser u : selectedUsers) {
+ if (u == null) {
+ continue;
+ }
+ WebUserRole effectiveRole = selectedRole != null ? selectedRole : u.getRole();
+ if (effectiveRole == null) {
+ continue;
+ }
+ Map counts = userRoleApplicationService.previewCounts(
+ operation, u, effectiveRole, selectedDepartments, aspects);
+ for (Map.Entry e : counts.entrySet()) {
+ totals.merge(e.getKey(), e.getValue(), Long::sum);
+ }
+ usersCounted++;
+ }
+ previewText = usersCounted + " users; " + formatPreview(totals);
+ } finally {
+ processing = false;
+ }
+ }
+ //
+
+ //
+ private WebUser actor() {
+ return sessionController.getLoggedUser();
+ }
+
+ private RoleOperation parseOperation(String op) {
+ if (op == null) {
+ return null;
+ }
+ try {
+ return RoleOperation.valueOf(op);
+ } catch (IllegalArgumentException e) {
+ return null;
+ }
+ }
+
+ private Set selectedAspects() {
+ Set aspects = new LinkedHashSet<>();
+ if (aspectPrivileges) {
+ aspects.add(RoleAspect.PRIVILEGES);
+ }
+ if (aspectIcons) {
+ aspects.add(RoleAspect.ICONS);
+ }
+ if (aspectSubscriptions) {
+ aspects.add(RoleAspect.SUBSCRIPTIONS);
+ }
+ if (aspectLoginPage) {
+ aspects.add(RoleAspect.LOGIN_PAGE);
+ }
+ if (aspects.isEmpty()) {
+ JsfUtil.addErrorMessage("Select at least one aspect (Privileges, Icons, Subscriptions, Login Page).");
+ return null;
+ }
+ return aspects;
+ }
+
+ private String formatPreview(Map counts) {
+ if (counts == null || counts.isEmpty()) {
+ return "No changes";
+ }
+ StringBuilder sb = new StringBuilder();
+ appendAspect(sb, "Privileges", counts.get(RoleAspect.PRIVILEGES));
+ appendAspect(sb, "Icons", counts.get(RoleAspect.ICONS));
+ appendAspect(sb, "Subscriptions", counts.get(RoleAspect.SUBSCRIPTIONS));
+ appendAspect(sb, "Login Page", counts.get(RoleAspect.LOGIN_PAGE));
+ return sb.length() == 0 ? "No changes" : sb.toString();
+ }
+
+ private void appendAspect(StringBuilder sb, String label, Long count) {
+ if (count == null) {
+ return;
+ }
+ if (sb.length() > 0) {
+ sb.append("; ");
+ }
+ sb.append(label).append(": ").append(count).append(" changes");
+ }
+
+ /**
+ * Pattern reused from UserPrivilageController.fillWebUserDepartments /
+ * WebUserRoleUserController.fillWebUserDepartments.
+ */
+ public List fillWebUserDepartments(WebUser wu) {
+ Set departmentSet = new HashSet<>();
+ String jpql = "SELECT i.department "
+ + " FROM WebUserDepartment i "
+ + " WHERE i.retired = :ret "
+ + " AND i.webUser = :wu "
+ + " AND i.department.name is not null "
+ + " ORDER BY i.department.name";
+ Map m = new HashMap<>();
+ m.put("ret", false);
+ m.put("wu", wu);
+ List depts = departmentFacade.findByJpql(jpql, m);
+ departmentSet.addAll(depts);
+ return new ArrayList<>(departmentSet);
+ }
+ //
+
+ //
+ public WebUser getCurrentUser() {
+ return currentUser;
+ }
+
+ public void setCurrentUser(WebUser currentUser) {
+ this.currentUser = currentUser;
+ }
+
+ public WebUserRole getSelectedRole() {
+ return selectedRole;
+ }
+
+ public void setSelectedRole(WebUserRole selectedRole) {
+ this.selectedRole = selectedRole;
+ }
+
+ public List getRoles() {
+ if (roles == null) {
+ roles = webUserRoleFacade.findByJpql("select r from WebUserRole r where r.retired=false order by r.name");
+ }
+ return roles;
+ }
+
+ public void setRoles(List roles) {
+ this.roles = roles;
+ }
+
+ public List getUserDepartments() {
+ return userDepartments;
+ }
+
+ public void setUserDepartments(List userDepartments) {
+ this.userDepartments = userDepartments;
+ }
+
+ public List getSelectedDepartments() {
+ return selectedDepartments;
+ }
+
+ public void setSelectedDepartments(List selectedDepartments) {
+ this.selectedDepartments = selectedDepartments;
+ }
+
+ public boolean isAspectPrivileges() {
+ return aspectPrivileges;
+ }
+
+ public void setAspectPrivileges(boolean aspectPrivileges) {
+ this.aspectPrivileges = aspectPrivileges;
+ }
+
+ public boolean isAspectIcons() {
+ return aspectIcons;
+ }
+
+ public void setAspectIcons(boolean aspectIcons) {
+ this.aspectIcons = aspectIcons;
+ }
+
+ public boolean isAspectSubscriptions() {
+ return aspectSubscriptions;
+ }
+
+ public void setAspectSubscriptions(boolean aspectSubscriptions) {
+ this.aspectSubscriptions = aspectSubscriptions;
+ }
+
+ public boolean isAspectLoginPage() {
+ return aspectLoginPage;
+ }
+
+ public void setAspectLoginPage(boolean aspectLoginPage) {
+ this.aspectLoginPage = aspectLoginPage;
+ }
+
+ public boolean isUpdateUserRole() {
+ return updateUserRole;
+ }
+
+ public void setUpdateUserRole(boolean updateUserRole) {
+ this.updateUserRole = updateUserRole;
+ }
+
+ public String getPreviewText() {
+ return previewText;
+ }
+
+ public void setPreviewText(String previewText) {
+ this.previewText = previewText;
+ }
+
+ public List getResults() {
+ return results;
+ }
+
+ public void setResults(List results) {
+ this.results = results;
+ }
+
+ public WebUserRole getFilterRole() {
+ return filterRole;
+ }
+
+ public void setFilterRole(WebUserRole filterRole) {
+ this.filterRole = filterRole;
+ }
+
+ public Department getFilterDepartment() {
+ return filterDepartment;
+ }
+
+ public void setFilterDepartment(Department filterDepartment) {
+ this.filterDepartment = filterDepartment;
+ }
+
+ public Institution getFilterInstitution() {
+ return filterInstitution;
+ }
+
+ public void setFilterInstitution(Institution filterInstitution) {
+ this.filterInstitution = filterInstitution;
+ }
+
+ public List getAllDepartments() {
+ if (allDepartments == null) {
+ // d.name is not null: legacy rows with null names would NPE the checkbox label renderer
+ allDepartments = departmentFacade.findByJpql("select d from Department d where d.retired=false and d.name is not null order by d.name");
+ }
+ return allDepartments;
+ }
+
+ public void setAllDepartments(List allDepartments) {
+ this.allDepartments = allDepartments;
+ }
+
+ public List getInstitutions() {
+ if (institutions == null) {
+ institutions = institutionFacade.findByJpql("select i from Institution i where i.retired=false and i.name is not null order by i.name");
+ }
+ return institutions;
+ }
+
+ public void setInstitutions(List institutions) {
+ this.institutions = institutions;
+ }
+
+ public List getFilteredUsers() {
+ return filteredUsers;
+ }
+
+ public void setFilteredUsers(List filteredUsers) {
+ this.filteredUsers = filteredUsers;
+ }
+
+ public List getSelectedUsers() {
+ return selectedUsers;
+ }
+
+ public void setSelectedUsers(List selectedUsers) {
+ this.selectedUsers = selectedUsers;
+ }
+
+ public DepartmentFacade getDepartmentFacade() {
+ return departmentFacade;
+ }
+ //
+
+ /**
+ * Explicit converter for Department multi-selects. UISelectMany over a
+ * List cannot detect the element type (generics erasure), so the
+ * forClass Department converter is never applied and submitted values
+ * stay Strings — this named converter must be set on the component.
+ */
+ @FacesConverter("userRoleDepartmentConverter")
+ public static class UserRoleDepartmentConverter implements Converter {
+
+ @Override
+ public Object getAsObject(FacesContext facesContext, UIComponent component, String value) {
+ if (value == null || value.trim().isEmpty()) {
+ return null;
+ }
+ UserRoleManagementController controller = (UserRoleManagementController) facesContext.getApplication().getELResolver().
+ getValue(facesContext.getELContext(), null, "userRoleManagementController");
+ try {
+ return controller.getDepartmentFacade().find(Long.valueOf(value));
+ } catch (NumberFormatException e) {
+ return null;
+ }
+ }
+
+ @Override
+ public String getAsString(FacesContext facesContext, UIComponent component, Object object) {
+ if (object == null) {
+ return null;
+ }
+ if (object instanceof Department) {
+ Department d = (Department) object;
+ return d.getId() == null ? null : String.valueOf(d.getId());
+ }
+ return null;
+ }
+ }
+
+}
diff --git a/src/main/java/com/divudi/bean/common/WebUserController.java b/src/main/java/com/divudi/bean/common/WebUserController.java
index e3b9bffeb08..9237931cf19 100644
--- a/src/main/java/com/divudi/bean/common/WebUserController.java
+++ b/src/main/java/com/divudi/bean/common/WebUserController.java
@@ -114,6 +114,8 @@ public class WebUserController implements Serializable {
ConfigOptionApplicationController configOptionApplicationController;
@Inject
WebUserRoleController webUserRoleController;
+ @Inject
+ private UserRoleManagementController userRoleManagementController;
/**
* Class Variables
@@ -1202,12 +1204,7 @@ public String navigateToManageUserRole() {
JsfUtil.addErrorMessage("Please select a user");
return "";
}
- webUserRoleController.setActivatediItems(null);
- webUserRoleUserController.setWebUser(selected);
- webUserRoleUserController.setDepartments(fillWebUserDepartments(selected));
- webUserRoleUserController.loadWebUserRoles();
- webUserRoleUserController.clear();
- return "/admin/users/user_role_users?faces-redirect=true";
+ return userRoleManagementController.navigateToManageUserRoleDefaults(selected);
}
public List fillWebUserDepartments(WebUser wu) {
diff --git a/src/main/java/com/divudi/bean/inward/AdmissionController.java b/src/main/java/com/divudi/bean/inward/AdmissionController.java
index a443e233a9e..0c668de1f37 100644
--- a/src/main/java/com/divudi/bean/inward/AdmissionController.java
+++ b/src/main/java/com/divudi/bean/inward/AdmissionController.java
@@ -1713,10 +1713,11 @@ private void savePatient() {
Person person = getPatient().getPerson();
// DON'T set to null - keep reference throughout
-
- if(patientForiegner){
- getPatient().getPerson().setForeigner(true);
- }
+
+ // Persist the "Mark as Foreigner" checkbox state onto the patient. This
+ // marks a new (or existing) patient as a foreigner when checked, and
+ // clears the flag when unchecked, keeping the record in sync with the UI.
+ getPatient().getPerson().setForeigner(patientForiegner);
// Save Person first (no flush yet)
if (person != null) {
@@ -2837,6 +2838,13 @@ public void setPatient(Patient patient) {
current.setPatient(patient);
patientAllergies = clinicalFindingValueController.findClinicalFindingValues(patient, ClinicalFindingValueType.PatientAllergy);
}
+ // When a patient is searched/selected, reflect their foreigner status on the
+ // "Mark as Foreigner" checkbox so it is ticked automatically for foreigners.
+ if (patient != null && patient.getPerson() != null) {
+ patientForiegner = patient.getPerson().isForeigner();
+ } else {
+ patientForiegner = false;
+ }
selectPaymentSchemeAsPerPatientMembership();
}
diff --git a/src/main/java/com/divudi/bean/inward/BhtSummeryController.java b/src/main/java/com/divudi/bean/inward/BhtSummeryController.java
index 6a622427eff..659ce8e3412 100644
--- a/src/main/java/com/divudi/bean/inward/BhtSummeryController.java
+++ b/src/main/java/com/divudi/bean/inward/BhtSummeryController.java
@@ -157,6 +157,7 @@ public class BhtSummeryController implements Serializable {
InwardPaymentController inwardPaymentController;
////////////////////////
private List departmentBillItems;
+ private Map latestCheckedBillItemsByItem;
private List profesionallFee;
private List doctorAndNurseFee;
private List allDoctorCharges;
@@ -2282,12 +2283,28 @@ public String toSettle() {
if (getPatientEncounter().getAdmissionType() == null) {
return "";
}
-
+
+ System.out.println("Patient Encounter = " + getPatientEncounter());
+ System.out.println("Admission Type = " + getPatientEncounter().getAdmissionType());
+ System.out.println("Admission Type Enum = " + getPatientEncounter().getAdmissionType().getAdmissionTypeEnum());
+
+ System.out.println("Match = " + (getPatientEncounter().getAdmissionType().getAdmissionTypeEnum() == AdmissionTypeEnum.Admission));
+
+ System.out.println("Privilege = " + getWebUserController().hasPrivilege("InwardBillSettleWithoutCheck"));
+
+ System.out.println("Option = " + configOptionApplicationController.getBooleanValueByKey("Need to check inward bills before discharge"));
+
+ System.out.println("Starting Bills Checking Process.... ");
if (getPatientEncounter().getAdmissionType().getAdmissionTypeEnum() == AdmissionTypeEnum.Admission && !getWebUserController().hasPrivilege("InwardBillSettleWithoutCheck")) {
+ System.out.println("Checking.... ---> ");
if (checkBill()) {
return "";
}
+ }else{
+ System.out.println("Ignore Checking.... ---> ");
}
+
+ System.out.println("End Bills Checking Process.... ");
if (getPatientEncounter().getPaymentMethod() == PaymentMethod.Credit) {
if (getPatientEncounter().getCreditCompany() == null) {
@@ -2494,6 +2511,7 @@ private void saveBillItem() {
temBi.setDiscount(cit.getDiscount());
temBi.setNetValue(cit.getNetTotal());
temBi.setAdjustedValue(cit.getAdjustedTotal());
+ temBi.setDescreption(cit.getComments());
temBi.setCreatedAt(new Date());
temBi.setCreater(getSessionController().getLoggedUser());
@@ -2541,6 +2559,7 @@ private void saveTempBillItem() {
temBi.setDiscount(cit.getDiscount());
temBi.setNetValue(cit.getNetTotal());
temBi.setAdjustedValue(cit.getAdjustedTotal());
+ temBi.setDescreption(cit.getComments());
temBi.setCreatedAt(new Date());
temBi.setCreater(getSessionController().getLoggedUser());
@@ -2578,6 +2597,7 @@ private void saveOriginalBillItem() {
temBi.setDiscount(cit.getDiscount());
temBi.setNetValue(cit.getNetTotal());
temBi.setAdjustedValue(cit.getAdjustedTotal());
+ temBi.setDescreption(cit.getComments());
temBi.setCreatedAt(new Date());
temBi.setCreater(getSessionController().getLoggedUser());
@@ -2876,6 +2896,7 @@ public void makeNull() {
patientItems = null;
paymentBill = null;
departmentBillItems = null;
+ latestCheckedBillItemsByItem = null;
printPreview = false;
current = null;
tmpPI = null;
@@ -3449,6 +3470,25 @@ private void createChargeItemTotals() {
setNetAdjustValue();
+ restoreChargeItemComments();
+
+ }
+
+ private void restoreChargeItemComments() {
+ if (getPatientEncounter() == null
+ || !getPatientEncounter().isPaymentFinalized()
+ || getPatientEncounter().getFinalBill() == null) {
+ return;
+ }
+
+ for (BillItem existing : getPatientEncounter().getFinalBill().getBillItems()) {
+ for (ChargeItemTotal cit : chargeItemTotals) {
+ if (existing.getInwardChargeType() == cit.getInwardChargeType()) {
+ cit.setComments(existing.getDescreption());
+ break;
+ }
+ }
+ }
}
private void setNetAdjustValue() {
@@ -3864,6 +3904,22 @@ public void setDepartmentBillItems(List departmentBillItems
this.departmentBillItems = departmentBillItems;
}
+ /**
+ * Returns the most recently checked inward BillItem for the given service
+ * item of the current patient encounter, or null if none has been checked.
+ * The Service Details tab binds to its {@code bill.checkedBy} /
+ * {@code bill.checkeAt} to show "Checked By" / "Checked At".
+ */
+ public BillItem getLatestCheckedBillItem(Item item) {
+ if (item == null || item.getId() == null) {
+ return null;
+ }
+ if (latestCheckedBillItemsByItem == null) {
+ latestCheckedBillItemsByItem = getInwardBean().getLatestCheckedBillItemsByItem(patientEncounter);
+ }
+ return latestCheckedBillItemsByItem.get(item.getId());
+ }
+
public DepartmentFacade getDepartmentFacade() {
return departmentFacade;
}
diff --git a/src/main/java/com/divudi/bean/inward/BillBhtController.java b/src/main/java/com/divudi/bean/inward/BillBhtController.java
index f7cca4fa2c5..4d10a0b6f60 100644
--- a/src/main/java/com/divudi/bean/inward/BillBhtController.java
+++ b/src/main/java/com/divudi/bean/inward/BillBhtController.java
@@ -42,6 +42,7 @@
import com.divudi.core.entity.Institution;
import com.divudi.core.entity.ItemFee;
import com.divudi.core.entity.PatientEncounter;
+import com.divudi.core.entity.inward.RoomCategory;
import com.divudi.core.entity.PaymentScheme;
import com.divudi.core.entity.PriceMatrix;
import com.divudi.core.entity.WebUser;
@@ -544,6 +545,21 @@ public void setPriceMatrixController(PriceMatrixController priceMatrixController
this.priceMatrixController = priceMatrixController;
}
+ /**
+ * The room category of the patient's current room, or null when the patient
+ * is not yet in a room (or the room has no facility charge / category). Used
+ * as the room-category dimension of the inward service-margin matrix lookup
+ * (issue #21977); null means "wildcard row only", preserving legacy behaviour.
+ */
+ private RoomCategory resolveCurrentRoomCategory(PatientEncounter encounter) {
+ if (encounter == null
+ || encounter.getCurrentPatientRoom() == null
+ || encounter.getCurrentPatientRoom().getRoomFacilityCharge() == null) {
+ return null;
+ }
+ return encounter.getCurrentPatientRoom().getRoomFacilityCharge().getRoomCategory();
+ }
+
public List saveBillItems(Bill bill, List billEntries, WebUser webUser, Department matrixDepartment, PaymentMethod paymentMethod) {
List list = new ArrayList<>();
for (BillEntry e : billEntries) {
@@ -558,7 +574,7 @@ public List saveBillItems(Bill bill, List billEntries, WebU
billItem.setSearialNo(list.size());
for (BillFee bf : billItem.getBillFees()) {
- PriceMatrix priceMatrix = getPriceMatrixController().fetchInwardMargin(billItem, bf.getFeeUnitGrossValue() != null ? bf.getFeeUnitGrossValue() : bf.getFeeGrossValue(), matrixDepartment, paymentMethod, bill.getPatientEncounter() != null ? bill.getPatientEncounter().getAdmissionType() : null);
+ PriceMatrix priceMatrix = getPriceMatrixController().fetchInwardMargin(billItem, bf.getFeeUnitGrossValue() != null ? bf.getFeeUnitGrossValue() : bf.getFeeGrossValue(), matrixDepartment, paymentMethod, null, bill.getPatientEncounter() != null ? bill.getPatientEncounter().getAdmissionType() : null, resolveCurrentRoomCategory(bill.getPatientEncounter()));
getInwardBean().setBillFeeMargin(bf, bf.getBillItem().getItem(), priceMatrix, bill.getPatientEncounter());
getBillFeeFacade().edit(bf);
@@ -1021,7 +1037,7 @@ public List billFeeFromBillItemWithMatrix(BillItem billItem, PatientEnc
for (Fee i : itemFee) {
BillFee billFee = getBillBean().createBillFee(billItem, i, patientEncounter);
- PriceMatrix priceMatrix = getPriceMatrixController().fetchInwardMargin(billItem, billFee.getFeeGrossValue(), matrixDepartment, paymentMethod, patientEncounter != null ? patientEncounter.getAdmissionType() : null);
+ PriceMatrix priceMatrix = getPriceMatrixController().fetchInwardMargin(billItem, billFee.getFeeGrossValue(), matrixDepartment, paymentMethod, null, patientEncounter != null ? patientEncounter.getAdmissionType() : null, resolveCurrentRoomCategory(patientEncounter));
getInwardBean().setBillFeeMargin(billFee, billItem.getItem(), priceMatrix, patientEncounter);
@@ -1131,7 +1147,7 @@ public void feeChanged(BillFee bf) {
? bf.getBillItem().getQty() : 1.0;
bf.setFeeUnitGrossValue(bf.getFeeGrossValue() / qty);
- PriceMatrix priceMatrix = getPriceMatrixController().fetchInwardMargin(bf.getBillItem(), bf.getFeeUnitGrossValue(), getPatientEncounter().getCurrentPatientRoom().getRoomFacilityCharge().getDepartment(), getPatientEncounter().getPaymentMethod(), getPatientEncounter().getAdmissionType());
+ PriceMatrix priceMatrix = getPriceMatrixController().fetchInwardMargin(bf.getBillItem(), bf.getFeeUnitGrossValue(), getPatientEncounter().getCurrentPatientRoom().getRoomFacilityCharge().getDepartment(), getPatientEncounter().getPaymentMethod(), null, getPatientEncounter().getAdmissionType(), resolveCurrentRoomCategory(getPatientEncounter()));
getInwardBean().updateBillItemMargin(bf, bf.getFeeGrossValue(), getPatientEncounter(), getPatientEncounter().getCurrentPatientRoom().getRoomFacilityCharge().getDepartment(), priceMatrix);
@@ -1154,7 +1170,7 @@ public void feeChangedSurgery(BillFee bf) {
lstBillItems = null;
getLstBillItems();
- PriceMatrix priceMatrix = getPriceMatrixController().fetchInwardMargin(bf.getBillItem(), bf.getFeeGrossValue(), getBatchBill().getFromDepartment(), getPatientEncounter().getPaymentMethod(), getPatientEncounter().getAdmissionType());
+ PriceMatrix priceMatrix = getPriceMatrixController().fetchInwardMargin(bf.getBillItem(), bf.getFeeGrossValue(), getBatchBill().getFromDepartment(), getPatientEncounter().getPaymentMethod(), null, getPatientEncounter().getAdmissionType(), resolveCurrentRoomCategory(getPatientEncounter()));
getInwardBean().updateBillItemMargin(bf, bf.getFeeGrossValue(), getPatientEncounter(), getBatchBill().getFromDepartment(), priceMatrix);
diff --git a/src/main/java/com/divudi/bean/inward/InwardBeanController.java b/src/main/java/com/divudi/bean/inward/InwardBeanController.java
index ae9906ebf22..d72642f0b63 100644
--- a/src/main/java/com/divudi/bean/inward/InwardBeanController.java
+++ b/src/main/java/com/divudi/bean/inward/InwardBeanController.java
@@ -2000,6 +2000,48 @@ private Map getBulkCheckedBillItemCounts(List- items, PatientEn
return countMap;
}
+ /**
+ * Bulk query returning the most recently checked inward BillItem for each
+ * item of the given patient encounter. Used by the Service Details tab to
+ * display "Checked By" / "Checked At" per aggregated item row without adding
+ * transient fields to the shared Item entity.
+ *
+ * @return map of itemId -> latest checked BillItem
+ */
+ public Map
getLatestCheckedBillItemsByItem(PatientEncounter patientEncounter) {
+ Map map = new HashMap<>();
+ if (patientEncounter == null) {
+ return map;
+ }
+
+ HashMap hm = new HashMap();
+ String sql = "SELECT b FROM BillItem b "
+ + " WHERE b.retired=false "
+ + " and b.bill.billType=:btp "
+ + " and b.bill.patientEncounter=:pe "
+ + " and type(b.bill)=:cls "
+ + " and b.bill.checkedBy is not null "
+ + " and b.bill.checkeAt is not null "
+ + " and b.bill.cancelled=false "
+ + " order by b.bill.checkeAt desc ";
+
+ hm.put("btp", BillType.InwardBill);
+ hm.put("pe", patientEncounter);
+ hm.put("cls", BilledBill.class);
+
+ List results = getBillItemFacade().findByJpql(sql, hm, TemporalType.TIMESTAMP);
+ if (results != null) {
+ // Ordered by checkeAt desc, so the first row seen per item is the latest.
+ for (BillItem bi : results) {
+ if (bi.getItem() != null && bi.getItem().getId() != null
+ && !map.containsKey(bi.getItem().getId())) {
+ map.put(bi.getItem().getId(), bi);
+ }
+ }
+ }
+ return map;
+ }
+
public Fee getStaffFeeForInward(WebUser webUser) {
String sql = "Select f From InwardFee f "
+ " where f.retired=false "
@@ -2590,7 +2632,7 @@ public void setBillFeeMargin(BillFee billFee, Item item, PriceMatrix priceMatrix
Department dept = item.getDepartment() != null ? item.getDepartment()
: (bi.getBill() != null ? bi.getBill().getDepartment() : null);
PriceMatrix ccMatrix = priceMatrixController.fetchInwardMargin(bi, svcValue, dept,
- patientEncounter.getPaymentMethod(), cc, patientEncounter.getAdmissionType());
+ patientEncounter.getPaymentMethod(), cc, patientEncounter.getAdmissionType(), resolveCurrentRoomCategory(patientEncounter));
if (ccMatrix != null) {
effectivePriceMatrix = ccMatrix;
}
@@ -2636,6 +2678,21 @@ private com.divudi.core.entity.Institution resolveSingleCreditCompany(PatientEnc
return null;
}
+ /**
+ * Room category of the encounter's current room, or null when the patient is
+ * not in a room (or the room has no facility charge / category). Drives the
+ * room-category dimension of the inward margin matrix (issue #21977); null
+ * means "wildcard row only", preserving legacy behaviour.
+ */
+ private com.divudi.core.entity.inward.RoomCategory resolveCurrentRoomCategory(PatientEncounter encounter) {
+ if (encounter == null
+ || encounter.getCurrentPatientRoom() == null
+ || encounter.getCurrentPatientRoom().getRoomFacilityCharge() == null) {
+ return null;
+ }
+ return encounter.getCurrentPatientRoom().getRoomFacilityCharge().getRoomCategory();
+ }
+
/**
* Apply the Inward Discount Matrix discount to a BillFee.
*
@@ -2688,7 +2745,7 @@ public void updateBillItemMargin(BillItem billItem, double serviceValue, Patient
com.divudi.core.entity.Institution creditCompany = resolveSingleCreditCompany(patientEncounter);
if (creditCompany != null) {
PriceMatrix ccMatrix = priceMatrixController.fetchInwardMargin(billItem, serviceValue, matrixDepartment,
- patientEncounter.getPaymentMethod(), creditCompany, patientEncounter.getAdmissionType());
+ patientEncounter.getPaymentMethod(), creditCompany, patientEncounter.getAdmissionType(), resolveCurrentRoomCategory(patientEncounter));
if (ccMatrix != null) {
effectivePriceMatrix = ccMatrix;
}
@@ -2717,7 +2774,7 @@ public void updateBillItemMargin(BillFee billFee, double serviceValue, PatientEn
com.divudi.core.entity.Institution creditCompany = resolveSingleCreditCompany(patientEncounter);
if (creditCompany != null) {
PriceMatrix ccMatrix = priceMatrixController.fetchInwardMargin(billFee.getBillItem(), serviceValue,
- matrixDepartment, patientEncounter.getPaymentMethod(), creditCompany, patientEncounter.getAdmissionType());
+ matrixDepartment, patientEncounter.getPaymentMethod(), creditCompany, patientEncounter.getAdmissionType(), resolveCurrentRoomCategory(patientEncounter));
if (ccMatrix != null) {
effectivePriceMatrix = ccMatrix;
}
diff --git a/src/main/java/com/divudi/bean/inward/InwardManagementReportController.java b/src/main/java/com/divudi/bean/inward/InwardManagementReportController.java
new file mode 100644
index 00000000000..c6c6d4bea9d
--- /dev/null
+++ b/src/main/java/com/divudi/bean/inward/InwardManagementReportController.java
@@ -0,0 +1,689 @@
+package com.divudi.bean.inward;
+
+import com.divudi.core.data.DepartmentType;
+import com.divudi.core.data.dto.DepartmentDto;
+import com.divudi.core.data.dto.HospitalCensusSummaryDto;
+import com.divudi.core.data.dto.HospitalCensusDetailDto;
+import com.divudi.core.data.inward.BedStatus;
+import com.divudi.core.entity.Department;
+import com.divudi.core.entity.Institution;
+import com.divudi.core.facade.DepartmentFacade;
+import com.divudi.core.facade.RoomFacilityChargeFacade;
+
+import javax.ejb.EJB;
+import javax.enterprise.context.SessionScoped;
+import javax.inject.Named;
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import javax.persistence.TemporalType;
+
+import com.itextpdf.text.*;
+import com.itextpdf.text.pdf.*;
+import java.io.IOException;
+import javax.faces.context.FacesContext;
+import javax.servlet.http.HttpServletResponse;
+import java.io.OutputStream;
+import java.text.SimpleDateFormat;
+import javax.faces.application.FacesMessage;
+
+/**
+ * HIGH-PERFORMANCE Hospital Census Report Controller.
+ *
+ * OPTIMIZATION STRATEGY: ====================== BEFORE : 8 queries × N
+ * departments = O(N) roundtrips. AFTER : 3 fixed queries regardless of
+ * department count = O(1) roundtrips.
+ *
+ * 1. SINGLE GROUP-BY query for all bed counts (replaces N × countTotalBeds). 2.
+ * SINGLE GROUP-BY aggregation for all PatientRoom metrics (replaces N × 5 count
+ * queries + N × 2 entity-fetch calls). 3. SINGLE DTO-projection query for
+ * detail rows (replaces full entity hydration + lazy-chain navigation in
+ * buildWardDetail). 4. Department joins use the numeric PK (d.id), never the
+ * name VARCHAR.
+ *
+ * @author Senior Software Engineer - Performance Team
+ */
+@Named
+@SessionScoped
+public class InwardManagementReportController implements Serializable {
+
+ @EJB
+ private DepartmentFacade departmentFacade;
+ @EJB
+ private RoomFacilityChargeFacade roomFacilityChargeFacade;
+
+ private Institution institution;
+ private Institution site;
+ private Department department;
+ private Date fromDate;
+ private Date toDate;
+ private Department ward;
+ private String reportType;
+
+ private List hospitalCensusSummaryDtos;
+ private List hospitalCensusDetailDtos;
+ private List departmentList;
+
+ public InwardManagementReportController() {
+ }
+
+ public void createHospitalCensusReport() {
+
+ hospitalCensusSummaryDtos = new ArrayList<>();
+ hospitalCensusDetailDtos = new ArrayList<>();
+
+ departmentList = fetchInwardDepartments();
+ if (departmentList == null || departmentList.isEmpty()) {
+ return;
+ }
+
+ // Collect department ids
+ List deptIds = collectDeptIds(departmentList);
+
+ // Query 1 : Bed counts
+ Map bedCountByDept = fetchBedCountsByDept(deptIds);
+
+ // Query 2 : Census metrics
+ Map metricsByDept = fetchPatientRoomMetrics(deptIds);
+
+ // Build Summary DTOs
+ for (DepartmentDto dept : departmentList) {
+
+ long deptId = dept.getId();
+
+ long totalBeds = bedCountByDept.getOrDefault(deptId, 0L);
+ long[] m = metricsByDept.getOrDefault(deptId, new long[9]);
+
+ long currentPresent = m[0];
+ long previousDaysTotal = m[1];
+ long newAdmissions = m[2];
+ long transferIn = m[3];
+ long transferOut = m[4];
+ long normalDischarges = m[5];
+ long lama = m[6];
+ long deaths = m[7];
+ long totalFinalDischarges = m[8];
+
+ long others = Math.max(0, totalFinalDischarges - normalDischarges - lama - deaths);
+
+ HospitalCensusSummaryDto summary = new HospitalCensusSummaryDto();
+
+ summary.setWard(dept.getName());
+ summary.setTotalBeds(totalBeds);
+ summary.setOpenBeds(totalBeds - currentPresent);
+ summary.setPreviousDaysTotal(previousDaysTotal);
+ summary.setNewAdmissions(newAdmissions);
+ summary.setTransferIn(transferIn);
+ summary.setTransferOut(transferOut);
+ summary.setMarkedForDischarge(0);
+ summary.setNormalDischarges(normalDischarges);
+ summary.setLama(lama);
+ summary.setDeaths(deaths);
+ summary.setOthers(others);
+ summary.setTotalPresent(currentPresent);
+
+ if (totalBeds > 0) {
+ summary.setBedOccupancyRate((currentPresent * 100.0) / totalBeds);
+ } else {
+ summary.setBedOccupancyRate(0.0);
+ }
+
+ hospitalCensusSummaryDtos.add(summary);
+ }
+
+ // Load details ONLY for Detail report
+ if (!"Summary".equalsIgnoreCase(reportType)) {
+ hospitalCensusDetailDtos = fetchDetailDtos(deptIds);
+ }
+ }
+
+ /**
+ * Fetches all active inward departments filtered by the user-selected
+ * institution and/or department. Returns lightweight DTOs only.
+ */
+ private List fetchInwardDepartments() {
+ StringBuilder jpql = new StringBuilder()
+ .append(" select new com.divudi.core.data.dto.DepartmentDto(d.id, d.name) ")
+ .append(" from Department d ")
+ .append(" where d.retired = false ")
+ .append(" and d.departmentType IN :dt ");
+
+ Map params = new HashMap<>();
+ params.put("dt", Arrays.asList(DepartmentType.Inward));
+
+ if (institution != null) {
+ jpql.append(" and d.institution = :ins ");
+ params.put("ins", institution);
+ }
+ if (department != null) {
+ jpql.append(" and d = :dept ");
+ params.put("dept", department);
+ }
+ if (ward != null) {
+ jpql.append(" and d = :ward ");
+ params.put("ward", ward);
+ }
+ jpql.append(" order by d.name ");
+
+ return (List) departmentFacade.findDTOsByJpql(jpql.toString(), params, TemporalType.TIMESTAMP);
+ }
+
+ /**
+ * ONE query: COUNT of active beds grouped by department id. Replaces N ×
+ * countTotalBeds().
+ *
+ * @return map of departmentId → bed count
+ */
+ private Map fetchBedCountsByDept(List deptIds) {
+ if (deptIds.isEmpty()) {
+ return Collections.emptyMap();
+ }
+ String jpql = "select r.department.id, count(r) "
+ + "from RoomFacilityCharge r "
+ + "where r.retired = false "
+ + "and r.department.id in :deptIds "
+ + "group by r.department.id";
+
+ Map params = new HashMap<>();
+ params.put("deptIds", deptIds);
+
+ List> rows = roomFacilityChargeFacade.findDTOsByJpql(jpql, params);
+ Map result = new HashMap<>();
+ for (Object row : rows) {
+ Object[] cols = (Object[]) row;
+ result.put(((Number) cols[0]).longValue(), ((Number) cols[1]).longValue());
+ }
+ return result;
+ }
+
+ /**
+ * ONE query: all PatientRoom census metrics grouped by department id.
+ *
+ * Returns a map of departmentId → long[9] where: [0] currentPresent
+ * (admitted as-of toDate) [1] previousDaysTotal (admitted as-of fromDate)
+ * [2] newAdmissions (first admission, within [fromDate, toDate]) [3]
+ * transferIn (room transfer in, within period) [4] transferOut (room
+ * transfer out, within period) [5] normalDischarges (final discharge; dc IS
+ * NULL or name contains routine/normal) [6] lama (final discharge; dc name
+ * contains lama/against) [7] deaths (final discharge; dc name contains
+ * death/dead/died) [8] totalFinalDischarges (all final discharges; used to
+ * compute 'others' in Java)
+ *
+ * Design notes: - Explicit LEFT JOINs on patientEncounter and
+ * dischargeCondition are required. Implicit path navigation
+ * (pr.patientEncounter.dischargeCondition) creates inner joins, making "dc
+ * IS NULL" always false and skewing normal-discharge counts. -
+ * EclipseLink's JPQL parser does not support NOT LIKE inside CASE WHEN
+ * conditional expressions. The 'others' category is therefore computed in
+ * Java as: others = totalFinalDischarges - normalDischarges - lama -
+ * deaths.
+ */
+ private Map fetchPatientRoomMetrics(List deptIds) {
+
+ if (deptIds == null || deptIds.isEmpty()) {
+ return Collections.emptyMap();
+ }
+
+ String jpql
+ = "select d.id, "
+ // 0 Current Present
+ + "(select count(pr1) "
+ + " from PatientRoom pr1 "
+ + " where pr1.retired=false "
+ + " and pr1.roomFacilityCharge.department.id=d.id "
+ + " and pr1.roomFacilityCharge.room.bedStatus =:bs "
+ + " and pr1.admittedAt<=:td "
+ + " and (pr1.dischargedAt is null or pr1.dischargedAt>:td)), "
+ // 1 Previous Day Total
+ + "(select count(pr2) "
+ + " from PatientRoom pr2 "
+ + " where pr2.retired=false "
+ + " and pr2.roomFacilityCharge.department.id=d.id "
+ + " and pr2.admittedAt<=:fd "
+ + " and (pr2.dischargedAt is null or pr2.dischargedAt>:fd)), "
+ // 2 New Admission
+ + "(select count(pr3) "
+ + " from PatientRoom pr3 "
+ + " where pr3.retired=false "
+ + " and pr3.roomFacilityCharge.department.id=d.id "
+ + " and pr3.previousRoom is null "
+ + " and pr3.admittedAt>=:fd "
+ + " and pr3.admittedAt<=:td), "
+ // 3 Transfer In
+ + "(select count(pr4) "
+ + " from PatientRoom pr4 "
+ + " where pr4.retired=false "
+ + " and pr4.roomFacilityCharge.department.id=d.id "
+ + " and pr4.previousRoom is not null "
+ + " and pr4.admittedAt>=:fd "
+ + " and pr4.admittedAt<=:td), "
+ // 4 Transfer Out
+ + "(select count(pr5) "
+ + " from PatientRoom pr5 "
+ + " where pr5.retired=false "
+ + " and pr5.roomFacilityCharge.department.id=d.id "
+ + " and pr5.nextRoom is not null "
+ + " and pr5.dischargedAt>=:fd "
+ + " and pr5.dischargedAt<=:td), "
+ // 5 Normal Discharge
+ + "(select count(pr6) "
+ + " from PatientRoom pr6 "
+ + " left join pr6.patientEncounter pe6 "
+ + " left join pe6.dischargeCondition dc6 "
+ + " where pr6.retired=false "
+ + " and pr6.roomFacilityCharge.department.id=d.id "
+ + " and pr6.nextRoom is null "
+ + " and pr6.dischargedAt>=:fd "
+ + " and pr6.dischargedAt<=:td "
+ + " and (dc6 is null "
+ + " or lower(dc6.name) like '%routine%' "
+ + " or lower(dc6.name) like '%normal%')), "
+ // 6 LAMA
+ + "(select count(pr7) "
+ + " from PatientRoom pr7 "
+ + " left join pr7.patientEncounter pe7 "
+ + " left join pe7.dischargeCondition dc7 "
+ + " where pr7.retired=false "
+ + " and pr7.roomFacilityCharge.department.id=d.id "
+ + " and pr7.nextRoom is null "
+ + " and pr7.dischargedAt>=:fd "
+ + " and pr7.dischargedAt<=:td "
+ + " and dc7 is not null "
+ + " and (lower(dc7.name) like '%lama%' "
+ + " or lower(dc7.name) like '%against%')), "
+ // 7 Death
+ + "(select count(pr8) "
+ + " from PatientRoom pr8 "
+ + " left join pr8.patientEncounter pe8 "
+ + " left join pe8.dischargeCondition dc8 "
+ + " where pr8.retired=false "
+ + " and pr8.roomFacilityCharge.department.id=d.id "
+ + " and pr8.nextRoom is null "
+ + " and pr8.dischargedAt>=:fd "
+ + " and pr8.dischargedAt<=:td "
+ + " and dc8 is not null "
+ + " and (lower(dc8.name) like '%death%' "
+ + " or lower(dc8.name) like '%dead%' "
+ + " or lower(dc8.name) like '%died%')), "
+ // 8 Total Final Discharge
+ + "(select count(pr9) "
+ + " from PatientRoom pr9 "
+ + " where pr9.retired=false "
+ + " and pr9.roomFacilityCharge.department.id=d.id "
+ + " and pr9.nextRoom is null "
+ + " and pr9.dischargedAt>=:fd "
+ + " and pr9.dischargedAt<=:td) "
+ + "from Department d "
+ + "where d.id in :deptIds";
+
+ Map params = new HashMap<>();
+ params.put("deptIds", deptIds);
+ params.put("fd", fromDate);
+ params.put("td", toDate);
+ params.put("bs", BedStatus.Available);
+
+ List rows = (List) departmentFacade.findDTOsByJpql(jpql, params, TemporalType.TIMESTAMP);
+
+ Map result = new HashMap<>();
+
+ for (Object[] row : rows) {
+
+ Long deptId = ((Number) row[0]).longValue();
+
+ long[] metrics = new long[9];
+
+ for (int i = 0; i < 9; i++) {
+ metrics[i] = row[i + 1] == null ? 0L : ((Number) row[i + 1]).longValue();
+ }
+
+ result.put(deptId, metrics);
+ }
+
+ return result;
+ }
+
+ /**
+ * ONE query: DTO-projection for the detail grid — no entity hydration.
+ *
+ * Fetches only the 9 scalar columns needed by HospitalCensusDetailDto. Age
+ * is computed inside the DTO constructor from dob to keep JPQL clean.
+ *
+ * Replaces N × (fetchCurrentPatients entity load + buildWardDetail loop +
+ * lazy PatientEncounter / Patient / Person navigation).
+ */
+ @SuppressWarnings("unchecked")
+ private List fetchDetailDtos(List deptIds) {
+ if (deptIds.isEmpty()) {
+ return Collections.emptyList();
+ }
+ String jpql
+ = "select new com.divudi.core.data.dto.HospitalCensusDetailDto("
+ + " d.name, " // area
+ + " pat.phn, " // mrn
+ + " per.name, " // personName
+ + " per.dob, " // dob (age computed in ctor)
+ + " per.sex, " // sex (enum)
+ + " pe.dateOfAdmission, " // doa
+ + " rfc.name, " // bedNo
+ + " pe.discharged, " // discharged flag
+ + " conPer.name" // consultant name (nullable)
+ + ") "
+ + "from PatientRoom pr "
+ + "join pr.roomFacilityCharge rfc "
+ + "join rfc.department d "
+ + "join pr.patientEncounter pe "
+ + "join pe.patient pat "
+ + "join pat.person per "
+ + "left join pe.referringConsultant con "
+ + "left join con.person conPer "
+ + "where pr.retired = false "
+ + "and d.id in :deptIds "
+ + "and pr.admittedAt <= :td "
+ + "and (pr.dischargedAt is null or pr.dischargedAt > :td) "
+ + "order by d.name, pat.phn";
+
+ Map params = new HashMap<>();
+ params.put("deptIds", deptIds);
+ params.put("td", toDate);
+
+ return (List) departmentFacade.findDTOsByJpql(jpql, params, TemporalType.TIMESTAMP);
+ }
+
+ private static List collectDeptIds(List departments) {
+ List ids = new ArrayList<>(departments.size());
+ for (DepartmentDto d : departments) {
+ ids.add(d.getId());
+ }
+ return ids;
+ }
+
+ public void createSummaryReportPdf() {
+ if (hospitalCensusSummaryDtos == null || hospitalCensusSummaryDtos.isEmpty()) {
+ addErrorMessage("No summary data to export. Run the report first.");
+ return;
+ }
+ try {
+ java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
+ Document document = new Document(PageSize.A4.rotate(), 20, 20, 30, 20);
+ PdfWriter.getInstance(document, baos);
+ document.open();
+
+ addReportTitle(document, "Hospital Census - Summary Report");
+ document.add(buildSummaryTable());
+
+ document.close();
+
+ OutputStream out = prepareResponse("Hospital_Census_Summary.pdf");
+ baos.writeTo(out);
+ out.flush();
+ FacesContext.getCurrentInstance().responseComplete();
+ } catch (DocumentException | IOException e) {
+ addErrorMessage("Failed to generate summary PDF: " + e.getMessage());
+ }
+ }
+
+ /**
+ * Streams the Detail report as a PDF download. Wired to a dedicated button
+ * (rendered only when reportType != 'Summary').
+ */
+ public void createDetailReportPdf() {
+ if (hospitalCensusDetailDtos == null || hospitalCensusDetailDtos.isEmpty()) {
+ addErrorMessage("No detail data to export. Run the report first.");
+ return;
+ }
+ try {
+ java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
+ Document document = new Document(PageSize.A4.rotate(), 20, 20, 30, 20);
+ PdfWriter.getInstance(document, baos);
+ document.open();
+
+ addReportTitle(document, "Hospital Census - Detail Report");
+ document.add(buildDetailTable());
+
+ document.close();
+
+ OutputStream out = prepareResponse("Hospital_Census_Detail.pdf");
+ baos.writeTo(out);
+ out.flush();
+ FacesContext.getCurrentInstance().responseComplete();
+ } catch (DocumentException | IOException e) {
+ addErrorMessage("Failed to generate detail PDF: " + e.getMessage());
+ }
+ }
+
+// =========================================================================
+// Shared PDF plumbing — kept generic so summary/detail methods stay thin
+// =========================================================================
+ private OutputStream prepareResponse(String filename) throws IOException {
+ FacesContext fc = FacesContext.getCurrentInstance();
+ HttpServletResponse response = (HttpServletResponse) fc.getExternalContext().getResponse();
+ response.reset();
+ response.setContentType("application/pdf");
+ response.setHeader("Content-Disposition", "attachment; filename=\"" + filename + "\"");
+ return response.getOutputStream();
+ }
+
+ private void addReportTitle(Document document, String titleText) throws DocumentException {
+ Font titleFont = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 14);
+ Paragraph title = new Paragraph(titleText, titleFont);
+ title.setSpacingAfter(4);
+ document.add(title);
+
+ Font subFont = FontFactory.getFont(FontFactory.HELVETICA, 9, Font.ITALIC, BaseColor.DARK_GRAY);
+ String range = (fromDate != null ? formatDate(fromDate) : "-")
+ + " to " + (toDate != null ? formatDate(toDate) : "-");
+ Paragraph sub = new Paragraph(range, subFont);
+ sub.setSpacingAfter(12);
+ document.add(sub);
+ }
+
+ private String formatDate(Date d) {
+ return new SimpleDateFormat("yyyy-MM-dd HH:mm").format(d);
+ }
+
+ private PdfPTable buildSummaryTable() throws DocumentException {
+ String[] headers = {
+ "Ward", "Total Beds", "Open Beds", "Prev. Day", "New Adm.",
+ "Transfer In", "Transfer Out", "Marked Disc.", "Normal Disc.",
+ "LAMA", "Deaths", "Others", "Total Present", "Occ. Rate %"
+ };
+ PdfPTable table = new PdfPTable(headers.length);
+ table.setWidthPercentage(100);
+ table.setHeaderRows(1);
+
+ Font headerFont = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 8, BaseColor.WHITE);
+ for (String h : headers) {
+ PdfPCell cell = new PdfPCell(new Phrase(h, headerFont));
+ cell.setBackgroundColor(new BaseColor(60, 60, 60));
+ cell.setPadding(4);
+ cell.setHorizontalAlignment(Element.ALIGN_CENTER);
+ table.addCell(cell);
+ }
+
+ Font bodyFont = FontFactory.getFont(FontFactory.HELVETICA, 8);
+ long grandBeds = 0, grandOpen = 0, grandPresent = 0;
+
+ for (HospitalCensusSummaryDto row : hospitalCensusSummaryDtos) {
+ addBodyCell(table, row.getWard(), bodyFont, Element.ALIGN_LEFT);
+ addBodyCell(table, String.valueOf(row.getTotalBeds()), bodyFont, Element.ALIGN_RIGHT);
+ addBodyCell(table, String.valueOf(row.getOpenBeds()), bodyFont, Element.ALIGN_RIGHT);
+ addBodyCell(table, String.valueOf(row.getPreviousDaysTotal()), bodyFont, Element.ALIGN_RIGHT);
+ addBodyCell(table, String.valueOf(row.getNewAdmissions()), bodyFont, Element.ALIGN_RIGHT);
+ addBodyCell(table, String.valueOf(row.getTransferIn()), bodyFont, Element.ALIGN_RIGHT);
+ addBodyCell(table, String.valueOf(row.getTransferOut()), bodyFont, Element.ALIGN_RIGHT);
+ addBodyCell(table, String.valueOf(row.getMarkedForDischarge()), bodyFont, Element.ALIGN_RIGHT);
+ addBodyCell(table, String.valueOf(row.getNormalDischarges()), bodyFont, Element.ALIGN_RIGHT);
+ addBodyCell(table, String.valueOf(row.getLama()), bodyFont, Element.ALIGN_RIGHT);
+ addBodyCell(table, String.valueOf(row.getDeaths()), bodyFont, Element.ALIGN_RIGHT);
+ addBodyCell(table, String.valueOf(row.getOthers()), bodyFont, Element.ALIGN_RIGHT);
+ addBodyCell(table, String.valueOf(row.getTotalPresent()), bodyFont, Element.ALIGN_RIGHT);
+ addBodyCell(table, String.format("%.2f", row.getBedOccupancyRate()), bodyFont, Element.ALIGN_RIGHT);
+
+ grandBeds += row.getTotalBeds();
+ grandOpen += row.getOpenBeds();
+ grandPresent += row.getTotalPresent();
+ }
+
+ // Grand total row
+ Font boldFont = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 8);
+ PdfPCell totalLabel = new PdfPCell(new Phrase("Grand Total", boldFont));
+ totalLabel.setColspan(1);
+ table.addCell(totalLabel);
+ addBodyCell(table, String.valueOf(grandBeds), boldFont, Element.ALIGN_RIGHT);
+ addBodyCell(table, String.valueOf(grandOpen), boldFont, Element.ALIGN_RIGHT);
+ for (int i = 0; i < 9; i++) {
+ table.addCell(new PdfPCell(new Phrase("")));
+ }
+ addBodyCell(table, String.valueOf(grandPresent), boldFont, Element.ALIGN_RIGHT);
+ table.addCell(new PdfPCell(new Phrase("")));
+
+ return table;
+ }
+
+ private PdfPTable buildDetailTable() throws DocumentException {
+ String[] headers = {"Area", "MRN", "Name", "Age/Sex", "DOA", "Bed No", "Status", "Consultant"};
+ PdfPTable table = new PdfPTable(headers.length);
+ table.setWidthPercentage(100);
+ table.setHeaderRows(1);
+ table.setWidths(new float[]{8, 10, 16, 8, 14, 10, 10, 16});
+
+ Font headerFont = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 8, BaseColor.WHITE);
+ for (String h : headers) {
+ PdfPCell cell = new PdfPCell(new Phrase(h, headerFont));
+ cell.setBackgroundColor(new BaseColor(60, 60, 60));
+ cell.setPadding(4);
+ cell.setHorizontalAlignment(Element.ALIGN_CENTER);
+ table.addCell(cell);
+ }
+
+ Font bodyFont = FontFactory.getFont(FontFactory.HELVETICA, 8);
+ SimpleDateFormat doaFmt = new SimpleDateFormat("M/d/yyyy H:mm");
+
+ for (HospitalCensusDetailDto row : hospitalCensusDetailDtos) {
+ addBodyCell(table, nvl(row.getArea()), bodyFont, Element.ALIGN_LEFT);
+ addBodyCell(table, nvl(row.getMrn()), bodyFont, Element.ALIGN_LEFT);
+ addBodyCell(table, nvl(row.getName()), bodyFont, Element.ALIGN_LEFT);
+ addBodyCell(table, nvl(row.getAgeSex()), bodyFont, Element.ALIGN_CENTER);
+ addBodyCell(table, row.getDoa() != null ? doaFmt.format(row.getDoa()) : "", bodyFont, Element.ALIGN_CENTER);
+ addBodyCell(table, nvl(row.getBedNo()), bodyFont, Element.ALIGN_CENTER);
+ addBodyCell(table, nvl(row.getStatus()), bodyFont, Element.ALIGN_CENTER);
+ addBodyCell(table, nvl(row.getConsultant()), bodyFont, Element.ALIGN_LEFT);
+ }
+ return table;
+ }
+
+ private void addBodyCell(PdfPTable table, String text, Font font, int align) {
+ PdfPCell cell = new PdfPCell(new Phrase(text != null ? text : "", font));
+ cell.setPadding(3);
+ cell.setHorizontalAlignment(align);
+ table.addCell(cell);
+ }
+
+ private String nvl(String s) {
+ return s != null ? s : "";
+ }
+
+ private void addErrorMessage(String msg) {
+ FacesContext.getCurrentInstance().addMessage(null,
+ new FacesMessage(FacesMessage.SEVERITY_ERROR, msg, null));
+ }
+
+ public Institution getInstitution() {
+ return institution;
+ }
+
+ public void setInstitution(Institution institution) {
+ this.institution = institution;
+ }
+
+ public Institution getSite() {
+ return site;
+ }
+
+ public void setSite(Institution site) {
+ this.site = site;
+ }
+
+ public Department getDepartment() {
+ return department;
+ }
+
+ public void setDepartment(Department department) {
+ this.department = department;
+ }
+
+ public Date getFromDate() {
+ if (fromDate == null) {
+ fromDate = com.divudi.core.util.CommonFunctions.getStartOfDay(new Date());
+ }
+ return fromDate;
+ }
+
+ public void setFromDate(Date fromDate) {
+ this.fromDate = fromDate;
+ }
+
+ public Date getToDate() {
+ if (toDate == null) {
+ toDate = com.divudi.core.util.CommonFunctions.getEndOfDay(new Date());
+ }
+ return toDate;
+ }
+
+ public void setToDate(Date toDate) {
+ this.toDate = toDate;
+ }
+
+ public Department getWard() {
+ return ward;
+ }
+
+ public void setWard(Department ward) {
+ this.ward = ward;
+ }
+
+ public String getReportType() {
+ return reportType;
+ }
+
+ public void setReportType(String reportType) {
+ this.reportType = reportType;
+ }
+
+ public List getDepartmentList() {
+ return departmentList;
+ }
+
+ public void setDepartmentList(List departmentList) {
+ this.departmentList = departmentList;
+ }
+
+ public List getHospitalCensusSummaryDtos() {
+ return hospitalCensusSummaryDtos;
+ }
+
+ public void setHospitalCensusSummaryDtos(List hospitalCensusSummaryDtos) {
+ this.hospitalCensusSummaryDtos = hospitalCensusSummaryDtos;
+ }
+
+ public List getHospitalCensusDetailDtos() {
+ return hospitalCensusDetailDtos;
+ }
+
+ public void setHospitalCensusDetailDtos(List hospitalCensusDetailDtos) {
+ this.hospitalCensusDetailDtos = hospitalCensusDetailDtos;
+ }
+
+ public DepartmentFacade getDepartmentFacade() {
+ return departmentFacade;
+ }
+
+ public void setDepartmentFacade(DepartmentFacade departmentFacade) {
+ this.departmentFacade = departmentFacade;
+ }
+}
diff --git a/src/main/java/com/divudi/bean/inward/InwardPriceAdjustmntController.java b/src/main/java/com/divudi/bean/inward/InwardPriceAdjustmntController.java
index d3d8eaefe2e..e6b7767ec03 100644
--- a/src/main/java/com/divudi/bean/inward/InwardPriceAdjustmntController.java
+++ b/src/main/java/com/divudi/bean/inward/InwardPriceAdjustmntController.java
@@ -73,6 +73,7 @@ public class InwardPriceAdjustmntController implements Serializable {
private Category roomLocation;
private Institution creditCompany;
private AdmissionType admissionType;
+ private Category roomCategory;
private void recreateModel() {
fromPrice = toPrice + 1;
@@ -80,6 +81,7 @@ private void recreateModel() {
margin = 0;
creditCompany = null;
admissionType = null;
+ roomCategory = null;
items = null;
}
@@ -89,6 +91,7 @@ public void preparedAdd() {
margin = 0;
creditCompany = null;
admissionType = null;
+ roomCategory = null;
items = null;
}
@@ -157,6 +160,7 @@ public void saveSelected() {
a.setMargin(margin);
a.setCreditCompany(creditCompany);
a.setAdmissionType(admissionType);
+ a.setRoomCategory(roomCategory);
a.setCreatedAt(new Date());
a.setCreater(getSessionController().getLoggedUser());
if (a.getId() == null) {
@@ -195,6 +199,7 @@ public void addForAllCategory() {
a.setMargin(margin);
a.setCreditCompany(creditCompany);
a.setAdmissionType(admissionType);
+ a.setRoomCategory(roomCategory);
a.setCreatedAt(new Date());
a.setCreater(getSessionController().getLoggedUser());
if (a.getId() == null) {
@@ -490,6 +495,14 @@ public void setAdmissionType(AdmissionType admissionType) {
this.admissionType = admissionType;
}
+ public Category getRoomCategory() {
+ return roomCategory;
+ }
+
+ public void setRoomCategory(Category roomCategory) {
+ this.roomCategory = roomCategory;
+ }
+
/**
*
*/
diff --git a/src/main/java/com/divudi/bean/inward/InwardSearch.java b/src/main/java/com/divudi/bean/inward/InwardSearch.java
index ecedc1011ea..dfe82762eaf 100644
--- a/src/main/java/com/divudi/bean/inward/InwardSearch.java
+++ b/src/main/java/com/divudi/bean/inward/InwardSearch.java
@@ -318,6 +318,8 @@ public String navigateToPaymentBillCancellation() {
paymentMethodData = new PaymentMethodData();
+ refillPaymentDetail();
+
return "inward_deposit_cancel_bill_payment?faces-redirect=true";
case INWARD_DEPOSIT_REFUND:
return "inward_deposit_refund_cancel_bill_payment?faces-redirect=true";
@@ -893,6 +895,7 @@ private boolean check() {
JsfUtil.addErrorMessage("Already Cancelled. Can not cancel again");
return true;
}
+
if (getBill().isRefunded()) {
JsfUtil.addErrorMessage("Already Returned. Can not cancel.");
return true;
@@ -902,6 +905,7 @@ private boolean check() {
JsfUtil.addErrorMessage("You can't cancel Because this Bill has no BHT");
return true;
}
+
if (getBill().getPatientEncounter().isDischarged()) {
JsfUtil.addErrorMessage("You can't cancel. Because this BHT is Already Discharged.");
return true;
@@ -911,6 +915,7 @@ private boolean check() {
JsfUtil.addErrorMessage("Please select a payment Method.");
return true;
}
+
if (getComment() == null || getComment().trim().equals("")) {
JsfUtil.addErrorMessage("Please enter a comment");
return true;
diff --git a/src/main/java/com/divudi/bean/inward/InwardStaffPaymentBillController.java b/src/main/java/com/divudi/bean/inward/InwardStaffPaymentBillController.java
index c458c493db0..7926a7c5b52 100644
--- a/src/main/java/com/divudi/bean/inward/InwardStaffPaymentBillController.java
+++ b/src/main/java/com/divudi/bean/inward/InwardStaffPaymentBillController.java
@@ -2092,5 +2092,9 @@ public IncomeBundle getBundle() {
public void setBundle(IncomeBundle bundle) {
this.bundle = bundle;
}
+
+ public String convertToWord(double d) {
+ return CommonFunctions.convertToWord(Math.abs(d));
+ }
}
diff --git a/src/main/java/com/divudi/bean/inward/ServiceFeeEdit.java b/src/main/java/com/divudi/bean/inward/ServiceFeeEdit.java
index 1fc263e8915..edbb022c86b 100644
--- a/src/main/java/com/divudi/bean/inward/ServiceFeeEdit.java
+++ b/src/main/java/com/divudi/bean/inward/ServiceFeeEdit.java
@@ -13,7 +13,9 @@
import com.divudi.core.entity.Bill;
import com.divudi.core.entity.BillFee;
import com.divudi.core.entity.BillItem;
+import com.divudi.core.entity.PatientEncounter;
import com.divudi.core.entity.PriceMatrix;
+import com.divudi.core.entity.inward.RoomCategory;
import com.divudi.core.facade.BillFacade;
import com.divudi.core.facade.BillFeeFacade;
import com.divudi.core.facade.BillItemFacade;
@@ -153,9 +155,17 @@ public void updateFee(RowEditEvent event) {
getBillFeeFacade().edit(billFee);
- PriceMatrix priceMatrix = getPriceMatrixController().fetchInwardMargin(billItem, billFee.getFeeGrossValue(), billItem.getBill().getFromDepartment(),billItem.getBill().getPatientEncounter().getPaymentMethod(), billItem.getBill().getPatientEncounter().getAdmissionType());
-
- getInwardBean().updateBillItemMargin(billItem, billFee.getFeeGrossValue(), billItem.getBill().getPatientEncounter(), billItem.getBill().getFromDepartment(), priceMatrix);
+ PatientEncounter encounter = billItem.getBill().getPatientEncounter();
+ // Room category of the patient's current room drives the room-category
+ // dimension of the inward margin matrix (issue #21977); null = wildcard.
+ RoomCategory roomCategory = (encounter != null
+ && encounter.getCurrentPatientRoom() != null
+ && encounter.getCurrentPatientRoom().getRoomFacilityCharge() != null)
+ ? encounter.getCurrentPatientRoom().getRoomFacilityCharge().getRoomCategory()
+ : null;
+ PriceMatrix priceMatrix = getPriceMatrixController().fetchInwardMargin(billItem, billFee.getFeeGrossValue(), billItem.getBill().getFromDepartment(), encounter.getPaymentMethod(), null, encounter.getAdmissionType(), roomCategory);
+
+ getInwardBean().updateBillItemMargin(billItem, billFee.getFeeGrossValue(), encounter, billItem.getBill().getFromDepartment(), priceMatrix);
getBillBean().updateBillItemByBillFee(billItem);
getBillBean().updateBillByBillFee(billItem.getBill());
diff --git a/src/main/java/com/divudi/bean/lab/PatientInvestigationController.java b/src/main/java/com/divudi/bean/lab/PatientInvestigationController.java
index 9249b7a2543..ec798c365f9 100644
--- a/src/main/java/com/divudi/bean/lab/PatientInvestigationController.java
+++ b/src/main/java/com/divudi/bean/lab/PatientInvestigationController.java
@@ -775,7 +775,12 @@ public String navigateToSampleManagementFromOPDBatchBillView(Bill bill) {
} else {
listingEntity = ListingEntity.BILLS;
bills = billFacade.findByJpql(jpql, params, TemporalType.TIMESTAMP);
- return "/lab/generate_barcode_p?faces-redirect=true";
+
+ if ((configOptionApplicationController.getBooleanValueByKey("Use the Nursing Laboratory Dashboard for inward laboratory process.", false)) && bill.getBillTypeAtomic() == BillTypeAtomic.INWARD_SERVICE_BILL) {
+ return "/inward/inward_lab_dashboard?faces-redirect=true";
+ }else{
+ return "/lab/generate_barcode_p?faces-redirect=true";
+ }
}
}
diff --git a/src/main/java/com/divudi/bean/pharmacy/BhtIssueReturnController.java b/src/main/java/com/divudi/bean/pharmacy/BhtIssueReturnController.java
index 03cda81352c..80d01bc648b 100644
--- a/src/main/java/com/divudi/bean/pharmacy/BhtIssueReturnController.java
+++ b/src/main/java/com/divudi/bean/pharmacy/BhtIssueReturnController.java
@@ -18,6 +18,8 @@
import com.divudi.core.entity.Bill;
import com.divudi.core.entity.BillItem;
import com.divudi.core.entity.Department;
+import com.divudi.core.entity.PatientEncounter;
+import com.divudi.core.entity.inward.RoomCategory;
import com.divudi.core.entity.PriceMatrix;
import com.divudi.core.entity.RefundBill;
import com.divudi.core.entity.pharmacy.PharmaceuticalBillItem;
@@ -313,11 +315,28 @@ private void saveComponent() {
}
+ /**
+ * The room category of the patient's current room, or null when the patient
+ * is not in a room (or the room has no facility charge / category). Drives the
+ * room-category dimension of the inward pharmacy-margin matrix (issue #21981);
+ * null means "wildcard row only", preserving legacy behaviour.
+ */
+ private RoomCategory resolveCurrentRoomCategory(PatientEncounter encounter) {
+ if (encounter == null
+ || encounter.getCurrentPatientRoom() == null
+ || encounter.getCurrentPatientRoom().getRoomFacilityCharge() == null) {
+ return null;
+ }
+ return encounter.getCurrentPatientRoom().getRoomFacilityCharge().getRoomCategory();
+ }
+
public void updateMargin(BillItem bi, Department matrixDepartment, PaymentMethod paymentMethod) {
double rate = Math.abs(bi.getRate());
double margin = 0;
- PriceMatrix priceMatrix = getPriceMatrixController().fetchInwardMargin(bi, rate, matrixDepartment, paymentMethod);
+ PatientEncounter encounter = bi.getBill() != null ? bi.getBill().getPatientEncounter() : null;
+ PriceMatrix priceMatrix = getPriceMatrixController().fetchInwardMargin(bi, rate, matrixDepartment, paymentMethod, null,
+ encounter != null ? encounter.getAdmissionType() : null, resolveCurrentRoomCategory(encounter));
if (priceMatrix != null) {
margin = ((bi.getGrossValue() * priceMatrix.getMargin()) / 100);
diff --git a/src/main/java/com/divudi/bean/pharmacy/DiscardCategoryController.java b/src/main/java/com/divudi/bean/pharmacy/DiscardCategoryController.java
index 56f0012a365..7ef6eb8747d 100644
--- a/src/main/java/com/divudi/bean/pharmacy/DiscardCategoryController.java
+++ b/src/main/java/com/divudi/bean/pharmacy/DiscardCategoryController.java
@@ -9,6 +9,7 @@
package com.divudi.bean.pharmacy;
import com.divudi.bean.common.SessionController;
+import com.divudi.bean.common.WebUserController;
import com.divudi.core.util.JsfUtil;
import com.divudi.core.entity.pharmacy.DiscardCategory;
import com.divudi.core.facade.DiscardCategoryFacade;
@@ -37,6 +38,8 @@ public class DiscardCategoryController implements Serializable {
private static final long serialVersionUID = 1L;
@Inject
SessionController sessionController;
+ @Inject
+ private WebUserController webUserController;
@EJB
private DiscardCategoryFacade ejbFacade;
List selectedItems;
@@ -61,6 +64,10 @@ private void recreateModel() {
}
public void saveSelected() {
+ if (!webUserController.hasPrivilege("PharmacyDiscardCategoryManage")) {
+ JsfUtil.addErrorMessage("You do not have the privilege to manage discard categories.");
+ return;
+ }
if (getCurrent().getId() != null && getCurrent().getId() > 0) {
getFacade().edit(current);
@@ -111,6 +118,10 @@ public void setCurrent(DiscardCategory current) {
}
public void delete() {
+ if (!webUserController.hasPrivilege("PharmacyDiscardCategoryManage")) {
+ JsfUtil.addErrorMessage("You do not have the privilege to manage discard categories.");
+ return;
+ }
if (current != null) {
current.setRetired(true);
diff --git a/src/main/java/com/divudi/bean/pharmacy/GoodsReturnController.java b/src/main/java/com/divudi/bean/pharmacy/GoodsReturnController.java
index 720f8abc41e..b56a7679c64 100644
--- a/src/main/java/com/divudi/bean/pharmacy/GoodsReturnController.java
+++ b/src/main/java/com/divudi/bean/pharmacy/GoodsReturnController.java
@@ -5,6 +5,7 @@
package com.divudi.bean.pharmacy;
import com.divudi.bean.common.SessionController;
+import com.divudi.bean.common.WebUserController;
import com.divudi.core.util.JsfUtil;
import com.divudi.core.data.BillClassType;
import com.divudi.core.data.BillNumberSuffix;
@@ -35,6 +36,8 @@
import java.util.Calendar;
import java.util.Date;
import java.util.List;
+import java.util.logging.Level;
+import java.util.logging.Logger;
import javax.ejb.EJB;
import javax.enterprise.context.SessionScoped;
import javax.inject.Inject;
@@ -48,6 +51,8 @@
@SessionScoped
public class GoodsReturnController implements Serializable {
+ private static final Logger LOGGER = Logger.getLogger(GoodsReturnController.class.getName());
+
/**
* EJBs
*/
@@ -78,6 +83,8 @@ public class GoodsReturnController implements Serializable {
private PharmacyController pharmacyController;
@Inject
private SessionController sessionController;
+ @Inject
+ private WebUserController webUserController;
/**
* Properties
*/
@@ -307,6 +314,9 @@ public void removeItem(BillItem bi) {
}
public void settle() {
+ if (!isAuthorized("SETTLE", "ReturnReceviedGoods")) {
+ return;
+ }
if (bill == null) {
JsfUtil.addErrorMessage("Select a Bill");
return;
@@ -636,4 +646,39 @@ private void updateOriginalBill() {
billFacade.edit(bill);
}
+ /**
+ * Authorization helper method to check Goods Return privileges and audit
+ * denied access
+ *
+ * @param action The action being attempted (SETTLE)
+ * @param requiredPrivilege The specific privilege required
+ * @return true if authorized, false if not
+ */
+ private boolean isAuthorized(String action, String requiredPrivilege) {
+ if (webUserController == null || sessionController == null) {
+ LOGGER.log(Level.SEVERE, "Authorization failed - missing controllers: action={0}, userId=null",
+ action);
+ return false;
+ }
+
+ if (!webUserController.hasPrivilege(requiredPrivilege)) {
+ // Audit denied access attempt
+ Long userId = sessionController.getLoggedUser() != null ? sessionController.getLoggedUser().getId() : null;
+ Long billId = null;
+ if (returnBill != null) {
+ billId = returnBill.getId();
+ } else if (bill != null) {
+ billId = bill.getId();
+ }
+
+ LOGGER.log(Level.WARNING, "SECURITY: Unauthorized Goods Return access attempt - action={0}, userId={1}, billId={2}, requiredPrivilege={3}",
+ new Object[]{action, userId, billId, requiredPrivilege});
+
+ JsfUtil.addErrorMessage("You don't have permission to " + action.toLowerCase() + " goods return requests.");
+ return false;
+ }
+
+ return true;
+ }
+
}
diff --git a/src/main/java/com/divudi/bean/pharmacy/GrnController.java b/src/main/java/com/divudi/bean/pharmacy/GrnController.java
index 9b8f1d6b7d1..a80bd43dfb4 100644
--- a/src/main/java/com/divudi/bean/pharmacy/GrnController.java
+++ b/src/main/java/com/divudi/bean/pharmacy/GrnController.java
@@ -5,6 +5,7 @@
package com.divudi.bean.pharmacy;
import com.divudi.bean.common.SessionController;
+import com.divudi.bean.common.WebUserController;
import com.divudi.bean.common.ConfigOptionController;
import com.divudi.core.util.JsfUtil;
import com.divudi.core.data.BillClassType;
@@ -46,6 +47,8 @@
import java.util.Date;
import java.util.HashMap;
import java.util.List;
+import java.util.logging.Level;
+import java.util.logging.Logger;
import javax.ejb.EJB;
import javax.enterprise.context.SessionScoped;
import javax.inject.Inject;
@@ -61,8 +64,12 @@
@SessionScoped
public class GrnController implements Serializable {
+ private static final Logger LOGGER = Logger.getLogger(GrnController.class.getName());
+
@Inject
private SessionController sessionController;
+ @Inject
+ private WebUserController webUserController;
private BilledBill bill;
@EJB
private BillNumberGenerator billNumberBean;
@@ -500,6 +507,21 @@ public Date getFromDate() {
}
public void request() {
+ if (!isAuthorized("REQUEST", "PharmacyGrnSave")) {
+ return;
+ }
+ doRequest();
+ }
+
+ /**
+ * Unguarded core of {@link #request()}. Called directly (bypassing the
+ * PharmacyGrnSave check) by {@link #finalizeBill()} when it needs to
+ * auto-save a not-yet-persisted draft as part of a Finalize action that
+ * has already been authorized under PharmacyGrnFinalize — a user with
+ * only the Finalize privilege must still be able to finalize a brand
+ * new GRN in one step.
+ */
+ private void doRequest() {
// if (Math.abs(difference) > 1) {
// JsfUtil.addErrorMessage("The invoice does not match..! Check again");
// return;
@@ -593,6 +615,9 @@ public void request() {
}
public void requestFinalize() {
+ if (!isAuthorized("REQUEST_FINALIZE", "PharmacyGrnFinalize")) {
+ return;
+ }
if (Math.abs(difference) > 1) {
JsfUtil.addErrorMessage("The invoice does not match..! Check again");
return;
@@ -692,6 +717,9 @@ public void requestFinalize() {
}
public void settle() {
+ if (!isAuthorized("SETTLE", "PharmacyGrnFinalize")) {
+ return;
+ }
if (Math.abs(difference) > 1) {
JsfUtil.addErrorMessage("The invoice does not match..! Check again");
return;
@@ -849,6 +877,9 @@ private boolean isPurchaseOrderFullyReceived(Bill purchaseOrderBill) {
public void settleWholesale() {
+ if (!isAuthorized("SETTLE_WHOLESALE", "PharmacyGrnFinalize")) {
+ return;
+ }
if (insTotal == 0 && difference != 0) {
JsfUtil.addErrorMessage("Fill the invoice Total");
return;
@@ -1039,7 +1070,7 @@ public void finalizeBill() {
return;
}
if (currentGrnBillPre.getId() == null) {
- request();
+ doRequest();
}
getCurrentGrnBillPre().setEditedAt(new Date());
getCurrentGrnBillPre().setEditor(sessionController.getLoggedUser());
@@ -1965,9 +1996,23 @@ private void saveImportBill(Bill importGrn) {
}
public void requestWithSaveApprove() {
+ if (!isAuthorized("REQUEST_WITH_SAVE_APPROVE", "PharmacyGrnSave")) {
+ return;
+ }
+ doRequestWithSaveApprove();
+ }
+
+ /**
+ * Unguarded core of {@link #requestWithSaveApprove()}. Called directly
+ * (bypassing the PharmacyGrnSave check) by
+ * {@link #approveGrnWithSaveApprove()} when it needs to auto-save a
+ * not-yet-persisted draft as part of an Approve action that has already
+ * been authorized under PharmacyGrnApprove.
+ */
+ private void doRequestWithSaveApprove() {
// Simple save method for save/approve workflow
// Allow saving with incomplete data - no validation required
-
+
// Set basic bill information
getCurrentGrnBillPre().setBillDate(new Date());
getCurrentGrnBillPre().setBillTime(new Date());
@@ -2058,6 +2103,9 @@ public void requestWithSaveApprove() {
}
public void approveGrnWithSaveApprove() {
+ if (!isAuthorized("APPROVE_GRN_WITH_SAVE_APPROVE", "PharmacyGrnApprove")) {
+ return;
+ }
// Always use bill's invoice number, ignore controller reference
if (getCurrentGrnBillPre().getInvoiceNumber() == null || getCurrentGrnBillPre().getInvoiceNumber().trim().isEmpty()) {
JsfUtil.addErrorMessage("Please fill invoice number");
@@ -2089,7 +2137,7 @@ public void approveGrnWithSaveApprove() {
// First ensure the bill is saved
if (getCurrentGrnBillPre().getId() == null) {
- requestWithSaveApprove(); // Save first if not already saved
+ doRequestWithSaveApprove(); // Save first if not already saved
}
// Process bill items for finalization with full stock management
@@ -2188,4 +2236,41 @@ public boolean isProfitAboveThreshold(BillItem bi) {
return profitMargin > threshold;
}
+ /**
+ * Authorization helper method to check GRN privileges and audit denied
+ * access
+ *
+ * @param action The action being attempted (SAVE, FINALIZE, APPROVE)
+ * @param requiredPrivilege The specific privilege required
+ * @return true if authorized, false if not
+ */
+ private boolean isAuthorized(String action, String requiredPrivilege) {
+ if (webUserController == null || sessionController == null) {
+ LOGGER.log(Level.SEVERE, "Authorization failed - missing controllers: action={0}, userId=null",
+ action);
+ return false;
+ }
+
+ if (!webUserController.hasPrivilege(requiredPrivilege)) {
+ // Audit denied access attempt
+ Long userId = sessionController.getLoggedUser() != null ? sessionController.getLoggedUser().getId() : null;
+ Long billId = null;
+ if (grnBill != null) {
+ billId = grnBill.getId();
+ } else if (currentGrnBillPre != null) {
+ billId = currentGrnBillPre.getId();
+ } else if (approveBill != null) {
+ billId = approveBill.getId();
+ }
+
+ LOGGER.log(Level.WARNING, "SECURITY: Unauthorized GRN access attempt - action={0}, userId={1}, billId={2}, requiredPrivilege={3}",
+ new Object[]{action, userId, billId, requiredPrivilege});
+
+ JsfUtil.addErrorMessage("You don't have permission to " + action.toLowerCase() + " GRN.");
+ return false;
+ }
+
+ return true;
+ }
+
}
diff --git a/src/main/java/com/divudi/bean/pharmacy/GrnCostingController.java b/src/main/java/com/divudi/bean/pharmacy/GrnCostingController.java
index 77e0660938a..bb336b3d31f 100644
--- a/src/main/java/com/divudi/bean/pharmacy/GrnCostingController.java
+++ b/src/main/java/com/divudi/bean/pharmacy/GrnCostingController.java
@@ -5,6 +5,7 @@
package com.divudi.bean.pharmacy;
import com.divudi.bean.common.SessionController;
+import com.divudi.bean.common.WebUserController;
import com.divudi.bean.common.ConfigOptionController;
import com.divudi.core.util.JsfUtil;
import com.divudi.core.data.BillClassType;
@@ -51,6 +52,8 @@
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.logging.Level;
+import java.util.logging.Logger;
import javax.ejb.EJB;
import javax.enterprise.context.SessionScoped;
import javax.inject.Inject;
@@ -79,8 +82,12 @@ public class GrnCostingController implements Serializable {
private static final long serialVersionUID = 1L;
private static final int PRICE_SCALE = 6;
+ private static final Logger LOGGER = Logger.getLogger(GrnCostingController.class.getName());
+
@Inject
private SessionController sessionController;
+ @Inject
+ private WebUserController webUserController;
private BilledBill bill;
@EJB
private BillNumberGenerator billNumberBean;
@@ -476,6 +483,9 @@ public void calculateRetailSaleValueAndFreeValueAtPurchaseRate(Bill b) {
}
public void requestFinalize() {
+ if (!isAuthorized("REQUEST_FINALIZE", "PharmacyGrnFinalize")) {
+ return;
+ }
if (Math.abs(difference) > 1) {
JsfUtil.addErrorMessage("The invoice does not match..! Check again");
return;
@@ -546,6 +556,9 @@ public void requestFinalize() {
}
public void settle() {
+ if (!isAuthorized("SETTLE", "PharmacyGrnFinalize")) {
+ return;
+ }
if (!validateInputs()) {
return;
}
@@ -750,7 +763,7 @@ private boolean validateInputs() {
}
private void saveGrnBill() {
- saveBill();
+ doSaveBill();
}
private void distributeValuesToItems() {
@@ -1041,6 +1054,9 @@ public Bill getApproveBill() {
}
public void finalizeBill() {
+ if (!isAuthorized("FINALIZE_BILL", "PharmacyGrnFinalize")) {
+ return;
+ }
if (currentGrnBillPre == null) {
JsfUtil.addErrorMessage("No Bill");
return;
@@ -1058,6 +1074,9 @@ public void finalizeBill() {
}
public void saveGrnPreBill() {
+ if (!isAuthorized("SAVE_GRN_PRE_BILL", "PharmacyGrnSave")) {
+ return;
+ }
getCurrentGrnBillPre().setBillDate(new Date());
getCurrentGrnBillPre().setBillTime(new Date());
getCurrentGrnBillPre().setPaymentMethod(getApproveBill().getPaymentMethod());
@@ -1094,6 +1113,20 @@ public void saveGrnPreBill() {
}
public void saveBill() {
+ if (!isAuthorized("SAVE_BILL", "PharmacyGrnSave")) {
+ return;
+ }
+ doSaveBill();
+ }
+
+ /**
+ * Unguarded core of {@link #saveBill()}. Called directly (bypassing the
+ * PharmacyGrnSave check) by {@link #saveGrnBill()}, which is invoked from
+ * {@link #settle()} — a single-step create+finalize action already
+ * authorized under PharmacyGrnFinalize. A user with only the Finalize
+ * privilege must still be able to settle a brand new GRN in one step.
+ */
+ private void doSaveBill() {
getGrnBill().setBillDate(new Date());
getGrnBill().setBillTime(new Date());
// getGrnBill().setPaymentMethod(getApproveBill().getPaymentMethod());
@@ -1124,6 +1157,9 @@ public void saveBill() {
}
public void saveWholesaleBill() {
+ if (!isAuthorized("SAVE_WHOLESALE_BILL", "PharmacyGrnSave")) {
+ return;
+ }
getGrnBill().setBillDate(new Date());
getGrnBill().setBillTime(new Date());
getGrnBill().setPaymentMethod(getApproveBill().getPaymentMethod());
@@ -2822,6 +2858,21 @@ private void deduplicateBillExpensesInMemory() {
}
public void requestWithSaveApprove() {
+ if (!isAuthorized("REQUEST_WITH_SAVE_APPROVE", "PharmacyGrnSave")) {
+ return;
+ }
+ doRequestWithSaveApprove();
+ }
+
+ /**
+ * Unguarded core of {@link #requestWithSaveApprove()}. Called directly
+ * (bypassing the PharmacyGrnSave check) by
+ * {@link #finalizeGrnWithSaveApprove()} and
+ * {@link #approveGrnWithSaveApprove()}, which auto-save a not-yet-
+ * persisted draft as part of a Finalize/Approve action that has already
+ * been authorized under its own privilege.
+ */
+ private void doRequestWithSaveApprove() {
// Simple save method for costing save/approve workflow
// Allow saving with incomplete data - no validation required
@@ -2919,6 +2970,9 @@ public void requestWithSaveApprove() {
}
public void finalizeGrnWithSaveApprove() {
+ if (!isAuthorized("FINALIZE_GRN_WITH_SAVE_APPROVE", "PharmacyGrnFinalize")) {
+ return;
+ }
// Apply same validations as authorize button
if (getCurrentGrnBillPre().getInvoiceNumber() == null || getCurrentGrnBillPre().getInvoiceNumber().trim().isEmpty()) {
JsfUtil.addErrorMessage("Please fill invoice number");
@@ -2961,7 +3015,7 @@ public void finalizeGrnWithSaveApprove() {
}
// First perform the save operation
- requestWithSaveApprove();
+ doRequestWithSaveApprove();
// Mark the bill as completed
getCurrentGrnBillPre().setCompleted(true);
@@ -3091,6 +3145,9 @@ public boolean checkItemBatch(List list) {
}
public void approveGrnWithSaveApprove() {
+ if (!isAuthorized("APPROVE_GRN_WITH_SAVE_APPROVE", "PharmacyGrnApprove")) {
+ return;
+ }
// Always use bill's invoice number, ignore controller reference
if (getCurrentGrnBillPre().getInvoiceNumber() == null || getCurrentGrnBillPre().getInvoiceNumber().trim().isEmpty()) {
JsfUtil.addErrorMessage("Please fill invoice number");
@@ -3117,7 +3174,7 @@ public void approveGrnWithSaveApprove() {
// First ensure the bill is saved
if (getCurrentGrnBillPre().getId() == null) {
- requestWithSaveApprove(); // Save first if not already saved
+ doRequestWithSaveApprove(); // Save first if not already saved
}
// Ensure bill discount distribution and calculate totals BEFORE processing items
@@ -4455,4 +4512,39 @@ public String convertToWord(Double d) {
return d == null ? "" : CommonFunctions.convertToWord(d);
}
+ /**
+ * Authorization helper method to check GRN Costing privileges and audit
+ * denied access
+ *
+ * @param action The action being attempted (SAVE, FINALIZE, APPROVE)
+ * @param requiredPrivilege The specific privilege required
+ * @return true if authorized, false if not
+ */
+ private boolean isAuthorized(String action, String requiredPrivilege) {
+ if (webUserController == null || sessionController == null) {
+ LOGGER.log(Level.SEVERE, "Authorization failed - missing controllers: action={0}, userId=null",
+ action);
+ return false;
+ }
+
+ if (!webUserController.hasPrivilege(requiredPrivilege)) {
+ // Audit denied access attempt
+ Long userId = sessionController.getLoggedUser() != null ? sessionController.getLoggedUser().getId() : null;
+ Long billId = null;
+ if (currentGrnBillPre != null) {
+ billId = currentGrnBillPre.getId();
+ } else if (approveBill != null) {
+ billId = approveBill.getId();
+ }
+
+ LOGGER.log(Level.WARNING, "SECURITY: Unauthorized GRN Costing access attempt - action={0}, userId={1}, billId={2}, requiredPrivilege={3}",
+ new Object[]{action, userId, billId, requiredPrivilege});
+
+ JsfUtil.addErrorMessage("You don't have permission to " + action.toLowerCase() + " GRN.");
+ return false;
+ }
+
+ return true;
+ }
+
}
diff --git a/src/main/java/com/divudi/bean/pharmacy/GrnReturnApprovalController.java b/src/main/java/com/divudi/bean/pharmacy/GrnReturnApprovalController.java
index e54db17ee73..8e460dea4f7 100644
--- a/src/main/java/com/divudi/bean/pharmacy/GrnReturnApprovalController.java
+++ b/src/main/java/com/divudi/bean/pharmacy/GrnReturnApprovalController.java
@@ -10,6 +10,7 @@
import com.divudi.core.facade.PharmaceuticalBillItemFacade;
import com.divudi.ejb.PharmacyBean;
import com.divudi.bean.common.SessionController;
+import com.divudi.bean.common.WebUserController;
import com.divudi.core.util.JsfUtil;
import java.io.Serializable;
import java.util.ArrayList;
@@ -18,6 +19,8 @@
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.logging.Level;
+import java.util.logging.Logger;
import javax.ejb.EJB;
import javax.enterprise.context.SessionScoped;
import javax.inject.Inject;
@@ -30,6 +33,8 @@
@SessionScoped
public class GrnReturnApprovalController implements Serializable {
+ private static final Logger LOGGER = Logger.getLogger(GrnReturnApprovalController.class.getName());
+
@EJB
private BillFacade billFacade;
@EJB
@@ -41,6 +46,8 @@ public class GrnReturnApprovalController implements Serializable {
@Inject
private SessionController sessionController;
+ @Inject
+ private WebUserController webUserController;
private Bill grnBill;
private Bill pendingReturn;
@@ -98,6 +105,9 @@ public List getPendingReturns() {
}
public void approveReturn(Bill b) {
+ if (!isAuthorized("APPROVE_RETURN", "ApproveGrnReturn")) {
+ return;
+ }
if (b == null) {
return;
}
@@ -138,5 +148,40 @@ public Bill getPendingReturn() {
public void setPendingReturn(Bill pendingReturn) {
this.pendingReturn = pendingReturn;
}
+
+ /**
+ * Authorization helper method to check GRN Return approval privileges
+ * and audit denied access
+ *
+ * @param action The action being attempted (APPROVE_RETURN)
+ * @param requiredPrivilege The specific privilege required
+ * @return true if authorized, false if not
+ */
+ private boolean isAuthorized(String action, String requiredPrivilege) {
+ if (webUserController == null || sessionController == null) {
+ LOGGER.log(Level.SEVERE, "Authorization failed - missing controllers: action={0}, userId=null",
+ action);
+ return false;
+ }
+
+ if (!webUserController.hasPrivilege(requiredPrivilege)) {
+ // Audit denied access attempt
+ Long userId = sessionController.getLoggedUser() != null ? sessionController.getLoggedUser().getId() : null;
+ Long billId = null;
+ if (pendingReturn != null) {
+ billId = pendingReturn.getId();
+ } else if (grnBill != null) {
+ billId = grnBill.getId();
+ }
+
+ LOGGER.log(Level.WARNING, "SECURITY: Unauthorized GRN Return approval access attempt - action={0}, userId={1}, billId={2}, requiredPrivilege={3}",
+ new Object[]{action, userId, billId, requiredPrivilege});
+
+ JsfUtil.addErrorMessage("You don't have permission to " + action.toLowerCase() + " GRN return requests.");
+ return false;
+ }
+
+ return true;
+ }
}
diff --git a/src/main/java/com/divudi/bean/pharmacy/GrnReturnWithCostingController.java b/src/main/java/com/divudi/bean/pharmacy/GrnReturnWithCostingController.java
index a87509f3a70..2991e909448 100644
--- a/src/main/java/com/divudi/bean/pharmacy/GrnReturnWithCostingController.java
+++ b/src/main/java/com/divudi/bean/pharmacy/GrnReturnWithCostingController.java
@@ -5,6 +5,7 @@
package com.divudi.bean.pharmacy;
import com.divudi.bean.common.SessionController;
+import com.divudi.bean.common.WebUserController;
import com.divudi.core.util.JsfUtil;
import com.divudi.bean.common.ConfigOptionApplicationController;
import com.divudi.core.data.BillClassType;
@@ -49,6 +50,8 @@
import java.util.List;
import java.util.Map;
import java.util.Optional;
+import java.util.logging.Level;
+import java.util.logging.Logger;
import javax.ejb.EJB;
import javax.enterprise.context.SessionScoped;
import javax.inject.Inject;
@@ -62,6 +65,8 @@
@SessionScoped
public class GrnReturnWithCostingController implements Serializable {
+ private static final Logger LOGGER = Logger.getLogger(GrnReturnWithCostingController.class.getName());
+
/**
* EJBs
*/
@@ -96,6 +101,8 @@ public class GrnReturnWithCostingController implements Serializable {
@Inject
private SessionController sessionController;
@Inject
+ private WebUserController webUserController;
+ @Inject
private ConfigOptionApplicationController configOptionApplicationController;
/**
* Properties
@@ -1149,6 +1156,9 @@ private void revertPendingReturnTotals() {
}
public void settleGrnReturn() {
+ if (!isAuthorized("SETTLE_GRN_RETURN", "ReturnReceviedGoods")) {
+ return;
+ }
if (returnBill == null) {
JsfUtil.addErrorMessage("No GRN Return bill");
return;
@@ -1730,4 +1740,39 @@ public void setPaymentFacade(PaymentFacade paymentFacade) {
this.paymentFacade = paymentFacade;
}
+ /**
+ * Authorization helper method to check GRN Return (costing) privileges
+ * and audit denied access
+ *
+ * @param action The action being attempted (SETTLE)
+ * @param requiredPrivilege The specific privilege required
+ * @return true if authorized, false if not
+ */
+ private boolean isAuthorized(String action, String requiredPrivilege) {
+ if (webUserController == null || sessionController == null) {
+ LOGGER.log(Level.SEVERE, "Authorization failed - missing controllers: action={0}, userId=null",
+ action);
+ return false;
+ }
+
+ if (!webUserController.hasPrivilege(requiredPrivilege)) {
+ // Audit denied access attempt
+ Long userId = sessionController.getLoggedUser() != null ? sessionController.getLoggedUser().getId() : null;
+ Long billId = null;
+ if (returnBill != null) {
+ billId = returnBill.getId();
+ } else if (bill != null) {
+ billId = bill.getId();
+ }
+
+ LOGGER.log(Level.WARNING, "SECURITY: Unauthorized GRN Return access attempt - action={0}, userId={1}, billId={2}, requiredPrivilege={3}",
+ new Object[]{action, userId, billId, requiredPrivilege});
+
+ JsfUtil.addErrorMessage("You don't have permission to " + action.toLowerCase() + " GRN return requests.");
+ return false;
+ }
+
+ return true;
+ }
+
}
diff --git a/src/main/java/com/divudi/bean/pharmacy/IssueReturnController.java b/src/main/java/com/divudi/bean/pharmacy/IssueReturnController.java
index 7beaffc8ad0..079c2a89577 100644
--- a/src/main/java/com/divudi/bean/pharmacy/IssueReturnController.java
+++ b/src/main/java/com/divudi/bean/pharmacy/IssueReturnController.java
@@ -6,6 +6,7 @@
import com.divudi.bean.common.PriceMatrixController;
import com.divudi.bean.common.SessionController;
+import com.divudi.bean.common.WebUserController;
import com.divudi.bean.common.ConfigOptionApplicationController;
import com.divudi.bean.common.ConfigOptionController;
import com.divudi.bean.common.PageMetadataRegistry;
@@ -27,6 +28,8 @@
import com.divudi.core.entity.BillItem;
import com.divudi.core.entity.BillItemFinanceDetails;
import com.divudi.core.entity.Department;
+import com.divudi.core.entity.PatientEncounter;
+import com.divudi.core.entity.inward.RoomCategory;
import com.divudi.core.entity.PriceMatrix;
import com.divudi.core.entity.RefundBill;
import com.divudi.core.entity.pharmacy.Ampp;
@@ -44,6 +47,8 @@
import java.util.Calendar;
import java.util.Date;
import java.util.List;
+import java.util.logging.Level;
+import java.util.logging.Logger;
import javax.annotation.PostConstruct;
import javax.ejb.EJB;
import javax.enterprise.context.SessionScoped;
@@ -58,6 +63,8 @@
@SessionScoped
public class IssueReturnController implements Serializable {
+ private static final Logger LOGGER = Logger.getLogger(IssueReturnController.class.getName());
+
///////
@EJB
private PharmaceuticalBillItemFacade pharmaceuticalBillItemFacade;
@@ -80,6 +87,8 @@ public class IssueReturnController implements Serializable {
@Inject
private SessionController sessionController;
@Inject
+ private WebUserController webUserController;
+ @Inject
private ConfigOptionApplicationController configOptionApplicationController;
@Inject
private ConfigOptionController configOptionController;
@@ -249,7 +258,49 @@ private boolean disposalReturnAlreadyProcessed() {
return false;
}
+ /**
+ * Authorization helper method to check Pharmacy Disposal Return
+ * privileges and audit denied access
+ *
+ * @param action The action being attempted (SAVE, FINALIZE, APPROVE)
+ * @param requiredPrivilege The specific privilege required
+ * @return true if authorized, false if not
+ */
+ private boolean isAuthorized(String action, String requiredPrivilege) {
+ if (webUserController == null || sessionController == null) {
+ LOGGER.log(Level.SEVERE, "Authorization failed - missing controllers: action={0}, userId=null",
+ new Object[]{action});
+ return false;
+ }
+
+ if (!webUserController.hasPrivilege(requiredPrivilege)) {
+ Long userId = sessionController.getLoggedUser() != null ? sessionController.getLoggedUser().getId() : null;
+
+ LOGGER.log(Level.WARNING, "SECURITY: Unauthorized Pharmacy Disposal Return access attempt - action={0}, userId={1}, requiredPrivilege={2}",
+ new Object[]{action, userId, requiredPrivilege});
+
+ JsfUtil.addErrorMessage("You don't have permission to " + action.toLowerCase() + " this disposal return.");
+ return false;
+ }
+
+ return true;
+ }
+
public void saveDisposalIssueReturnBill() {
+ if (!isAuthorized("SAVE", "CreateDisposalReturn")) {
+ return;
+ }
+ doSaveDisposalIssueReturnBill();
+ }
+
+ /**
+ * Unguarded core of {@link #saveDisposalIssueReturnBill()}. Called
+ * directly (bypassing the CreateDisposalReturn check) by
+ * {@link #finalizeDisposalIssueReturnBill()}, which auto-saves a
+ * not-yet-persisted draft as part of a Finalize action already
+ * authorized under FinalizeDisposalReturn.
+ */
+ private void doSaveDisposalIssueReturnBill() {
if (disposalReturnAlreadyProcessed()) {
return;
}
@@ -260,6 +311,9 @@ public void saveDisposalIssueReturnBill() {
}
public void finalizeDisposalIssueReturnBill() {
+ if (!isAuthorized("FINALIZE", "FinalizeDisposalReturn")) {
+ return;
+ }
if (disposalReturnAlreadyProcessed()) {
return;
}
@@ -274,7 +328,7 @@ public void finalizeDisposalIssueReturnBill() {
return;
}
- saveDisposalIssueReturnBill();
+ doSaveDisposalIssueReturnBill();
// Reload from DB so we don't trigger orphan-removal on billItems
returnBill = billService.reloadBill(getReturnBill());
if (returnBill != null) {
@@ -297,6 +351,9 @@ public void finalizeDisposalIssueReturnBill() {
}
public void settleDisposalIssueReturnBill() {
+ if (!isAuthorized("APPROVE", "ApproveDisposalReturn")) {
+ return;
+ }
// Re-settling an already-settled bill (double-click, stale screen, stale
// list row) would write a second set of items and stock movements and
// then clobber the bill header (#21266 RC5).
@@ -836,11 +893,28 @@ private void saveBillComponents() {
}
}
+ /**
+ * The room category of the patient's current room, or null when the patient
+ * is not in a room (or the room has no facility charge / category). Drives the
+ * room-category dimension of the inward pharmacy-margin matrix (issue #21981);
+ * null means "wildcard row only", preserving legacy behaviour.
+ */
+ private RoomCategory resolveCurrentRoomCategory(PatientEncounter encounter) {
+ if (encounter == null
+ || encounter.getCurrentPatientRoom() == null
+ || encounter.getCurrentPatientRoom().getRoomFacilityCharge() == null) {
+ return null;
+ }
+ return encounter.getCurrentPatientRoom().getRoomFacilityCharge().getRoomCategory();
+ }
+
public void updateMargin(BillItem bi, Department matrixDepartment, PaymentMethod paymentMethod) {
double rate = Math.abs(bi.getRate());
double margin = 0;
- PriceMatrix priceMatrix = getPriceMatrixController().fetchInwardMargin(bi, rate, matrixDepartment, paymentMethod);
+ PatientEncounter encounter = bi.getBill() != null ? bi.getBill().getPatientEncounter() : null;
+ PriceMatrix priceMatrix = getPriceMatrixController().fetchInwardMargin(bi, rate, matrixDepartment, paymentMethod, null,
+ encounter != null ? encounter.getAdmissionType() : null, resolveCurrentRoomCategory(encounter));
if (priceMatrix != null) {
margin = ((bi.getGrossValue() * priceMatrix.getMargin()) / 100);
diff --git a/src/main/java/com/divudi/bean/pharmacy/PharmacyBillSearch.java b/src/main/java/com/divudi/bean/pharmacy/PharmacyBillSearch.java
index cb5dafeeda1..952f6090588 100644
--- a/src/main/java/com/divudi/bean/pharmacy/PharmacyBillSearch.java
+++ b/src/main/java/com/divudi/bean/pharmacy/PharmacyBillSearch.java
@@ -35,6 +35,8 @@
import com.divudi.core.entity.BillItem;
import com.divudi.core.entity.CancelledBill;
import com.divudi.core.entity.Department;
+import com.divudi.core.entity.PatientEncounter;
+import com.divudi.core.entity.inward.RoomCategory;
import com.divudi.core.entity.Payment;
import com.divudi.core.entity.PaymentScheme;
import com.divudi.core.entity.PriceMatrix;
@@ -76,6 +78,8 @@
import java.util.List;
import java.util.Map;
import java.util.Objects;
+import java.util.logging.Level;
+import java.util.logging.Logger;
import javax.ejb.EJB;
import javax.enterprise.context.SessionScoped;
import javax.inject.Inject;
@@ -98,6 +102,8 @@
@SessionScoped
public class PharmacyBillSearch implements Serializable {
+ private static final Logger LOGGER = Logger.getLogger(PharmacyBillSearch.class.getName());
+
//
@EJB
private StaffService staffBean;
@@ -1128,15 +1134,32 @@ public void setPriceMatrixController(PriceMatrixController priceMatrixController
this.priceMatrixController = priceMatrixController;
}
+ /**
+ * The room category of the patient's current room, or null when the patient
+ * is not in a room (or the room has no facility charge / category). Drives the
+ * room-category dimension of the inward pharmacy-margin matrix (issue #21981);
+ * null means "wildcard row only", preserving legacy behaviour.
+ */
+ private RoomCategory resolveCurrentRoomCategory(PatientEncounter encounter) {
+ if (encounter == null
+ || encounter.getCurrentPatientRoom() == null
+ || encounter.getCurrentPatientRoom().getRoomFacilityCharge() == null) {
+ return null;
+ }
+ return encounter.getCurrentPatientRoom().getRoomFacilityCharge().getRoomCategory();
+ }
+
public void updateMargin(List billItems, Bill bill, Department matrixDepartment, PaymentMethod paymentMethod) {
double total = 0;
double netTotal = 0;
+ PatientEncounter encounter = bill != null ? bill.getPatientEncounter() : null;
for (BillItem bi : billItems) {
double rate = Math.abs(bi.getRate());
double margin = 0;
- PriceMatrix priceMatrix = getPriceMatrixController().fetchInwardMargin(bi, rate, matrixDepartment, paymentMethod);
+ PriceMatrix priceMatrix = getPriceMatrixController().fetchInwardMargin(bi, rate, matrixDepartment, paymentMethod, null,
+ encounter != null ? encounter.getAdmissionType() : null, resolveCurrentRoomCategory(encounter));
if (priceMatrix != null) {
margin = ((bi.getGrossValue() * priceMatrix.getMargin()) / 100);
@@ -1291,7 +1314,39 @@ private boolean errorsPresentOnPoBillCancellation() {
return false;
}
+ /**
+ * Authorization helper shared by the PO / transfer-issue / GRN
+ * cancellation actions: checks the privilege and audits denied access.
+ *
+ * @param action The action being attempted (e.g. CANCEL, PHARMACY_GRN_CANCEL)
+ * @param requiredPrivilege The specific privilege required
+ * @return true if authorized, false if not
+ */
+ private boolean isAuthorized(String action, String requiredPrivilege) {
+ if (webUserController == null || sessionController == null) {
+ LOGGER.log(Level.SEVERE, "Authorization failed - missing controllers: action={0}, userId=null, billId={1}",
+ new Object[]{action, bill != null ? bill.getId() : "null"});
+ return false;
+ }
+
+ if (!webUserController.hasPrivilege(requiredPrivilege)) {
+ Long userId = sessionController.getLoggedUser() != null ? sessionController.getLoggedUser().getId() : null;
+ Long billId = bill != null ? bill.getId() : null;
+
+ LOGGER.log(Level.WARNING, "SECURITY: Unauthorized access attempt - action={0}, userId={1}, billId={2}, requiredPrivilege={3}",
+ new Object[]{action, userId, billId, requiredPrivilege});
+
+ JsfUtil.addErrorMessage("You don't have permission to " + action.toLowerCase().replace('_', ' ') + ".");
+ return false;
+ }
+
+ return true;
+ }
+
public String cancelPoBill() {
+ if (!isAuthorized("CANCEL", "PharmacyOrderCancellation")) {
+ return "";
+ }
if (getSelectedBills() == null || getSelectedBills().isEmpty()) {
JsfUtil.addErrorMessage("Please select Bills");
return "";
@@ -3325,6 +3380,9 @@ public void pharmacyReturnCashCancel() {
}
public void pharmacyPoCancel() {
+ if (!isAuthorized("CANCEL", "PharmacyOrderCancellation")) {
+ return;
+ }
if (getBill() != null && getBill().getId() != null && getBill().getId() != 0) {
if (pharmacyErrorCheck()) {
return;
@@ -3360,6 +3418,9 @@ public void pharmacyPoCancel() {
}
public void pharmacyPoRequestCancel() {
+ if (!isAuthorized("CANCEL", "PharmacyOrderCancellation")) {
+ return;
+ }
if (getBill() != null && getBill().getId() != null && getBill().getId() != 0) {
if (getBill().getReferenceBill() != null && !getBill().getReferenceBill().isCancelled()) {
JsfUtil.addErrorMessage("Sorry You cant Cancell Approved Bill");
@@ -3455,6 +3516,9 @@ private boolean checkBillItemStock() {
}
public void pharmacyGrnCancel() {
+ if (!isAuthorized("PHARMACY_GRN_CANCEL", "PharmacyGrnCancel")) {
+ return;
+ }
if (getBill() != null && getBill().getId() != null && getBill().getId() != 0) {
if (pharmacyErrorCheck()) {
return;
@@ -3550,6 +3614,9 @@ public void pharmacyGrnCancel() {
}
public void pharmacyTransferIssueCancel() {
+ if (!isAuthorized("CANCEL", "PharmacyTransferIssueCancel")) {
+ return;
+ }
if (getBill() != null && getBill().getId() != null && getBill().getId() != 0) {
if (pharmacyErrorCheck()) {
return;
@@ -3591,6 +3658,9 @@ public void pharmacyTransferIssueCancel() {
}
public void pharmacyTransferIssueInpatientCancel() {
+ if (!isAuthorized("CANCEL", "PharmacyTransferIssueCancel")) {
+ return;
+ }
if (getBill() != null && getBill().getId() != null && getBill().getId() != 0) {
if (pharmacyErrorCheck()) {
return;
@@ -3760,6 +3830,9 @@ public void pharmacyPurchaseCancel() {
}
public void pharmacyGrnReturnCancel() {
+ if (!isAuthorized("PHARMACY_GRN_RETURN_CANCEL", "PharmacyGrnReturnCancel")) {
+ return;
+ }
if (getBill() != null && getBill().getId() != null && getBill().getId() != 0) {
if (pharmacyErrorCheck()) {
return;
diff --git a/src/main/java/com/divudi/bean/pharmacy/PharmacyIssueController.java b/src/main/java/com/divudi/bean/pharmacy/PharmacyIssueController.java
index 1db5c758529..9f281f36005 100644
--- a/src/main/java/com/divudi/bean/pharmacy/PharmacyIssueController.java
+++ b/src/main/java/com/divudi/bean/pharmacy/PharmacyIssueController.java
@@ -7,6 +7,7 @@
import com.divudi.bean.common.BillBeanController;
import com.divudi.bean.common.SessionController;
+import com.divudi.bean.common.WebUserController;
import com.divudi.bean.common.ConfigOptionApplicationController;
import com.divudi.bean.common.ConfigOptionController;
import com.divudi.bean.common.PageMetadataRegistry;
@@ -69,6 +70,8 @@
import java.util.Optional;
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.ConcurrentHashMap;
+import java.util.logging.Level;
+import java.util.logging.Logger;
import javax.persistence.TemporalType;
import javax.annotation.PostConstruct;
import javax.ejb.EJB;
@@ -99,6 +102,11 @@
@SessionScoped
public class PharmacyIssueController implements Serializable {
+ private static final Logger LOGGER = Logger.getLogger(PharmacyIssueController.class.getName());
+
+ @Inject
+ private WebUserController webUserController;
+
String errorMessage = null;
private static final ConcurrentHashMap lockMap = new ConcurrentHashMap<>();
@@ -1018,7 +1026,49 @@ public void settleDisposalIssueBill() {
// Save → Finalize → Approve workflow for Disposal Issue
// ──────────────────────────────────────────────────────────────────────────
+ /**
+ * Authorization helper method to check Pharmacy Disposal Issue
+ * privileges and audit denied access
+ *
+ * @param action The action being attempted (SAVE, FINALIZE, APPROVE, CANCEL)
+ * @param requiredPrivilege The specific privilege required
+ * @return true if authorized, false if not
+ */
+ private boolean isAuthorized(String action, String requiredPrivilege) {
+ if (webUserController == null || sessionController == null) {
+ LOGGER.log(Level.SEVERE, "Authorization failed - missing controllers: action={0}, userId=null",
+ new Object[]{action});
+ return false;
+ }
+
+ if (!webUserController.hasPrivilege(requiredPrivilege)) {
+ Long userId = sessionController.getLoggedUser() != null ? sessionController.getLoggedUser().getId() : null;
+
+ LOGGER.log(Level.WARNING, "SECURITY: Unauthorized Pharmacy Disposal Issue access attempt - action={0}, userId={1}, requiredPrivilege={2}",
+ new Object[]{action, userId, requiredPrivilege});
+
+ JsfUtil.addErrorMessage("You don't have permission to " + action.toLowerCase() + " this disposal issue.");
+ return false;
+ }
+
+ return true;
+ }
+
public String saveDisposalIssueDraft() {
+ if (!isAuthorized("SAVE", "PharmacyDisposalIssue")) {
+ return null;
+ }
+ return doSaveDisposalIssueDraft();
+ }
+
+ /**
+ * Unguarded core of {@link #saveDisposalIssueDraft()}. Called directly
+ * (bypassing the PharmacyDisposalIssue check) by
+ * {@link #finalizeCurrentDraft()}, which auto-saves a not-yet-persisted
+ * draft as part of a Finalize action that has already been authorized
+ * under PharmacyDisposalIssueFinalize.
+ */
+ private String doSaveDisposalIssueDraft() {
editingQty = null;
errorMessage = null;
if (toDepartment == null) {
@@ -1092,6 +1142,9 @@ public String saveDisposalIssueDraft() {
}
public String finalizeCurrentDraft() {
+ if (!isAuthorized("FINALIZE", "PharmacyDisposalIssueFinalize")) {
+ return null;
+ }
if (toDepartment == null) {
JsfUtil.addErrorMessage("Please select a Department first.");
return null;
@@ -1113,7 +1166,7 @@ public String finalizeCurrentDraft() {
if (errorCheckForSaleBill()) {
return null;
}
- saveDisposalIssueDraft();
+ doSaveDisposalIssueDraft();
if (getPreBill().getId() == null) {
JsfUtil.addErrorMessage("Could not save draft. Cannot finalize.");
return null;
@@ -1277,6 +1330,9 @@ public String finalizeDisposalIssue(Long billId) {
}
public String approveDisposalIssue(Long billId) {
+ if (!isAuthorized("APPROVE", "PharmacyDisposalIssueApprove")) {
+ return null;
+ }
if (billId == null) {
JsfUtil.addErrorMessage("No draft selected.");
return null;
@@ -1414,6 +1470,9 @@ public String approveDisposalIssue(Long billId) {
}
public String cancelPendingDisposalIssue(Long billId) {
+ if (!isAuthorized("CANCEL", "PharmacyDisposalIssueCancel")) {
+ return null;
+ }
if (billId == null) {
JsfUtil.addErrorMessage("No draft selected.");
return null;
@@ -1431,6 +1490,9 @@ public String cancelPendingDisposalIssue(Long billId) {
}
public String cancelFinalizedDisposalIssue(Long billId) {
+ if (!isAuthorized("CANCEL", "PharmacyDisposalIssueCancel")) {
+ return null;
+ }
if (billId == null) {
JsfUtil.addErrorMessage("No draft selected.");
return null;
diff --git a/src/main/java/com/divudi/bean/pharmacy/PharmacyRequestForBhtController.java b/src/main/java/com/divudi/bean/pharmacy/PharmacyRequestForBhtController.java
index 464dbc587a1..b438d8c939c 100644
--- a/src/main/java/com/divudi/bean/pharmacy/PharmacyRequestForBhtController.java
+++ b/src/main/java/com/divudi/bean/pharmacy/PharmacyRequestForBhtController.java
@@ -37,6 +37,7 @@
import com.divudi.core.entity.Item;
import com.divudi.core.entity.Patient;
import com.divudi.core.entity.PatientEncounter;
+import com.divudi.core.entity.inward.RoomCategory;
import com.divudi.core.entity.PreBill;
import com.divudi.core.entity.PriceMatrix;
import com.divudi.core.entity.pharmacy.Amp;
@@ -1184,16 +1185,33 @@ private void settleBhtIssueRequestAccept(BillType btp, Department matrixDepartme
}
+ /**
+ * The room category of the patient's current room, or null when the patient
+ * is not in a room (or the room has no facility charge / category). Drives the
+ * room-category dimension of the inward pharmacy-margin matrix (issue #21981);
+ * null means "wildcard row only", preserving legacy behaviour.
+ */
+ private RoomCategory resolveCurrentRoomCategory(PatientEncounter encounter) {
+ if (encounter == null
+ || encounter.getCurrentPatientRoom() == null
+ || encounter.getCurrentPatientRoom().getRoomFacilityCharge() == null) {
+ return null;
+ }
+ return encounter.getCurrentPatientRoom().getRoomFacilityCharge().getRoomCategory();
+ }
+
public void updateMargin(List billItems, Bill bill, Department matrixDepartment, PaymentMethod paymentMethod) {
double total = 0;
double netTotal = 0;
double marginTotal = 0;
+ PatientEncounter encounter = bill != null ? bill.getPatientEncounter() : null;
for (BillItem bi : billItems) {
double rate = Math.abs(bi.getRate());
double margin = 0;
- PriceMatrix priceMatrix = getPriceMatrixController().fetchInwardMargin(bi, rate, matrixDepartment, paymentMethod);
+ PriceMatrix priceMatrix = getPriceMatrixController().fetchInwardMargin(bi, rate, matrixDepartment, paymentMethod, null,
+ encounter != null ? encounter.getAdmissionType() : null, resolveCurrentRoomCategory(encounter));
if (priceMatrix != null) {
margin = ((bi.getGrossValue() * priceMatrix.getMargin()) / 100);
diff --git a/src/main/java/com/divudi/bean/pharmacy/PharmacySaleBhtController.java b/src/main/java/com/divudi/bean/pharmacy/PharmacySaleBhtController.java
index 2d175091d1b..697b772e69a 100644
--- a/src/main/java/com/divudi/bean/pharmacy/PharmacySaleBhtController.java
+++ b/src/main/java/com/divudi/bean/pharmacy/PharmacySaleBhtController.java
@@ -43,6 +43,7 @@
import com.divudi.core.entity.Item;
import com.divudi.core.entity.Patient;
import com.divudi.core.entity.PatientEncounter;
+import com.divudi.core.entity.inward.RoomCategory;
import com.divudi.core.entity.PreBill;
import com.divudi.core.entity.Staff;
import com.divudi.core.entity.PriceMatrix;
@@ -2085,10 +2086,27 @@ private boolean settleBhtIssueRequestAccept(BillType btp, BillTypeAtomic bta, De
return true;
}
+ /**
+ * The room category of the patient's current room, or null when the patient
+ * is not in a room (or the room has no facility charge / category). Drives the
+ * room-category dimension of the inward pharmacy-margin matrix (issue #21981);
+ * null means "wildcard row only", preserving legacy behaviour.
+ */
+ private RoomCategory resolveCurrentRoomCategory(PatientEncounter encounter) {
+ if (encounter == null
+ || encounter.getCurrentPatientRoom() == null
+ || encounter.getCurrentPatientRoom().getRoomFacilityCharge() == null) {
+ return null;
+ }
+ return encounter.getCurrentPatientRoom().getRoomFacilityCharge().getRoomCategory();
+ }
+
public void updateMargin(BillItem bi, Department matrixDepartment, PaymentMethod paymentMethod) {
double rate = Math.abs(bi.getRate());
double margin;
- PriceMatrix priceMatrix = getPriceMatrixController().fetchInwardMargin(bi, rate, matrixDepartment, paymentMethod);
+ PatientEncounter encounter = bi.getBill() != null ? bi.getBill().getPatientEncounter() : null;
+ PriceMatrix priceMatrix = getPriceMatrixController().fetchInwardMargin(bi, rate, matrixDepartment, paymentMethod, null,
+ encounter != null ? encounter.getAdmissionType() : null, resolveCurrentRoomCategory(encounter));
if (priceMatrix != null) {
margin = ((bi.getGrossValue() * priceMatrix.getMargin()) / 100);
bi.setMarginRate((bi.getRate() * (priceMatrix.getMargin() + 100)) / 100);
@@ -2107,12 +2125,14 @@ public void updateMargin(List billItems, Bill bill, Department matrixD
double total = 0;
double netTotal = 0;
double marginTotal = 0;
+ PatientEncounter encounter = bill != null ? bill.getPatientEncounter() : null;
for (BillItem bi : billItems) {
double rate = Math.abs(bi.getRate());
double margin;
- PriceMatrix priceMatrix = getPriceMatrixController().fetchInwardMargin(bi, rate, matrixDepartment, paymentMethod);
+ PriceMatrix priceMatrix = getPriceMatrixController().fetchInwardMargin(bi, rate, matrixDepartment, paymentMethod, null,
+ encounter != null ? encounter.getAdmissionType() : null, resolveCurrentRoomCategory(encounter));
if (priceMatrix != null) {
margin = ((bi.getGrossValue() * priceMatrix.getMargin()) / 100);
@@ -2599,7 +2619,10 @@ public void calculateRates(BillItem bi) {
bi,
estimatedValueBeforeAddingMarginToCalculateMatrix,
matrixDept,
- paymentMethod
+ paymentMethod,
+ null,
+ getPatientEncounter() != null ? getPatientEncounter().getAdmissionType() : null,
+ resolveCurrentRoomCategory(getPatientEncounter())
);
}
@@ -2645,7 +2668,8 @@ public void calculateRates(BillItem bi, double retailRate) {
PriceMatrix priceMatrix = null;
// Discharge medicines are issued without the inward price matrix (no service charge).
if (!dischargeIssueMode && bi.getItem() != null) {
- priceMatrix = getPriceMatrixController().fetchInwardMargin(bi, estimatedValue, matrixDept, paymentMethod);
+ priceMatrix = getPriceMatrixController().fetchInwardMargin(bi, estimatedValue, matrixDept, paymentMethod, null,
+ getPatientEncounter() != null ? getPatientEncounter().getAdmissionType() : null, resolveCurrentRoomCategory(getPatientEncounter()));
}
double marginPercentage = priceMatrix != null ? priceMatrix.getMargin() / 100 : 0.0;
diff --git a/src/main/java/com/divudi/bean/pharmacy/PurchaseOrderController.java b/src/main/java/com/divudi/bean/pharmacy/PurchaseOrderController.java
index 9b5ef4487f5..4ab983ad1f0 100644
--- a/src/main/java/com/divudi/bean/pharmacy/PurchaseOrderController.java
+++ b/src/main/java/com/divudi/bean/pharmacy/PurchaseOrderController.java
@@ -6,6 +6,7 @@
import com.divudi.bean.common.NotificationController;
import com.divudi.bean.common.SessionController;
+import com.divudi.bean.common.WebUserController;
import com.divudi.bean.common.ConfigOptionApplicationController;
import com.divudi.bean.common.ConfigOptionController;
import com.divudi.core.util.JsfUtil;
@@ -36,6 +37,8 @@
import java.util.Calendar;
import java.util.Date;
import java.util.List;
+import java.util.logging.Level;
+import java.util.logging.Logger;
import javax.ejb.EJB;
import javax.enterprise.context.SessionScoped;
import javax.inject.Inject;
@@ -52,8 +55,12 @@
@SessionScoped
public class PurchaseOrderController implements Serializable {
+ private static final Logger LOGGER = Logger.getLogger(PurchaseOrderController.class.getName());
+
@Inject
private SessionController sessionController;
+ @Inject
+ private WebUserController webUserController;
@EJB
private BillFacade billFacade;
@EJB
@@ -171,7 +178,39 @@ public String navigateToPurchaseOrderApproval() {
return "/pharmacy/pharmacy_purhcase_order_approving?faces-redirect=true";
}
+ /**
+ * Authorization helper method to check Purchase Order Approval
+ * privileges and audit denied access
+ *
+ * @param action The action being attempted (APPROVE)
+ * @param requiredPrivilege The specific privilege required
+ * @return true if authorized, false if not
+ */
+ private boolean isAuthorized(String action, String requiredPrivilege) {
+ if (webUserController == null || sessionController == null) {
+ LOGGER.log(Level.SEVERE, "Authorization failed - missing controllers: action={0}, userId=null, billId={1}",
+ new Object[]{action, requestedBill != null ? requestedBill.getId() : "null"});
+ return false;
+ }
+
+ if (!webUserController.hasPrivilege(requiredPrivilege)) {
+ Long userId = sessionController.getLoggedUser() != null ? sessionController.getLoggedUser().getId() : null;
+ Long billId = requestedBill != null ? requestedBill.getId() : null;
+
+ LOGGER.log(Level.WARNING, "SECURITY: Unauthorized Purchase Order access attempt - action={0}, userId={1}, billId={2}, requiredPrivilege={3}",
+ new Object[]{action, userId, billId, requiredPrivilege});
+
+ JsfUtil.addErrorMessage("You don't have permission to " + action.toLowerCase() + " purchase orders.");
+ return false;
+ }
+
+ return true;
+ }
+
public String approve() {
+ if (!isAuthorized("APPROVE", "PurchaseOrdersApprovel")) {
+ return "";
+ }
// Check if the requested bill is already approved to prevent double approving
if (getRequestedBill() != null && getRequestedBill().getReferenceBill() != null) {
JsfUtil.addErrorMessage("This purchase order is already approved");
diff --git a/src/main/java/com/divudi/bean/pharmacy/PurchaseOrderRequestController.java b/src/main/java/com/divudi/bean/pharmacy/PurchaseOrderRequestController.java
index a7f8a87aca2..ac85e854971 100644
--- a/src/main/java/com/divudi/bean/pharmacy/PurchaseOrderRequestController.java
+++ b/src/main/java/com/divudi/bean/pharmacy/PurchaseOrderRequestController.java
@@ -11,6 +11,7 @@
import com.divudi.bean.common.NotificationController;
import com.divudi.bean.common.PageMetadataRegistry;
import com.divudi.bean.common.SessionController;
+import com.divudi.bean.common.WebUserController;
import com.divudi.core.data.BillType;
import com.divudi.core.data.DepartmentType;
@@ -94,6 +95,8 @@ public class PurchaseOrderRequestController implements Serializable {
@Inject
private SessionController sessionController;
@Inject
+ private WebUserController webUserController;
+ @Inject
ConfigOptionApplicationController configOptionApplicationController;
@Inject
EnumController enumController;
@@ -124,6 +127,9 @@ public class PurchaseOrderRequestController implements Serializable {
private String emailRecipient;
public void removeSelected() {
+ if (!isAuthorized("SAVE", "PurchaseOrderSave")) {
+ return;
+ }
if (selectedBillItems == null) {
return;
}
@@ -270,6 +276,9 @@ public void addItem() {
}
public void removeItem(BillItem bi) {
+ if (!isAuthorized("SAVE", "PurchaseOrderSave")) {
+ return;
+ }
if (currentBill == null || bi == null) {
return;
}
@@ -1041,7 +1050,39 @@ public void addAllSupplierItemsBelowRol() {
}
}
+ /**
+ * Authorization helper method to check Purchase Order privileges and
+ * audit denied access
+ *
+ * @param action The action being attempted (SAVE, FINALIZE)
+ * @param requiredPrivilege The specific privilege required
+ * @return true if authorized, false if not
+ */
+ private boolean isAuthorized(String action, String requiredPrivilege) {
+ if (webUserController == null || sessionController == null) {
+ LOGGER.log(Level.SEVERE, "Authorization failed - missing controllers: action={0}, userId=null, billId={1}",
+ new Object[]{action, currentBill != null ? currentBill.getId() : "null"});
+ return false;
+ }
+
+ if (!webUserController.hasPrivilege(requiredPrivilege)) {
+ Long userId = sessionController.getLoggedUser() != null ? sessionController.getLoggedUser().getId() : null;
+ Long billId = currentBill != null ? currentBill.getId() : null;
+
+ LOGGER.log(Level.WARNING, "SECURITY: Unauthorized Purchase Order access attempt - action={0}, userId={1}, billId={2}, requiredPrivilege={3}",
+ new Object[]{action, userId, billId, requiredPrivilege});
+
+ JsfUtil.addErrorMessage("You don't have permission to " + action.toLowerCase() + " purchase order requests.");
+ return false;
+ }
+
+ return true;
+ }
+
public void saveRequest() {
+ if (!isAuthorized("SAVE", "PurchaseOrderSave")) {
+ return;
+ }
boolean saved = saveRequestWithoutMessage();
if (saved) {
JsfUtil.addSuccessMessage("Request Saved");
@@ -1122,6 +1163,9 @@ public List- completeItemForSelectedDepartmentType(String query) {
}
public synchronized void finalizeRequest() {
+ if (!isAuthorized("FINALIZE", "PurchaseOrderFinalize")) {
+ return;
+ }
if (currentBill == null) {
JsfUtil.addErrorMessage("No Bill");
return;
diff --git a/src/main/java/com/divudi/bean/pharmacy/TransferIssueCancellationController.java b/src/main/java/com/divudi/bean/pharmacy/TransferIssueCancellationController.java
index b245d6c517f..9bc3b79bd80 100644
--- a/src/main/java/com/divudi/bean/pharmacy/TransferIssueCancellationController.java
+++ b/src/main/java/com/divudi/bean/pharmacy/TransferIssueCancellationController.java
@@ -6,6 +6,7 @@
import com.divudi.bean.common.BillController;
import com.divudi.bean.common.SessionController;
+import com.divudi.bean.common.WebUserController;
import com.divudi.core.util.JsfUtil;
import com.divudi.core.data.BillClassType;
import com.divudi.core.data.BillNumberSuffix;
@@ -31,6 +32,8 @@
import java.util.Calendar;
import java.util.Date;
import java.util.List;
+import java.util.logging.Level;
+import java.util.logging.Logger;
import javax.ejb.EJB;
import javax.enterprise.context.SessionScoped;
import javax.inject.Inject;
@@ -82,6 +85,7 @@
public class TransferIssueCancellationController implements Serializable {
private static final long serialVersionUID = 1L;
+ private static final Logger LOGGER = Logger.getLogger(TransferIssueCancellationController.class.getName());
// EJB Dependencies
@EJB
@@ -104,6 +108,8 @@ public class TransferIssueCancellationController implements Serializable {
private UserStockController userStockController;
@Inject
private PharmacyController pharmacyController;
+ @Inject
+ private WebUserController webUserController;
// Properties
private Bill originalBill;
@@ -532,6 +538,9 @@ private void createBillFinanceDetails() {
*/
@Transactional
public void confirmCancellation() {
+ if (!isAuthorized("CANCEL", "PharmacyTransferIssueCancel")) {
+ return;
+ }
try {
// Validation: Check cancellation reason
if (cancellationReason == null || cancellationReason.trim().isEmpty()) {
@@ -765,4 +774,33 @@ public BillController getBillController() {
public UserStockController getUserStockController() {
return userStockController;
}
+
+ /**
+ * Authorization helper method to check Transfer Issue Cancellation
+ * privileges and audit denied access.
+ *
+ * @param action The action being attempted (CANCEL)
+ * @param requiredPrivilege The specific privilege required
+ * @return true if authorized, false if not
+ */
+ private boolean isAuthorized(String action, String requiredPrivilege) {
+ if (webUserController == null || sessionController == null) {
+ LOGGER.log(Level.SEVERE, "Authorization failed - missing controllers: action={0}, userId=null, billId={1}",
+ new Object[]{action, originalBillId});
+ return false;
+ }
+
+ if (!webUserController.hasPrivilege(requiredPrivilege)) {
+ // Audit denied access attempt
+ Long userId = sessionController.getLoggedUser() != null ? sessionController.getLoggedUser().getId() : null;
+
+ LOGGER.log(Level.WARNING, "SECURITY: Unauthorized Transfer Issue Cancellation access attempt - action={0}, userId={1}, billId={2}, requiredPrivilege={3}",
+ new Object[]{action, userId, originalBillId, requiredPrivilege});
+
+ JsfUtil.addErrorMessage("You don't have permission to " + action.toLowerCase() + " transfer issue requests.");
+ return false;
+ }
+
+ return true;
+ }
}
diff --git a/src/main/java/com/divudi/bean/pharmacy/TransferIssueDirectController.java b/src/main/java/com/divudi/bean/pharmacy/TransferIssueDirectController.java
index ab28eef4391..0a2bce8b07b 100644
--- a/src/main/java/com/divudi/bean/pharmacy/TransferIssueDirectController.java
+++ b/src/main/java/com/divudi/bean/pharmacy/TransferIssueDirectController.java
@@ -6,6 +6,7 @@
import com.divudi.bean.common.NotificationController;
import com.divudi.bean.common.SessionController;
+import com.divudi.bean.common.WebUserController;
import com.divudi.core.util.JsfUtil;
import com.divudi.service.BillService;
import com.divudi.core.data.BillType;
@@ -42,6 +43,8 @@
import java.util.List;
import java.util.Map;
import java.util.Objects;
+import java.util.logging.Level;
+import java.util.logging.Logger;
import javax.ejb.EJB;
import javax.enterprise.context.SessionScoped;
import javax.inject.Inject;
@@ -56,6 +59,8 @@
@SessionScoped
public class TransferIssueDirectController implements Serializable {
+ private static final Logger LOGGER = Logger.getLogger(TransferIssueDirectController.class.getName());
+
@EJB
private BillFacade billFacade;
@EJB
@@ -77,6 +82,8 @@ public class TransferIssueDirectController implements Serializable {
@Inject
private SessionController sessionController;
@Inject
+ private WebUserController webUserController;
+ @Inject
private PharmacyController pharmacyController;
@Inject
private ConfigOptionApplicationController configOptionApplicationController;
@@ -225,6 +232,9 @@ private boolean isBatchAlreadyAdded(Stock stock) {
* Settles the direct issue transaction
*/
public synchronized void settleDirectIssue() {
+ if (!isAuthorized("SETTLE_DIRECT_ISSUE", "PharmacyDisbursementDirectIssue")) {
+ return;
+ }
if (issuedBill != null && issuedBill.getId() != null) {
JsfUtil.addErrorMessage("This bill has already been saved.");
return;
@@ -874,4 +884,34 @@ public void setBillItemValue(Double billItemValue) {
this.billItemValue = billItemValue;
}
+ /**
+ * Authorization helper method to check Pharmacy Direct Transfer Issue
+ * privileges and audit denied access
+ *
+ * @param action The action being attempted (e.g. SETTLE_DIRECT_ISSUE)
+ * @param requiredPrivilege The specific privilege required
+ * @return true if authorized, false if not
+ */
+ private boolean isAuthorized(String action, String requiredPrivilege) {
+ if (webUserController == null || sessionController == null) {
+ LOGGER.log(Level.SEVERE, "Authorization failed - missing controllers: action={0}, userId=null, billId={1}",
+ new Object[]{action, issuedBill != null ? issuedBill.getId() : "null"});
+ return false;
+ }
+
+ if (!webUserController.hasPrivilege(requiredPrivilege)) {
+ // Audit denied access attempt
+ Long userId = sessionController.getLoggedUser() != null ? sessionController.getLoggedUser().getId() : null;
+ Long billId = issuedBill != null ? issuedBill.getId() : null;
+
+ LOGGER.log(Level.WARNING, "SECURITY: Unauthorized Pharmacy Direct Transfer Issue access attempt - action={0}, userId={1}, billId={2}, requiredPrivilege={3}",
+ new Object[]{action, userId, billId, requiredPrivilege});
+
+ JsfUtil.addErrorMessage("You don't have permission to perform this direct transfer issue action.");
+ return false;
+ }
+
+ return true;
+ }
+
}
diff --git a/src/main/java/com/divudi/bean/pharmacy/TransferIssueForRequestsController.java b/src/main/java/com/divudi/bean/pharmacy/TransferIssueForRequestsController.java
index 1870fe82cdc..63e5a96f500 100644
--- a/src/main/java/com/divudi/bean/pharmacy/TransferIssueForRequestsController.java
+++ b/src/main/java/com/divudi/bean/pharmacy/TransferIssueForRequestsController.java
@@ -7,6 +7,7 @@
import com.divudi.bean.common.BillController;
import com.divudi.bean.common.PageMetadataRegistry;
import com.divudi.bean.common.SessionController;
+import com.divudi.bean.common.WebUserController;
import com.divudi.core.data.OptionScope;
import com.divudi.core.data.admin.ConfigOptionInfo;
import com.divudi.core.data.admin.PageMetadata;
@@ -49,6 +50,8 @@
import java.util.List;
import java.util.Map;
import java.util.Optional;
+import java.util.logging.Level;
+import java.util.logging.Logger;
import javax.annotation.PostConstruct;
import javax.ejb.EJB;
import javax.enterprise.context.SessionScoped;
@@ -66,6 +69,8 @@
@SessionScoped
public class TransferIssueForRequestsController implements Serializable {
+ private static final Logger LOGGER = Logger.getLogger(TransferIssueForRequestsController.class.getName());
+
@EJB
private BillFacade billFacade;
@EJB
@@ -89,6 +94,8 @@ public class TransferIssueForRequestsController implements Serializable {
@Inject
private SessionController sessionController;
@Inject
+ private WebUserController webUserController;
+ @Inject
private BillController billController;
@Inject
private ConfigOptionApplicationController configOptionApplicationController;
@@ -459,6 +466,9 @@ public String loadDraftIssueForEditing(Bill draft) {
* Saves the current items as a PHARMACY_ISSUE_PRE bill without moving stock.
*/
public void saveDraftIssue() {
+ if (!isAuthorized("SAVE_DRAFT_ISSUE", "PharmacyIssueForRequestSave")) {
+ return;
+ }
if (getIssuedBill().getToDepartment() == null) {
JsfUtil.addErrorMessage("Please select a department to issue to");
return;
@@ -557,6 +567,9 @@ public void saveDraftIssue() {
* Finalizes the current PHARMACY_ISSUE_PRE draft (sets completed=true).
*/
public void finalizeDraftIssue() {
+ if (!isAuthorized("FINALIZE_DRAFT_ISSUE", "PharmacyIssueForRequestFinalize")) {
+ return;
+ }
if (getIssuedBill() == null || getIssuedBill().getId() == null) {
JsfUtil.addErrorMessage("No draft to finalize. Please save first.");
return;
@@ -582,6 +595,9 @@ public void finalizeDraftIssue() {
* Approves the PHARMACY_ISSUE_PRE draft: moves stock and converts to PHARMACY_ISSUE.
*/
public synchronized void approveDraftIssue() {
+ if (!isAuthorized("APPROVE_DRAFT_ISSUE", "PharmacyIssueForRequestApprove")) {
+ return;
+ }
if (getIssuedBill() == null || getIssuedBill().getId() == null) {
JsfUtil.addErrorMessage("No finalized draft to approve.");
return;
@@ -741,6 +757,9 @@ public synchronized void approveDraftIssue() {
* Cancels a saved/finalized PHARMACY_ISSUE_PRE draft (retires it before approval).
*/
public void cancelPendingIssue() {
+ if (!isAuthorized("CANCEL_PENDING_ISSUE", "PharmacyTransferIssueCancel")) {
+ return;
+ }
if (getIssuedBill() == null || getIssuedBill().getId() == null) {
JsfUtil.addErrorMessage("No draft to cancel.");
return;
@@ -771,6 +790,9 @@ public void cancelPendingIssue() {
}
public synchronized void settle() {
+ if (!isAuthorized("SETTLE_ISSUE", "PharmacyIssueForRequestFinalize")) {
+ return;
+ }
if (getIssuedBill() != null && getIssuedBill().getId() != null) {
JsfUtil.addErrorMessage("This bill has already been saved.");
return;
@@ -1922,4 +1944,34 @@ private void registerPageMetadata() {
pageMetadataRegistry.registerPage(issuedListMetadata);
}
+ /**
+ * Authorization helper method to check Pharmacy Transfer Issue For
+ * Requests privileges and audit denied access
+ *
+ * @param action The action being attempted (e.g. SAVE_DRAFT_ISSUE, FINALIZE_DRAFT_ISSUE, APPROVE_DRAFT_ISSUE)
+ * @param requiredPrivilege The specific privilege required
+ * @return true if authorized, false if not
+ */
+ private boolean isAuthorized(String action, String requiredPrivilege) {
+ if (webUserController == null || sessionController == null) {
+ LOGGER.log(Level.SEVERE, "Authorization failed - missing controllers: action={0}, userId=null, billId={1}",
+ new Object[]{action, issuedBill != null ? issuedBill.getId() : "null"});
+ return false;
+ }
+
+ if (!webUserController.hasPrivilege(requiredPrivilege)) {
+ // Audit denied access attempt
+ Long userId = sessionController.getLoggedUser() != null ? sessionController.getLoggedUser().getId() : null;
+ Long billId = issuedBill != null ? issuedBill.getId() : null;
+
+ LOGGER.log(Level.WARNING, "SECURITY: Unauthorized Pharmacy Transfer Issue For Requests access attempt - action={0}, userId={1}, billId={2}, requiredPrivilege={3}",
+ new Object[]{action, userId, billId, requiredPrivilege});
+
+ JsfUtil.addErrorMessage("You don't have permission to " + action.toLowerCase() + " transfer issues.");
+ return false;
+ }
+
+ return true;
+ }
+
}
diff --git a/src/main/java/com/divudi/bean/pharmacy/TransferIssueNativeSqlController.java b/src/main/java/com/divudi/bean/pharmacy/TransferIssueNativeSqlController.java
index db4571a3ab3..ddca4cf6d8c 100644
--- a/src/main/java/com/divudi/bean/pharmacy/TransferIssueNativeSqlController.java
+++ b/src/main/java/com/divudi/bean/pharmacy/TransferIssueNativeSqlController.java
@@ -8,6 +8,7 @@
import com.divudi.bean.common.ConfigOptionApplicationController;
import com.divudi.bean.common.PageMetadataRegistry;
import com.divudi.bean.common.SessionController;
+import com.divudi.bean.common.WebUserController;
import com.divudi.core.data.BillClassType;
import com.divudi.core.data.BillNumberSuffix;
import com.divudi.core.data.BillType;
@@ -31,6 +32,8 @@
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
+import java.util.logging.Level;
+import java.util.logging.Logger;
import javax.annotation.PostConstruct;
import javax.ejb.EJB;
import javax.enterprise.context.SessionScoped;
@@ -55,6 +58,8 @@ public class TransferIssueNativeSqlController implements Serializable {
private static final long serialVersionUID = 1L;
+ private static final Logger LOGGER = Logger.getLogger(TransferIssueNativeSqlController.class.getName());
+
// ---- State ----
private Bill requestedBill;
private Long requestedBillId;
@@ -68,6 +73,9 @@ public class TransferIssueNativeSqlController implements Serializable {
@Inject
private SessionController sessionController;
+ @Inject
+ private WebUserController webUserController;
+
@Inject
private ConfigOptionApplicationController configOptionApplicationController;
@@ -166,6 +174,9 @@ private boolean hasPendingNativeIssueForDepartment() {
}
public void saveDraftNativeIssue() {
+ if (!isAuthorized("SAVE_DRAFT_NATIVE_ISSUE", "PharmacyIssueForRequestSave")) {
+ return;
+ }
if (issueItems == null || issueItems.isEmpty()) {
JsfUtil.addErrorMessage("No items to save. Please check the request.");
return;
@@ -231,6 +242,9 @@ public String loadDraftNativeIssueForEditing(Bill draft) {
}
public void finalizeDraftNativeIssue() {
+ if (!isAuthorized("FINALIZE_DRAFT_NATIVE_ISSUE", "PharmacyIssueForRequestFinalize")) {
+ return;
+ }
if (issuedBill == null || issuedBill.getId() == null) {
JsfUtil.addErrorMessage("No draft to finalize.");
return;
@@ -253,6 +267,9 @@ public void finalizeDraftNativeIssue() {
}
public synchronized String approveDraftNativeIssue() {
+ if (!isAuthorized("APPROVE_DRAFT_NATIVE_ISSUE", "PharmacyIssueForRequestApprove")) {
+ return null;
+ }
if (issueItems == null || issueItems.isEmpty()) {
JsfUtil.addErrorMessage("No items to issue. Reload the draft and check quantities.");
return null;
@@ -308,6 +325,9 @@ public synchronized String approveDraftNativeIssue() {
}
public String cancelPendingNativeIssue() {
+ if (!isAuthorized("CANCEL_PENDING_NATIVE_ISSUE", "PharmacyTransferIssueCancel")) {
+ return "";
+ }
if (issuedBill == null || issuedBill.getId() == null) {
JsfUtil.addErrorMessage("No draft to cancel.");
return null;
@@ -353,6 +373,9 @@ public String viewByBillId(Long billId) {
* Mirrors TransferIssueForRequestsController.settle().
*/
public void settle() {
+ if (!isAuthorized("SETTLE_NATIVE_ISSUE", "PharmacyIssueForRequestFinalize")) {
+ return;
+ }
if (issueItems == null || issueItems.isEmpty()) {
JsfUtil.addErrorMessage("Nothing to issue. Please check quantities.");
return;
@@ -756,4 +779,34 @@ public boolean isDraftMode() {
public void setDraftMode(boolean draftMode) {
this.draftMode = draftMode;
}
+
+ /**
+ * Authorization helper method to check Pharmacy Transfer Issue (native
+ * SQL / fast issue) privileges and audit denied access
+ *
+ * @param action The action being attempted (e.g. SAVE_DRAFT_NATIVE_ISSUE, FINALIZE_DRAFT_NATIVE_ISSUE)
+ * @param requiredPrivilege The specific privilege required
+ * @return true if authorized, false if not
+ */
+ private boolean isAuthorized(String action, String requiredPrivilege) {
+ if (webUserController == null || sessionController == null) {
+ LOGGER.log(Level.SEVERE, "Authorization failed - missing controllers: action={0}, userId=null, billId={1}",
+ new Object[]{action, issuedBill != null ? issuedBill.getId() : "null"});
+ return false;
+ }
+
+ if (!webUserController.hasPrivilege(requiredPrivilege)) {
+ // Audit denied access attempt
+ Long userId = sessionController.getLoggedUser() != null ? sessionController.getLoggedUser().getId() : null;
+ Long billId = issuedBill != null ? issuedBill.getId() : null;
+
+ LOGGER.log(Level.WARNING, "SECURITY: Unauthorized Pharmacy Transfer Issue (native) access attempt - action={0}, userId={1}, billId={2}, requiredPrivilege={3}",
+ new Object[]{action, userId, billId, requiredPrivilege});
+
+ JsfUtil.addErrorMessage("You don't have permission to " + action.toLowerCase() + " fast transfer issues.");
+ return false;
+ }
+
+ return true;
+ }
}
diff --git a/src/main/java/com/divudi/bean/pharmacy/TransferReceiveCancellationController.java b/src/main/java/com/divudi/bean/pharmacy/TransferReceiveCancellationController.java
index ffa26062afc..6a3852e22e5 100644
--- a/src/main/java/com/divudi/bean/pharmacy/TransferReceiveCancellationController.java
+++ b/src/main/java/com/divudi/bean/pharmacy/TransferReceiveCancellationController.java
@@ -6,6 +6,7 @@
import com.divudi.bean.common.BillController;
import com.divudi.bean.common.SessionController;
+import com.divudi.bean.common.WebUserController;
import com.divudi.core.util.JsfUtil;
import com.divudi.core.data.BillClassType;
import com.divudi.core.data.BillNumberSuffix;
@@ -32,6 +33,8 @@
import java.util.Calendar;
import java.util.Date;
import java.util.List;
+import java.util.logging.Level;
+import java.util.logging.Logger;
import javax.ejb.EJB;
import javax.enterprise.context.SessionScoped;
import javax.inject.Inject;
@@ -96,6 +99,7 @@
public class TransferReceiveCancellationController implements Serializable {
private static final long serialVersionUID = 1L;
+ private static final Logger LOGGER = Logger.getLogger(TransferReceiveCancellationController.class.getName());
// EJB Dependencies
@EJB
@@ -116,6 +120,8 @@ public class TransferReceiveCancellationController implements Serializable {
private BillController billController;
@Inject
private PharmacyController pharmacyController;
+ @Inject
+ private WebUserController webUserController;
// Properties
private Bill originalReceiveBill;
@@ -539,6 +545,9 @@ private void createBillFinanceDetails() {
*/
@Transactional
public void confirmCancellation() {
+ if (!isAuthorized("CANCEL", "PharmacyTransferReceiveCancel")) {
+ return;
+ }
try {
// Validation: Check cancellation reason
if (cancellationReason == null || cancellationReason.trim().isEmpty()) {
@@ -885,4 +894,33 @@ public SessionController getSessionController() {
public BillController getBillController() {
return billController;
}
+
+ /**
+ * Authorization helper method to check Transfer Receive Cancellation
+ * privileges and audit denied access.
+ *
+ * @param action The action being attempted (CANCEL)
+ * @param requiredPrivilege The specific privilege required
+ * @return true if authorized, false if not
+ */
+ private boolean isAuthorized(String action, String requiredPrivilege) {
+ if (webUserController == null || sessionController == null) {
+ LOGGER.log(Level.SEVERE, "Authorization failed - missing controllers: action={0}, userId=null, billId={1}",
+ new Object[]{action, originalReceiveBillId});
+ return false;
+ }
+
+ if (!webUserController.hasPrivilege(requiredPrivilege)) {
+ // Audit denied access attempt
+ Long userId = sessionController.getLoggedUser() != null ? sessionController.getLoggedUser().getId() : null;
+
+ LOGGER.log(Level.WARNING, "SECURITY: Unauthorized Transfer Receive Cancellation access attempt - action={0}, userId={1}, billId={2}, requiredPrivilege={3}",
+ new Object[]{action, userId, originalReceiveBillId, requiredPrivilege});
+
+ JsfUtil.addErrorMessage("You don't have permission to " + action.toLowerCase() + " transfer receive requests.");
+ return false;
+ }
+
+ return true;
+ }
}
diff --git a/src/main/java/com/divudi/bean/pharmacy/TransferReceiveController.java b/src/main/java/com/divudi/bean/pharmacy/TransferReceiveController.java
index 86121afc28a..2208ce21207 100644
--- a/src/main/java/com/divudi/bean/pharmacy/TransferReceiveController.java
+++ b/src/main/java/com/divudi/bean/pharmacy/TransferReceiveController.java
@@ -6,6 +6,7 @@
import com.divudi.bean.common.PageMetadataRegistry;
import com.divudi.bean.common.SessionController;
+import com.divudi.bean.common.WebUserController;
import com.divudi.bean.common.ConfigOptionApplicationController;
import com.divudi.core.data.OptionScope;
import com.divudi.core.data.admin.ConfigOptionInfo;
@@ -53,6 +54,8 @@
import java.util.Date;
import java.util.List;
import java.util.Optional;
+import java.util.logging.Level;
+import java.util.logging.Logger;
import javax.annotation.PostConstruct;
import javax.ejb.EJB;
import javax.enterprise.context.SessionScoped;
@@ -68,6 +71,8 @@
@SessionScoped
public class TransferReceiveController implements Serializable {
+ private static final Logger LOGGER = Logger.getLogger(TransferReceiveController.class.getName());
+
private Bill issuedBill;
private Bill receivedBill;
private boolean printPreview;
@@ -80,6 +85,8 @@ public class TransferReceiveController implements Serializable {
private SessionController sessionController;
@Inject
private PharmacyController pharmacyController;
+ @Inject
+ private WebUserController webUserController;
////
@EJB
private BillFacade billFacade;
@@ -471,6 +478,9 @@ private boolean wouldCauseOverReceiving() {
}
public void settle() {
+ if (!isAuthorized("FINALIZE", "PharmacyReceiveFinalize")) {
+ return;
+ }
// Re-entrancy guard: a second near-simultaneous submit (rapid double-click
// on the non-AJAX Receive button) is ignored until the first completes.
if (settling) {
@@ -871,6 +881,9 @@ private void fillData(Bill inputBill) {
}
public void saveRequest() {
+ if (!isAuthorized("SAVE", "PharmacyReceiveSave")) {
+ return;
+ }
getReceivedBill().setBillType(BillType.PharmacyTransferReceive);
getReceivedBill().setBillTypeAtomic(BillTypeAtomic.PHARMACY_RECEIVE_PRE);
@@ -905,6 +918,9 @@ public void saveRequest() {
}
public void finalizeRequest() {
+ if (!isAuthorized("FINALIZE", "PharmacyReceiveFinalize")) {
+ return;
+ }
// Check if a bill has been selected
if (getReceivedBill() == null) {
JsfUtil.addErrorMessage("No Bill Selected");
@@ -971,6 +987,9 @@ public void finalizeRequest() {
}
public void settleApprove() {
+ if (!isAuthorized("APPROVE", "PharmacyReceiveApprove")) {
+ return;
+ }
if (getReceivedBill().getId() == null) {
JsfUtil.addErrorMessage("No Bill");
return;
@@ -1760,4 +1779,34 @@ private void registerPageMetadata() {
pageMetadataRegistry.registerPage(receiveMetadata);
}
+ /**
+ * Authorization helper method to check Transfer Receive privileges and
+ * audit denied access.
+ *
+ * @param action The action being attempted (SAVE, FINALIZE, APPROVE)
+ * @param requiredPrivilege The specific privilege required
+ * @return true if authorized, false if not
+ */
+ private boolean isAuthorized(String action, String requiredPrivilege) {
+ if (webUserController == null || sessionController == null) {
+ LOGGER.log(Level.SEVERE, "Authorization failed - missing controllers: action={0}, userId=null, billId={1}",
+ new Object[]{action, receivedBill != null ? receivedBill.getId() : "null"});
+ return false;
+ }
+
+ if (!webUserController.hasPrivilege(requiredPrivilege)) {
+ // Audit denied access attempt
+ Long userId = sessionController.getLoggedUser() != null ? sessionController.getLoggedUser().getId() : null;
+ Long billId = receivedBill != null ? receivedBill.getId() : null;
+
+ LOGGER.log(Level.WARNING, "SECURITY: Unauthorized Transfer Receive access attempt - action={0}, userId={1}, billId={2}, requiredPrivilege={3}",
+ new Object[]{action, userId, billId, requiredPrivilege});
+
+ JsfUtil.addErrorMessage("You don't have permission to " + action.toLowerCase() + " transfer receive requests.");
+ return false;
+ }
+
+ return true;
+ }
+
}
diff --git a/src/main/java/com/divudi/bean/pharmacy/TransferReceiveNativeSqlController.java b/src/main/java/com/divudi/bean/pharmacy/TransferReceiveNativeSqlController.java
index 3215860b884..efc41eed521 100644
--- a/src/main/java/com/divudi/bean/pharmacy/TransferReceiveNativeSqlController.java
+++ b/src/main/java/com/divudi/bean/pharmacy/TransferReceiveNativeSqlController.java
@@ -8,6 +8,7 @@
import com.divudi.bean.common.ConfigOptionApplicationController;
import com.divudi.bean.common.PageMetadataRegistry;
import com.divudi.bean.common.SessionController;
+import com.divudi.bean.common.WebUserController;
import com.divudi.core.data.OptionScope;
import com.divudi.core.data.BillClassType;
import com.divudi.core.data.BillNumberSuffix;
@@ -75,6 +76,9 @@ public class TransferReceiveNativeSqlController implements Serializable {
@Inject
private SessionController sessionController;
+ @Inject
+ private WebUserController webUserController;
+
@Inject
private ConfigOptionApplicationController configOptionApplicationController;
@@ -246,6 +250,9 @@ public String loadPendingReceiveForApprove(Long preBillId) {
}
public String cancelPendingReceive(Long preBillId) {
+ if (!isAuthorized("CANCEL", "PharmacyTransferReceiveCancel")) {
+ return "";
+ }
if (preBillId == null) {
JsfUtil.addErrorMessage("No pending receive selected.");
return null;
@@ -313,6 +320,9 @@ public String viewByBillId(Long billId) {
* Mirrors TransferReceiveController.settle().
*/
public void settle() {
+ if (!isAuthorized("FINALIZE", "PharmacyReceiveFinalize")) {
+ return;
+ }
if (itemRowList == null || itemRowList.isEmpty()) {
JsfUtil.addErrorMessage("Nothing to Receive, Please check Received Quantity");
return;
@@ -360,6 +370,9 @@ public void settle() {
* Mirrors TransferReceiveController.settleApprove().
*/
public void settleApprove() {
+ if (!isAuthorized("APPROVE", "PharmacyReceiveApprove")) {
+ return;
+ }
if (receivedBillId == null) {
JsfUtil.addErrorMessage("No Bill Selected");
return;
@@ -421,6 +434,9 @@ public void settleApprove() {
* Mirrors TransferReceiveController.saveRequest().
*/
public void saveRequest() {
+ if (!isAuthorized("SAVE", "PharmacyReceiveSave")) {
+ return;
+ }
if (issuedBill == null || issuedBill.getId() == null) {
JsfUtil.addErrorMessage("No issued bill selected");
return;
@@ -449,6 +465,9 @@ public void saveRequest() {
* Mirrors TransferReceiveController.finalizeRequest().
*/
public void finalizeRequest() {
+ if (!isAuthorized("FINALIZE", "PharmacyReceiveFinalize")) {
+ return;
+ }
if (receivedBillId == null) {
JsfUtil.addErrorMessage("No saved request found. Please save first.");
return;
@@ -1004,4 +1023,33 @@ public TransferReceiveItemRowDto getSelectedItemRow() {
public void setSelectedItemRow(TransferReceiveItemRowDto selectedItemRow) {
this.selectedItemRow = selectedItemRow;
}
+
+ /**
+ * Authorization helper method to check Transfer Receive privileges and
+ * audit denied access.
+ *
+ * @param action The action being attempted (SAVE, FINALIZE, APPROVE, CANCEL)
+ * @param requiredPrivilege The specific privilege required
+ * @return true if authorized, false if not
+ */
+ private boolean isAuthorized(String action, String requiredPrivilege) {
+ if (webUserController == null || sessionController == null) {
+ LOGGER.log(Level.SEVERE, "Authorization failed - missing controllers: action={0}, userId=null, billId={1}",
+ new Object[]{action, receivedBillId});
+ return false;
+ }
+
+ if (!webUserController.hasPrivilege(requiredPrivilege)) {
+ // Audit denied access attempt
+ Long userId = sessionController.getLoggedUser() != null ? sessionController.getLoggedUser().getId() : null;
+
+ LOGGER.log(Level.WARNING, "SECURITY: Unauthorized Transfer Receive access attempt - action={0}, userId={1}, billId={2}, requiredPrivilege={3}",
+ new Object[]{action, userId, receivedBillId, requiredPrivilege});
+
+ JsfUtil.addErrorMessage("You don't have permission to " + action.toLowerCase() + " transfer receive requests.");
+ return false;
+ }
+
+ return true;
+ }
}
diff --git a/src/main/java/com/divudi/bean/pharmacy/TransferRequestController.java b/src/main/java/com/divudi/bean/pharmacy/TransferRequestController.java
index b553bf2dbcf..e0c331c0321 100644
--- a/src/main/java/com/divudi/bean/pharmacy/TransferRequestController.java
+++ b/src/main/java/com/divudi/bean/pharmacy/TransferRequestController.java
@@ -8,6 +8,7 @@
import com.divudi.bean.common.PageMetadataRegistry;
import com.divudi.bean.common.SearchController;
import com.divudi.bean.common.SessionController;
+import com.divudi.bean.common.WebUserController;
import com.divudi.core.data.*;
import com.divudi.core.data.admin.ConfigOptionInfo;
@@ -99,6 +100,8 @@ public class TransferRequestController implements Serializable {
@Inject
private SessionController sessionController;
@Inject
+ private WebUserController webUserController;
+ @Inject
private PharmacyCalculation pharmacyBillBean;
@Inject
private NotificationController notificationController;
@@ -331,6 +334,9 @@ public void saveBill() {
}
public void approveTransferRequestBill() {
+ if (!isAuthorized("APPROVE_REQUEST", "PharmacyDisbursementRequestApproval")) {
+ return;
+ }
if (billItems == null || billItems.isEmpty()) {
JsfUtil.addErrorMessage("No Bill Items");
return;
@@ -648,6 +654,9 @@ public void saveTransferRequestPreBillAndBillItems() {
}
public void saveTranserRequestPreBill() {
+ if (!isAuthorized("REQUEST", "PharmacyDisbursementRequest")) {
+ return;
+ }
if (errorsPresent()) {
return;
}
@@ -717,6 +726,9 @@ public String navigateToApproveRequest() {
}
public void finalizeTranserRequestPreBill() {
+ if (!isAuthorized("FINALIZE_REQUEST", "PharmacyDisbursementFinalizeRequest")) {
+ return;
+ }
if (errorsPresent()) {
return;
}
@@ -1893,4 +1905,34 @@ private void registerPageMetadata() {
pageMetadataRegistry.registerPage(requestListMetadata);
}
+ /**
+ * Authorization helper method to check Pharmacy Transfer Request
+ * privileges and audit denied access
+ *
+ * @param action The action being attempted (e.g. REQUEST, FINALIZE_REQUEST, APPROVE_REQUEST)
+ * @param requiredPrivilege The specific privilege required
+ * @return true if authorized, false if not
+ */
+ private boolean isAuthorized(String action, String requiredPrivilege) {
+ if (webUserController == null || sessionController == null) {
+ LOGGER.log(Level.SEVERE, "Authorization failed - missing controllers: action={0}, userId=null, billId={1}",
+ new Object[]{action, bill != null ? bill.getId() : "null"});
+ return false;
+ }
+
+ if (!webUserController.hasPrivilege(requiredPrivilege)) {
+ // Audit denied access attempt
+ Long userId = sessionController.getLoggedUser() != null ? sessionController.getLoggedUser().getId() : null;
+ Long billId = bill != null ? bill.getId() : null;
+
+ LOGGER.log(Level.WARNING, "SECURITY: Unauthorized Pharmacy Transfer Request access attempt - action={0}, userId={1}, billId={2}, requiredPrivilege={3}",
+ new Object[]{action, userId, billId, requiredPrivilege});
+
+ JsfUtil.addErrorMessage("You don't have permission to " + action.toLowerCase() + " transfer requests.");
+ return false;
+ }
+
+ return true;
+ }
+
}
diff --git a/src/main/java/com/divudi/bean/pharmacy/WardPharmacyBhtIssueReceiveController.java b/src/main/java/com/divudi/bean/pharmacy/WardPharmacyBhtIssueReceiveController.java
index feb3f46cb1a..5440ada6527 100644
--- a/src/main/java/com/divudi/bean/pharmacy/WardPharmacyBhtIssueReceiveController.java
+++ b/src/main/java/com/divudi/bean/pharmacy/WardPharmacyBhtIssueReceiveController.java
@@ -1,7 +1,9 @@
package com.divudi.bean.pharmacy;
import com.divudi.bean.common.SessionController;
+import com.divudi.bean.common.WebUserController;
import com.divudi.core.data.BillType;
+import com.divudi.core.data.Privileges;
import com.divudi.core.data.BillTypeAtomic;
import com.divudi.core.entity.Bill;
import com.divudi.core.entity.BilledBill;
@@ -42,6 +44,8 @@ public class WardPharmacyBhtIssueReceiveController implements Serializable {
@Inject
private SessionController sessionController;
+ @Inject
+ private WebUserController webUserController;
@EJB
private BillFacade billFacade;
@@ -155,6 +159,10 @@ private void generateBillComponent() {
}
public void settle() {
+ if (!webUserController.hasPrivilege(Privileges.InwardPharmacyBhtReceive.name())) {
+ JsfUtil.addErrorMessage("You do not have the privilege to receive this issue.");
+ return;
+ }
if (settling) {
return;
}
diff --git a/src/main/java/com/divudi/bean/pharmacy/WardPharmacyReturnToPharmacyController.java b/src/main/java/com/divudi/bean/pharmacy/WardPharmacyReturnToPharmacyController.java
index 45a231e8c6f..d79cf3c61d4 100644
--- a/src/main/java/com/divudi/bean/pharmacy/WardPharmacyReturnToPharmacyController.java
+++ b/src/main/java/com/divudi/bean/pharmacy/WardPharmacyReturnToPharmacyController.java
@@ -227,6 +227,10 @@ public void cancelReturnBill() {
}
public void settle() {
+ if (!webUserController.hasPrivilege(Privileges.InwardPharmacyReturnSubmit.name())) {
+ JsfUtil.addErrorMessage("You do not have the privilege to submit this return.");
+ return;
+ }
if (settling) {
return;
}
diff --git a/src/main/java/com/divudi/core/data/Privileges.java b/src/main/java/com/divudi/core/data/Privileges.java
index 6b0ee874d86..8fb642acdaf 100644
--- a/src/main/java/com/divudi/core/data/Privileges.java
+++ b/src/main/java/com/divudi/core/data/Privileges.java
@@ -128,6 +128,8 @@ public enum Privileges {
InwardPharmacyIssueRequestCancel("Inward Pharmacy Issue Request Cancel"),
InwardPharmacyIssueRequestSearch("Inward Pharmacy Issue Request Search"),
InwardPharmacyReturnCancel("Inward Pharmacy Return Cancel"),
+ InwardPharmacyReturnSubmit("Inward Pharmacy Return Submit"),
+ InwardPharmacyBhtReceive("Inward Pharmacy BHT Receive"),
InwardBillSettleWithoutCheck("Inward Bill Settle Without Check"),
TheaterIssueBHT("Theater Issue BHT"),
InpatientClinicalAssessment("Inpatient Clinical Assessment"),
@@ -526,8 +528,11 @@ public enum Privileges {
PharmacyIssueForRequestSave("Pharmacy Issue For Request Save"),
PharmacyIssueForRequestFinalize("Pharmacy Issue For Request Finalize"),
PharmacyIssueForRequestApprove("Pharmacy Issue For Request Approve"),
+ PharmacyReceiveSave("Pharmacy Receive Save"),
PharmacyReceiveFinalize("Pharmacy Receive Finalize"),
PharmacyReceiveApprove("Pharmacy Receive Approve"),
+ PharmacyTransferIssueCancel("Pharmacy Transfer Issue Cancel"),
+ PharmacyTransferReceiveCancel("Pharmacy Transfer Receive Cancel"),
// Pharmacy Inpatient medication management
InpatientMedicationManagementMenue("Inpatient Medication Management Menu"),
PharmacyDirectIssueToBht("Pharmacy Direct Issue to BHT"),
@@ -572,6 +577,8 @@ public enum Privileges {
PharmacyDisposalIssue("Pharmacy Disposal Issue"),
PharmacyDisposalIssueFinalize("Pharmacy Disposal Issue Finalize"),
PharmacyDisposalIssueApprove("Pharmacy Disposal Issue Approve"),
+ PharmacyDisposalIssueCancel("Pharmacy Disposal Issue Cancel"),
+ PharmacyDiscardCategoryManage("Pharmacy Discard Category Manage"),
PharmacyDisposalSearchIssueBill("Pharmacy Disposal Search Issue Bill"),
PharmacyDisposalSearchIssueBillItems("Pharmacy Disposal Search Issue Bill Items"),
PharmacyDisposalSearchIssueReturnBill("Pharmacy Disposal Search Issue Return Bill"),
@@ -636,6 +643,8 @@ public enum Privileges {
PharmacyDirectPurchaseFinalize("Pharmacy Direct Purchase Finalize"),
PharmacyDirectPurchaseApprove("Pharmacy Direct Purchase Approve"),
PurchaseOrdersApprovel("Purchase Orders Approval"),
+ PurchaseOrderSave("Purchase Order Save"),
+ PurchaseOrderFinalize("Purchase Order Finalize"),
TransferReciveApproval("Transfer Receive Approval"),
GoodsRecipt("Goods Receipt"),
ReturnReceviedGoods("Return Received Goods"),
@@ -654,6 +663,8 @@ public enum Privileges {
PharmacyGrnSave("Pharmacy GRN Save"),
PharmacyGrnFinalize("Pharmacy GRN Finalize"),
PharmacyGrnApprove("Pharmacy GRN Approve"),
+ PharmacyGrnCancel("Pharmacy GRN Cancel"),
+ PharmacyGrnReturnCancel("Pharmacy GRN Return Cancel"),
PharmacyItemSearch("Pharmacy Item Search"),
PharmacyGenarateReports("Pharmacy Generate Reports"),
PharmacySummaryViews("Pharmacy Summary Views"),
@@ -935,6 +946,8 @@ public String getCategory() {
case PharmacyBhtRequestForceComplete:
case PharmacyReturnFromWardForceComplete:
case InwardPharmacyReturnCancel:
+ case InwardPharmacyReturnSubmit:
+ case InwardPharmacyBhtReceive:
case PharmacySearchInpatientDirectIssuesbyBill:
case PharmacySearchInpatientDirectIssuesbyItem:
case PharmacySearchInpatientDirectIssueReturnsbyBill:
@@ -949,6 +962,8 @@ public String getCategory() {
case PharmacyDirectPurchaseFinalize:
case PharmacyDirectPurchaseApprove:
case PurchaseOrdersApprovel:
+ case PurchaseOrderSave:
+ case PurchaseOrderFinalize:
case GoodsRecipt:
case ReturnReceviedGoods:
case CreateGrnReturn:
@@ -973,8 +988,11 @@ public String getCategory() {
case PharmacyIssueForRequestSave:
case PharmacyIssueForRequestFinalize:
case PharmacyIssueForRequestApprove:
+ case PharmacyReceiveSave:
case PharmacyReceiveFinalize:
case PharmacyReceiveApprove:
+ case PharmacyTransferIssueCancel:
+ case PharmacyTransferReceiveCancel:
// Retail Transactions
case PharmacyRetailTransaction:
@@ -1007,6 +1025,8 @@ public String getCategory() {
case PharmacyDisposalIssue:
case PharmacyDisposalIssueFinalize:
case PharmacyDisposalIssueApprove:
+ case PharmacyDisposalIssueCancel:
+ case PharmacyDiscardCategoryManage:
case PharmacyDisposalSearchIssueBill:
case PharmacyDisposalSearchIssueBillItems:
case PharmacyDisposalSearchIssueReturnBill:
@@ -1043,6 +1063,8 @@ public String getCategory() {
case PharmacyGrnSave:
case PharmacyGrnFinalize:
case PharmacyGrnApprove:
+ case PharmacyGrnCancel:
+ case PharmacyGrnReturnCancel:
case PrintOriginalPoBillFromReprint:
case PrintOriginalGrnBillFromReprint:
case PharmacyItemNameEdit:
diff --git a/src/main/java/com/divudi/core/data/dataStructure/ChargeItemTotal.java b/src/main/java/com/divudi/core/data/dataStructure/ChargeItemTotal.java
index 7fc2b790d25..ba5d626c056 100644
--- a/src/main/java/com/divudi/core/data/dataStructure/ChargeItemTotal.java
+++ b/src/main/java/com/divudi/core/data/dataStructure/ChargeItemTotal.java
@@ -21,6 +21,7 @@ public class ChargeItemTotal {
private double discount = 0;
private double netTotal = 0;
private double adjustedTotal = 0.0;
+ private String comments;
private List
patientRooms;
List billFees;
@@ -71,6 +72,14 @@ public void setAdjustedTotal(double adjustedTotal) {
this.adjustedTotal = adjustedTotal;
}
+ public String getComments() {
+ return comments;
+ }
+
+ public void setComments(String comments) {
+ this.comments = comments;
+ }
+
public List getPatientRooms() {
if (patientRooms == null) {
patientRooms = new ArrayList<>();
diff --git a/src/main/java/com/divudi/core/data/dto/DepartmentDto.java b/src/main/java/com/divudi/core/data/dto/DepartmentDto.java
index e8e93e7a08c..84eeea1a683 100644
--- a/src/main/java/com/divudi/core/data/dto/DepartmentDto.java
+++ b/src/main/java/com/divudi/core/data/dto/DepartmentDto.java
@@ -25,6 +25,7 @@ public DepartmentDto() {
/**
* Constructor for JPQL queries
*/
+
public DepartmentDto(Long id, String name, String code, String departmentCode,
Boolean retired, Boolean inactive) {
this.id = id;
@@ -34,6 +35,10 @@ public DepartmentDto(Long id, String name, String code, String departmentCode,
this.retired = retired != null ? retired : false;
this.inactive = inactive != null ? inactive : false;
}
+ public DepartmentDto(Long id, String name) {
+ this.id = id;
+ this.name = name;
+ }
public Long getId() {
return id;
diff --git a/src/main/java/com/divudi/core/data/dto/HospitalCensusDetailDto.java b/src/main/java/com/divudi/core/data/dto/HospitalCensusDetailDto.java
new file mode 100644
index 00000000000..02fea90959b
--- /dev/null
+++ b/src/main/java/com/divudi/core/data/dto/HospitalCensusDetailDto.java
@@ -0,0 +1,164 @@
+package com.divudi.core.data.dto;
+
+import com.divudi.core.data.Sex;
+import java.io.Serializable;
+import java.util.Date;
+import org.joda.time.LocalDate;
+import org.joda.time.Period;
+import org.joda.time.PeriodType;
+
+public class HospitalCensusDetailDto implements Serializable {
+
+ private String area;
+ private String mrn;
+ private String name;
+ private String ageSex;
+ private Date doa;
+ private String bedNo;
+ private String status;
+ private String consultant;
+ private double deposited;
+ private double balance;
+
+ /**
+ * Default no-arg constructor (required by JSF/CDI).
+ */
+ public HospitalCensusDetailDto() {
+ }
+
+ /**
+ * JPQL projection constructor — used by the batch detail query in
+ * InwardManagementReportController to avoid full entity hydration.
+ *
+ * @param area Ward / department name
+ * @param mrn Patient hospital number (phn)
+ * @param personName Patient display name
+ * @param dob Date of birth (used to compute age string)
+ * @param sex Sex enum (may be null)
+ * @param doa Date of admission
+ * @param bedNo Bed / room name
+ * @param discharged Whether the encounter is marked discharged
+ * @param consultant Referring doctor name (may be null)
+ */
+ public HospitalCensusDetailDto(
+ String area,
+ String mrn,
+ String personName,
+ Date dob,
+ Sex sex,
+ Date doa,
+ String bedNo,
+ Boolean discharged,
+ String consultant) {
+ this.area = area;
+ this.mrn = mrn;
+ this.name = personName;
+ this.doa = doa;
+ this.bedNo = bedNo;
+ this.status = Boolean.TRUE.equals(discharged) ? "Discharged" : "Admitted";
+ this.consultant = consultant != null ? consultant : "";
+ this.ageSex = buildAgeSex(dob, sex);
+ }
+
+ // -----------------------------------------------------------------------
+ // Private helpers
+ // -----------------------------------------------------------------------
+
+ private static String buildAgeSex(Date dob, Sex sex) {
+ String ageStr = "";
+ if (dob != null) {
+ try {
+ Period p = new Period(
+ new LocalDate(dob),
+ LocalDate.now(),
+ PeriodType.yearMonthDay());
+ ageStr = p.getYears() + "Y " + p.getMonths() + "M " + p.getDays() + "D";
+ } catch (Exception ignored) {
+ // keep empty on any date arithmetic failure
+ }
+ }
+ String sexStr = sex != null ? sex.toString() : "";
+ return ageStr + " / " + sexStr;
+ }
+
+ public String getArea() {
+ return area;
+ }
+
+ public void setArea(String area) {
+ this.area = area;
+ }
+
+ public String getMrn() {
+ return mrn;
+ }
+
+ public void setMrn(String mrn) {
+ this.mrn = mrn;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public String getAgeSex() {
+ return ageSex;
+ }
+
+ public void setAgeSex(String ageSex) {
+ this.ageSex = ageSex;
+ }
+
+ public Date getDoa() {
+ return doa;
+ }
+
+ public void setDoa(Date doa) {
+ this.doa = doa;
+ }
+
+ public String getBedNo() {
+ return bedNo;
+ }
+
+ public void setBedNo(String bedNo) {
+ this.bedNo = bedNo;
+ }
+
+ public String getStatus() {
+ return status;
+ }
+
+ public void setStatus(String status) {
+ this.status = status;
+ }
+
+ public String getConsultant() {
+ return consultant;
+ }
+
+ public void setConsultant(String consultant) {
+ this.consultant = consultant;
+ }
+
+ public double getDeposited() {
+ return deposited;
+ }
+
+ public void setDeposited(double deposited) {
+ this.deposited = deposited;
+ }
+
+ public double getBalance() {
+ return balance;
+ }
+
+ public void setBalance(double balance) {
+ this.balance = balance;
+ }
+
+}
diff --git a/src/main/java/com/divudi/core/data/dto/HospitalCensusSummaryDto.java b/src/main/java/com/divudi/core/data/dto/HospitalCensusSummaryDto.java
new file mode 100644
index 00000000000..2a36155d666
--- /dev/null
+++ b/src/main/java/com/divudi/core/data/dto/HospitalCensusSummaryDto.java
@@ -0,0 +1,134 @@
+package com.divudi.core.data.dto;
+
+import com.divudi.core.entity.Department;
+import java.io.Serializable;
+
+public class HospitalCensusSummaryDto implements Serializable {
+
+ private String ward;
+ private long totalBeds;
+ private long openBeds;
+ private long previousDaysTotal;
+ private long newAdmissions;
+ private long transferIn;
+ private long transferOut;
+ private long markedForDischarge;
+ private long normalDischarges;
+ private long lama;
+ private long deaths;
+ private long others;
+ private long totalPresent;
+ private double bedOccupancyRate;
+
+ public String getWard() {
+ return ward;
+ }
+
+ public void setWard(String ward) {
+ this.ward = ward;
+ }
+
+ public long getTotalBeds() {
+ return totalBeds;
+ }
+
+ public void setTotalBeds(long totalBeds) {
+ this.totalBeds = totalBeds;
+ }
+
+ public long getOpenBeds() {
+ return openBeds;
+ }
+
+ public void setOpenBeds(long openBeds) {
+ this.openBeds = openBeds;
+ }
+
+ public long getPreviousDaysTotal() {
+ return previousDaysTotal;
+ }
+
+ public void setPreviousDaysTotal(long previousDaysTotal) {
+ this.previousDaysTotal = previousDaysTotal;
+ }
+
+ public long getNewAdmissions() {
+ return newAdmissions;
+ }
+
+ public void setNewAdmissions(long newAdmissions) {
+ this.newAdmissions = newAdmissions;
+ }
+
+ public long getTransferIn() {
+ return transferIn;
+ }
+
+ public void setTransferIn(long transferIn) {
+ this.transferIn = transferIn;
+ }
+
+ public long getTransferOut() {
+ return transferOut;
+ }
+
+ public void setTransferOut(long transferOut) {
+ this.transferOut = transferOut;
+ }
+
+ public long getMarkedForDischarge() {
+ return markedForDischarge;
+ }
+
+ public void setMarkedForDischarge(long markedForDischarge) {
+ this.markedForDischarge = markedForDischarge;
+ }
+
+ public long getNormalDischarges() {
+ return normalDischarges;
+ }
+
+ public void setNormalDischarges(long normalDischarges) {
+ this.normalDischarges = normalDischarges;
+ }
+
+ public long getLama() {
+ return lama;
+ }
+
+ public void setLama(long lama) {
+ this.lama = lama;
+ }
+
+ public long getDeaths() {
+ return deaths;
+ }
+
+ public void setDeaths(long deaths) {
+ this.deaths = deaths;
+ }
+
+ public long getOthers() {
+ return others;
+ }
+
+ public void setOthers(long others) {
+ this.others = others;
+ }
+
+ public long getTotalPresent() {
+ return totalPresent;
+ }
+
+ public void setTotalPresent(long totalPresent) {
+ this.totalPresent = totalPresent;
+ }
+
+ public double getBedOccupancyRate() {
+ return bedOccupancyRate;
+ }
+
+ public void setBedOccupancyRate(double bedOccupancyRate) {
+ this.bedOccupancyRate = bedOccupancyRate;
+ }
+}
diff --git a/src/main/java/com/divudi/core/entity/WebUserDefaultLoginPage.java b/src/main/java/com/divudi/core/entity/WebUserDefaultLoginPage.java
new file mode 100644
index 00000000000..0b05f54f5fe
--- /dev/null
+++ b/src/main/java/com/divudi/core/entity/WebUserDefaultLoginPage.java
@@ -0,0 +1,155 @@
+/*
+* Dr M H B Ariyaratne
+ * buddhika.ari@gmail.com
+ */
+package com.divudi.core.entity;
+
+import com.divudi.core.data.LoginPage;
+import java.io.Serializable;
+import java.util.Date;
+import javax.persistence.Entity;
+import javax.persistence.EnumType;
+import javax.persistence.Enumerated;
+import javax.persistence.GeneratedValue;
+import javax.persistence.GenerationType;
+import javax.persistence.Id;
+import javax.persistence.ManyToOne;
+import javax.persistence.Temporal;
+
+/**
+ * Per user + department default login page. Runtime resolution reads this
+ * entity first; falls back to {@link WebUser#getLoginPage()} then HOME.
+ *
+ * @author www.divudi.com
+ */
+@Entity
+public class WebUserDefaultLoginPage implements Serializable {
+
+ static final long serialVersionUID = 1L;
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ Long id;
+ @ManyToOne
+ private WebUser webUser;
+ @ManyToOne
+ private Department department;
+ @Enumerated(EnumType.STRING)
+ private LoginPage loginPage;
+ //Created Properties
+ @ManyToOne
+ WebUser creater;
+ @Temporal(javax.persistence.TemporalType.TIMESTAMP)
+ Date createdAt;
+ //Retairing properties
+ boolean retired;
+ @ManyToOne
+ WebUser retirer;
+ @Temporal(javax.persistence.TemporalType.TIMESTAMP)
+ Date retiredAt;
+ String retireComments;
+
+ public Long getId() {
+ return id;
+ }
+
+ public void setId(Long id) {
+ this.id = id;
+ }
+
+ public WebUser getWebUser() {
+ return webUser;
+ }
+
+ public void setWebUser(WebUser webUser) {
+ this.webUser = webUser;
+ }
+
+ public Department getDepartment() {
+ return department;
+ }
+
+ public void setDepartment(Department department) {
+ this.department = department;
+ }
+
+ public LoginPage getLoginPage() {
+ return loginPage;
+ }
+
+ public void setLoginPage(LoginPage loginPage) {
+ this.loginPage = loginPage;
+ }
+
+ public WebUser getCreater() {
+ return creater;
+ }
+
+ public void setCreater(WebUser creater) {
+ this.creater = creater;
+ }
+
+ public Date getCreatedAt() {
+ return createdAt;
+ }
+
+ public void setCreatedAt(Date createdAt) {
+ this.createdAt = createdAt;
+ }
+
+ public boolean isRetired() {
+ return retired;
+ }
+
+ public void setRetired(boolean retired) {
+ this.retired = retired;
+ }
+
+ public WebUser getRetirer() {
+ return retirer;
+ }
+
+ public void setRetirer(WebUser retirer) {
+ this.retirer = retirer;
+ }
+
+ public Date getRetiredAt() {
+ return retiredAt;
+ }
+
+ public void setRetiredAt(Date retiredAt) {
+ this.retiredAt = retiredAt;
+ }
+
+ public String getRetireComments() {
+ return retireComments;
+ }
+
+ public void setRetireComments(String retireComments) {
+ this.retireComments = retireComments;
+ }
+
+ @Override
+ public int hashCode() {
+ int hash = 0;
+ hash += (id != null ? id.hashCode() : 0);
+ return hash;
+ }
+
+ @Override
+ public boolean equals(Object object) {
+ if (!(object instanceof WebUserDefaultLoginPage)) {
+ return false;
+ }
+ WebUserDefaultLoginPage other = (WebUserDefaultLoginPage) object;
+ if ((this.id == null && other.id != null) || (this.id != null && !this.id.equals(other.id))) {
+ return false;
+ }
+ return true;
+ }
+
+ @Override
+ public String toString() {
+ return "com.divudi.core.entity.WebUserDefaultLoginPage[ id=" + id + " ]";
+ }
+
+}
diff --git a/src/main/java/com/divudi/core/entity/WebUserRole.java b/src/main/java/com/divudi/core/entity/WebUserRole.java
index 30f4380d094..ff5ef6e8e2c 100644
--- a/src/main/java/com/divudi/core/entity/WebUserRole.java
+++ b/src/main/java/com/divudi/core/entity/WebUserRole.java
@@ -7,10 +7,13 @@
*/
package com.divudi.core.entity;
+import com.divudi.core.data.LoginPage;
import com.fasterxml.jackson.annotation.JsonIgnore;
import java.io.Serializable;
import java.util.Date;
import javax.persistence.Entity;
+import javax.persistence.EnumType;
+import javax.persistence.Enumerated;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
@@ -63,6 +66,10 @@ public class WebUserRole implements Serializable {
@JsonIgnore
String activateComments;
+ // Template login page for this role (admin-time only; never read at runtime).
+ @Enumerated(EnumType.STRING)
+ private LoginPage loginPage;
+
public Long getId() {
return id;
}
@@ -167,6 +174,14 @@ public void setRetirer(WebUser retirer) {
this.retirer = retirer;
}
+ public LoginPage getLoginPage() {
+ return loginPage;
+ }
+
+ public void setLoginPage(LoginPage loginPage) {
+ this.loginPage = loginPage;
+ }
+
@Override
public int hashCode() {
int hash = 0;
diff --git a/src/main/java/com/divudi/core/entity/WebUserRoleUser.java b/src/main/java/com/divudi/core/entity/WebUserRoleUser.java
index 0496e284565..27b93c4176c 100644
--- a/src/main/java/com/divudi/core/entity/WebUserRoleUser.java
+++ b/src/main/java/com/divudi/core/entity/WebUserRoleUser.java
@@ -30,6 +30,7 @@ public class WebUserRoleUser implements Serializable {
private WebUserRole webUserRole;
@ManyToOne
private WebUser webUser;
+ @ManyToOne
private Department department;
//Created Properties
diff --git a/src/main/java/com/divudi/core/facade/WebUserDefaultLoginPageFacade.java b/src/main/java/com/divudi/core/facade/WebUserDefaultLoginPageFacade.java
new file mode 100644
index 00000000000..8e0a88021e4
--- /dev/null
+++ b/src/main/java/com/divudi/core/facade/WebUserDefaultLoginPageFacade.java
@@ -0,0 +1,29 @@
+/*
+* Dr M H B Ariyaratne
+ * buddhika.ari@gmail.com
+ */
+package com.divudi.core.facade;
+
+import com.divudi.core.entity.WebUserDefaultLoginPage;
+import javax.ejb.Stateless;
+import javax.persistence.EntityManager;
+import javax.persistence.PersistenceContext;
+
+/**
+ *
+ * @author www.divudi.com
+ */
+@Stateless
+public class WebUserDefaultLoginPageFacade extends AbstractFacade {
+ @PersistenceContext(unitName = "hmisPU")
+ private EntityManager em;
+
+ @Override
+ protected EntityManager getEntityManager() {
+ if(em == null){}return em;
+ }
+
+ public WebUserDefaultLoginPageFacade() {
+ super(WebUserDefaultLoginPage.class);
+ }
+}
diff --git a/src/main/java/com/divudi/ejb/SmsManagerEjb.java b/src/main/java/com/divudi/ejb/SmsManagerEjb.java
index ca5429b919c..072e3e820c5 100644
--- a/src/main/java/com/divudi/ejb/SmsManagerEjb.java
+++ b/src/main/java/com/divudi/ejb/SmsManagerEjb.java
@@ -75,6 +75,24 @@ public class SmsManagerEjb {
// Processes pending lab report approval SMS messages based on configurable delay strategies
@Schedule(second = "0", minute = "*/1", hour = "*", persistent = false)
public void processPendingLabReportApprovalSmsQueue() {
+ // Guard the automatic-timer callback. If a timeout method throws an
+ // exception out to the EJB container (e.g. a transient database
+ // connectivity failure while reading config options or running the
+ // query below), the non-persistent automatic timer can stop firing and
+ // never recover until the server is restarted. That was the root cause
+ // of the recurring "pending Lab Report SMS freeze" on Southern Lanka
+ // production: a brief DB outage killed this timer, and every restart
+ // only masked it. Swallow everything here so the timer always survives
+ // and resumes sending on its own once the underlying issue clears.
+ try {
+ processPendingLabReportApprovalSmsQueueInternal();
+ } catch (Throwable t) {
+ Logger.getLogger(SmsManagerEjb.class.getName()).log(Level.SEVERE,
+ "processPendingLabReportApprovalSmsQueue failed; timer will retry next minute", t);
+ }
+ }
+
+ private void processPendingLabReportApprovalSmsQueueInternal() {
if (configOptionApplicationController == null || smsFacade == null) {
return;
}
diff --git a/src/main/java/com/divudi/service/AnthropicApiService.java b/src/main/java/com/divudi/service/AnthropicApiService.java
index 882f57d4640..8fcaf86bd34 100644
--- a/src/main/java/com/divudi/service/AnthropicApiService.java
+++ b/src/main/java/com/divudi/service/AnthropicApiService.java
@@ -1217,6 +1217,132 @@ private JsonArray buildToolsArray() {
.add("required", Json.createArrayBuilder().add("method")))
.build();
+ JsonObject userRoleResetTool = Json.createObjectBuilder()
+ .add("name", "user_role_reset")
+ .add("description",
+ "Reset a user's role-template aspects (privileges/icons/subscriptions/login page) to exactly match a "
+ + "role template for the given departments: retires records the user has that the template doesn't, "
+ + "and adds records the template has that the user lacks. Roles are admin-time templates only — "
+ + "this stamps user-level records; it never changes runtime behavior directly. "
+ + "roleId is optional — omit it to reset to the user's own current role (the API 400s if the user "
+ + "has no role). Set preview=true first to see added/retired counts per aspect without writing "
+ + "anything, then call again with preview omitted/false to apply. Always confirm with the user "
+ + "before applying (preview=false).")
+ .add("input_schema", Json.createObjectBuilder()
+ .add("type", "object")
+ .add("properties", Json.createObjectBuilder()
+ .add("id", Json.createObjectBuilder().add("type", "string").add("description", "Target WebUser ID"))
+ .add("roleId", Json.createObjectBuilder().add("type", "string").add("description", "Role template ID; omit to use the user's own role"))
+ .add("departmentIds", Json.createObjectBuilder().add("type", "string").add("description", "Comma-separated department IDs to reset"))
+ .add("aspects", Json.createObjectBuilder().add("type", "string").add("description", "Comma-separated aspects: PRIVILEGES, ICONS, SUBSCRIPTIONS, LOGIN_PAGE. Default PRIVILEGES"))
+ .add("updateUserRole", Json.createObjectBuilder().add("type", "string").add("description", "'true' or 'false' — also set WebUser.role to roleId. Default true"))
+ .add("preview", Json.createObjectBuilder().add("type", "string").add("description", "'true' to preview counts without writing. Default false")))
+ .add("required", Json.createArrayBuilder().add("id").add("departmentIds")))
+ .build();
+
+ JsonObject userRoleExpandTool = Json.createObjectBuilder()
+ .add("name", "user_role_expand")
+ .add("description",
+ "Add role-template records (privileges/icons/subscriptions/login page) the user is missing, for the "
+ + "given departments. Existing extra records the user already has beyond the template are left "
+ + "untouched. roleId is required. Set preview=true first to see how many records would be added "
+ + "per aspect, then call again with preview omitted/false to apply. Always confirm with the user "
+ + "before applying.")
+ .add("input_schema", Json.createObjectBuilder()
+ .add("type", "object")
+ .add("properties", Json.createObjectBuilder()
+ .add("id", Json.createObjectBuilder().add("type", "string").add("description", "Target WebUser ID"))
+ .add("roleId", Json.createObjectBuilder().add("type", "string").add("description", "Role template ID (required)"))
+ .add("departmentIds", Json.createObjectBuilder().add("type", "string").add("description", "Comma-separated department IDs"))
+ .add("aspects", Json.createObjectBuilder().add("type", "string").add("description", "Comma-separated aspects: PRIVILEGES, ICONS, SUBSCRIPTIONS, LOGIN_PAGE. Default PRIVILEGES"))
+ .add("preview", Json.createObjectBuilder().add("type", "string").add("description", "'true' to preview counts without writing. Default false")))
+ .add("required", Json.createArrayBuilder().add("id").add("roleId").add("departmentIds")))
+ .build();
+
+ JsonObject userRoleNarrowTool = Json.createObjectBuilder()
+ .add("name", "user_role_narrow")
+ .add("description",
+ "Retire the user's records (privileges/icons/subscriptions/login page) that match a role template, "
+ + "for the given departments. Records the user has that are NOT part of the template are left "
+ + "untouched. roleId is required. Set preview=true first to see how many records would be retired "
+ + "per aspect, then call again with preview omitted/false to apply. Always confirm with the user "
+ + "before applying.")
+ .add("input_schema", Json.createObjectBuilder()
+ .add("type", "object")
+ .add("properties", Json.createObjectBuilder()
+ .add("id", Json.createObjectBuilder().add("type", "string").add("description", "Target WebUser ID"))
+ .add("roleId", Json.createObjectBuilder().add("type", "string").add("description", "Role template ID (required)"))
+ .add("departmentIds", Json.createObjectBuilder().add("type", "string").add("description", "Comma-separated department IDs"))
+ .add("aspects", Json.createObjectBuilder().add("type", "string").add("description", "Comma-separated aspects: PRIVILEGES, ICONS, SUBSCRIPTIONS, LOGIN_PAGE. Default PRIVILEGES"))
+ .add("preview", Json.createObjectBuilder().add("type", "string").add("description", "'true' to preview counts without writing. Default false")))
+ .add("required", Json.createArrayBuilder().add("id").add("roleId").add("departmentIds")))
+ .build();
+
+ JsonObject userBulkRoleOperationsTool = Json.createObjectBuilder()
+ .add("name", "user_bulk_role_operations")
+ .add("description",
+ "Apply RESET, EXPAND, or NARROW role-template operations to many users at once. Target users are "
+ + "either an explicit userIds list (wins if given) or a filter by filterRoleId/filterDepartmentId "
+ + "(users with that role and/or an active loggable department assignment). "
+ + "Two-step safety gate mirroring the UI confirm dialog: first call with preview=true (confirm "
+ + "omitted/false) to see the resolved user count and per-aspect totals (capped at the first 200 "
+ + "users); only after showing this to the user and getting explicit approval, repeat the identical "
+ + "call with confirm=true (preview omitted/false) to actually apply. Calling with neither preview "
+ + "nor confirm set is rejected by the API.")
+ .add("input_schema", Json.createObjectBuilder()
+ .add("type", "object")
+ .add("properties", Json.createObjectBuilder()
+ .add("action", Json.createObjectBuilder()
+ .add("type", "string")
+ .add("enum", Json.createArrayBuilder().add("RESET").add("EXPAND").add("NARROW"))
+ .add("description", "Operation to apply to every resolved user"))
+ .add("userIds", Json.createObjectBuilder().add("type", "string").add("description", "Comma-separated WebUser IDs. Wins over filterRoleId/filterDepartmentId if given"))
+ .add("filterRoleId", Json.createObjectBuilder().add("type", "string").add("description", "Only used when userIds is omitted: match users with this role"))
+ .add("filterDepartmentId", Json.createObjectBuilder().add("type", "string").add("description", "Only used when userIds is omitted: match users with an active loggable assignment to this department"))
+ .add("roleId", Json.createObjectBuilder().add("type", "string").add("description", "Target template role ID. Omit to use each user's own current role"))
+ .add("departmentIds", Json.createObjectBuilder().add("type", "string").add("description", "Comma-separated department IDs to operate on (required)"))
+ .add("aspects", Json.createObjectBuilder().add("type", "string").add("description", "Comma-separated aspects: PRIVILEGES, ICONS, SUBSCRIPTIONS, LOGIN_PAGE. Default PRIVILEGES"))
+ .add("updateUserRole", Json.createObjectBuilder().add("type", "string").add("description", "'true' or 'false' — for RESET, also set each user's WebUser.role to roleId. Default true"))
+ .add("preview", Json.createObjectBuilder().add("type", "string").add("description", "'true' for the first, read-only call"))
+ .add("confirm", Json.createObjectBuilder().add("type", "string").add("description", "'true' for the second call that actually applies the operation")))
+ .add("required", Json.createArrayBuilder().add("action").add("departmentIds")))
+ .build();
+
+ JsonObject listUserRolesTool = Json.createObjectBuilder()
+ .add("name", "list_user_roles")
+ .add("description",
+ "List active user roles with their role-template summary: id, name, description, template login "
+ + "page, and counts of active role-level privileges, template icons, and template subscriptions. "
+ + "Use this to discover valid roleId values before calling user_role_reset/expand/narrow or "
+ + "user_bulk_role_operations.")
+ .add("input_schema", Json.createObjectBuilder()
+ .add("type", "object")
+ .add("properties", Json.createObjectBuilder())
+ .add("required", Json.createArrayBuilder()))
+ .build();
+
+ JsonObject setUserLoginPageTool = Json.createObjectBuilder()
+ .add("name", "set_user_login_page")
+ .add("description",
+ "Set or remove a user's default login page override for one department. This is separate from a "
+ + "role's template login page (admin-time only) and from the legacy WebUser.loginPage fallback. "
+ + "Runtime resolution order: this user+department override, then WebUser.loginPage, then HOME. "
+ + "action SET (default) requires loginPage (a LoginPage enum name); action DELETE removes the "
+ + "override for that department, falling back to the legacy behavior. Always confirm with the "
+ + "user before calling.")
+ .add("input_schema", Json.createObjectBuilder()
+ .add("type", "object")
+ .add("properties", Json.createObjectBuilder()
+ .add("action", Json.createObjectBuilder()
+ .add("type", "string")
+ .add("enum", Json.createArrayBuilder().add("SET").add("DELETE"))
+ .add("description", "SET to upsert the override (default), DELETE to remove it"))
+ .add("id", Json.createObjectBuilder().add("type", "string").add("description", "Target WebUser ID"))
+ .add("departmentId", Json.createObjectBuilder().add("type", "string").add("description", "Department ID"))
+ .add("loginPage", Json.createObjectBuilder().add("type", "string").add("description", "LoginPage enum name — required for action SET")))
+ .add("required", Json.createArrayBuilder().add("id").add("departmentId")))
+ .build();
+
JsonObject managePharmacyItemsTool = Json.createObjectBuilder()
.add("name", "manage_pharmacy_items")
.add("description",
@@ -1566,6 +1692,12 @@ private JsonArray buildToolsArray() {
.add(manageSubscriptionsTool)
.add(manageStaffTool)
.add(manageUsersTool)
+ .add(userRoleResetTool)
+ .add(userRoleExpandTool)
+ .add(userRoleNarrowTool)
+ .add(userBulkRoleOperationsTool)
+ .add(listUserRolesTool)
+ .add(setUserLoginPageTool)
.add(managePharmacyItemsTool)
.add(managePharmacyDiscountsTool)
.add(managePaymentSchemesTool)
@@ -1868,6 +2000,24 @@ private String executeToolCall(String toolName, JsonObject toolInput, String git
case "manage_users": {
return callUsersApi(toolInput, hmisBaseUrl, hmisApiKey);
}
+ case "user_role_reset": {
+ return callUserRoleOperationApi("reset", toolInput, false, hmisBaseUrl, hmisApiKey);
+ }
+ case "user_role_expand": {
+ return callUserRoleOperationApi("expand", toolInput, true, hmisBaseUrl, hmisApiKey);
+ }
+ case "user_role_narrow": {
+ return callUserRoleOperationApi("narrow", toolInput, true, hmisBaseUrl, hmisApiKey);
+ }
+ case "user_bulk_role_operations": {
+ return callUserBulkRoleOperationsApi(toolInput, hmisBaseUrl, hmisApiKey);
+ }
+ case "list_user_roles": {
+ return callListUserRolesApi(hmisBaseUrl, hmisApiKey);
+ }
+ case "set_user_login_page": {
+ return callSetUserLoginPageApi(toolInput, hmisBaseUrl, hmisApiKey);
+ }
case "manage_pharmacy_items": {
return callPharmacyItemsApi(toolInput, hmisBaseUrl, hmisApiKey);
}
@@ -4284,6 +4434,111 @@ private String callUsersApi(JsonObject input, String hmisBaseUrl, String hmisApi
}
}
+ private String callUserRoleOperationApi(String action, JsonObject input, boolean roleRequired, String hmisBaseUrl, String hmisApiKey) {
+ if (hmisBaseUrl == null || hmisBaseUrl.trim().isEmpty()) {
+ return "Error: HMIS base URL is not configured.";
+ }
+ if (hmisApiKey == null || hmisApiKey.trim().isEmpty()) {
+ return "Error: HMIS API key is not configured.";
+ }
+ try {
+ String id = requireText(jsonString(input, "id"), "id");
+ String url = hmisBaseUrl.replaceAll("/$", "") + "/api/users/" + id + "/role/" + action;
+ String roleId = jsonString(input, "roleId");
+ if (roleRequired && roleId.isEmpty()) {
+ return "Error: roleId is required for role " + action + ".";
+ }
+ javax.json.JsonObjectBuilder b = Json.createObjectBuilder();
+ addLong(b, "roleId", roleId);
+ b.add("departmentIds", csvLongArray(jsonString(input, "departmentIds")));
+ String aspects = jsonString(input, "aspects");
+ if (!aspects.isEmpty()) b.add("aspects", csvArray(aspects));
+ addBoolean(b, "updateUserRole", jsonString(input, "updateUserRole"));
+ addBoolean(b, "preview", jsonString(input, "preview"));
+ return callHmisApi(url, "POST", b.build().toString(), hmisApiKey);
+ } catch (Exception e) {
+ return "User role " + action + " error: " + e.getMessage();
+ }
+ }
+
+ private String callUserBulkRoleOperationsApi(JsonObject input, String hmisBaseUrl, String hmisApiKey) {
+ if (hmisBaseUrl == null || hmisBaseUrl.trim().isEmpty()) {
+ return "Error: HMIS base URL is not configured.";
+ }
+ if (hmisApiKey == null || hmisApiKey.trim().isEmpty()) {
+ return "Error: HMIS API key is not configured.";
+ }
+ try {
+ String url = hmisBaseUrl.replaceAll("/$", "") + "/api/users/bulk/role-operations";
+ javax.json.JsonObjectBuilder b = Json.createObjectBuilder();
+ b.add("action", requireText(jsonString(input, "action"), "action"));
+ String userIds = jsonString(input, "userIds");
+ if (!userIds.isEmpty()) {
+ b.add("userIds", csvLongArray(userIds));
+ } else {
+ String filterRoleId = jsonString(input, "filterRoleId");
+ String filterDepartmentId = jsonString(input, "filterDepartmentId");
+ if (!filterRoleId.isEmpty() || !filterDepartmentId.isEmpty()) {
+ javax.json.JsonObjectBuilder filter = Json.createObjectBuilder();
+ addLong(filter, "roleId", filterRoleId);
+ addLong(filter, "departmentId", filterDepartmentId);
+ b.add("filter", filter);
+ }
+ }
+ addLong(b, "roleId", jsonString(input, "roleId"));
+ b.add("departmentIds", csvLongArray(jsonString(input, "departmentIds")));
+ String aspects = jsonString(input, "aspects");
+ if (!aspects.isEmpty()) b.add("aspects", csvArray(aspects));
+ addBoolean(b, "updateUserRole", jsonString(input, "updateUserRole"));
+ addBoolean(b, "preview", jsonString(input, "preview"));
+ addBoolean(b, "confirm", jsonString(input, "confirm"));
+ return callHmisApi(url, "POST", b.build().toString(), hmisApiKey);
+ } catch (Exception e) {
+ return "Bulk role operations error: " + e.getMessage();
+ }
+ }
+
+ private String callListUserRolesApi(String hmisBaseUrl, String hmisApiKey) {
+ if (hmisBaseUrl == null || hmisBaseUrl.trim().isEmpty()) {
+ return "Error: HMIS base URL is not configured.";
+ }
+ if (hmisApiKey == null || hmisApiKey.trim().isEmpty()) {
+ return "Error: HMIS API key is not configured.";
+ }
+ try {
+ String url = hmisBaseUrl.replaceAll("/$", "") + "/api/users/roles";
+ return callHmisApi(url, "GET", null, hmisApiKey);
+ } catch (Exception e) {
+ return "List user roles error: " + e.getMessage();
+ }
+ }
+
+ private String callSetUserLoginPageApi(JsonObject input, String hmisBaseUrl, String hmisApiKey) {
+ if (hmisBaseUrl == null || hmisBaseUrl.trim().isEmpty()) {
+ return "Error: HMIS base URL is not configured.";
+ }
+ if (hmisApiKey == null || hmisApiKey.trim().isEmpty()) {
+ return "Error: HMIS API key is not configured.";
+ }
+ try {
+ String id = requireText(jsonString(input, "id"), "id");
+ String departmentId = requireText(jsonString(input, "departmentId"), "departmentId");
+ String action = input.containsKey("action") ? input.getString("action", "SET").toUpperCase() : "SET";
+ String base = hmisBaseUrl.replaceAll("/$", "") + "/api/users/" + id + "/login-page";
+ if ("DELETE".equals(action)) {
+ return callHmisApi(base + "/" + departmentId, "DELETE", null, hmisApiKey);
+ }
+ String loginPage = requireText(jsonString(input, "loginPage"), "loginPage");
+ String body = Json.createObjectBuilder()
+ .add("departmentId", Long.parseLong(departmentId))
+ .add("loginPage", loginPage)
+ .build().toString();
+ return callHmisApi(base, "PUT", body, hmisApiKey);
+ } catch (Exception e) {
+ return "Set user login page error: " + e.getMessage();
+ }
+ }
+
private String callPharmacyItemsApi(JsonObject input, String hmisBaseUrl, String hmisApiKey) {
if (hmisBaseUrl == null || hmisBaseUrl.trim().isEmpty()) {
return "Error: HMIS base URL is not configured.";
@@ -5182,7 +5437,19 @@ public String buildSystemPrompt(String hmisApiBaseUrl, String userHmisApiKey, St
+ "DELETE /{id}/departments/{assignmentId} removes one loggable department. "
+ "DELETE /{id}/departments/{deptId}/privileges bulk-revokes all privileges for a department. "
+ "POST /{id}/departments/{deptId}/privileges/all grants every privilege for a department. "
- + "POST /{id}/privileges/all with optional body {departmentIds:[...]} grants every privilege across multiple departments at once.",
+ + "POST /{id}/privileges/all with optional body {departmentIds:[...]} grants every privilege across multiple departments at once.\n\n"
+ + "Role-template operations: roles (WebUserRole) are admin-time templates only — runtime behavior "
+ + "(privilege checks, login-page resolution, icons, subscriptions) reads user-level records exclusively. "
+ + "These endpoints stamp/reset a user's own records FROM a role template; they never change runtime behavior directly. "
+ + "aspects (default [\"PRIVILEGES\"]): PRIVILEGES, ICONS, SUBSCRIPTIONS, LOGIN_PAGE. "
+ + "RESET converges the user's records to exactly the template (retires extras, adds missing); roleId omitted defaults to the "
+ + "user's own WebUser.role and 400s if the user has none. EXPAND adds template records the user lacks, leaving extras untouched "
+ + "(roleId required). NARROW retires the user's records that match the template, leaving non-template records untouched "
+ + "(roleId required). Any single-user call with preview=true returns counts (added/retired per aspect) without writing. "
+ + "Bulk operations (POST /users/bulk/role-operations) target either explicit userIds or a {roleId?, departmentId?} filter "
+ + "(userIds wins) and use a two-step safety gate: call once with preview=true to see the resolved user count and per-aspect "
+ + "totals (capped at first 200 users), then repeat the identical call with confirm=true to actually apply — calling with "
+ + "neither preview nor confirm is rejected. GET /users/roles lists active roles with template summary counts.",
githubUrl(branch, "developer_docs/API_USER_MANAGEMENT.md"),
new String[][]{
{"GET", "/users", "List users. Filters: query, departmentId, page, size"},
@@ -5204,7 +5471,14 @@ public String buildSystemPrompt(String hmisApiBaseUrl, String userHmisApiKey, St
{"DELETE", "/users/{id}/departments/{departmentId}/privileges", "Bulk-revoke all active privileges for a user scoped to a department"},
{"POST", "/users/{id}/departments/{departmentId}/privileges/all", "Assign every Privileges enum value to a user for a department (skips duplicates)"},
{"PUT", "/users/{id}/staff", "Link an existing Staff record to the user (body: {staffId})"},
- {"POST", "/users/{id}/privileges/all", "Assign every privilege across supplied departmentIds (or all loggable depts). Returns per-dept summary"}
+ {"POST", "/users/{id}/privileges/all", "Assign every privilege across supplied departmentIds (or all loggable depts). Returns per-dept summary"},
+ {"POST", "/users/{id}/role/reset", "Reset a user's aspects to a role template (roleId optional; defaults to the user's own role)"},
+ {"POST", "/users/{id}/role/expand", "Add role-template records the user lacks (roleId required)"},
+ {"POST", "/users/{id}/role/narrow", "Retire the user's records that match a role template (roleId required)"},
+ {"POST", "/users/bulk/role-operations", "Bulk RESET/EXPAND/NARROW for many users; preview=true then confirm=true"},
+ {"GET", "/users/roles", "List active roles with template summary counts (privileges/icons/subscriptions, template login page)"},
+ {"PUT", "/users/{id}/login-page", "Upsert the user's default login page for a department (body: {departmentId, loginPage})"},
+ {"DELETE", "/users/{id}/login-page/{departmentId}", "Retire the user's default login-page override for a department"}
});
appendModule(sb, "User Roles", "/user-roles",
diff --git a/src/main/java/com/divudi/service/UserRoleApplicationService.java b/src/main/java/com/divudi/service/UserRoleApplicationService.java
new file mode 100644
index 00000000000..03b4447a412
--- /dev/null
+++ b/src/main/java/com/divudi/service/UserRoleApplicationService.java
@@ -0,0 +1,813 @@
+/*
+* Dr M H B Ariyaratne
+ * buddhika.ari@gmail.com
+ */
+package com.divudi.service;
+
+import com.divudi.core.data.Icon;
+import com.divudi.core.data.LoginPage;
+import com.divudi.core.data.Privileges;
+import com.divudi.core.data.TriggerType;
+import com.divudi.core.entity.AuditEvent;
+import com.divudi.core.entity.Department;
+import com.divudi.core.entity.TriggerSubscription;
+import com.divudi.core.entity.UserIcon;
+import com.divudi.core.entity.WebUser;
+import com.divudi.core.entity.WebUserDefaultLoginPage;
+import com.divudi.core.entity.WebUserPrivilege;
+import com.divudi.core.entity.WebUserRole;
+import com.divudi.core.entity.WebUserRoleUser;
+import com.divudi.core.facade.TriggerSubscriptionFacade;
+import com.divudi.core.facade.UserIconFacade;
+import com.divudi.core.facade.WebUserDefaultLoginPageFacade;
+import com.divudi.core.facade.WebUserFacade;
+import com.divudi.core.facade.WebUserPrivilegeFacade;
+import com.divudi.core.facade.WebUserRolePrivilegeFacade;
+import com.divudi.core.facade.WebUserRoleUserFacade;
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.EnumMap;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import javax.ejb.EJB;
+import javax.ejb.Stateless;
+
+/**
+ * Single engine for role-template based user-management operations (used by
+ * the admin UI, the REST API, and the AI assistant). Roles are admin-time
+ * templates only; runtime behaviour reads user-level records exclusively.
+ * This service stamps/resets those user-level records from a role template.
+ *
+ * JPQL only, batched writes, one AuditEvent + one WebUserRoleUser history
+ * row per {@link #apply} call.
+ *
+ * @author www.divudi.com
+ */
+@Stateless
+public class UserRoleApplicationService {
+
+ @EJB
+ private WebUserPrivilegeFacade webUserPrivilegeFacade;
+ @EJB
+ private WebUserRolePrivilegeFacade webUserRolePrivilegeFacade;
+ @EJB
+ private UserIconFacade userIconFacade;
+ @EJB
+ private TriggerSubscriptionFacade triggerSubscriptionFacade;
+ @EJB
+ private WebUserDefaultLoginPageFacade webUserDefaultLoginPageFacade;
+ @EJB
+ private WebUserRoleUserFacade webUserRoleUserFacade;
+ @EJB
+ private WebUserFacade webUserFacade;
+ @EJB
+ private AuditEventService auditEventService;
+
+ public enum RoleAspect {
+ PRIVILEGES, ICONS, SUBSCRIPTIONS, LOGIN_PAGE
+ }
+
+ public enum RoleOperation {
+ RESET, EXPAND, NARROW
+ }
+
+ /**
+ * Result of applying one role operation to one user. Counts are keyed by
+ * aspect so the UI/AI can report "added N privileges, retired M icons"
+ * style summaries.
+ */
+ public static class RoleApplicationResult implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+ private final WebUser user;
+ private final Map added = new EnumMap<>(RoleAspect.class);
+ private final Map retired = new EnumMap<>(RoleAspect.class);
+ private String errorMessage;
+
+ public RoleApplicationResult(WebUser user) {
+ this.user = user;
+ }
+
+ void addCounts(RoleAspect aspect, int addedCount, int retiredCount) {
+ added.merge(aspect, addedCount, Integer::sum);
+ retired.merge(aspect, retiredCount, Integer::sum);
+ }
+
+ public WebUser getUser() {
+ return user;
+ }
+
+ public Map getAdded() {
+ return added;
+ }
+
+ public Map getRetired() {
+ return retired;
+ }
+
+ public String getErrorMessage() {
+ return errorMessage;
+ }
+
+ public void setErrorMessage(String errorMessage) {
+ this.errorMessage = errorMessage;
+ }
+
+ public boolean isSuccess() {
+ return errorMessage == null;
+ }
+
+ public int getTotalAdded() {
+ int total = 0;
+ for (int v : added.values()) {
+ total += v;
+ }
+ return total;
+ }
+
+ public int getTotalRetired() {
+ int total = 0;
+ for (int v : retired.values()) {
+ total += v;
+ }
+ return total;
+ }
+
+ public String summary() {
+ if (!isSuccess()) {
+ return "error=" + errorMessage;
+ }
+ return "added=" + added + ", retired=" + retired;
+ }
+
+ public static String summarize(List results) {
+ if (results == null || results.isEmpty()) {
+ return "no users processed";
+ }
+ int succeeded = 0;
+ int failed = 0;
+ int totalAdded = 0;
+ int totalRetired = 0;
+ for (RoleApplicationResult r : results) {
+ if (r.isSuccess()) {
+ succeeded++;
+ totalAdded += r.getTotalAdded();
+ totalRetired += r.getTotalRetired();
+ } else {
+ failed++;
+ }
+ }
+ return "users=" + results.size() + ", succeeded=" + succeeded + ", failed=" + failed
+ + ", totalAdded=" + totalAdded + ", totalRetired=" + totalRetired;
+ }
+ }
+
+ /**
+ * Applies a role operation to one user across the given departments and
+ * aspects. RESET converges the user's active set to exactly the role
+ * template's set (records already matching the template are left
+ * untouched rather than being blindly retired-and-recreated, so audit
+ * counts reflect real changes only).
+ */
+ public RoleApplicationResult apply(RoleOperation op, WebUser targetUser, WebUserRole role, List departments,
+ Set aspects, boolean updateUserRole, WebUser actor) {
+
+ RoleApplicationResult result = new RoleApplicationResult(targetUser);
+
+ if (op == null) {
+ result.setErrorMessage("Operation is required");
+ return result;
+ }
+ if (targetUser == null) {
+ result.setErrorMessage("Target user is required");
+ return result;
+ }
+ if (departments == null || departments.isEmpty()) {
+ result.setErrorMessage("At least one department is required");
+ return result;
+ }
+ if (aspects == null || aspects.isEmpty()) {
+ result.setErrorMessage("At least one aspect is required");
+ return result;
+ }
+
+ WebUserRole effectiveRole = role != null ? role : targetUser.getRole();
+ if (effectiveRole == null) {
+ result.setErrorMessage("No role available: role not specified and user has no default role");
+ return result;
+ }
+
+ Date now = new Date();
+
+ List privilegesToCreate = new ArrayList<>();
+ List privilegesToEdit = new ArrayList<>();
+ List iconsToCreate = new ArrayList<>();
+ List iconsToEdit = new ArrayList<>();
+ List subscriptionsToCreate = new ArrayList<>();
+ List subscriptionsToEdit = new ArrayList<>();
+ List loginPagesToCreate = new ArrayList<>();
+ List loginPagesToEdit = new ArrayList<>();
+ List historyRows = new ArrayList<>();
+
+ for (Department dept : departments) {
+ if (dept == null) {
+ continue;
+ }
+ if (aspects.contains(RoleAspect.PRIVILEGES)) {
+ applyPrivilegesAspect(op, targetUser, effectiveRole, dept, actor, now, result, privilegesToCreate, privilegesToEdit);
+ }
+ if (aspects.contains(RoleAspect.ICONS)) {
+ applyIconsAspect(op, targetUser, effectiveRole, dept, now, result, iconsToCreate, iconsToEdit);
+ }
+ if (aspects.contains(RoleAspect.SUBSCRIPTIONS)) {
+ applySubscriptionsAspect(op, targetUser, effectiveRole, dept, actor, now, result, subscriptionsToCreate, subscriptionsToEdit);
+ }
+ if (aspects.contains(RoleAspect.LOGIN_PAGE)) {
+ applyLoginPageAspect(op, targetUser, effectiveRole, dept, actor, now, result, loginPagesToCreate, loginPagesToEdit);
+ }
+ if (op == RoleOperation.RESET || op == RoleOperation.EXPAND) {
+ WebUserRoleUser history = new WebUserRoleUser();
+ history.setWebUserRole(effectiveRole);
+ history.setWebUser(targetUser);
+ history.setDepartment(dept);
+ history.setCreater(actor);
+ history.setCreatedAt(now);
+ historyRows.add(history);
+ }
+ }
+
+ if (!privilegesToEdit.isEmpty()) {
+ webUserPrivilegeFacade.batchEdit(privilegesToEdit);
+ }
+ if (!privilegesToCreate.isEmpty()) {
+ webUserPrivilegeFacade.batchCreate(privilegesToCreate);
+ }
+ if (!iconsToEdit.isEmpty()) {
+ userIconFacade.batchEdit(iconsToEdit);
+ }
+ if (!iconsToCreate.isEmpty()) {
+ userIconFacade.batchCreate(iconsToCreate);
+ }
+ if (!subscriptionsToEdit.isEmpty()) {
+ triggerSubscriptionFacade.batchEdit(subscriptionsToEdit);
+ }
+ if (!subscriptionsToCreate.isEmpty()) {
+ triggerSubscriptionFacade.batchCreate(subscriptionsToCreate);
+ }
+ if (!loginPagesToEdit.isEmpty()) {
+ webUserDefaultLoginPageFacade.batchEdit(loginPagesToEdit);
+ }
+ if (!loginPagesToCreate.isEmpty()) {
+ webUserDefaultLoginPageFacade.batchCreate(loginPagesToCreate);
+ }
+ if (!historyRows.isEmpty()) {
+ webUserRoleUserFacade.batchCreate(historyRows);
+ }
+
+ if (op == RoleOperation.RESET && updateUserRole && !effectiveRole.equals(targetUser.getRole())) {
+ targetUser.setRole(effectiveRole);
+ webUserFacade.edit(targetUser);
+ }
+
+ writeAuditEvent(op, targetUser, effectiveRole, departments, aspects, result, actor);
+
+ return result;
+ }
+
+ /**
+ * Loops {@link #apply} over many users. When {@code role} is null each
+ * user's own current role is used; a user with no role and no role
+ * specified gets an error result instead of being skipped silently.
+ */
+ public List applyBulk(RoleOperation op, List targetUsers, WebUserRole role,
+ List departments, Set aspects, boolean updateUserRole, WebUser actor) {
+ List results = new ArrayList<>();
+ if (targetUsers == null) {
+ return results;
+ }
+ for (WebUser user : targetUsers) {
+ if (user == null) {
+ continue;
+ }
+ WebUserRole effectiveRole = role != null ? role : user.getRole();
+ if (effectiveRole == null) {
+ RoleApplicationResult r = new RoleApplicationResult(user);
+ r.setErrorMessage("User has no default role and no role was specified");
+ results.add(r);
+ continue;
+ }
+ results.add(apply(op, user, effectiveRole, departments, aspects, updateUserRole, actor));
+ }
+ return results;
+ }
+
+ /**
+ * Read-only preview of how many records each aspect would add+retire,
+ * without writing anything. Used by UI confirm dialogs and the AI
+ * assistant's confirm flow.
+ */
+ public Map previewCounts(RoleOperation op, WebUser targetUser, WebUserRole role,
+ List departments, Set aspects) {
+ Map preview = new EnumMap<>(RoleAspect.class);
+ if (op == null || targetUser == null || departments == null || departments.isEmpty()
+ || aspects == null || aspects.isEmpty()) {
+ return preview;
+ }
+ WebUserRole effectiveRole = role != null ? role : targetUser.getRole();
+ if (effectiveRole == null) {
+ return preview;
+ }
+ for (RoleAspect aspect : aspects) {
+ long total = 0;
+ for (Department dept : departments) {
+ if (dept == null) {
+ continue;
+ }
+ total += previewAspectCount(op, targetUser, effectiveRole, dept, aspect);
+ }
+ preview.put(aspect, total);
+ }
+ return preview;
+ }
+
+ //
+ private void applyPrivilegesAspect(RoleOperation op, WebUser user, WebUserRole role, Department dept, WebUser actor, Date now,
+ RoleApplicationResult result, List toCreate, List toEdit) {
+ Set templateSet = fetchTemplatePrivileges(role);
+ List active = fetchActivePrivileges(user, dept);
+ Set activeValues = new HashSet<>();
+ for (WebUserPrivilege w : active) {
+ if (w.getPrivilege() != null) {
+ activeValues.add(w.getPrivilege());
+ }
+ }
+
+ int added = 0;
+ int retired = 0;
+
+ if (op == RoleOperation.RESET || op == RoleOperation.NARROW) {
+ for (WebUserPrivilege w : active) {
+ boolean matchesTemplate = w.getPrivilege() != null && templateSet.contains(w.getPrivilege());
+ boolean shouldRetire = op == RoleOperation.NARROW ? matchesTemplate : !matchesTemplate;
+ if (shouldRetire) {
+ w.setRetired(true);
+ w.setRetirer(actor);
+ w.setRetiredAt(now);
+ toEdit.add(w);
+ retired++;
+ }
+ }
+ }
+
+ if (op == RoleOperation.RESET || op == RoleOperation.EXPAND) {
+ Set missing = new HashSet<>();
+ for (Privileges p : templateSet) {
+ if (!activeValues.contains(p)) {
+ missing.add(p);
+ }
+ }
+ Map retiredCandidates = fetchRetiredPrivilegeCandidates(user, dept, missing);
+ for (Privileges p : missing) {
+ WebUserPrivilege candidate = retiredCandidates.get(p);
+ if (candidate != null) {
+ candidate.setRetired(false);
+ toEdit.add(candidate);
+ } else {
+ WebUserPrivilege fresh = new WebUserPrivilege();
+ fresh.setWebUser(user);
+ fresh.setDepartment(dept);
+ fresh.setPrivilege(p);
+ fresh.setCreater(actor);
+ fresh.setCreatedAt(now);
+ toCreate.add(fresh);
+ }
+ added++;
+ }
+ }
+
+ result.addCounts(RoleAspect.PRIVILEGES, added, retired);
+ }
+
+ private Set fetchTemplatePrivileges(WebUserRole role) {
+ List> rows = webUserRolePrivilegeFacade.findLightsByJpql(
+ "select distinct p.privilege from WebUserRolePrivilege p where p.webUserRole=:role and p.retired=false and p.privilege is not null",
+ params("role", role));
+ Set set = new HashSet<>();
+ for (Object o : rows) {
+ set.add((Privileges) o);
+ }
+ return set;
+ }
+
+ private List fetchActivePrivileges(WebUser user, Department dept) {
+ return webUserPrivilegeFacade.findByJpql(
+ "select w from WebUserPrivilege w where w.webUser=:user and w.department=:dept and w.retired=false",
+ params("user", user, "dept", dept));
+ }
+
+ private Map fetchRetiredPrivilegeCandidates(WebUser user, Department dept, Set values) {
+ Map map = new HashMap<>();
+ if (values.isEmpty()) {
+ return map;
+ }
+ List rows = webUserPrivilegeFacade.findByJpql(
+ "select w from WebUserPrivilege w where w.webUser=:user and w.department=:dept and w.retired=true and w.privilege in :vals order by w.id desc",
+ params("user", user, "dept", dept, "vals", values));
+ for (WebUserPrivilege w : rows) {
+ map.putIfAbsent(w.getPrivilege(), w);
+ }
+ return map;
+ }
+ //
+
+ //
+ private void applyIconsAspect(RoleOperation op, WebUser user, WebUserRole role, Department dept, Date now,
+ RoleApplicationResult result, List toCreate, List toEdit) {
+ Map template = fetchTemplateIcons(role);
+ Set templateSet = template.keySet();
+ List active = fetchActiveIcons(user, dept);
+ Set activeValues = new HashSet<>();
+ for (UserIcon u : active) {
+ if (u.getIcon() != null) {
+ activeValues.add(u.getIcon());
+ }
+ }
+
+ int added = 0;
+ int retired = 0;
+
+ if (op == RoleOperation.RESET || op == RoleOperation.NARROW) {
+ for (UserIcon u : active) {
+ boolean matchesTemplate = u.getIcon() != null && templateSet.contains(u.getIcon());
+ boolean shouldRetire = op == RoleOperation.NARROW ? matchesTemplate : !matchesTemplate;
+ if (shouldRetire) {
+ u.setRetired(true);
+ toEdit.add(u);
+ retired++;
+ }
+ }
+ }
+
+ if (op == RoleOperation.RESET || op == RoleOperation.EXPAND) {
+ Set missing = new HashSet<>();
+ for (Icon icon : templateSet) {
+ if (!activeValues.contains(icon)) {
+ missing.add(icon);
+ }
+ }
+ Map retiredCandidates = fetchRetiredIconCandidates(user, dept, missing);
+ for (Icon icon : missing) {
+ UserIcon candidate = retiredCandidates.get(icon);
+ if (candidate != null) {
+ candidate.setRetired(false);
+ toEdit.add(candidate);
+ } else {
+ UserIcon fresh = new UserIcon();
+ fresh.setWebUser(user);
+ fresh.setDepartment(dept);
+ fresh.setIcon(icon);
+ UserIcon templateRow = template.get(icon);
+ fresh.setOrderNumber(templateRow != null ? templateRow.getOrderNumber() : 0d);
+ toCreate.add(fresh);
+ }
+ added++;
+ }
+ }
+
+ result.addCounts(RoleAspect.ICONS, added, retired);
+ }
+
+ private Map fetchTemplateIcons(WebUserRole role) {
+ List rows = userIconFacade.findByJpql(
+ "select u from UserIcon u where u.webUserRole=:role and u.webUser is null and u.retired=false and u.icon is not null",
+ params("role", role));
+ Map map = new LinkedHashMap<>();
+ for (UserIcon u : rows) {
+ map.put(u.getIcon(), u);
+ }
+ return map;
+ }
+
+ private List fetchActiveIcons(WebUser user, Department dept) {
+ return userIconFacade.findByJpql(
+ "select u from UserIcon u where u.webUser=:user and u.department=:dept and u.retired=false",
+ params("user", user, "dept", dept));
+ }
+
+ private Map fetchRetiredIconCandidates(WebUser user, Department dept, Set values) {
+ Map map = new HashMap<>();
+ if (values.isEmpty()) {
+ return map;
+ }
+ List rows = userIconFacade.findByJpql(
+ "select u from UserIcon u where u.webUser=:user and u.department=:dept and u.retired=true and u.icon in :vals order by u.id desc",
+ params("user", user, "dept", dept, "vals", values));
+ for (UserIcon u : rows) {
+ map.putIfAbsent(u.getIcon(), u);
+ }
+ return map;
+ }
+ //
+
+ //
+ private void applySubscriptionsAspect(RoleOperation op, WebUser user, WebUserRole role, Department dept, WebUser actor, Date now,
+ RoleApplicationResult result, List toCreate, List toEdit) {
+ Map template = fetchTemplateSubscriptions(role);
+ Set templateSet = template.keySet();
+ List active = fetchActiveSubscriptions(user, dept);
+ Set activeValues = new HashSet<>();
+ for (TriggerSubscription t : active) {
+ if (t.getTriggerType() != null) {
+ activeValues.add(t.getTriggerType());
+ }
+ }
+
+ int added = 0;
+ int retired = 0;
+
+ if (op == RoleOperation.RESET || op == RoleOperation.NARROW) {
+ for (TriggerSubscription t : active) {
+ boolean matchesTemplate = t.getTriggerType() != null && templateSet.contains(t.getTriggerType());
+ boolean shouldRetire = op == RoleOperation.NARROW ? matchesTemplate : !matchesTemplate;
+ if (shouldRetire) {
+ t.setRetired(true);
+ t.setRetirer(actor);
+ t.setRetiredAt(now);
+ toEdit.add(t);
+ retired++;
+ }
+ }
+ }
+
+ if (op == RoleOperation.RESET || op == RoleOperation.EXPAND) {
+ Set missing = new HashSet<>();
+ for (TriggerType tt : templateSet) {
+ if (!activeValues.contains(tt)) {
+ missing.add(tt);
+ }
+ }
+ Map retiredCandidates = fetchRetiredSubscriptionCandidates(user, dept, missing);
+ for (TriggerType tt : missing) {
+ TriggerSubscription candidate = retiredCandidates.get(tt);
+ if (candidate != null) {
+ candidate.setRetired(false);
+ toEdit.add(candidate);
+ } else {
+ TriggerSubscription fresh = new TriggerSubscription();
+ fresh.setWebUser(user);
+ fresh.setDepartment(dept);
+ fresh.setTriggerType(tt);
+ TriggerSubscription templateRow = template.get(tt);
+ fresh.setOrderNumber(templateRow != null ? templateRow.getOrderNumber() : 0d);
+ fresh.setCreater(actor);
+ fresh.setCreatedAt(now);
+ toCreate.add(fresh);
+ }
+ added++;
+ }
+ }
+
+ result.addCounts(RoleAspect.SUBSCRIPTIONS, added, retired);
+ }
+
+ private Map fetchTemplateSubscriptions(WebUserRole role) {
+ List rows = triggerSubscriptionFacade.findByJpql(
+ "select t from TriggerSubscription t where t.webUserRole=:role and t.webUser is null and t.retired=false and t.triggerType is not null",
+ params("role", role));
+ Map map = new LinkedHashMap<>();
+ for (TriggerSubscription t : rows) {
+ map.put(t.getTriggerType(), t);
+ }
+ return map;
+ }
+
+ private List fetchActiveSubscriptions(WebUser user, Department dept) {
+ return triggerSubscriptionFacade.findByJpql(
+ "select t from TriggerSubscription t where t.webUser=:user and t.department=:dept and t.retired=false",
+ params("user", user, "dept", dept));
+ }
+
+ private Map fetchRetiredSubscriptionCandidates(WebUser user, Department dept, Set values) {
+ Map map = new HashMap<>();
+ if (values.isEmpty()) {
+ return map;
+ }
+ List rows = triggerSubscriptionFacade.findByJpql(
+ "select t from TriggerSubscription t where t.webUser=:user and t.department=:dept and t.retired=true and t.triggerType in :vals order by t.id desc",
+ params("user", user, "dept", dept, "vals", values));
+ for (TriggerSubscription t : rows) {
+ map.putIfAbsent(t.getTriggerType(), t);
+ }
+ return map;
+ }
+ //
+
+ //
+ private void applyLoginPageAspect(RoleOperation op, WebUser user, WebUserRole role, Department dept, WebUser actor, Date now,
+ RoleApplicationResult result, List toCreate, List toEdit) {
+ LoginPage templateValue = role.getLoginPage();
+ WebUserDefaultLoginPage active = fetchActiveLoginPage(user, dept);
+
+ int added = 0;
+ int retired = 0;
+
+ switch (op) {
+ case RESET:
+ if (active != null) {
+ active.setRetired(true);
+ active.setRetirer(actor);
+ active.setRetiredAt(now);
+ toEdit.add(active);
+ retired++;
+ }
+ if (templateValue != null) {
+ WebUserDefaultLoginPage fresh = new WebUserDefaultLoginPage();
+ fresh.setWebUser(user);
+ fresh.setDepartment(dept);
+ fresh.setLoginPage(templateValue);
+ fresh.setCreater(actor);
+ fresh.setCreatedAt(now);
+ toCreate.add(fresh);
+ added++;
+ }
+ break;
+ case EXPAND:
+ if (active == null && templateValue != null) {
+ WebUserDefaultLoginPage fresh = new WebUserDefaultLoginPage();
+ fresh.setWebUser(user);
+ fresh.setDepartment(dept);
+ fresh.setLoginPage(templateValue);
+ fresh.setCreater(actor);
+ fresh.setCreatedAt(now);
+ toCreate.add(fresh);
+ added++;
+ }
+ break;
+ case NARROW:
+ if (active != null && templateValue != null && templateValue == active.getLoginPage()) {
+ active.setRetired(true);
+ active.setRetirer(actor);
+ active.setRetiredAt(now);
+ toEdit.add(active);
+ retired++;
+ }
+ break;
+ default:
+ break;
+ }
+
+ result.addCounts(RoleAspect.LOGIN_PAGE, added, retired);
+ }
+
+ private WebUserDefaultLoginPage fetchActiveLoginPage(WebUser user, Department dept) {
+ return webUserDefaultLoginPageFacade.findFirstByJpql(
+ "select w from WebUserDefaultLoginPage w where w.webUser=:user and w.department=:dept and w.retired=false order by w.id desc",
+ params("user", user, "dept", dept));
+ }
+ //
+
+ //
+ private long previewAspectCount(RoleOperation op, WebUser user, WebUserRole role, Department dept, RoleAspect aspect) {
+ switch (aspect) {
+ case PRIVILEGES: {
+ Set templateSet = fetchTemplatePrivileges(role);
+ Set activeSet = new HashSet<>();
+ for (WebUserPrivilege w : fetchActivePrivileges(user, dept)) {
+ if (w.getPrivilege() != null) {
+ activeSet.add(w.getPrivilege());
+ }
+ }
+ return previewDiffCount(op, templateSet, activeSet);
+ }
+ case ICONS: {
+ Set templateSet = fetchTemplateIcons(role).keySet();
+ Set activeSet = new HashSet<>();
+ for (UserIcon u : fetchActiveIcons(user, dept)) {
+ if (u.getIcon() != null) {
+ activeSet.add(u.getIcon());
+ }
+ }
+ return previewDiffCount(op, templateSet, activeSet);
+ }
+ case SUBSCRIPTIONS: {
+ Set templateSet = fetchTemplateSubscriptions(role).keySet();
+ Set activeSet = new HashSet<>();
+ for (TriggerSubscription t : fetchActiveSubscriptions(user, dept)) {
+ if (t.getTriggerType() != null) {
+ activeSet.add(t.getTriggerType());
+ }
+ }
+ return previewDiffCount(op, templateSet, activeSet);
+ }
+ case LOGIN_PAGE: {
+ LoginPage templateValue = role.getLoginPage();
+ WebUserDefaultLoginPage active = fetchActiveLoginPage(user, dept);
+ switch (op) {
+ case RESET:
+ return (active != null ? 1 : 0) + (templateValue != null ? 1 : 0);
+ case EXPAND:
+ return (active == null && templateValue != null) ? 1 : 0;
+ case NARROW:
+ return (active != null && templateValue != null && templateValue == active.getLoginPage()) ? 1 : 0;
+ default:
+ return 0;
+ }
+ }
+ default:
+ return 0;
+ }
+ }
+
+ private long previewDiffCount(RoleOperation op, Set templateSet, Set activeSet) {
+ switch (op) {
+ case RESET: {
+ long count = 0;
+ for (V v : activeSet) {
+ if (!templateSet.contains(v)) {
+ count++;
+ }
+ }
+ for (V v : templateSet) {
+ if (!activeSet.contains(v)) {
+ count++;
+ }
+ }
+ return count;
+ }
+ case EXPAND: {
+ long count = 0;
+ for (V v : templateSet) {
+ if (!activeSet.contains(v)) {
+ count++;
+ }
+ }
+ return count;
+ }
+ case NARROW: {
+ long count = 0;
+ for (V v : activeSet) {
+ if (templateSet.contains(v)) {
+ count++;
+ }
+ }
+ return count;
+ }
+ default:
+ return 0;
+ }
+ }
+ //
+
+ private void writeAuditEvent(RoleOperation op, WebUser targetUser, WebUserRole role, List departments,
+ Set aspects, RoleApplicationResult result, WebUser actor) {
+ try {
+ AuditEvent ae = new AuditEvent();
+ ae.setEventDataTime(new Date());
+ ae.setEventTrigger("ROLE_TEMPLATE_" + op.name());
+ ae.setEntityType("WebUser");
+ ae.setObjectId(targetUser.getId());
+ if (actor != null) {
+ ae.setWebUserId(actor.getId());
+ }
+ ae.setBeforeJson(buildRequestSummary(op, targetUser, role, departments, aspects));
+ ae.setAfterJson(result.summary());
+ auditEventService.saveAuditEvent(ae);
+ } catch (Exception e) {
+ // Audit logging must never break the role-template operation itself.
+ }
+ }
+
+ private String buildRequestSummary(RoleOperation op, WebUser targetUser, WebUserRole role, List departments, Set aspects) {
+ StringBuilder sb = new StringBuilder();
+ sb.append("operation=").append(op.name());
+ sb.append(", user=").append(targetUser.getName());
+ sb.append(", role=").append(role != null ? role.getName() : "null");
+ sb.append(", departments=").append(departmentNames(departments));
+ sb.append(", aspects=").append(aspects);
+ return sb.toString();
+ }
+
+ private List departmentNames(List departments) {
+ List names = new ArrayList<>();
+ for (Department d : departments) {
+ if (d != null) {
+ names.add(d.getName());
+ }
+ }
+ return names;
+ }
+
+ private Map params(Object... kv) {
+ Map m = new HashMap<>();
+ for (int i = 0; i < kv.length; i += 2) {
+ m.put((String) kv[i], kv[i + 1]);
+ }
+ return m;
+ }
+
+}
diff --git a/src/main/java/com/divudi/service/pharmacy/InpatientDirectIssueNativeSqlService.java b/src/main/java/com/divudi/service/pharmacy/InpatientDirectIssueNativeSqlService.java
index 5ed87820841..14fe995e3df 100644
--- a/src/main/java/com/divudi/service/pharmacy/InpatientDirectIssueNativeSqlService.java
+++ b/src/main/java/com/divudi/service/pharmacy/InpatientDirectIssueNativeSqlService.java
@@ -181,11 +181,7 @@ public void settle(Bill bill, List items) {
LOGGER.log(Level.INFO, "[NativeSettle] Starting finance details ms={0}", System.currentTimeMillis() - t0);
double[] billTotals = insertFinanceDetails(billId, biIds, pbIds, items);
- // Step 5: Update bill-level totals natively, then refresh the managed entity.
- // em.refresh(bill) reloads both the updated totals and the natively-written
- // BILLFINANCEDETAILS_ID FK (set by insertFinanceDetails) into L1. Without the
- // refresh, L1 retains billFinanceDetails=null and that stale null FK would be
- // merged into L2 at commit, causing subsequent EAGER loads to return null. (#20435)
+ // Step 5: Update bill-level totals natively.
em.createNativeQuery(
"UPDATE " + billTable() + " SET total=?, netTotal=?, grantTotal=? WHERE ID=?")
.setParameter(1, billTotals[0]) // grossTotal
@@ -193,17 +189,35 @@ public void settle(Bill bill, List items) {
.setParameter(3, billTotals[0]) // grantTotal = grossTotal (intentional naming per Bill entity)
.setParameter(4, billId)
.executeUpdate();
- em.refresh(bill);
- // Evict natively-written entity classes from the EclipseLink L2 cache.
- // Bill is intentionally NOT evicted — em.refresh(bill) loaded the correct full
- // state (totals + BILLFINANCEDETAILS_ID FK) and the commit merges it into L2.
+ // Reconcile the JPA caches with the natively-written state WITHOUT the
+ // catastrophic full-graph reload that em.refresh(bill) triggers.
+ //
+ // Previously this used em.refresh(bill) to pull the natively-written
+ // BILLFINANCEDETAILS_ID FK + totals back into L1 so a stale null FK would
+ // not be merged into L2 at commit (#20435). But em.refresh reloads the
+ // Bill's entire EAGER graph, and Bill.stockBill (EAGER) ↔ StockBill.bill
+ // (EAGER) form a circular EAGER reference; every Bill pulled in drags its
+ // own EAGER one-to-ones (pharmacyBill/stockBill/billFinanceDetails/
+ // currentRequest). For a batch whose related bill-graph is not yet in the
+ // L2 cache this fanned out into a ~30s recursive load — the "first issue of
+ // a batch is slow, later issues of the same batch are fast" symptom (#21888).
+ //
+ // Instead: detach the managed Bill so its stale (billFinanceDetails=null)
+ // state is NOT merged into L2 at commit, and evict Bill from L2 so the next
+ // read reloads the correct FK straight from the database. Same correctness
+ // guarantee as the refresh, none of the EAGER-graph cost.
+ long tReconcile = System.currentTimeMillis();
+ em.detach(bill);
javax.persistence.Cache cache = em.getEntityManagerFactory().getCache();
+ cache.evict(Bill.class, billId);
cache.evict(StockHistory.class);
cache.evict(Stock.class);
cache.evict(BillItem.class);
cache.evict(BillFinanceDetails.class);
cache.evict(BillItemFinanceDetails.class);
+ LOGGER.log(Level.INFO, "[NativeSettle] cache reconcile done ms={0} reconcileMs={1}",
+ new Object[]{System.currentTimeMillis() - t0, System.currentTimeMillis() - tReconcile});
LOGGER.log(Level.INFO, "[NativeSettle] DONE items={0} ms={1}",
new Object[]{items.size(), System.currentTimeMillis() - t0});
diff --git a/src/main/java/com/divudi/ws/common/CapabilityStatementResource.java b/src/main/java/com/divudi/ws/common/CapabilityStatementResource.java
index 142799375ab..c0ec17d9566 100644
--- a/src/main/java/com/divudi/ws/common/CapabilityStatementResource.java
+++ b/src/main/java/com/divudi/ws/common/CapabilityStatementResource.java
@@ -350,7 +350,19 @@ private javax.json.JsonArray buildResources() {
+ "Supports filtering by departmentId and query string. "
+ "DELETE /{id}/departments/{assignmentId} revokes one loggable department. "
+ "DELETE /{id}/departments/{departmentId}/privileges bulk-revokes all privileges for a department. "
- + "POST /{id}/departments/{departmentId}/privileges/all assigns every privilege for a department.",
+ + "POST /{id}/departments/{departmentId}/privileges/all assigns every privilege for a department. "
+ + "Role-template operations (roles are admin-time templates; runtime reads user-level records only): "
+ + "POST /{id}/role/reset resets a user's records for the given aspects/departments to a role template "
+ + "(roleId optional — defaults to the user's own role; body: {roleId?, departmentIds[], aspects[]?, updateUserRole?, preview?}). "
+ + "POST /{id}/role/expand and POST /{id}/role/narrow add/strip a role template's records "
+ + "(body: {roleId, departmentIds[], aspects[]?, preview?}; roleId required). "
+ + "aspects values: PRIVILEGES, ICONS, SUBSCRIPTIONS, LOGIN_PAGE (default [\"PRIVILEGES\"]). "
+ + "preview=true returns counts without writing. "
+ + "POST /bulk/role-operations applies RESET/EXPAND/NARROW to many users at once (explicit userIds or a role/department filter); "
+ + "for safety it requires preview=true first, then confirm=true to actually apply. "
+ + "GET /roles lists active roles with template summary counts (privileges/icons/subscriptions) and template login page. "
+ + "PUT /{id}/login-page (body: {departmentId, loginPage}) and DELETE /{id}/login-page/{departmentId} manage the "
+ + "per-user-per-department default login page override.",
"API Key",
"GET", "POST", "PUT", "DELETE"))
.add(resource("User Bulk Privileges", "/api/users/bulk-privileges",
diff --git a/src/main/java/com/divudi/ws/common/UserManagementApi.java b/src/main/java/com/divudi/ws/common/UserManagementApi.java
index 87bb9000da6..8e21aa3d9bf 100644
--- a/src/main/java/com/divudi/ws/common/UserManagementApi.java
+++ b/src/main/java/com/divudi/ws/common/UserManagementApi.java
@@ -13,6 +13,10 @@
import com.divudi.core.entity.*;
import com.divudi.core.facade.*;
import com.divudi.core.light.common.WebUserLight;
+import com.divudi.service.UserRoleApplicationService;
+import com.divudi.service.UserRoleApplicationService.RoleAspect;
+import com.divudi.service.UserRoleApplicationService.RoleApplicationResult;
+import com.divudi.service.UserRoleApplicationService.RoleOperation;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonSyntaxException;
@@ -59,6 +63,14 @@ public class UserManagementApi {
private PersonFacade personFacade;
@EJB
private StaffFacade staffFacade;
+ @EJB
+ private UserIconFacade userIconFacade;
+ @EJB
+ private TriggerSubscriptionFacade triggerSubscriptionFacade;
+ @EJB
+ private WebUserDefaultLoginPageFacade webUserDefaultLoginPageFacade;
+ @EJB
+ private UserRoleApplicationService userRoleApplicationService;
private static final Gson gson = new GsonBuilder().setDateFormat("yyyy-MM-dd HH:mm:ss").create();
@@ -879,6 +891,579 @@ public Response bulkAssignPrivileges(String body) {
}
}
+ // ── Role-template operations (issue #22023) ─────────────────────────────
+ // Roles are admin-time templates; these endpoints stamp/reset user-level
+ // records (privileges/icons/subscriptions/login page) from a role
+ // template via UserRoleApplicationService — same engine used by the UI
+ // and the AI assistant.
+
+ /**
+ * POST /api/users/{id}/role/reset
+ * Body: {"roleId": optional long, "departmentIds": [long,...] required,
+ * "aspects": optional ["PRIVILEGES","ICONS","SUBSCRIPTIONS","LOGIN_PAGE"] default ["PRIVILEGES"],
+ * "updateUserRole": optional bool default true, "preview": optional bool default false}
+ * roleId omitted/null uses the target user's own WebUser.role (400 if the user has no role).
+ */
+ @POST
+ @Path("/{id}/role/reset")
+ @Consumes(MediaType.APPLICATION_JSON)
+ @Produces(MediaType.APPLICATION_JSON)
+ public Response resetUserRole(@PathParam("id") Long id, String body) {
+ return applyRoleOperation(RoleOperation.RESET, id, body, false);
+ }
+
+ /**
+ * POST /api/users/{id}/role/expand
+ * Body: {"roleId": required long, "departmentIds": [long,...] required,
+ * "aspects": optional default ["PRIVILEGES"], "preview": optional bool default false}
+ */
+ @POST
+ @Path("/{id}/role/expand")
+ @Consumes(MediaType.APPLICATION_JSON)
+ @Produces(MediaType.APPLICATION_JSON)
+ public Response expandUserRole(@PathParam("id") Long id, String body) {
+ return applyRoleOperation(RoleOperation.EXPAND, id, body, true);
+ }
+
+ /**
+ * POST /api/users/{id}/role/narrow
+ * Body: {"roleId": required long, "departmentIds": [long,...] required,
+ * "aspects": optional default ["PRIVILEGES"], "preview": optional bool default false}
+ */
+ @POST
+ @Path("/{id}/role/narrow")
+ @Consumes(MediaType.APPLICATION_JSON)
+ @Produces(MediaType.APPLICATION_JSON)
+ public Response narrowUserRole(@PathParam("id") Long id, String body) {
+ return applyRoleOperation(RoleOperation.NARROW, id, body, true);
+ }
+
+ private Response applyRoleOperation(RoleOperation op, Long id, String body, boolean roleRequired) {
+ try {
+ WebUser apiUser = validateApiUser();
+ if (apiUser == null) return errorResponse("Not a valid key", 401);
+ if (!isAdmin(apiUser)) return errorResponse("Insufficient privileges", 403);
+ WebUser u = webUserFacade.find(id);
+ if (u == null || u.isRetired()) return errorResponse("User not found", 404);
+
+ Map req = parseJsonBody(body);
+ WebUserRole role;
+ Object roleIdObj = req.get("roleId");
+ if (roleIdObj != null) {
+ role = findRoleOrThrow(toLong(roleIdObj, "roleId"));
+ } else if (roleRequired) {
+ throw new ApiValidationException("roleId is required", 400);
+ } else {
+ role = u.getRole();
+ if (role == null) {
+ return errorResponse("User has no default role; specify roleId explicitly", 400);
+ }
+ }
+
+ List departments = resolveDepartments(extractLongList(req, "departmentIds", true));
+ Set aspects = parseAspects(extractStringList(req, "aspects", Collections.singletonList("PRIVILEGES")));
+ boolean updateUserRole = toBoolean(req.get("updateUserRole"), true);
+ boolean preview = toBoolean(req.get("preview"), false);
+
+ if (preview) {
+ Map counts = userRoleApplicationService.previewCounts(op, u, role, departments, aspects);
+ return successResponse(previewResponse(op, u, role, departments, aspects, counts));
+ }
+
+ RoleApplicationResult result = userRoleApplicationService.apply(op, u, role, departments, aspects, updateUserRole, apiUser);
+ if (!result.isSuccess()) return errorResponse(result.getErrorMessage(), 400);
+ return successResponse(resultResponse(op, result));
+ } catch (ApiValidationException e) {
+ return errorResponse(e.getMessage(), e.getCode());
+ } catch (JsonSyntaxException e) {
+ return errorResponse("Invalid JSON format", 400);
+ } catch (IllegalArgumentException e) {
+ return errorResponse(e.getMessage(), 400);
+ } catch (Exception e) {
+ return errorResponse("Internal server error", 500);
+ }
+ }
+
+ /**
+ * POST /api/users/bulk/role-operations
+ * Body: {"action": "RESET"|"EXPAND"|"NARROW", "userIds": [long] optional,
+ * "filter": {"roleId": long optional, "departmentId": long optional} optional,
+ * "roleId": optional (target template role), "departmentIds": [long] required,
+ * "aspects": optional default ["PRIVILEGES"], "updateUserRole": optional bool default true,
+ * "preview": optional bool default false, "confirm": optional bool default false}
+ * Explicit userIds wins over filter. Safety gate: preview=false and confirm=false is
+ * rejected — callers must preview first, then repeat with confirm=true to apply.
+ */
+ @POST
+ @Path("/bulk/role-operations")
+ @Consumes(MediaType.APPLICATION_JSON)
+ @Produces(MediaType.APPLICATION_JSON)
+ public Response bulkRoleOperations(String body) {
+ try {
+ WebUser apiUser = validateApiUser();
+ if (apiUser == null) return errorResponse("Not a valid key", 401);
+ if (!isAdmin(apiUser)) return errorResponse("Insufficient privileges", 403);
+
+ Map req = parseJsonBody(body);
+ Object actionObj = req.get("action");
+ if (!(actionObj instanceof String) || ((String) actionObj).trim().isEmpty()) {
+ return errorResponse("action is required", 400);
+ }
+ RoleOperation op;
+ try {
+ op = RoleOperation.valueOf(((String) actionObj).trim());
+ } catch (IllegalArgumentException e) {
+ return errorResponse("Invalid action: " + actionObj + ". Valid values: RESET, EXPAND, NARROW", 400);
+ }
+
+ Long roleId = req.get("roleId") != null ? toLong(req.get("roleId"), "roleId") : null;
+ WebUserRole role = roleId != null ? findRoleOrThrow(roleId) : null;
+
+ List departments = resolveDepartments(extractLongList(req, "departmentIds", true));
+ Set aspects = parseAspects(extractStringList(req, "aspects", Collections.singletonList("PRIVILEGES")));
+ boolean updateUserRole = toBoolean(req.get("updateUserRole"), true);
+ boolean preview = toBoolean(req.get("preview"), false);
+ boolean confirm = toBoolean(req.get("confirm"), false);
+
+ // Resolve target users: explicit userIds wins over filter.
+ List userIds = extractLongList(req, "userIds", false);
+ List users;
+ List missingUserIds = new ArrayList<>();
+ if (userIds != null && !userIds.isEmpty()) {
+ users = new ArrayList<>();
+ for (Long uid : userIds) {
+ WebUser candidate = webUserFacade.find(uid);
+ if (candidate == null || candidate.isRetired()) {
+ missingUserIds.add(uid);
+ } else {
+ users.add(candidate);
+ }
+ }
+ } else {
+ users = resolveUsersByFilter(req.get("filter"));
+ }
+
+ final int MAX_BULK_USERS = 500;
+ if (users.size() > MAX_BULK_USERS) {
+ return errorResponse("Too many users resolved (" + users.size() + "). Narrow the filter; max allowed: " + MAX_BULK_USERS, 400);
+ }
+
+ if (!preview && !confirm) {
+ Map m = new LinkedHashMap<>();
+ m.put("status", "error");
+ m.put("code", 400);
+ m.put("message", "Safety gate: call again with preview=true to review impact, then repeat with confirm=true to apply.");
+ m.put("resolvedUserCount", users.size());
+ if (!missingUserIds.isEmpty()) m.put("missingUserIds", missingUserIds);
+ return Response.status(400).entity(gson.toJson(m)).build();
+ }
+
+ if (preview) {
+ Map totals = new EnumMap<>(RoleAspect.class);
+ for (RoleAspect a : aspects) totals.put(a, 0L);
+ int previewedCount = Math.min(users.size(), 200);
+ for (int i = 0; i < previewedCount; i++) {
+ WebUser targetUser = users.get(i);
+ WebUserRole effectiveRole = role != null ? role : targetUser.getRole();
+ if (effectiveRole == null) continue;
+ Map counts = userRoleApplicationService.previewCounts(op, targetUser, effectiveRole, departments, aspects);
+ for (RoleAspect a : aspects) {
+ totals.merge(a, counts.getOrDefault(a, 0L), Long::sum);
+ }
+ }
+ Map m = new LinkedHashMap<>();
+ m.put("preview", true);
+ m.put("action", op.name());
+ m.put("userCount", users.size());
+ m.put("previewedUserCount", previewedCount);
+ Map aspectTotals = new LinkedHashMap<>();
+ for (RoleAspect a : aspects) aspectTotals.put(a.name(), totals.get(a));
+ m.put("previewCounts", aspectTotals);
+ if (!missingUserIds.isEmpty()) m.put("missingUserIds", missingUserIds);
+ return successResponse(m);
+ }
+
+ // confirm == true: apply for real
+ List results = userRoleApplicationService.applyBulk(op, users, role, departments, aspects, updateUserRole, apiUser);
+ List> perUser = new ArrayList<>();
+ int succeeded = 0;
+ int failed = 0;
+ int totalAdded = 0;
+ int totalRetired = 0;
+ for (RoleApplicationResult r : results) {
+ perUser.add(resultResponse(op, r));
+ if (r.isSuccess()) {
+ succeeded++;
+ totalAdded += r.getTotalAdded();
+ totalRetired += r.getTotalRetired();
+ } else {
+ failed++;
+ }
+ }
+ Map summary = new LinkedHashMap<>();
+ summary.put("action", op.name());
+ summary.put("usersProcessed", results.size());
+ summary.put("succeeded", succeeded);
+ summary.put("failed", failed);
+ summary.put("totalAdded", totalAdded);
+ summary.put("totalRetired", totalRetired);
+
+ Map out = new LinkedHashMap<>();
+ out.put("preview", false);
+ out.put("summary", summary);
+ out.put("results", perUser);
+ if (!missingUserIds.isEmpty()) out.put("missingUserIds", missingUserIds);
+ return successResponse(out);
+ } catch (ApiValidationException e) {
+ return errorResponse(e.getMessage(), e.getCode());
+ } catch (JsonSyntaxException e) {
+ return errorResponse("Invalid JSON format", 400);
+ } catch (IllegalArgumentException e) {
+ return errorResponse(e.getMessage(), 400);
+ } catch (Exception e) {
+ return errorResponse("Internal server error", 500);
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private List resolveUsersByFilter(Object filterObj) {
+ Map filter = (filterObj instanceof Map) ? (Map) filterObj : null;
+ if (filter == null || filter.isEmpty()) {
+ throw new ApiValidationException("Either userIds or filter is required", 400);
+ }
+ Long filterRoleId = filter.get("roleId") != null ? toLong(filter.get("roleId"), "filter.roleId") : null;
+ Long filterDeptId = filter.get("departmentId") != null ? toLong(filter.get("departmentId"), "filter.departmentId") : null;
+ WebUserRole filterRole = filterRoleId != null ? findRoleOrThrow(filterRoleId) : null;
+ Department filterDept = null;
+ if (filterDeptId != null) {
+ filterDept = departmentFacade.find(filterDeptId);
+ if (filterDept == null) {
+ throw new ApiValidationException("filter.departmentId not found: " + filterDeptId, 404);
+ }
+ }
+ Map params = new HashMap<>();
+ StringBuilder jpql = new StringBuilder("select distinct w from WebUser w");
+ if (filterDept != null) {
+ jpql.append(" join WebUserDepartment wud on wud.webUser=w");
+ }
+ jpql.append(" where w.retired=false");
+ if (filterRole != null) {
+ jpql.append(" and w.role=:role");
+ params.put("role", filterRole);
+ }
+ if (filterDept != null) {
+ jpql.append(" and wud.retired=false and wud.department=:dept");
+ params.put("dept", filterDept);
+ }
+ jpql.append(" order by w.name");
+ return webUserFacade.findByJpql(jpql.toString(), params);
+ }
+
+ /**
+ * GET /api/users/roles
+ * Active roles with template summary: id, name, description, template login page,
+ * and counts of active role-level privileges / template icons / template subscriptions.
+ */
+ @GET
+ @Path("/roles")
+ @Produces(MediaType.APPLICATION_JSON)
+ public Response listUserRolesWithTemplateCounts() {
+ WebUser apiUser = validateApiUser();
+ if (apiUser == null) return errorResponse("Not a valid key", 401);
+ List roles = webUserRoleFacade.findByJpql("select r from WebUserRole r where r.retired=false order by r.name");
+ List> out = new ArrayList<>();
+ for (WebUserRole role : roles) {
+ Map m = new LinkedHashMap<>();
+ m.put("id", role.getId());
+ m.put("name", role.getName());
+ m.put("description", role.getDescription());
+ m.put("loginPage", role.getLoginPage() != null ? role.getLoginPage().name() : null);
+ Map rp = kv("role", role);
+ long privilegeCount = webUserRolePrivilegeFacade.findLongByJpql(
+ "select count(p) from WebUserRolePrivilege p where p.retired=false and p.webUserRole=:role", rp);
+ long iconCount = userIconFacade.findLongByJpql(
+ "select count(u) from UserIcon u where u.retired=false and u.webUserRole=:role and u.webUser is null", rp);
+ long subscriptionCount = triggerSubscriptionFacade.findLongByJpql(
+ "select count(t) from TriggerSubscription t where t.retired=false and t.webUserRole=:role and t.webUser is null", rp);
+ m.put("privilegeCount", privilegeCount);
+ m.put("iconCount", iconCount);
+ m.put("subscriptionCount", subscriptionCount);
+ out.add(m);
+ }
+ return successResponse(out);
+ }
+
+ /**
+ * PUT /api/users/{id}/login-page
+ * Body: {"departmentId": required long, "loginPage": required string (LoginPage enum name)}
+ * Upserts the active WebUserDefaultLoginPage row for user+department: retires the old
+ * active row (if any) and creates a new one.
+ */
+ @PUT
+ @Path("/{id}/login-page")
+ @Consumes(MediaType.APPLICATION_JSON)
+ @Produces(MediaType.APPLICATION_JSON)
+ public Response setUserLoginPage(@PathParam("id") Long id, String body) {
+ try {
+ WebUser apiUser = validateApiUser();
+ if (apiUser == null) return errorResponse("Not a valid key", 401);
+ if (!isAdmin(apiUser)) return errorResponse("Insufficient privileges", 403);
+ WebUser u = webUserFacade.find(id);
+ if (u == null || u.isRetired()) return errorResponse("User not found", 404);
+
+ Map req = parseJsonBody(body);
+ Long departmentId = req.get("departmentId") != null ? toLong(req.get("departmentId"), "departmentId") : null;
+ if (departmentId == null) return errorResponse("departmentId is required", 400);
+ Department dept = departmentFacade.find(departmentId);
+ if (dept == null) return errorResponse("Department not found: " + departmentId, 404);
+
+ Object loginPageObj = req.get("loginPage");
+ if (!(loginPageObj instanceof String) || ((String) loginPageObj).trim().isEmpty()) {
+ return errorResponse("loginPage is required", 400);
+ }
+ LoginPage loginPage;
+ try {
+ loginPage = LoginPage.valueOf(((String) loginPageObj).trim());
+ } catch (IllegalArgumentException e) {
+ return errorResponse("Invalid loginPage: " + loginPageObj, 400);
+ }
+
+ Date now = new Date();
+ WebUserDefaultLoginPage active = webUserDefaultLoginPageFacade.findFirstByJpql(
+ "select w from WebUserDefaultLoginPage w where w.retired=false and w.webUser=:u and w.department=:d order by w.id desc",
+ kv("u", u, "d", dept));
+ if (active != null) {
+ active.setRetired(true);
+ active.setRetirer(apiUser);
+ active.setRetiredAt(now);
+ webUserDefaultLoginPageFacade.edit(active);
+ }
+ WebUserDefaultLoginPage fresh = new WebUserDefaultLoginPage();
+ fresh.setWebUser(u);
+ fresh.setDepartment(dept);
+ fresh.setLoginPage(loginPage);
+ fresh.setCreater(apiUser);
+ fresh.setCreatedAt(now);
+ webUserDefaultLoginPageFacade.create(fresh);
+
+ Map m = new LinkedHashMap<>();
+ m.put("id", fresh.getId());
+ m.put("userId", u.getId());
+ m.put("departmentId", dept.getId());
+ m.put("loginPage", fresh.getLoginPage().name());
+ return successResponse(m);
+ } catch (JsonSyntaxException e) {
+ return errorResponse("Invalid JSON format", 400);
+ } catch (Exception e) {
+ return errorResponse("Internal server error", 500);
+ }
+ }
+
+ /**
+ * DELETE /api/users/{id}/login-page/{departmentId}
+ * Retires the active WebUserDefaultLoginPage row for this user+department, if any.
+ */
+ @DELETE
+ @Path("/{id}/login-page/{departmentId}")
+ @Produces(MediaType.APPLICATION_JSON)
+ public Response deleteUserLoginPage(@PathParam("id") Long id, @PathParam("departmentId") Long departmentId) {
+ WebUser apiUser = validateApiUser();
+ if (apiUser == null) return errorResponse("Not a valid key", 401);
+ if (!isAdmin(apiUser)) return errorResponse("Insufficient privileges", 403);
+ WebUser u = webUserFacade.find(id);
+ if (u == null || u.isRetired()) return errorResponse("User not found", 404);
+ Department dept = departmentFacade.find(departmentId);
+ if (dept == null) return errorResponse("Department not found: " + departmentId, 404);
+
+ WebUserDefaultLoginPage active = webUserDefaultLoginPageFacade.findFirstByJpql(
+ "select w from WebUserDefaultLoginPage w where w.retired=false and w.webUser=:u and w.department=:d order by w.id desc",
+ kv("u", u, "d", dept));
+ if (active == null) return errorResponse("No active login page override for this user/department", 404);
+ active.setRetired(true);
+ active.setRetirer(apiUser);
+ active.setRetiredAt(new Date());
+ webUserDefaultLoginPageFacade.edit(active);
+ return successResponse("Login page override retired");
+ }
+
+ // ── Role-template helpers ────────────────────────────────────────────────
+
+ private Map previewResponse(RoleOperation op, WebUser user, WebUserRole role, List departments,
+ Set aspects, Map counts) {
+ Map m = new LinkedHashMap<>();
+ m.put("preview", true);
+ m.put("operation", op.name());
+ m.put("userId", user.getId());
+ m.put("roleId", role.getId());
+ m.put("roleName", role.getName());
+ m.put("departmentIds", departmentIdsOf(departments));
+ Map aspectCounts = new LinkedHashMap<>();
+ for (RoleAspect a : aspects) aspectCounts.put(a.name(), counts.getOrDefault(a, 0L));
+ m.put("previewCounts", aspectCounts);
+ return m;
+ }
+
+ private Map resultResponse(RoleOperation op, RoleApplicationResult result) {
+ Map m = new LinkedHashMap<>();
+ m.put("preview", false);
+ m.put("operation", op.name());
+ m.put("userId", result.getUser() != null ? result.getUser().getId() : null);
+ m.put("userName", result.getUser() != null ? result.getUser().getName() : null);
+ m.put("success", result.isSuccess());
+ if (!result.isSuccess()) {
+ m.put("errorMessage", result.getErrorMessage());
+ }
+ Map added = new LinkedHashMap<>();
+ for (Map.Entry e : result.getAdded().entrySet()) added.put(e.getKey().name(), e.getValue());
+ Map retired = new LinkedHashMap<>();
+ for (Map.Entry e : result.getRetired().entrySet()) retired.put(e.getKey().name(), e.getValue());
+ m.put("added", added);
+ m.put("retired", retired);
+ m.put("totalAdded", result.getTotalAdded());
+ m.put("totalRetired", result.getTotalRetired());
+ return m;
+ }
+
+ private List departmentIdsOf(List departments) {
+ List ids = new ArrayList<>();
+ for (Department d : departments) ids.add(d.getId());
+ return ids;
+ }
+
+ private WebUserRole findRoleOrThrow(Long roleId) {
+ WebUserRole role = roleId != null ? webUserRoleFacade.find(roleId) : null;
+ if (role == null || role.isRetired()) {
+ throw new ApiValidationException("Role not found: " + roleId, 404);
+ }
+ return role;
+ }
+
+ private List resolveDepartments(List ids) {
+ if (ids == null || ids.isEmpty()) {
+ throw new ApiValidationException("departmentIds is required and must be non-empty", 400);
+ }
+ List depts = new ArrayList<>();
+ List invalid = new ArrayList<>();
+ for (Long deptId : ids) {
+ Department d = departmentFacade.find(deptId);
+ if (d == null) {
+ invalid.add(deptId);
+ } else {
+ depts.add(d);
+ }
+ }
+ if (!invalid.isEmpty()) {
+ throw new ApiValidationException("Department(s) not found: " + invalid, 404);
+ }
+ return depts;
+ }
+
+ private Set parseAspects(List names) {
+ Set set = new LinkedHashSet<>();
+ for (String n : names) {
+ try {
+ set.add(RoleAspect.valueOf(n == null ? null : n.trim()));
+ } catch (IllegalArgumentException | NullPointerException e) {
+ throw new ApiValidationException(
+ "Invalid aspect: " + n + ". Valid values: PRIVILEGES, ICONS, SUBSCRIPTIONS, LOGIN_PAGE", 400);
+ }
+ }
+ if (set.isEmpty()) {
+ throw new ApiValidationException("aspects must contain at least one value", 400);
+ }
+ return set;
+ }
+
+ @SuppressWarnings("unchecked")
+ private Map parseJsonBody(String body) {
+ if (body == null || body.trim().isEmpty()) {
+ return new HashMap<>();
+ }
+ Map parsed = gson.fromJson(body, Map.class);
+ return parsed != null ? parsed : new HashMap<>();
+ }
+
+ @SuppressWarnings("unchecked")
+ private List extractLongList(Map req, String key, boolean required) {
+ Object obj = req.get(key);
+ if (obj == null) {
+ if (required) throw new ApiValidationException(key + " is required", 400);
+ return null;
+ }
+ if (!(obj instanceof List)) {
+ throw new ApiValidationException(key + " must be an array", 400);
+ }
+ List out = new ArrayList<>();
+ for (Object item : (List) obj) {
+ if (item instanceof Number) {
+ out.add(((Number) item).longValue());
+ } else {
+ throw new ApiValidationException(key + " must be an array of integers, got: " + item, 400);
+ }
+ }
+ return out;
+ }
+
+ @SuppressWarnings("unchecked")
+ private List extractStringList(Map req, String key, List defaultVal) {
+ Object obj = req.get(key);
+ if (obj == null) return defaultVal;
+ if (!(obj instanceof List)) {
+ throw new ApiValidationException(key + " must be an array", 400);
+ }
+ List out = new ArrayList<>();
+ for (Object item : (List) obj) {
+ if (item instanceof String) {
+ out.add((String) item);
+ } else {
+ throw new ApiValidationException(key + " must be an array of strings, got: " + item, 400);
+ }
+ }
+ return out;
+ }
+
+ private Long toLong(Object obj, String field) {
+ if (obj == null) return null;
+ if (obj instanceof Number) return ((Number) obj).longValue();
+ if (obj instanceof String) {
+ try {
+ return Long.parseLong(((String) obj).trim());
+ } catch (NumberFormatException e) {
+ throw new ApiValidationException(field + " must be numeric", 400);
+ }
+ }
+ throw new ApiValidationException(field + " must be numeric", 400);
+ }
+
+ private boolean toBoolean(Object obj, boolean defaultVal) {
+ if (obj == null) return defaultVal;
+ if (obj instanceof Boolean) return (Boolean) obj;
+ if (obj instanceof String) return Boolean.parseBoolean((String) obj);
+ return defaultVal;
+ }
+
+ private Map kv(Object... pairs) {
+ Map m = new HashMap<>();
+ for (int i = 0; i < pairs.length; i += 2) {
+ m.put((String) pairs[i], pairs[i + 1]);
+ }
+ return m;
+ }
+
+ /** Lightweight validation exception carrying the intended HTTP status code (400/404). */
+ private static class ApiValidationException extends RuntimeException {
+ private final int code;
+
+ ApiValidationException(String message, int code) {
+ super(message);
+ this.code = code;
+ }
+
+ int getCode() {
+ return code;
+ }
+ }
+
private void applyUserChanges(WebUser u, UserUpsertRequestDTO req, WebUser actor, boolean create) {
if (req.getName() != null) u.setName(req.getName().trim());
if (req.getCode() != null) u.setCode(req.getCode());
diff --git a/src/main/java/com/divudi/ws/inward/InwardPriceAdjustmentApi.java b/src/main/java/com/divudi/ws/inward/InwardPriceAdjustmentApi.java
index b001ffd5253..d096dffd085 100644
--- a/src/main/java/com/divudi/ws/inward/InwardPriceAdjustmentApi.java
+++ b/src/main/java/com/divudi/ws/inward/InwardPriceAdjustmentApi.java
@@ -25,6 +25,7 @@
import com.divudi.core.entity.WebUser;
import com.divudi.core.entity.inward.AdmissionType;
import com.divudi.core.entity.inward.InwardPriceAdjustment;
+import com.divudi.core.entity.inward.RoomCategory;
import com.divudi.core.entity.lab.Investigation;
import com.divudi.core.entity.lab.InvestigationCategory;
import com.divudi.core.entity.pharmacy.PharmaceuticalItemCategory;
@@ -148,6 +149,7 @@ public Response list() {
Long departmentId = longParam("departmentId");
Long categoryId = longParam("categoryId");
Long creditCompanyId = longParam("creditCompanyId");
+ Long roomCategoryId = longParam("roomCategoryId");
String paymentMethodStr = param("paymentMethod");
int limit = intParam("limit", 200, 1, 1000);
@@ -195,6 +197,10 @@ public Response list() {
jpql.append(" and a.creditCompany.id = :ccid");
params.put("ccid", creditCompanyId);
}
+ if (roomCategoryId != null) {
+ jpql.append(" and a.roomCategory.id = :rcid");
+ params.put("rcid", roomCategoryId);
+ }
jpql.append(" order by a.department.name, a.category.name, a.fromPrice");
@@ -326,8 +332,21 @@ public Response create(String requestBody) {
}
}
+ RoomCategory roomCategory = null;
+ Long roomCategoryId = asLong(body.get("roomCategoryId"));
+ if (roomCategoryId != null) {
+ Category rc = categoryFacade.find(roomCategoryId);
+ if (rc == null || rc.isRetired()) {
+ return errorResponse("Room category not found: " + roomCategoryId, 400);
+ }
+ if (!(rc instanceof RoomCategory)) {
+ return errorResponse("Category " + roomCategoryId + " is not a RoomCategory", 400);
+ }
+ roomCategory = (RoomCategory) rc;
+ }
+
InwardPriceAdjustment existing = findDuplicate(
- department, category, paymentMethod, fromPrice, toPrice, creditCompany);
+ department, category, paymentMethod, fromPrice, toPrice, creditCompany, roomCategory);
if (existing != null) {
Map payload = new LinkedHashMap<>();
payload.put("status", "already_exists");
@@ -346,6 +365,7 @@ public Response create(String requestBody) {
entry.setToPrice(toPrice);
entry.setMargin(margin);
entry.setCreditCompany(creditCompany);
+ entry.setRoomCategory(roomCategory);
if (department != null) {
entry.setInstitution(department.getInstitution());
}
@@ -483,9 +503,26 @@ public Response update(@PathParam("id") Long id, String requestBody) {
}
}
+ if (body.containsKey("roomCategoryId")) {
+ Long rcId = asLong(body.get("roomCategoryId"));
+ if (rcId == null) {
+ entry.setRoomCategory(null);
+ } else {
+ Category rc = categoryFacade.find(rcId);
+ if (rc == null || rc.isRetired()) {
+ return errorResponse("Room category not found: " + rcId, 400);
+ }
+ if (!(rc instanceof RoomCategory)) {
+ return errorResponse("Category " + rcId + " is not a RoomCategory", 400);
+ }
+ entry.setRoomCategory((RoomCategory) rc);
+ }
+ }
+
InwardPriceAdjustment dup = findDuplicate(
entry.getDepartment(), entry.getCategory(), entry.getPaymentMethod(),
- entry.getFromPrice(), entry.getToPrice(), entry.getCreditCompany());
+ entry.getFromPrice(), entry.getToPrice(), entry.getCreditCompany(),
+ entry.getRoomCategory());
if (dup != null && !dup.getId().equals(entry.getId())) {
Map payload = new LinkedHashMap<>();
payload.put("status", "already_exists");
@@ -720,12 +757,52 @@ public Response searchCreditCompanies() {
}
}
+ /**
+ * Search room categories by name.
+ * GET /api/inward-price-adjustment/room-categories/search?query=&limit=
+ */
+ @GET
+ @Path("/room-categories/search")
+ @Produces(MediaType.APPLICATION_JSON)
+ public Response searchRoomCategories() {
+ try {
+ WebUser user = validateApiKey(requestContext.getHeader("Finance"));
+ if (user == null) {
+ return errorResponse("Not a valid key", 401);
+ }
+ String query = param("query");
+ int limit = intParam("limit", 30, 1, 200);
+ StringBuilder jpql = new StringBuilder(
+ "select c from RoomCategory c where c.retired = false");
+ Map params = new HashMap<>();
+ if (query != null && !query.trim().isEmpty()) {
+ jpql.append(" and upper(c.name) like :q");
+ params.put("q", "%" + query.trim().toUpperCase() + "%");
+ }
+ jpql.append(" order by c.name");
+ List results = categoryFacade.findByJpql(jpql.toString(), params, limit);
+ List> payload = new ArrayList<>();
+ if (results != null) {
+ for (Category c : results) {
+ Map row = new LinkedHashMap<>();
+ row.put("id", c.getId());
+ row.put("name", c.getName());
+ payload.add(row);
+ }
+ }
+ return successResponse(payload);
+ } catch (Exception e) {
+ return errorResponse("An error occurred: " + e.getMessage(), 500);
+ }
+ }
+
// =========================================================================
// Helpers
// =========================================================================
private InwardPriceAdjustment findDuplicate(Department department, Category category,
- PaymentMethod paymentMethod, Double fromPrice, Double toPrice, Institution creditCompany) {
+ PaymentMethod paymentMethod, Double fromPrice, Double toPrice, Institution creditCompany,
+ Category roomCategory) {
StringBuilder jpql = new StringBuilder(
"select a from InwardPriceAdjustment a where a.retired = false");
@@ -763,6 +840,12 @@ private InwardPriceAdjustment findDuplicate(Department department, Category cate
jpql.append(" and a.creditCompany = :cc");
params.put("cc", creditCompany);
}
+ if (roomCategory == null) {
+ jpql.append(" and a.roomCategory is null");
+ } else {
+ jpql.append(" and a.roomCategory = :rc");
+ params.put("rc", roomCategory);
+ }
@SuppressWarnings("unchecked")
List list = (List) (List>)
@@ -831,6 +914,15 @@ private Map toDto(PriceMatrix pm) {
row.put("creditCompany", null);
}
+ if (pm.getRoomCategory() != null) {
+ Map rc = new LinkedHashMap<>();
+ rc.put("id", pm.getRoomCategory().getId());
+ rc.put("name", pm.getRoomCategory().getName());
+ row.put("roomCategory", rc);
+ } else {
+ row.put("roomCategory", null);
+ }
+
row.put("retired", pm.isRetired());
return row;
}
@@ -955,6 +1047,7 @@ public Response diagnoseInwardMargin() {
double price = priceParam != null ? priceParam : item.getTotal();
AdmissionType admissionType = encounter.getAdmissionType();
Institution creditCompany = resolveSingleCreditCompany(encounter);
+ RoomCategory roomCategory = resolveCurrentRoomCategory(encounter);
// The category actually used by the price-matrix lookup: for an
// Investigation it is the investigation category (unless the config
@@ -967,8 +1060,9 @@ public Response diagnoseInwardMargin() {
effectiveCategory = item.getCategory();
}
- // Price-matrix lookup: reuse the exact cascade + config gating used in billing.
- PriceMatrix priceMatrix = priceMatrixController.fetchInwardMargin(item, price, department, paymentMethod, creditCompany);
+ // Price-matrix lookup: reuse the exact cascade + config gating used in billing,
+ // including the admission-type and room-category dimensions (issues #21551, #21977).
+ PriceMatrix priceMatrix = priceMatrixController.fetchInwardMargin(item, price, department, paymentMethod, creditCompany, admissionType, roomCategory);
// Margin is applied to every non-Staff fee on the item: BillBhtController
// creates a BillFee per item fee and calls setBillFeeMargin, whose
@@ -1039,6 +1133,8 @@ public Response diagnoseInwardMargin() {
data.put("patientEncounterId", encounter.getId());
data.put("admissionTypeId", admissionType != null ? admissionType.getId() : null);
data.put("creditCompanyId", creditCompany != null ? creditCompany.getId() : null);
+ data.put("roomCategoryId", roomCategory != null ? roomCategory.getId() : null);
+ data.put("roomCategoryName", roomCategory != null ? roomCategory.getName() : null);
data.put("price", price);
data.put("marginWillBeApplied", marginWillBeApplied);
data.put("expectedMarginPercent", matrixFound ? priceMatrix.getMargin() : null);
@@ -1084,6 +1180,21 @@ private Institution resolveSingleCreditCompany(PatientEncounter encounter) {
return null;
}
+ /**
+ * Room category of the encounter's current room, or null when the patient is
+ * not in a room (or the room has no facility charge / category) — mirrors
+ * InwardBeanController.resolveCurrentRoomCategory so the diagnostic matches
+ * real billing (issue #21977).
+ */
+ private RoomCategory resolveCurrentRoomCategory(PatientEncounter encounter) {
+ if (encounter == null
+ || encounter.getCurrentPatientRoom() == null
+ || encounter.getCurrentPatientRoom().getRoomFacilityCharge() == null) {
+ return null;
+ }
+ return encounter.getCurrentPatientRoom().getRoomFacilityCharge().getRoomCategory();
+ }
+
/**
* The item's active non-Staff fees — these are the billable fees the inward
* margin can apply to (Staff fees are excluded by setBillFeeMargin). A null
diff --git a/src/main/webapp/admin/users/index.xhtml b/src/main/webapp/admin/users/index.xhtml
index 0110a2c3607..625cd7c2856 100644
--- a/src/main/webapp/admin/users/index.xhtml
+++ b/src/main/webapp/admin/users/index.xhtml
@@ -43,13 +43,22 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+