From abed2743107cf5fb0bcc02dc100f49ab487b8bda Mon Sep 17 00:00:00 2001 From: Pubudu Date: Wed, 8 Jul 2026 12:01:51 +0530 Subject: [PATCH 01/46] Signed-off-by: Pubudu --- .../InwardManagementReportController.java | 492 ++++++++++++++++++ .../divudi/core/data/dto/DepartmentDto.java | 5 + .../data/dto/HospitalCensusDetailDto.java | 164 ++++++ .../data/dto/HospitalCensusSummaryDto.java | 134 +++++ .../managementReports/hospital_census.xhtml | 294 +++++------ 5 files changed, 943 insertions(+), 146 deletions(-) create mode 100644 src/main/java/com/divudi/bean/inward/InwardManagementReportController.java create mode 100644 src/main/java/com/divudi/core/data/dto/HospitalCensusDetailDto.java create mode 100644 src/main/java/com/divudi/core/data/dto/HospitalCensusSummaryDto.java 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..f63b55116f9 --- /dev/null +++ b/src/main/java/com/divudi/bean/inward/InwardManagementReportController.java @@ -0,0 +1,492 @@ +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.entity.Department; +import com.divudi.core.entity.Institution; +import com.divudi.core.entity.inward.RoomFacilityCharge; +import com.divudi.core.facade.DepartmentFacade; +import com.divudi.core.facade.PatientEncounterFacade; +import com.divudi.core.facade.PatientRoomFacade; +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; + +/** + * 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 PatientRoomFacade patientRoomFacade; + @EJB + private PatientEncounterFacade patientEncounterFacade; + @EJB + private RoomFacilityChargeFacade roomFacilityChargeFacade; + + private Institution institution; + private Institution site; + private Department department; + private Date fromDate; + private Date toDate; + private RoomFacilityCharge 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); + } + jpql.append(" order by d.name "); + + return (List) departmentFacade.findDTOsByJpql(jpql.toString(), params); + } + + /** + * 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.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); + + List rows = (List) departmentFacade.findDTOsByJpql(jpql, params); + + 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 + + " doc_per.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.referringDoctor doc " + + "left join doc.person doc_per " + + "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); + } + + // ========================================================================= + // Private utilities + // ========================================================================= + private boolean isSummaryOnly() { + return "Summary".equals(reportType); + } + + private static List collectDeptIds(List departments) { + List ids = new ArrayList<>(departments.size()); + for (DepartmentDto d : departments) { + ids.add(d.getId()); + } + return ids; + } + + // ========================================================================= + // Getters & Setters + // ========================================================================= + 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() { + return fromDate; + } + + public void setFromDate(Date fromDate) { + this.fromDate = fromDate; + } + + public Date getToDate() { + return toDate; + } + + public void setToDate(Date toDate) { + this.toDate = toDate; + } + + public RoomFacilityCharge getWard() { + return ward; + } + + public void setWard(RoomFacilityCharge 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/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/webapp/reports/managementReports/hospital_census.xhtml b/src/main/webapp/reports/managementReports/hospital_census.xhtml index b1c484b9274..a55e02cbcac 100644 --- a/src/main/webapp/reports/managementReports/hospital_census.xhtml +++ b/src/main/webapp/reports/managementReports/hospital_census.xhtml @@ -18,34 +18,34 @@ - - + + - + - - + + @@ -56,7 +56,7 @@ - + @@ -74,14 +74,14 @@ - + @@ -89,15 +89,15 @@ - - + + @@ -107,13 +107,13 @@ itemLabel="#{dept.name}" itemValue="#{dept}"/> - + @@ -123,36 +123,36 @@ itemLabel="#{dept.name}" itemValue="#{dept}"/> - + - - - - - - - - + + + + + + + + - + - + @@ -160,7 +160,7 @@ + action="#{inwardManagementReportController.createHospitalCensusReport()}"> + action="#"> - + - - - - - - - + + + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + + + - - - - + + + + - - - + + + - + - + @@ -350,63 +352,63 @@ - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + From c611f3bd4d6a24d9cadeb1054fc189407d703bcb Mon Sep 17 00:00:00 2001 From: Pubudu Date: Wed, 8 Jul 2026 12:04:56 +0530 Subject: [PATCH 02/46] Signed-off-by: Pubudu --- .../bean/inward/InwardManagementReportController.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/java/com/divudi/bean/inward/InwardManagementReportController.java b/src/main/java/com/divudi/bean/inward/InwardManagementReportController.java index f63b55116f9..c955dd46d72 100644 --- a/src/main/java/com/divudi/bean/inward/InwardManagementReportController.java +++ b/src/main/java/com/divudi/bean/inward/InwardManagementReportController.java @@ -427,6 +427,9 @@ public void setDepartment(Department department) { } public Date getFromDate() { + if (fromDate == null) { + fromDate = com.divudi.core.util.CommonFunctions.getStartOfDay(new Date()); + } return fromDate; } @@ -435,6 +438,9 @@ public void setFromDate(Date fromDate) { } public Date getToDate() { + if (toDate == null) { + toDate = com.divudi.core.util.CommonFunctions.getEndOfDay(new Date()); + } return toDate; } From 2fc8d60bf0aba2b7407f4564b0debe6c4e47a073 Mon Sep 17 00:00:00 2001 From: Pubudu Date: Wed, 8 Jul 2026 15:15:07 +0530 Subject: [PATCH 03/46] Signed-off-by: Pubudu --- .../InwardManagementReportController.java | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/divudi/bean/inward/InwardManagementReportController.java b/src/main/java/com/divudi/bean/inward/InwardManagementReportController.java index c955dd46d72..d7434110c75 100644 --- a/src/main/java/com/divudi/bean/inward/InwardManagementReportController.java +++ b/src/main/java/com/divudi/bean/inward/InwardManagementReportController.java @@ -4,6 +4,7 @@ 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.entity.inward.RoomFacilityCharge; @@ -23,6 +24,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import javax.persistence.TemporalType; /** * HIGH-PERFORMANCE Hospital Census Report Controller. @@ -224,10 +226,22 @@ private Map fetchPatientRoomMetrics(List deptIds) { String jpql = "select d.id, " // 0 Current Present + // + "(select count(rm) FROM " + // + " RoomFacilityCharge rm " + // + " WHERE rm.retired=false " + // + " and rm.department.id=d.id " + // + " AND rm.room.filled!=true " + // + " AND rm.room.id NOT IN( " + // + " SELECT pr1.roomFacilityCharge.room.id" + // + " FROM PatientRoom pr1" + // + " WHERE pr1.retired=false " + // + " AND pr1.discharged=false))," + "(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>=:fd " + " and pr1.admittedAt<=:td " + " and (pr1.dischargedAt is null or pr1.dischargedAt>:td)), " // 1 Previous Day Total @@ -316,8 +330,9 @@ private Map fetchPatientRoomMetrics(List deptIds) { params.put("deptIds", deptIds); params.put("fd", fromDate); params.put("td", toDate); + params.put("bs", BedStatus.Available); - List rows = (List) departmentFacade.findDTOsByJpql(jpql, params); + List rows = (List) departmentFacade.findDTOsByJpql(jpql, params, TemporalType.TIMESTAMP); Map result = new HashMap<>(); From 25cd218ddcc2fb97d08e428025c807daa2d5cea1 Mon Sep 17 00:00:00 2001 From: Pubudu Date: Wed, 8 Jul 2026 15:20:09 +0530 Subject: [PATCH 04/46] Signed-off-by: Pubudu --- .../InwardManagementReportController.java | 29 ++----------------- 1 file changed, 2 insertions(+), 27 deletions(-) diff --git a/src/main/java/com/divudi/bean/inward/InwardManagementReportController.java b/src/main/java/com/divudi/bean/inward/InwardManagementReportController.java index d7434110c75..86daa1be2f1 100644 --- a/src/main/java/com/divudi/bean/inward/InwardManagementReportController.java +++ b/src/main/java/com/divudi/bean/inward/InwardManagementReportController.java @@ -9,8 +9,6 @@ import com.divudi.core.entity.Institution; import com.divudi.core.entity.inward.RoomFacilityCharge; import com.divudi.core.facade.DepartmentFacade; -import com.divudi.core.facade.PatientEncounterFacade; -import com.divudi.core.facade.PatientRoomFacade; import com.divudi.core.facade.RoomFacilityChargeFacade; import javax.ejb.EJB; @@ -49,10 +47,6 @@ public class InwardManagementReportController implements Serializable { @EJB private DepartmentFacade departmentFacade; @EJB - private PatientRoomFacade patientRoomFacade; - @EJB - private PatientEncounterFacade patientEncounterFacade; - @EJB private RoomFacilityChargeFacade roomFacilityChargeFacade; private Institution institution; @@ -164,7 +158,7 @@ private List fetchInwardDepartments() { } jpql.append(" order by d.name "); - return (List) departmentFacade.findDTOsByJpql(jpql.toString(), params); + return (List) departmentFacade.findDTOsByJpql(jpql.toString(), params, TemporalType.TIMESTAMP); } /** @@ -226,16 +220,6 @@ private Map fetchPatientRoomMetrics(List deptIds) { String jpql = "select d.id, " // 0 Current Present - // + "(select count(rm) FROM " - // + " RoomFacilityCharge rm " - // + " WHERE rm.retired=false " - // + " and rm.department.id=d.id " - // + " AND rm.room.filled!=true " - // + " AND rm.room.id NOT IN( " - // + " SELECT pr1.roomFacilityCharge.room.id" - // + " FROM PatientRoom pr1" - // + " WHERE pr1.retired=false " - // + " AND pr1.discharged=false))," + "(select count(pr1) " + " from PatientRoom pr1 " + " where pr1.retired=false " @@ -396,15 +380,9 @@ private List fetchDetailDtos(List deptIds) { params.put("deptIds", deptIds); params.put("td", toDate); - return (List) departmentFacade.findDTOsByJpql(jpql, params); + return (List) departmentFacade.findDTOsByJpql(jpql, params, TemporalType.TIMESTAMP); } - // ========================================================================= - // Private utilities - // ========================================================================= - private boolean isSummaryOnly() { - return "Summary".equals(reportType); - } private static List collectDeptIds(List departments) { List ids = new ArrayList<>(departments.size()); @@ -414,9 +392,6 @@ private static List collectDeptIds(List departments) { return ids; } - // ========================================================================= - // Getters & Setters - // ========================================================================= public Institution getInstitution() { return institution; } From 627ff75998ccd70c2f64c9e2d2ebc9ac77524aa2 Mon Sep 17 00:00:00 2001 From: Pubudu Date: Wed, 8 Jul 2026 15:40:51 +0530 Subject: [PATCH 05/46] Signed-off-by: Pubudu --- .../managementReports/hospital_census.xhtml | 64 ++++++++----------- 1 file changed, 27 insertions(+), 37 deletions(-) diff --git a/src/main/webapp/reports/managementReports/hospital_census.xhtml b/src/main/webapp/reports/managementReports/hospital_census.xhtml index a55e02cbcac..5e2b6aaf32a 100644 --- a/src/main/webapp/reports/managementReports/hospital_census.xhtml +++ b/src/main/webapp/reports/managementReports/hospital_census.xhtml @@ -77,7 +77,7 @@ value="#{inwardManagementReportController.site}" filter="true"> - @@ -91,53 +91,43 @@ - - + + - - + filterMatchMode="contains" filter="true"> + + + + + + - - - - + filterMatchMode="contains" filter="true"> + + - - - - + filterMatchMode="contains" filter="true"> + + @@ -160,10 +150,10 @@ - From 80a74be2cd18346d55d66c938dd5729debfff417 Mon Sep 17 00:00:00 2001 From: Pubudu Date: Wed, 8 Jul 2026 15:43:26 +0530 Subject: [PATCH 06/46] Signed-off-by: Pubudu --- .../managementReports/hospital_census.xhtml | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/src/main/webapp/reports/managementReports/hospital_census.xhtml b/src/main/webapp/reports/managementReports/hospital_census.xhtml index 5e2b6aaf32a..e09a80c0ac9 100644 --- a/src/main/webapp/reports/managementReports/hospital_census.xhtml +++ b/src/main/webapp/reports/managementReports/hospital_census.xhtml @@ -91,7 +91,7 @@ - + - + - + id="exlBtn" + class="ui-button-success mx-1" + icon="fas fa-file-excel" + ajax="false" + value="Download"> + From c15cea5e375b95b2ebd959fa6f245658852c42e7 Mon Sep 17 00:00:00 2001 From: Pubudu Date: Wed, 8 Jul 2026 15:53:31 +0530 Subject: [PATCH 07/46] Signed-off-by: Pubudu --- .../reports/managementReports/hospital_census.xhtml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/main/webapp/reports/managementReports/hospital_census.xhtml b/src/main/webapp/reports/managementReports/hospital_census.xhtml index e09a80c0ac9..cfbe3c2a85b 100644 --- a/src/main/webapp/reports/managementReports/hospital_census.xhtml +++ b/src/main/webapp/reports/managementReports/hospital_census.xhtml @@ -180,7 +180,17 @@ + + + Date: Wed, 8 Jul 2026 16:04:13 +0530 Subject: [PATCH 08/46] Signed-off-by: Pubudu --- .../InwardManagementReportController.java | 193 +++++++++++++++++- .../managementReports/hospital_census.xhtml | 20 +- 2 files changed, 207 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/divudi/bean/inward/InwardManagementReportController.java b/src/main/java/com/divudi/bean/inward/InwardManagementReportController.java index 86daa1be2f1..3ad66ab74d6 100644 --- a/src/main/java/com/divudi/bean/inward/InwardManagementReportController.java +++ b/src/main/java/com/divudi/bean/inward/InwardManagementReportController.java @@ -24,6 +24,15 @@ 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. * @@ -383,7 +392,6 @@ private List fetchDetailDtos(List deptIds) { return (List) departmentFacade.findDTOsByJpql(jpql, params, TemporalType.TIMESTAMP); } - private static List collectDeptIds(List departments) { List ids = new ArrayList<>(departments.size()); for (DepartmentDto d : departments) { @@ -392,6 +400,189 @@ private static List collectDeptIds(List departments) { return ids; } + public void createSummaryReportPdf() { + if (hospitalCensusSummaryDtos == null || hospitalCensusSummaryDtos.isEmpty()) { + addErrorMessage("No summary data to export. Run the report first."); + return; + } + try { + Document document = new Document(PageSize.A4.rotate(), 20, 20, 30, 20); + OutputStream out = prepareResponse("Hospital_Census_Summary.pdf"); + PdfWriter.getInstance(document, out); + document.open(); + + addReportTitle(document, "Hospital Census - Summary Report"); + document.add(buildSummaryTable()); + + document.close(); + 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 { + Document document = new Document(PageSize.A4.rotate(), 20, 20, 30, 20); + OutputStream out = prepareResponse("Hospital_Census_Detail.pdf"); + PdfWriter.getInstance(document, out); + document.open(); + + addReportTitle(document, "Hospital Census - Detail Report"); + document.add(buildDetailTable()); + + document.close(); + 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; } diff --git a/src/main/webapp/reports/managementReports/hospital_census.xhtml b/src/main/webapp/reports/managementReports/hospital_census.xhtml index cfbe3c2a85b..4d5af29eb8f 100644 --- a/src/main/webapp/reports/managementReports/hospital_census.xhtml +++ b/src/main/webapp/reports/managementReports/hospital_census.xhtml @@ -199,14 +199,24 @@ + action="#{inwardManagementReportController.createSummaryReportPdf()}"> + + From 5045bab7e5ef51d009bb679830017cd995a1510c Mon Sep 17 00:00:00 2001 From: Pubudu Date: Wed, 8 Jul 2026 16:17:12 +0530 Subject: [PATCH 09/46] Signed-off-by: Pubudu --- .../bean/inward/InwardManagementReportController.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/divudi/bean/inward/InwardManagementReportController.java b/src/main/java/com/divudi/bean/inward/InwardManagementReportController.java index 3ad66ab74d6..1be84889353 100644 --- a/src/main/java/com/divudi/bean/inward/InwardManagementReportController.java +++ b/src/main/java/com/divudi/bean/inward/InwardManagementReportController.java @@ -369,7 +369,7 @@ private List fetchDetailDtos(List deptIds) { + " pe.dateOfAdmission, " // doa + " rfc.name, " // bedNo + " pe.discharged, " // discharged flag - + " doc_per.name" // consultant name (nullable) + + " conPer.name" // consultant name (nullable) + ") " + "from PatientRoom pr " + "join pr.roomFacilityCharge rfc " @@ -377,8 +377,8 @@ private List fetchDetailDtos(List deptIds) { + "join pr.patientEncounter pe " + "join pe.patient pat " + "join pat.person per " - + "left join pe.referringDoctor doc " - + "left join doc.person doc_per " + + "left join pe.referringConsultant con " + + "left join con.person conPer " + "where pr.retired = false " + "and d.id in :deptIds " + "and pr.admittedAt <= :td " From 98c1db35bfd5b29a7180ee2063dd80bcf3e9a255 Mon Sep 17 00:00:00 2001 From: Pubudu Date: Wed, 8 Jul 2026 16:49:03 +0530 Subject: [PATCH 10/46] Signed-off-by: Pubudu --- .../bean/common/DepartmentController.java | 13 +++++++ .../InwardManagementReportController.java | 11 +++--- .../managementReports/hospital_census.xhtml | 34 ++++++++++++------- 3 files changed, 41 insertions(+), 17 deletions(-) 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/inward/InwardManagementReportController.java b/src/main/java/com/divudi/bean/inward/InwardManagementReportController.java index 1be84889353..6f7fdb3c98b 100644 --- a/src/main/java/com/divudi/bean/inward/InwardManagementReportController.java +++ b/src/main/java/com/divudi/bean/inward/InwardManagementReportController.java @@ -7,7 +7,6 @@ import com.divudi.core.data.inward.BedStatus; import com.divudi.core.entity.Department; import com.divudi.core.entity.Institution; -import com.divudi.core.entity.inward.RoomFacilityCharge; import com.divudi.core.facade.DepartmentFacade; import com.divudi.core.facade.RoomFacilityChargeFacade; @@ -63,7 +62,7 @@ public class InwardManagementReportController implements Serializable { private Department department; private Date fromDate; private Date toDate; - private RoomFacilityCharge ward; + private Department ward; private String reportType; private List hospitalCensusSummaryDtos; @@ -165,6 +164,10 @@ private List fetchInwardDepartments() { 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); @@ -629,11 +632,11 @@ public void setToDate(Date toDate) { this.toDate = toDate; } - public RoomFacilityCharge getWard() { + public Department getWard() { return ward; } - public void setWard(RoomFacilityCharge ward) { + public void setWard(Department ward) { this.ward = ward; } diff --git a/src/main/webapp/reports/managementReports/hospital_census.xhtml b/src/main/webapp/reports/managementReports/hospital_census.xhtml index 4d5af29eb8f..802bbecdd67 100644 --- a/src/main/webapp/reports/managementReports/hospital_census.xhtml +++ b/src/main/webapp/reports/managementReports/hospital_census.xhtml @@ -133,9 +133,9 @@ - + - + @@ -145,18 +145,26 @@ - + - - - - - + + + + + + + +
From 281a4fd136309bc004d9c517bb3d21803b451000 Mon Sep 17 00:00:00 2001 From: Pubudu Date: Wed, 8 Jul 2026 17:22:36 +0530 Subject: [PATCH 11/46] Signed-off-by: Pubudu --- .../InwardManagementReportController.java | 17 +++++++--- .../managementReports/hospital_census.xhtml | 33 +++++++++---------- 2 files changed, 28 insertions(+), 22 deletions(-) diff --git a/src/main/java/com/divudi/bean/inward/InwardManagementReportController.java b/src/main/java/com/divudi/bean/inward/InwardManagementReportController.java index 6f7fdb3c98b..c6c6d4bea9d 100644 --- a/src/main/java/com/divudi/bean/inward/InwardManagementReportController.java +++ b/src/main/java/com/divudi/bean/inward/InwardManagementReportController.java @@ -237,7 +237,6 @@ private Map fetchPatientRoomMetrics(List deptIds) { + " where pr1.retired=false " + " and pr1.roomFacilityCharge.department.id=d.id " + " and pr1.roomFacilityCharge.room.bedStatus =:bs " - + " and pr1.admittedAt>=:fd " + " and pr1.admittedAt<=:td " + " and (pr1.dischargedAt is null or pr1.dischargedAt>:td)), " // 1 Previous Day Total @@ -409,15 +408,19 @@ public void createSummaryReportPdf() { return; } try { + java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream(); Document document = new Document(PageSize.A4.rotate(), 20, 20, 30, 20); - OutputStream out = prepareResponse("Hospital_Census_Summary.pdf"); - PdfWriter.getInstance(document, out); + 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()); @@ -434,15 +437,19 @@ public void createDetailReportPdf() { return; } try { + java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream(); Document document = new Document(PageSize.A4.rotate(), 20, 20, 30, 20); - OutputStream out = prepareResponse("Hospital_Census_Detail.pdf"); - PdfWriter.getInstance(document, out); + 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()); diff --git a/src/main/webapp/reports/managementReports/hospital_census.xhtml b/src/main/webapp/reports/managementReports/hospital_census.xhtml index 802bbecdd67..ee433715b7d 100644 --- a/src/main/webapp/reports/managementReports/hospital_census.xhtml +++ b/src/main/webapp/reports/managementReports/hospital_census.xhtml @@ -243,85 +243,85 @@ rowsPerPageTemplate="5,10,15,25,50,100,500,1000" styleClass="table-sm"> - + - + - + - + - + - + - + - + - + - + - + - + - + - + @@ -331,15 +331,14 @@ - + - - + From c78895fbaa778f0ad53f3629f068d47ae0d5b6f2 Mon Sep 17 00:00:00 2001 From: buddhika Date: Thu, 9 Jul 2026 04:55:52 +0530 Subject: [PATCH 12/46] Add per-charge-type Comment column to inward final bill Charge Types rows can now record a reason per charge type, persisted on the summary BillItem's existing descreption field and reloaded when revisiting an already-settled bill. Closes #21939 --- .../testing/playwright-e2e-workflow.md | 21 ++++++++++++++++++ .../bean/inward/BhtSummeryController.java | 22 +++++++++++++++++++ .../data/dataStructure/ChargeItemTotal.java | 9 ++++++++ .../webapp/inward/inward_bill_final.xhtml | 12 ++++++++++ 4 files changed, 64 insertions(+) diff --git a/developer_docs/testing/playwright-e2e-workflow.md b/developer_docs/testing/playwright-e2e-workflow.md index 45a1f500768..ce4fa9f61ed 100644 --- a/developer_docs/testing/playwright-e2e-workflow.md +++ b/developer_docs/testing/playwright-e2e-workflow.md @@ -457,6 +457,27 @@ 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`. + ## Quick checklist - [ ] Confirmed environment + URL with the developer; credentials kept out of the repo. diff --git a/src/main/java/com/divudi/bean/inward/BhtSummeryController.java b/src/main/java/com/divudi/bean/inward/BhtSummeryController.java index 6a622427eff..ce229a796ac 100644 --- a/src/main/java/com/divudi/bean/inward/BhtSummeryController.java +++ b/src/main/java/com/divudi/bean/inward/BhtSummeryController.java @@ -2494,6 +2494,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 +2542,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 +2580,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()); @@ -3449,6 +3452,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() { 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/webapp/inward/inward_bill_final.xhtml b/src/main/webapp/inward/inward_bill_final.xhtml index 2eeef5d01e2..2cce8fa98c7 100644 --- a/src/main/webapp/inward/inward_bill_final.xhtml +++ b/src/main/webapp/inward/inward_bill_final.xhtml @@ -636,6 +636,18 @@ + + + + + + From 632471ebe54d61a6c0afde1628d1c179b111e81e Mon Sep 17 00:00:00 2001 From: Dr M H B Ariyaratne Date: Thu, 9 Jul 2026 09:34:19 +0530 Subject: [PATCH 13/46] Add lab-analyzer-integration subagent for ASTM/HL7/FHIR analyzer work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grounds the agent in the existing com.divudi.ws.lims.* integration layer (MiddlewareController, LimsMiddlewareController, MachineApi, the Analyzer enum, and API_LIMS.md) so it can extend or debug analyzer/middleware integrations with protocol-accurate detail instead of generic LIS knowledge. Closes #21962 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude Sonnet 5 --- .claude/agents/lab-analyzer-integration.md | 91 ++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 .claude/agents/lab-analyzer-integration.md diff --git a/.claude/agents/lab-analyzer-integration.md b/.claude/agents/lab-analyzer-integration.md new file mode 100644 index 00000000000..5dc3027c13a --- /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 real sample ID and are responding to genuine analyzer output — a stray call creates a spurious result record that can affect patient care. When testing new analyzer wiring, prefer 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 From ad784e733402481df46d77331c9cc88a78edec80 Mon Sep 17 00:00:00 2001 From: Dr M H B Ariyaratne Date: Thu, 9 Jul 2026 10:16:21 +0530 Subject: [PATCH 14/46] Tighten write-safety wording per CodeRabbit review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes "real" from "verified real sample ID" (ambiguous — read as license to write live results once an ID looks valid) and makes QC/test samples the required option for non-production validation rather than just a preference. Addresses CodeRabbit review comment on PR #21963. --- .claude/agents/lab-analyzer-integration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/agents/lab-analyzer-integration.md b/.claude/agents/lab-analyzer-integration.md index 5dc3027c13a..f608c8cd6f3 100644 --- a/.claude/agents/lab-analyzer-integration.md +++ b/.claude/agents/lab-analyzer-integration.md @@ -57,7 +57,7 @@ If a user's request implies behavior that contradicts what's actually in these f 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 real sample ID and are responding to genuine analyzer output — a stray call creates a spurious result record that can affect patient care. When testing new analyzer wiring, prefer a QC/test sample explicitly marked as such, and verify the read paths (`test_orders_for_sample_requests`, `QBP^Q11`) before touching write paths. +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. From 044cb9310b3ee2cc941efaa50657fded4d30b213 Mon Sep 17 00:00:00 2001 From: Dr M H B Ariyaratne Date: Thu, 9 Jul 2026 11:00:16 +0530 Subject: [PATCH 15/46] Exclude retired BillItem lines from Transfer Receive bill print The Pharmacy Transfer Receive print/reprint composites bound their item tables to bill.billItems, an unfiltered @OneToMany that lazy-loads every BillItem regardless of retired status. Retired lines (from duplicate-cleanup retirements, cancelled lines, etc.) therefore rendered on the printed bill. Switch all six transfer-receive print composites to bill.activeBillItems, the in-memory retired-item filter added for the same problem class in issue #21856. This is a view-only change scoped to the transfer-receive print path: - transferReceive.xhtml - pharmacy_transfer_receive_receipt.xhtml (item count too) - transferReceive_custom_1_letter.xhtml - transfeRecieve_detailed.xhtml - transfer_receive_custom_1.xhtml - transfer_receive_custom_2.xhtml Closes #21913 Co-Authored-By: Claude Opus 4.8 --- .../resources/pharmacy/pharmacy_transfer_receive_receipt.xhtml | 2 +- .../webapp/resources/pharmacy/transfeRecieve_detailed.xhtml | 2 +- src/main/webapp/resources/pharmacy/transferReceive.xhtml | 2 +- .../resources/pharmacy/transferReceive_custom_1_letter.xhtml | 2 +- .../webapp/resources/pharmacy/transfer_receive_custom_1.xhtml | 2 +- .../webapp/resources/pharmacy/transfer_receive_custom_2.xhtml | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/webapp/resources/pharmacy/pharmacy_transfer_receive_receipt.xhtml b/src/main/webapp/resources/pharmacy/pharmacy_transfer_receive_receipt.xhtml index 7d6dcace963..66ec1fa6a4f 100644 --- a/src/main/webapp/resources/pharmacy/pharmacy_transfer_receive_receipt.xhtml +++ b/src/main/webapp/resources/pharmacy/pharmacy_transfer_receive_receipt.xhtml @@ -224,7 +224,7 @@ Number of Items - + diff --git a/src/main/webapp/resources/pharmacy/transfeRecieve_detailed.xhtml b/src/main/webapp/resources/pharmacy/transfeRecieve_detailed.xhtml index 8a9270ea5dd..92e4c2e9857 100644 --- a/src/main/webapp/resources/pharmacy/transfeRecieve_detailed.xhtml +++ b/src/main/webapp/resources/pharmacy/transfeRecieve_detailed.xhtml @@ -90,7 +90,7 @@
- diff --git a/src/main/webapp/resources/pharmacy/transferReceive.xhtml b/src/main/webapp/resources/pharmacy/transferReceive.xhtml index 2f55bea0b9a..c216907aa31 100644 --- a/src/main/webapp/resources/pharmacy/transferReceive.xhtml +++ b/src/main/webapp/resources/pharmacy/transferReceive.xhtml @@ -113,7 +113,7 @@ rowIndexVar="rowIndex" styleClass="no-border-table compact-table" style=" border: none !important; font-size: #{configOptionApplicationController.getShortTextValueByKey('Report Font Size of Item List in Pharmacy Disbursement Reports', '10pt')}!Important; line-height: 1.1 !important;" - value="#{cc.attrs.bill.billItems}" sortBy="#{bip.searialNo}" var="bip"> + value="#{cc.attrs.bill.activeBillItems}" sortBy="#{bip.searialNo}" var="bip"> - + #{rowIndex.index + 1} diff --git a/src/main/webapp/resources/pharmacy/transfer_receive_custom_1.xhtml b/src/main/webapp/resources/pharmacy/transfer_receive_custom_1.xhtml index 8aaa9ad4e13..0bd8d7b97cd 100644 --- a/src/main/webapp/resources/pharmacy/transfer_receive_custom_1.xhtml +++ b/src/main/webapp/resources/pharmacy/transfer_receive_custom_1.xhtml @@ -117,7 +117,7 @@ rowIndexVar="rowIndex" styleClass="no-border-table compact-table" style="border: none !important; font-size: #{configOptionApplicationController.getShortTextValueByKey('Report Font Size of Item List in Pharmacy Disbursement Reports', '10pt')}!Important; line-height: 1.1 !important;" - value="#{cc.attrs.bill.billItems}" sortBy="#{bip.searialNo}" var="bip"> + value="#{cc.attrs.bill.activeBillItems}" sortBy="#{bip.searialNo}" var="bip"> - From 38772450289e49814f92b83ed0d3dfe8c5d42536 Mon Sep 17 00:00:00 2001 From: Dr M H B Ariyaratne Date: Thu, 9 Jul 2026 13:40:12 +0530 Subject: [PATCH 16/46] Fix 30s Direct Issue settle: replace em.refresh(bill) with detach + L2 evict Settling an inpatient Direct Issue (BHT Issue) bill took ~30 seconds on the first issue of a batch and was fast for subsequent issues of the same batch. Split timing instrumentation in InpatientDirectIssueNativeSqlService.settle() showed the entire cost was a single em.refresh(bill) call: everything up to the bill-totals UPDATE ran in ~257ms, the L2 evictions in 0ms, and em.refresh(bill) alone took ~30,120ms. Root cause: Bill.stockBill is FetchType.EAGER and StockBill.bill is also EAGER, forming a circular EAGER reference. em.refresh(bill) reloads the Bill's whole EAGER graph, and every Bill pulled in drags its own EAGER one-to-ones (pharmacyBill / stockBill / billFinanceDetails / currentRequest), fanning recursively across the batch's related bill graph. When that graph is not yet in the EclipseLink L2 cache (first issue of a batch) the load takes ~30s; once warm (later issues of the same batch) it is fast -- exactly the reported symptom. The refresh existed only to keep the JPA caches consistent with the natively written BILLFINANCEDETAILS_ID FK (issue #20435). Replace it with the same pattern every sibling native-settle service already uses (RetailSale, TransferIssue/Receive, Wholesale, GRN) and which never had this problem: detach the managed Bill so its stale null FK is not merged into L2 at commit, and evict Bill from L2 so the next read reloads the correct FK from the DB. Same #20435 correctness guarantee, none of the EAGER-graph cost. Verified end-to-end with Playwright against a local deployment: settle time dropped from 30,377ms to 210ms (~145x). DB linkage correct (BILLFINANCEDETAILS_ID + BillItemFinanceDetails rows written, totals match) and the bill preview renders correctly. Closes #21888 Co-Authored-By: Claude Opus 4.8 --- .../InpatientDirectIssueNativeSqlService.java | 32 +++++++++++++------ 1 file changed, 23 insertions(+), 9 deletions(-) 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}); From 9c78f03cc0c2b8ef797e0af276e4e3c2b4cd7fca Mon Sep 17 00:00:00 2001 From: damithdeshan98 Date: Thu, 9 Jul 2026 21:03:35 +0530 Subject: [PATCH 17/46] fix(sms): prevent Lab Report SMS timer freeze on transient DB failure The @Schedule automatic timer processPendingLabReportApprovalSmsQueue read config options and ran its JPQL query outside any try/catch, inside the container transaction. On Southern Lanka production a transient database connectivity storm made the timeout method throw a system exception to the EJB container, after which the non-persistent automatic timer stopped firing and never self-recovered even once the DB was healthy again - producing the recurring pending Lab Report SMS freeze that only a server restart cleared, roughly every 3-4 days. Wrap the whole timeout callback in try/catch(Throwable) so the timer callback never propagates an exception to the container; the timer keeps firing every minute and resumes sending automatically once the underlying issue clears. Signed-off-by: damithdeshan98 --- .../java/com/divudi/ejb/SmsManagerEjb.java | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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; } From 45a15dc612ed1b5fe2c30914cfa92e98e57d3d55 Mon Sep 17 00:00:00 2001 From: Dr M H B Ariyaratne Date: Fri, 10 Jul 2026 09:35:23 +0530 Subject: [PATCH 18/46] feat(inward): room-facility-category-aware service margin matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the existing (previously unused) PriceMatrix.roomCategory field through the Inward Price Adjustment service-margin UI, REST API, and the billing lookup, so admins can make the service charge depend on the patient's current room facility category. Semantics mirror the admissionType dimension (#21551): a matrix row with a room category applies only to that category; a row without one is a wildcard applying to all; when both match, the room-category-specific row wins; and when the patient is not yet in a room the lookup restricts to wildcard rows. Pure additive, backward compatible — no schema migration (the column already existed). - PriceMatrixController: consolidate the inward-margin lookup into one predicate-builder that threads paymentMethod/creditCompany/admissionType/ roomCategory; existing overloads retained as delegates; new room-category- aware fetchInwardMargin + getInwardPriceAdjustment overloads. Tie-break between admissionType and roomCategory specificity is config-driven ("Inward Matrix - Room Category takes priority over Admission Type", default off). - BillBhtController (inpatient + surgery), ServiceFeeEdit, and InwardBeanController credit-company overrides pass the patient's current room category (currentPatientRoom.roomFacilityCharge.roomCategory). - InwardPriceAdjustmntController + inward_price_adjustment_service.xhtml: Room Category selector on the add form and edit dialog, matrix table column, persisted in saveSelected + addForAllCategory. - InwardPriceAdjustmentApi: roomCategoryId in list/create/update/getById + duplicate detection, new room-categories/search lookup, and threaded into /diagnose so the diagnostic matches real billing. - Docs: new Playwright gotcha (§21) for the inward item picker. Verified end-to-end against real settled bills: an A/C-room Cash BHT got the room-specific 25% margin (feeMargin 475 on 1900) while a Non-A/C Cash BHT fell back to the wildcard 10% (feeMargin 190). Closes #21977 Co-Authored-By: Claude Opus 4.8 --- .../testing/playwright-e2e-workflow.md | 15 ++ .../bean/common/PriceMatrixController.java | 146 ++++++++++++++++++ .../divudi/bean/inward/BillBhtController.java | 24 ++- .../bean/inward/InwardBeanController.java | 21 ++- .../InwardPriceAdjustmntController.java | 13 ++ .../divudi/bean/inward/ServiceFeeEdit.java | 16 +- .../ws/inward/InwardPriceAdjustmentApi.java | 121 ++++++++++++++- .../inward_price_adjustment_service.xhtml | 31 ++++ 8 files changed, 372 insertions(+), 15 deletions(-) diff --git a/developer_docs/testing/playwright-e2e-workflow.md b/developer_docs/testing/playwright-e2e-workflow.md index ce4fa9f61ed..61cd32baec2 100644 --- a/developer_docs/testing/playwright-e2e-workflow.md +++ b/developer_docs/testing/playwright-e2e-workflow.md @@ -478,6 +478,21 @@ cached per session at login and won't pick up a new row otherwise. This came up `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 `
+
+
+ +
+
+ + + + +
+
+
@@ -278,6 +294,14 @@ + + + +
+ + + + + +
From 8f003d85a0d8ff27b24134fdf60999e4db521874 Mon Sep 17 00:00:00 2001 From: damithdeshan98 Date: Fri, 10 Jul 2026 12:29:36 +0530 Subject: [PATCH 19/46] feat(inward): add latest-checked BillItem lookup for interim bill service details Add InwardBeanController.getLatestCheckedBillItemsByItem() which bulk-loads the most recently checked inward BillItem per item for a patient encounter, and expose it via BhtSummeryController.getLatestCheckedBillItem(Item). Check information (checkedBy/checkeAt) is sourced from Bill via BillItem rather than adding transient fields to the shared master Item entity. Supports #21983 --- .../bean/inward/BhtSummeryController.java | 36 +++++++++++++++- .../bean/inward/InwardBeanController.java | 42 +++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/divudi/bean/inward/BhtSummeryController.java b/src/main/java/com/divudi/bean/inward/BhtSummeryController.java index ce229a796ac..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) { @@ -2879,6 +2896,7 @@ public void makeNull() { patientItems = null; paymentBill = null; departmentBillItems = null; + latestCheckedBillItemsByItem = null; printPreview = false; current = null; tmpPI = null; @@ -3886,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/InwardBeanController.java b/src/main/java/com/divudi/bean/inward/InwardBeanController.java index e0543baaabd..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 " From dd5e9fc51bc9fbbebbf5e5aff425652f87bfe337 Mon Sep 17 00:00:00 2001 From: damithdeshan98 Date: Fri, 10 Jul 2026 12:29:53 +0530 Subject: [PATCH 20/46] feat(inward): show Checked By / Checked At and Pending Check Count in Service Details Add three columns to the Service Details tab of the interim bill page: Pending Check Count (billed count minus checked count), Checked By and Checked At (from the most recent checked bill for each item, via bhtSummeryController.getLatestCheckedBillItem). Closes #21983 --- .../webapp/inward/inward_bill_intrim.xhtml | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/main/webapp/inward/inward_bill_intrim.xhtml b/src/main/webapp/inward/inward_bill_intrim.xhtml index 28823e359bb..5d06fd565c8 100644 --- a/src/main/webapp/inward/inward_bill_intrim.xhtml +++ b/src/main/webapp/inward/inward_bill_intrim.xhtml @@ -564,12 +564,30 @@ + + + + + + + + + + + + + + + + + + @@ -592,9 +610,24 @@ + + + + + + + + + + + + + + + Date: Fri, 10 Jul 2026 13:26:39 +0530 Subject: [PATCH 21/46] fix(inward): prefill payment details on inward deposit cancellation navigation Call refillPaymentDetail() when navigating to the inward deposit payment cancellation page so the payment method data (including the patient deposit account) is populated from the original bill before the page loads. Without this, cancelling an inward deposit paid via Patient Deposit could fail and render an error page even though the deposit was reduced. Closes #18195 --- src/main/java/com/divudi/bean/inward/InwardSearch.java | 5 +++++ 1 file changed, 5 insertions(+) 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; From b7465da6f9bab969297d2cb75a4ae90c8e40d928 Mon Sep 17 00:00:00 2001 From: damithdeshan98 Date: Fri, 10 Jul 2026 16:05:14 +0530 Subject: [PATCH 22/46] feat(inward): route inward service bills to Nursing Laboratory Dashboard When navigating to sample management from the OPD batch bill view, redirect inward service bills to /inward/inward_lab_dashboard when the config option "Use the Nursing Laboratory Dashboard for inward laboratory process." is enabled. Falls back to the existing /lab/generate_barcode_p page otherwise, so behaviour is unchanged for deployments that leave the option disabled. Closes #21988 --- .../divudi/bean/lab/PatientInvestigationController.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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"; + } } } From 608c16e6a5b3cf04438d2707f8844016d7eeac24 Mon Sep 17 00:00:00 2001 From: damithdeshan98 Date: Fri, 10 Jul 2026 17:25:20 +0530 Subject: [PATCH 23/46] fix(inward): sync patient foreigner flag with Mark as Foreigner checkbox When a patient is searched/selected during admission, reflect their foreigner status on the 'Mark as Foreigner' checkbox so it is ticked automatically for foreigners. On admit, persist the checkbox state onto the patient (mark when checked, clear when unchecked) so the recorded foreigner category stays in sync with the UI. This fixes the Interim Bill showing a Foreigner patient as Local because the foreigner flag was not being reliably set at admission. Closes #21934 --- .../divudi/bean/inward/AdmissionController.java | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) 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(); } From c3d31470062e63c86102c37c9adfbd4fc3384642 Mon Sep 17 00:00:00 2001 From: damithdeshan98 Date: Fri, 10 Jul 2026 17:25:28 +0530 Subject: [PATCH 24/46] fix(inward): refresh foreigner checkbox on patient phone-number search Add the caller-supplied extraUpdateIds to the phone-number quick-search button's AJAX update so the 'Mark as Foreigner' checkbox (rendered outside the composite) is re-rendered when a single patient is found, matching the datatable-select path. Part of #21934 --- .../ezcomp/common/patient_details_for_addmission.xhtml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/webapp/resources/ezcomp/common/patient_details_for_addmission.xhtml b/src/main/webapp/resources/ezcomp/common/patient_details_for_addmission.xhtml index 64be4acae27..e1b1e33547c 100644 --- a/src/main/webapp/resources/ezcomp/common/patient_details_for_addmission.xhtml +++ b/src/main/webapp/resources/ezcomp/common/patient_details_for_addmission.xhtml @@ -78,9 +78,9 @@ id="btnSearchbyPhoneNo" icon="fa fa-search" title="Quick Search from Phone Number" - action="#{patientController.quickSearchPatientLongPhoneNumber(cc.attrs.controller)}" + action="#{patientController.quickSearchPatientLongPhoneNumber(cc.attrs.controller)}" process="btnSearchbyPhoneNo txtQuickSearchPhoneNumber" - update="ptImp selectPatient editPatient viewPatient ptAl" + update="ptImp selectPatient editPatient viewPatient ptAl #{cc.attrs.extraUpdateIds}" styleClass="btn-sm mx-1"/> Date: Fri, 10 Jul 2026 18:13:09 +0530 Subject: [PATCH 25/46] feat(inward): room-facility-category-aware pharmacy margin matrix Extend the room-facility-category margin dimension (delivered for services in #21977 / PR #21979) to the pharmacy Inward Price Adjustment matrix, so the pharmacy margin can depend on the room category the patient occupies. The service side is already merged; PriceMatrix.roomCategory, the room- category-aware fetchInwardMargin overloads on PriceMatrixController, the InwardPriceAdjustmntController.roomCategory persistence, and the InwardPriceAdjustmentApi roomCategoryId handling (scope-agnostic) all exist from #21977. This change is purely UI wiring plus pharmacy billing call sites -- no schema migration. - inward_price_adjustment_pharmacy.xhtml: Room Category selector on the add form and edit dialog, plus a Room Category matrix table column, mirroring inward_price_adjustment_service.xhtml. - Pharmacy billing call sites now pass the patient's current room category to fetchInwardMargin (previously they passed neither credit company, admission type, nor room category). A null-safe resolveCurrentRoomCategory helper (same pattern as BillBhtController from #21977) derives it from bill.getPatientEncounter().getCurrentPatientRoom().getRoomFacilityCharge() .getRoomCategory(); the encounter is read from the bill item's bill so no method signature changes. Wired: PharmacySaleBhtController (ward BHT issue, theatre surgery issue, inpatient direct issues), PharmacyRequestForBht- Controller, BhtIssueReturnController, IssueReturnController, and PharmacyBillSearch. Store controllers were confirmed unused and skipped. Semantics mirror #21977: a row with a room category applies only to that category; a row without one is a wildcard applying to all; the room-category- specific row wins when both match; a patient not in a room gets wildcard only. Pure additive, backward compatible. Verified end-to-end on local Payara (CareCode Model Hospital): added an Inward/Tablet/Cash A/C-specific row (25%) beside an all-room-categories wildcard row (10%); the ward BHT issue page for an A/C patient computed a 25% Matrix Value (8.73 on 34.90), and the /api/inward-price-adjustment/ diagnose endpoint -- which runs the exact same fetchInwardMargin call the pharmacy controllers now use -- returned the A/C-specific 25% row for the A/C patient and fell back to the wildcard 10% row for a Non-A/C patient. Closes #21981 Co-Authored-By: Claude Opus 4.8 --- .../pharmacy/BhtIssueReturnController.java | 21 +++++++++++- .../bean/pharmacy/IssueReturnController.java | 21 +++++++++++- .../bean/pharmacy/PharmacyBillSearch.java | 21 +++++++++++- .../PharmacyRequestForBhtController.java | 20 +++++++++++- .../pharmacy/PharmacySaleBhtController.java | 32 ++++++++++++++++--- .../inward_price_adjustment_pharmacy.xhtml | 31 ++++++++++++++++++ 6 files changed, 138 insertions(+), 8 deletions(-) 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/IssueReturnController.java b/src/main/java/com/divudi/bean/pharmacy/IssueReturnController.java index 7beaffc8ad0..9e50c25860c 100644 --- a/src/main/java/com/divudi/bean/pharmacy/IssueReturnController.java +++ b/src/main/java/com/divudi/bean/pharmacy/IssueReturnController.java @@ -27,6 +27,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; @@ -836,11 +838,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..61be2e48e11 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; @@ -1128,15 +1130,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); 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/webapp/inward/inward_price_adjustment_pharmacy.xhtml b/src/main/webapp/inward/inward_price_adjustment_pharmacy.xhtml index d84d8d563da..2df30b8f268 100644 --- a/src/main/webapp/inward/inward_price_adjustment_pharmacy.xhtml +++ b/src/main/webapp/inward/inward_price_adjustment_pharmacy.xhtml @@ -119,6 +119,22 @@
+
+
+ +
+
+ + + + +
+
+
@@ -283,6 +299,14 @@ + + + +
+ + + + + +
From d454a7328c8b007dfc3eea3cd4ebad0020e34637 Mon Sep 17 00:00:00 2001 From: Dr M H B Ariyaratne Date: Fri, 10 Jul 2026 18:15:45 +0530 Subject: [PATCH 26/46] docs(testing): note inward pharmacy margin uses inpatient department (#21981) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Playwright workflow §22 documenting that the inward price-adjustment margin for pharmacy inpatient issues is looked up against the patient's current room facility-charge department (config-gated, default on), not the issuing pharmacy — plus the overlapping-rows pitfall and the /diagnose API shortcut. Surfaced while QAing the room-category pharmacy margin. Co-Authored-By: Claude Opus 4.8 --- .../testing/playwright-e2e-workflow.md | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/developer_docs/testing/playwright-e2e-workflow.md b/developer_docs/testing/playwright-e2e-workflow.md index 61cd32baec2..b21ef6e10a7 100644 --- a/developer_docs/testing/playwright-e2e-workflow.md +++ b/developer_docs/testing/playwright-e2e-workflow.md @@ -493,6 +493,27 @@ 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). + ## Quick checklist - [ ] Confirmed environment + URL with the developer; credentials kept out of the repo. From 5905dedcf5efeced4fbab073bc3f5d4b38ce8093 Mon Sep 17 00:00:00 2001 From: Dr M H B Ariyaratne Date: Sat, 11 Jul 2026 03:43:37 +0530 Subject: [PATCH 27/46] fix(pharmacy): enforce privilege guards across GRN & GRN Return workflow Audit found GRN create/save/finalize/approve and both GRN Return paths (new + legacy) were enforced almost entirely client-side, mostly only on menu/list links rather than the actual action pages or their controller methods. Adds server-side isAuthorized() checks (modeled on GrnReturnWorkflowController's existing pattern) to every mutating method, converts privilege-gated buttons from rendered to disabled per project convention, adds page-level guards to previously-unguarded pages, and introduces two new privileges (PharmacyGrnCancel, PharmacyGrnReturnCancel) for GRN/GRN-Return cancellation, which previously had no privilege check at all. Also fixes a bug surfaced while adding the guards: several Finalize/Approve-guarded methods internally call a Save-guarded method to auto-persist a not-yet-saved draft in one step. Naively guarding the inner method would have silently broken "finalize/approve a new GRN in one click" for users who hold Finalize/Approve but not Save. Fixed by splitting each inner method into a guarded public wrapper and an unguarded private core that the outer already-authorized action calls directly. Closes #15221, #15223, #16839 Co-Authored-By: Claude Sonnet 5 --- .../bean/common/UserPrivilageController.java | 2 + .../bean/pharmacy/GoodsReturnController.java | 45 +++++++++ .../divudi/bean/pharmacy/GrnController.java | 91 ++++++++++++++++++- .../bean/pharmacy/GrnCostingController.java | 85 ++++++++++++++++- .../pharmacy/GrnReturnApprovalController.java | 45 +++++++++ .../GrnReturnWithCostingController.java | 45 +++++++++ .../bean/pharmacy/PharmacyBillSearch.java | 41 +++++++++ .../java/com/divudi/core/data/Privileges.java | 4 + .../pharmacy/grn_return_with_costing.xhtml | 13 ++- .../webapp/pharmacy/pharmacy_cancel_grn.xhtml | 19 +++- .../pharmacy/pharmacy_cancel_grn_bill.xhtml | 12 ++- .../pharmacy/pharmacy_cancel_grn_return.xhtml | 17 +++- src/main/webapp/pharmacy/pharmacy_grn.xhtml | 1 + .../pharmacy/pharmacy_grn_costing.xhtml | 1 + ...armacy_grn_costing_with_save_approve.xhtml | 9 +- .../pharmacy_grn_return_approval.xhtml | 24 ++--- .../webapp/pharmacy/pharmacy_grn_wh.xhtml | 1 + .../pharmacy/pharmacy_grn_with_approval.xhtml | 2 + .../pharmacy_grn_with_save_approve.xhtml | 2 + .../pharmacy/pharmacy_return_good.xhtml | 10 ++ 20 files changed, 438 insertions(+), 31 deletions(-) diff --git a/src/main/java/com/divudi/bean/common/UserPrivilageController.java b/src/main/java/com/divudi/bean/common/UserPrivilageController.java index 31a5763b73f..d339b07df9a 100644 --- a/src/main/java/com/divudi/bean/common/UserPrivilageController.java +++ b/src/main/java/com/divudi/bean/common/UserPrivilageController.java @@ -791,6 +791,8 @@ private TreeNode createPrivilegeHolderTreeNodes() { 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); 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..058710beb2e 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; } @@ -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,9 @@ public void saveGrnPreBill() { } public void saveBill() { + if (!isAuthorized("SAVE_BILL", "PharmacyGrnSave")) { + return; + } getGrnBill().setBillDate(new Date()); getGrnBill().setBillTime(new Date()); // getGrnBill().setPaymentMethod(getApproveBill().getPaymentMethod()); @@ -1124,6 +1146,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 +2847,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 +2959,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 +3004,7 @@ public void finalizeGrnWithSaveApprove() { } // First perform the save operation - requestWithSaveApprove(); + doRequestWithSaveApprove(); // Mark the bill as completed getCurrentGrnBillPre().setCompleted(true); @@ -3091,6 +3134,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 +3163,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 +4501,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/PharmacyBillSearch.java b/src/main/java/com/divudi/bean/pharmacy/PharmacyBillSearch.java index 61be2e48e11..f5c9255e8a3 100644 --- a/src/main/java/com/divudi/bean/pharmacy/PharmacyBillSearch.java +++ b/src/main/java/com/divudi/bean/pharmacy/PharmacyBillSearch.java @@ -78,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; @@ -100,6 +102,8 @@ @SessionScoped public class PharmacyBillSearch implements Serializable { + private static final Logger LOGGER = Logger.getLogger(PharmacyBillSearch.class.getName()); + // @EJB private StaffService staffBean; @@ -3474,6 +3478,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; @@ -3779,6 +3786,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; @@ -5755,4 +5765,35 @@ public void fetchIssueSearchDtos(int maxResult) { } } + /** + * Authorization helper method to check GRN cancellation privileges and + * audit denied access + * + * @param action The action being attempted (PHARMACY_GRN_CANCEL, + * PHARMACY_GRN_RETURN_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", + action); + 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 GRN cancellation 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; + } + } diff --git a/src/main/java/com/divudi/core/data/Privileges.java b/src/main/java/com/divudi/core/data/Privileges.java index 6b0ee874d86..413ff0258c0 100644 --- a/src/main/java/com/divudi/core/data/Privileges.java +++ b/src/main/java/com/divudi/core/data/Privileges.java @@ -654,6 +654,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"), @@ -1043,6 +1045,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/webapp/pharmacy/grn_return_with_costing.xhtml b/src/main/webapp/pharmacy/grn_return_with_costing.xhtml index f3084a1d192..c38ffa77e68 100644 --- a/src/main/webapp/pharmacy/grn_return_with_costing.xhtml +++ b/src/main/webapp/pharmacy/grn_return_with_costing.xhtml @@ -10,6 +10,12 @@ + + You are NOT authorized + + + + - @@ -278,6 +285,8 @@ + + diff --git a/src/main/webapp/pharmacy/pharmacy_cancel_grn.xhtml b/src/main/webapp/pharmacy/pharmacy_cancel_grn.xhtml index 0fa75cf7eea..7d15b6614db 100644 --- a/src/main/webapp/pharmacy/pharmacy_cancel_grn.xhtml +++ b/src/main/webapp/pharmacy/pharmacy_cancel_grn.xhtml @@ -11,6 +11,12 @@ + + You are NOT authorized + + + +

Configuration Settings has disabled GRN Cancellations. Please change the option GRN Cancellations are allowed

@@ -25,13 +31,14 @@
- - +
@@ -184,7 +191,9 @@ - + + + diff --git a/src/main/webapp/pharmacy/pharmacy_cancel_grn_bill.xhtml b/src/main/webapp/pharmacy/pharmacy_cancel_grn_bill.xhtml index e9280042a36..36425e2e8e1 100644 --- a/src/main/webapp/pharmacy/pharmacy_cancel_grn_bill.xhtml +++ b/src/main/webapp/pharmacy/pharmacy_cancel_grn_bill.xhtml @@ -10,6 +10,13 @@ + + + You are NOT authorized + + + + - + - + - + - + - +
@@ -987,6 +988,7 @@ ajax="false" action="#{wardPharmacyReturnToPharmacyController.navigateToReturn}" icon="fa fa-truck-ramp-box" + rendered="#{webUserController.hasPrivilege('InwardPharmacyReturnSubmit')}" class="w-100"/>
diff --git a/src/main/webapp/nurse/index.xhtml b/src/main/webapp/nurse/index.xhtml index 5d608b3263e..551037f6044 100644 --- a/src/main/webapp/nurse/index.xhtml +++ b/src/main/webapp/nurse/index.xhtml @@ -504,6 +504,7 @@ ajax="false" action="#{admissionController.navigateToPharmacyBhtRequest}" icon="fa fa-prescription-bottle-alt" + rendered="#{webUserController.hasPrivilege('InwardPharmacyIssueRequest')}" class="w-100"> @@ -514,6 +515,7 @@ ajax="false" action="#{wardPharmacyBhtIssueReceiveController.navigateToPendingList}" icon="fa fa-truck-medical" + rendered="#{webUserController.hasPrivilege('InwardPharmacyBhtReceive')}" class="w-100"> @@ -523,6 +525,7 @@ ajax="false" action="#{wardPharmacyReturnToPharmacyController.navigateToReturn}" icon="fa fa-truck-ramp-box" + rendered="#{webUserController.hasPrivilege('InwardPharmacyReturnSubmit')}" class="w-100"> diff --git a/src/main/webapp/pharmacy/pharmacy_cancel_transfer_issue.xhtml b/src/main/webapp/pharmacy/pharmacy_cancel_transfer_issue.xhtml index 4f51b307b3c..0c7c8ece9e3 100644 --- a/src/main/webapp/pharmacy/pharmacy_cancel_transfer_issue.xhtml +++ b/src/main/webapp/pharmacy/pharmacy_cancel_transfer_issue.xhtml @@ -33,8 +33,9 @@ value="Cancel Bill" icon="fa fa-cancel" style="float: right" - class="ui-button-danger" action="#{pharmacyBillSearch.pharmacyTransferIssueCancel()}" > - + class="ui-button-danger" action="#{pharmacyBillSearch.pharmacyTransferIssueCancel()}" + disabled="#{not webUserController.hasPrivilege('PharmacyTransferIssueCancel')}" > + diff --git a/src/main/webapp/pharmacy/pharmacy_cancel_transfer_issue_for_inpatient_requests.xhtml b/src/main/webapp/pharmacy/pharmacy_cancel_transfer_issue_for_inpatient_requests.xhtml index 056dc681919..cfafb47dafc 100644 --- a/src/main/webapp/pharmacy/pharmacy_cancel_transfer_issue_for_inpatient_requests.xhtml +++ b/src/main/webapp/pharmacy/pharmacy_cancel_transfer_issue_for_inpatient_requests.xhtml @@ -23,8 +23,9 @@ value="Cancel Bill" icon="fa fa-cancel" style="float: right" - class="ui-button-danger" action="#{pharmacyBillSearch.pharmacyTransferIssueInpatientCancel()}" > - + class="ui-button-danger" action="#{pharmacyBillSearch.pharmacyTransferIssueInpatientCancel()}" + disabled="#{not webUserController.hasPrivilege('PharmacyTransferIssueCancel')}" > + diff --git a/src/main/webapp/pharmacy/pharmacy_transfer_issue.xhtml b/src/main/webapp/pharmacy/pharmacy_transfer_issue.xhtml index 8c55f1aec41..854d7ce67b3 100644 --- a/src/main/webapp/pharmacy/pharmacy_transfer_issue.xhtml +++ b/src/main/webapp/pharmacy/pharmacy_transfer_issue.xhtml @@ -181,6 +181,7 @@ onclick="if (!confirm('Are you sure you want to Settle This Bill ?')) return false;" ajax="false" class="ui-button-success mx-2" + disabled="#{not webUserController.hasPrivilege('PharmacyIssueForRequestFinalize')}" rendered="#{!configOptionApplicationController.getBooleanValueByKey('Use Save Finalize Approve Workflow for Issue for Requests', false) and !transferIssueForRequestsController.draftMode}"/> diff --git a/src/main/webapp/pharmacy/pharmacy_transfer_issue_cancel.xhtml b/src/main/webapp/pharmacy/pharmacy_transfer_issue_cancel.xhtml index efd1732c13b..bb2d6302dfe 100644 --- a/src/main/webapp/pharmacy/pharmacy_transfer_issue_cancel.xhtml +++ b/src/main/webapp/pharmacy/pharmacy_transfer_issue_cancel.xhtml @@ -170,6 +170,7 @@ value="Cancel Bill" icon="fas fa-ban" action="#{transferIssueCancellationController.confirmCancellation()}" + disabled="#{not webUserController.hasPrivilege('PharmacyTransferIssueCancel')}" onclick="if (!confirm('Are you sure you want to CANCEL this Transfer Issue Bill?\n\nThis action will:\n- Reverse stock movements\n- Reverse cost accounting\n- Mark the original bill as cancelled\n\nThis cannot be undone!')) return false;" ajax="false" class="ui-button-danger m-2" diff --git a/src/main/webapp/pharmacy/pharmacy_transfer_issue_direct.xhtml b/src/main/webapp/pharmacy/pharmacy_transfer_issue_direct.xhtml index 017921f627d..749049e2cf2 100644 --- a/src/main/webapp/pharmacy/pharmacy_transfer_issue_direct.xhtml +++ b/src/main/webapp/pharmacy/pharmacy_transfer_issue_direct.xhtml @@ -36,7 +36,8 @@ - + diff --git a/src/main/webapp/pharmacy/pharmacy_transfer_issue_direct_department.xhtml b/src/main/webapp/pharmacy/pharmacy_transfer_issue_direct_department.xhtml index 969b7adfad7..07fdef53a38 100644 --- a/src/main/webapp/pharmacy/pharmacy_transfer_issue_direct_department.xhtml +++ b/src/main/webapp/pharmacy/pharmacy_transfer_issue_direct_department.xhtml @@ -57,7 +57,7 @@
-
+
Quick Access - Recently Issued To @@ -72,7 +72,7 @@ ajax="false" />
-
+
@@ -326,6 +326,7 @@ onclick="if (!confirm('Are you sure you want to Settle This Bill ?')) return false;" ajax="false" + disabled="#{not webUserController.hasPrivilege('PharmacyDisbursementDirectIssue')}" styleClass="ui-button-success" /> @@ -380,7 +381,7 @@ -
+
Transfer Summary @@ -403,7 +404,7 @@
- +
diff --git a/src/main/webapp/pharmacy/pharmacy_transfer_issue_native.xhtml b/src/main/webapp/pharmacy/pharmacy_transfer_issue_native.xhtml index 68dc30460d4..9067c946888 100644 --- a/src/main/webapp/pharmacy/pharmacy_transfer_issue_native.xhtml +++ b/src/main/webapp/pharmacy/pharmacy_transfer_issue_native.xhtml @@ -42,6 +42,7 @@ class="ui-button-success mx-2" action="#{transferIssueNativeSqlController.settle}" ajax="false" + disabled="#{not webUserController.hasPrivilege('PharmacyIssueForRequestFinalize')}" rendered="#{!configOptionApplicationController.getBooleanValueByKey('Use Save Finalize Approve Workflow for Issue for Requests', false) and !transferIssueNativeSqlController.draftMode}"/> diff --git a/src/main/webapp/pharmacy/pharmacy_transfer_receive.xhtml b/src/main/webapp/pharmacy/pharmacy_transfer_receive.xhtml index 829e86f5804..f68eebab26d 100644 --- a/src/main/webapp/pharmacy/pharmacy_transfer_receive.xhtml +++ b/src/main/webapp/pharmacy/pharmacy_transfer_receive.xhtml @@ -41,6 +41,7 @@ class="ui-button-success mx-2" action="#{transferReceiveController.settle}" ajax="false" + disabled="#{not webUserController.hasPrivilege('PharmacyReceiveFinalize')}" onclick="return confirm('Are you sure you want to receive this transfer?');" > diff --git a/src/main/webapp/pharmacy/pharmacy_transfer_receive_cancel.xhtml b/src/main/webapp/pharmacy/pharmacy_transfer_receive_cancel.xhtml index a67a0e15c16..ccec812f3e5 100644 --- a/src/main/webapp/pharmacy/pharmacy_transfer_receive_cancel.xhtml +++ b/src/main/webapp/pharmacy/pharmacy_transfer_receive_cancel.xhtml @@ -180,6 +180,7 @@ value="Cancel Bill" icon="fas fa-ban" action="#{transferReceiveCancellationController.confirmCancellation()}" + disabled="#{not webUserController.hasPrivilege('PharmacyTransferReceiveCancel')}" onclick="if (!confirm('Are you sure you want to CANCEL this Transfer Receive Bill?\n\nThis action will:\n- Reverse stock movements\n- Reverse cost accounting\n- Mark the original bill as cancelled\n\nThis cannot be undone!')) return false;" ajax="false" class="ui-button-danger m-2" diff --git a/src/main/webapp/pharmacy/pharmacy_transfer_receive_native.xhtml b/src/main/webapp/pharmacy/pharmacy_transfer_receive_native.xhtml index dce0897aa41..c9717166548 100644 --- a/src/main/webapp/pharmacy/pharmacy_transfer_receive_native.xhtml +++ b/src/main/webapp/pharmacy/pharmacy_transfer_receive_native.xhtml @@ -35,7 +35,8 @@ style="float: right;" class="ui-button-success mx-2" action="#{transferReceiveNativeSqlController.settle}" - ajax="false"> + ajax="false" + disabled="#{not webUserController.hasPrivilege('PharmacyReceiveFinalize')}"> diff --git a/src/main/webapp/pharmacy/pharmacy_transfer_receive_native_approval.xhtml b/src/main/webapp/pharmacy/pharmacy_transfer_receive_native_approval.xhtml index 9e6bd585585..4fb43081c83 100644 --- a/src/main/webapp/pharmacy/pharmacy_transfer_receive_native_approval.xhtml +++ b/src/main/webapp/pharmacy/pharmacy_transfer_receive_native_approval.xhtml @@ -31,6 +31,7 @@ class="ui-button-success mx-1" icon="fas fa-check" ajax="false" + disabled="#{not webUserController.hasPrivilege('PharmacyReceiveApprove')}" style="float: right;" onclick="if (!confirm('Are you sure you want to settle this transfer receive?')) return false;"> diff --git a/src/main/webapp/pharmacy/pharmacy_transfer_receive_native_with_approval.xhtml b/src/main/webapp/pharmacy/pharmacy_transfer_receive_native_with_approval.xhtml index 8599f403288..699c09ab95d 100644 --- a/src/main/webapp/pharmacy/pharmacy_transfer_receive_native_with_approval.xhtml +++ b/src/main/webapp/pharmacy/pharmacy_transfer_receive_native_with_approval.xhtml @@ -42,6 +42,7 @@ styleClass="ui-button-stage-finalize mx-1" icon="fas fa-lock" ajax="false" + disabled="#{not webUserController.hasPrivilege('PharmacyReceiveFinalize')}" onclick="return confirm('Are you sure you want to finalize this receive? This cannot be undone.');" style="float: right;"> @@ -50,6 +51,7 @@ action="#{transferReceiveNativeSqlController.saveRequest()}" styleClass="ui-button-stage-save mx-1" icon="fas fa-floppy-disk" + disabled="#{not webUserController.hasPrivilege('PharmacyReceiveSave')}" ajax="false" style="float: right;"> diff --git a/src/main/webapp/pharmacy/pharmacy_transfer_receive_with_approval.xhtml b/src/main/webapp/pharmacy/pharmacy_transfer_receive_with_approval.xhtml index 8648fa87949..72470ae2777 100644 --- a/src/main/webapp/pharmacy/pharmacy_transfer_receive_with_approval.xhtml +++ b/src/main/webapp/pharmacy/pharmacy_transfer_receive_with_approval.xhtml @@ -36,7 +36,7 @@ styleClass="ui-button-stage-finalize mx-1" icon="fas fa-lock" ajax="false" - disabled="#{transferReceiveController.receivedBill.checkedBy ne null}" + disabled="#{transferReceiveController.receivedBill.checkedBy ne null or not webUserController.hasPrivilege('PharmacyReceiveFinalize')}" onclick="return confirm('Are you sure you want to finalize this receive? This cannot be undone.');" style="float: right;"> @@ -45,7 +45,7 @@ action="#{transferReceiveController.saveRequest()}" styleClass="ui-button-stage-save mx-1" icon="fas fa-floppy-disk" - disabled="#{transferReceiveController.receivedBill.checkedBy ne null}" + disabled="#{transferReceiveController.receivedBill.checkedBy ne null or not webUserController.hasPrivilege('PharmacyReceiveSave')}" ajax="false" style="float: right;"> diff --git a/src/main/webapp/pharmacy/pharmacy_transfer_request.xhtml b/src/main/webapp/pharmacy/pharmacy_transfer_request.xhtml index 5a9c19f9c37..a40258f9c00 100644 --- a/src/main/webapp/pharmacy/pharmacy_transfer_request.xhtml +++ b/src/main/webapp/pharmacy/pharmacy_transfer_request.xhtml @@ -120,6 +120,7 @@ ajax="false" icon="fas fa-floppy-disk" styleClass="mx-1 ui-button-stage-save" + disabled="#{not webUserController.hasPrivilege('PharmacyDisbursementRequest')}" />
diff --git a/src/main/webapp/pharmacy/pharmacy_transfer_request_approval.xhtml b/src/main/webapp/pharmacy/pharmacy_transfer_request_approval.xhtml index 0a361755b47..182b0f4cecc 100644 --- a/src/main/webapp/pharmacy/pharmacy_transfer_request_approval.xhtml +++ b/src/main/webapp/pharmacy/pharmacy_transfer_request_approval.xhtml @@ -36,7 +36,8 @@ ajax="false" icon="fas fa-check-double" styleClass="mx-1 ui-button-stage-approve" - onclick="return confirm('Are you sure you want to approve this transfer request?');"> + onclick="return confirm('Are you sure you want to approve this transfer request?');" + disabled="#{not webUserController.hasPrivilege('PharmacyDisbursementRequestApproval')}"> diff --git a/src/main/webapp/pharmacy/pharmacy_transfer_request_for_approvel.xhtml b/src/main/webapp/pharmacy/pharmacy_transfer_request_for_approvel.xhtml index 27f337ed18f..170cda2e0b0 100644 --- a/src/main/webapp/pharmacy/pharmacy_transfer_request_for_approvel.xhtml +++ b/src/main/webapp/pharmacy/pharmacy_transfer_request_for_approvel.xhtml @@ -58,7 +58,8 @@ action="#{transferRequestController.saveTranserRequest()}" ajax="false" icon="fas fa-floppy-disk" - styleClass="mx-1 ui-button-stage-save"> + styleClass="mx-1 ui-button-stage-save" + disabled="#{not webUserController.hasPrivilege('PharmacyDisbursementRequest')}"> + onclick="return confirm('Are you sure you want to send this request for approval? This cannot be undone.');" + disabled="#{not webUserController.hasPrivilege('PharmacyDisbursementFinalizeRequest')}"> diff --git a/src/main/webapp/ward/ward_pharmacy_return_to_pharmacy.xhtml b/src/main/webapp/ward/ward_pharmacy_return_to_pharmacy.xhtml index e74b067aaf7..c67ce056978 100644 --- a/src/main/webapp/ward/ward_pharmacy_return_to_pharmacy.xhtml +++ b/src/main/webapp/ward/ward_pharmacy_return_to_pharmacy.xhtml @@ -151,6 +151,7 @@ class="ui-button-success" update=":formWardReturnToPharmacy" actionListener="#{wardPharmacyReturnToPharmacyController.settle}" + disabled="#{not webUserController.hasPrivilege('InwardPharmacyReturnSubmit')}" onclick="if (!confirm('Return the selected items to the pharmacy via the selected porter? This cannot be undone.')) { return false; }"/> From bd032f30aaf1e22fa486672f89557fe49503d0c0 Mon Sep 17 00:00:00 2001 From: Dr M H B Ariyaratne Date: Sat, 11 Jul 2026 07:11:35 +0530 Subject: [PATCH 31/46] fix(pharmacy): fix CodeRabbit review findings on PR #22025 - pharmacy_cancel_po.xhtml -> PharmacyBillSearch.pharmacyPoCancel() was a third, previously-undiscovered PO cancel entry point (distinct from cancelPoBill() and pharmacyPoRequestCancel()) with zero privilege check. Added page guard + button disabled + server-side isAuthorized() check, matching the pattern already used for the other two cancel paths. - PurchaseOrderRequestController.removeSelected()/removeItem() called saveRequestWithoutMessage() and persisted item retirements with no privilege check, bypassing the guard already on saveRequest(). Added PurchaseOrderSave checks to both, server-side and on their buttons. Co-Authored-By: Claude Sonnet 5 --- .../com/divudi/bean/pharmacy/PharmacyBillSearch.java | 3 +++ .../pharmacy/PurchaseOrderRequestController.java | 6 ++++++ src/main/webapp/pharmacy/pharmacy_cancel_po.xhtml | 12 +++++++++--- .../pharmacy/pharmacy_purhcase_order_request.xhtml | 2 ++ 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/divudi/bean/pharmacy/PharmacyBillSearch.java b/src/main/java/com/divudi/bean/pharmacy/PharmacyBillSearch.java index 151dac1962b..5679cc44120 100644 --- a/src/main/java/com/divudi/bean/pharmacy/PharmacyBillSearch.java +++ b/src/main/java/com/divudi/bean/pharmacy/PharmacyBillSearch.java @@ -3380,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; diff --git a/src/main/java/com/divudi/bean/pharmacy/PurchaseOrderRequestController.java b/src/main/java/com/divudi/bean/pharmacy/PurchaseOrderRequestController.java index 930825c59eb..ac85e854971 100644 --- a/src/main/java/com/divudi/bean/pharmacy/PurchaseOrderRequestController.java +++ b/src/main/java/com/divudi/bean/pharmacy/PurchaseOrderRequestController.java @@ -127,6 +127,9 @@ public class PurchaseOrderRequestController implements Serializable { private String emailRecipient; public void removeSelected() { + if (!isAuthorized("SAVE", "PurchaseOrderSave")) { + return; + } if (selectedBillItems == null) { return; } @@ -273,6 +276,9 @@ public void addItem() { } public void removeItem(BillItem bi) { + if (!isAuthorized("SAVE", "PurchaseOrderSave")) { + return; + } if (currentBill == null || bi == null) { return; } diff --git a/src/main/webapp/pharmacy/pharmacy_cancel_po.xhtml b/src/main/webapp/pharmacy/pharmacy_cancel_po.xhtml index 6626db9889b..c89d5cafd3c 100644 --- a/src/main/webapp/pharmacy/pharmacy_cancel_po.xhtml +++ b/src/main/webapp/pharmacy/pharmacy_cancel_po.xhtml @@ -10,6 +10,10 @@ + + You are NOT authorized + + @@ -23,8 +27,9 @@ value="Cancel Bill" icon="fa fa-cancel" style="float: right" - class="ui-button-danger" action="#{pharmacyBillSearch.pharmacyPoCancel}" > - + class="ui-button-danger" action="#{pharmacyBillSearch.pharmacyPoCancel}" + disabled="#{not webUserController.hasPrivilege('PharmacyOrderCancellation')}" > + @@ -118,7 +123,8 @@ - + +
diff --git a/src/main/webapp/pharmacy/pharmacy_purhcase_order_request.xhtml b/src/main/webapp/pharmacy/pharmacy_purhcase_order_request.xhtml index a8aa4d66e02..ade8279d6b6 100644 --- a/src/main/webapp/pharmacy/pharmacy_purhcase_order_request.xhtml +++ b/src/main/webapp/pharmacy/pharmacy_purhcase_order_request.xhtml @@ -56,6 +56,7 @@ styleClass="ui-button-secondary"/>
@@ -204,6 +205,7 @@ update="itemList :#{p:resolveFirstComponentWithId('tot',view).clientId} :form:itemHistoryAboveWrapper :form:itemHistoryBelowWrapper" tabindex="-1" style="padding: 0;" + disabled="#{not webUserController.hasPrivilege('PurchaseOrderSave')}" action="#{purchaseOrderRequestController.removeItem(bi)}"/> From 2fdc32c6af5bb306c73a8165145e697d3131d717 Mon Sep 17 00:00:00 2001 From: Dr M H B Ariyaratne Date: Sat, 11 Jul 2026 07:31:19 +0530 Subject: [PATCH 32/46] feat(pharmacy): add privilege guards to discard/disposal issue workflow Server-side isAuthorized() guards added to every mutating method in PharmacyIssueController and IssueReturnController (previously zero server-side checks in either). Adds PharmacyDisposalIssueCancel (Cancel had no privilege at all) and PharmacyDiscardCategoryManage (the admin CRUD page for the Discard Category lookup had zero guard). Page-level "not authorized" guards added to pharmacy_issue.xhtml, the three previously-unguarded search pages, and pharmacy_bill_return_issue.xhtml. Save/Finalize/Approve buttons converted from rendered to disabled per the project's disabled-preferred standard. "Returns and Cancellations" menu entry now gated on the union of GRN-return + Disposal-return privileges. Proactively split two cross-call chains to avoid the finalize-calls- unguarded-save bug pattern found in #22019/#22021: - PharmacyIssueController.finalizeCurrentDraft() -> doSaveDisposalIssueDraft() - IssueReturnController.finalizeDisposalIssueReturnBill() -> doSaveDisposalIssueReturnBill() Part of #21994 Part of #21992 Co-Authored-By: Claude --- .../bean/common/UserPrivilageController.java | 2 + .../pharmacy/DiscardCategoryController.java | 11 ++++ .../bean/pharmacy/IssueReturnController.java | 57 ++++++++++++++++- .../pharmacy/PharmacyIssueController.java | 64 ++++++++++++++++++- .../java/com/divudi/core/data/Privileges.java | 4 ++ src/main/webapp/pharmacy/admin/index.xhtml | 2 +- .../pharmacy/pharmacy_bill_return_issue.xhtml | 5 +- .../pharmacy/pharmacy_discard_category.xhtml | 19 ++++-- src/main/webapp/pharmacy/pharmacy_issue.xhtml | 14 +++- .../pharmacy/pharmacy_search_issue_bill.xhtml | 5 ++ .../pharmacy_search_issue_bill_item.xhtml | 5 ++ .../pharmacy_search_issue_bill_return.xhtml | 5 ++ src/main/webapp/resources/ezcomp/menu.xhtml | 9 +-- 13 files changed, 185 insertions(+), 17 deletions(-) diff --git a/src/main/java/com/divudi/bean/common/UserPrivilageController.java b/src/main/java/com/divudi/bean/common/UserPrivilageController.java index 31a5763b73f..5ad922b1714 100644 --- a/src/main/java/com/divudi/bean/common/UserPrivilageController.java +++ b/src/main/java/com/divudi/bean/common/UserPrivilageController.java @@ -842,6 +842,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/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/IssueReturnController.java b/src/main/java/com/divudi/bean/pharmacy/IssueReturnController.java index 9e50c25860c..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; @@ -46,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; @@ -60,6 +63,8 @@ @SessionScoped public class IssueReturnController implements Serializable { + private static final Logger LOGGER = Logger.getLogger(IssueReturnController.class.getName()); + /////// @EJB private PharmaceuticalBillItemFacade pharmaceuticalBillItemFacade; @@ -82,6 +87,8 @@ public class IssueReturnController implements Serializable { @Inject private SessionController sessionController; @Inject + private WebUserController webUserController; + @Inject private ConfigOptionApplicationController configOptionApplicationController; @Inject private ConfigOptionController configOptionController; @@ -251,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; } @@ -262,6 +311,9 @@ public void saveDisposalIssueReturnBill() { } public void finalizeDisposalIssueReturnBill() { + if (!isAuthorized("FINALIZE", "FinalizeDisposalReturn")) { + return; + } if (disposalReturnAlreadyProcessed()) { return; } @@ -276,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) { @@ -299,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). 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/core/data/Privileges.java b/src/main/java/com/divudi/core/data/Privileges.java index 6b0ee874d86..a5e90d88410 100644 --- a/src/main/java/com/divudi/core/data/Privileges.java +++ b/src/main/java/com/divudi/core/data/Privileges.java @@ -572,6 +572,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"), @@ -1007,6 +1009,8 @@ public String getCategory() { case PharmacyDisposalIssue: case PharmacyDisposalIssueFinalize: case PharmacyDisposalIssueApprove: + case PharmacyDisposalIssueCancel: + case PharmacyDiscardCategoryManage: case PharmacyDisposalSearchIssueBill: case PharmacyDisposalSearchIssueBillItems: case PharmacyDisposalSearchIssueReturnBill: diff --git a/src/main/webapp/pharmacy/admin/index.xhtml b/src/main/webapp/pharmacy/admin/index.xhtml index 7b9c3e46005..2a5b72ad179 100644 --- a/src/main/webapp/pharmacy/admin/index.xhtml +++ b/src/main/webapp/pharmacy/admin/index.xhtml @@ -45,7 +45,7 @@ ajax="false" value="Manage Units" action="#{measurementUnitController.navigateToManageMeasurementUnit()}" > - + diff --git a/src/main/webapp/pharmacy/pharmacy_bill_return_issue.xhtml b/src/main/webapp/pharmacy/pharmacy_bill_return_issue.xhtml index a1ad56743a8..e2c5dadfeed 100644 --- a/src/main/webapp/pharmacy/pharmacy_bill_return_issue.xhtml +++ b/src/main/webapp/pharmacy/pharmacy_bill_return_issue.xhtml @@ -90,6 +90,7 @@ title="Save return bill as draft" action="#{issueReturnController.saveDisposalIssueReturnBill()}" rendered="#{issueReturnController.returnBill.checkedBy eq null and not issueReturnController.returnBill.billClosed and not issueReturnController.originalBill.fullReturned}" + disabled="#{not webUserController.hasPrivilege('CreateDisposalReturn')}" ajax="false"> diff --git a/src/main/webapp/pharmacy/pharmacy_discard_category.xhtml b/src/main/webapp/pharmacy/pharmacy_discard_category.xhtml index f6ab193a0ea..8d71e9832bb 100644 --- a/src/main/webapp/pharmacy/pharmacy_discard_category.xhtml +++ b/src/main/webapp/pharmacy/pharmacy_discard_category.xhtml @@ -9,6 +9,10 @@ + + You are NOT authorized + + @@ -26,13 +30,14 @@ action="#{discardCategoryController.prepareAdd()}" > - - @@ -68,6 +74,7 @@ + diff --git a/src/main/webapp/pharmacy/pharmacy_issue.xhtml b/src/main/webapp/pharmacy/pharmacy_issue.xhtml index fdf13c04c08..d7d90077d55 100644 --- a/src/main/webapp/pharmacy/pharmacy_issue.xhtml +++ b/src/main/webapp/pharmacy/pharmacy_issue.xhtml @@ -11,6 +11,10 @@ + + You are NOT authorized + + @@ -131,7 +135,8 @@ styleClass="ui-button-stage-save" ajax="false" action="#{pharmacyIssueController.saveDisposalIssueDraft()}" - rendered="#{pharmacyIssueController.preBill.checkedBy eq null and webUserController.hasPrivilege('PharmacyDisposalIssue')}" + rendered="#{pharmacyIssueController.preBill.checkedBy eq null}" + disabled="#{not webUserController.hasPrivilege('PharmacyDisposalIssue')}" style="min-width:100px"/> @@ -769,6 +776,7 @@ + diff --git a/src/main/webapp/pharmacy/pharmacy_search_issue_bill.xhtml b/src/main/webapp/pharmacy/pharmacy_search_issue_bill.xhtml index a63d31b49a7..1b60a49a9fa 100644 --- a/src/main/webapp/pharmacy/pharmacy_search_issue_bill.xhtml +++ b/src/main/webapp/pharmacy/pharmacy_search_issue_bill.xhtml @@ -12,6 +12,10 @@ + + You are NOT authorized + + @@ -178,6 +182,7 @@ + diff --git a/src/main/webapp/pharmacy/pharmacy_search_issue_bill_item.xhtml b/src/main/webapp/pharmacy/pharmacy_search_issue_bill_item.xhtml index 61672a2dcfd..f2faff30516 100644 --- a/src/main/webapp/pharmacy/pharmacy_search_issue_bill_item.xhtml +++ b/src/main/webapp/pharmacy/pharmacy_search_issue_bill_item.xhtml @@ -9,6 +9,10 @@ + + You are NOT authorized + + @@ -120,6 +124,7 @@ + diff --git a/src/main/webapp/pharmacy/pharmacy_search_issue_bill_return.xhtml b/src/main/webapp/pharmacy/pharmacy_search_issue_bill_return.xhtml index a99131e00a9..eaa088de45c 100644 --- a/src/main/webapp/pharmacy/pharmacy_search_issue_bill_return.xhtml +++ b/src/main/webapp/pharmacy/pharmacy_search_issue_bill_return.xhtml @@ -9,6 +9,10 @@ + + You are NOT authorized + + @@ -168,6 +172,7 @@ + diff --git a/src/main/webapp/resources/ezcomp/menu.xhtml b/src/main/webapp/resources/ezcomp/menu.xhtml index d4fdaa72f84..0bb0fbd1c29 100644 --- a/src/main/webapp/resources/ezcomp/menu.xhtml +++ b/src/main/webapp/resources/ezcomp/menu.xhtml @@ -1264,12 +1264,13 @@ --> - From 5c4001d683ed5cf532cde9828a3a71168809d19b Mon Sep 17 00:00:00 2001 From: Dr M H B Ariyaratne Date: Sat, 11 Jul 2026 07:37:40 +0530 Subject: [PATCH 33/46] fix(pharmacy): stop leaking raw action code in transfer issue error message CodeRabbit review comment on #22026 (comment id 3562856492): the denial message interpolated action.toLowerCase() directly, rendering as e.g. "You don't have permission to settle_direct_issue direct transfer issues." Use a fixed, human-readable message instead. Co-Authored-By: Claude --- .../com/divudi/bean/pharmacy/TransferIssueDirectController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/divudi/bean/pharmacy/TransferIssueDirectController.java b/src/main/java/com/divudi/bean/pharmacy/TransferIssueDirectController.java index d7383c08ac8..0a2bce8b07b 100644 --- a/src/main/java/com/divudi/bean/pharmacy/TransferIssueDirectController.java +++ b/src/main/java/com/divudi/bean/pharmacy/TransferIssueDirectController.java @@ -907,7 +907,7 @@ private boolean isAuthorized(String action, String requiredPrivilege) { 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 " + action.toLowerCase() + " direct transfer issues."); + JsfUtil.addErrorMessage("You don't have permission to perform this direct transfer issue action."); return false; } From 443f51da5f0feb3c644a3ab589124711d5aac63c Mon Sep 17 00:00:00 2001 From: buddhika Date: Sat, 11 Jul 2026 10:20:33 +0530 Subject: [PATCH 34/46] =?UTF-8?q?feat(admin):=20role-template=20user=20man?= =?UTF-8?q?agement=20=E2=80=94=20reset/expand/narrow=20privileges,=20icons?= =?UTF-8?q?,=20subscriptions=20&=20per-department=20default=20login=20page?= =?UTF-8?q?=20for=20single=20and=20bulk=20users?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roles become admin-time templates. New UserRoleApplicationService is the single engine (UI + REST + AI) applying RESET/EXPAND/NARROW of a role's template (privileges, icons, subscriptions, login page) onto users' department-level records, with previews, audit events, and WebUserRoleUser history rows. - New entity WebUserDefaultLoginPage (user+department) with runtime resolution in SessionController (user+dept row -> WebUser.loginPage -> HOME) - WebUserRole.loginPage template field editable on user_roles.xhtml - Reworked user_role_users.xhtml into Manage User Role & Defaults - New Bulk Role Operations page (filter by role/department/institution, select-all, preview, per-user results) - REST: /api/users/{id}/role/{reset|expand|narrow}, /api/users/bulk/role-operations (preview->confirm), GET /api/users/roles, PUT/DELETE /api/users/{id}/login-page, registered in CapabilityStatementResource + AnthropicApiService tools - Fix missing @ManyToOne on WebUserRoleUser.department - Playwright-verified end to end; learnings appended to testing workflow doc Closes #22023 Closes #6178 Closes #17493 Closes #4722 Co-Authored-By: Claude Fable 5 --- .../testing/playwright-e2e-workflow.md | 27 + .../divudi/bean/common/SessionController.java | 39 +- .../common/UserRoleManagementController.java | 704 +++++++++++++++ .../divudi/bean/common/WebUserController.java | 9 +- .../core/entity/WebUserDefaultLoginPage.java | 155 ++++ .../com/divudi/core/entity/WebUserRole.java | 15 + .../divudi/core/entity/WebUserRoleUser.java | 1 + .../facade/WebUserDefaultLoginPageFacade.java | 29 + .../divudi/service/AnthropicApiService.java | 278 +++++- .../service/UserRoleApplicationService.java | 813 ++++++++++++++++++ .../common/CapabilityStatementResource.java | 14 +- .../divudi/ws/common/UserManagementApi.java | 585 +++++++++++++ src/main/webapp/admin/users/index.xhtml | 14 +- .../users/user_role_bulk_operations.xhtml | 295 +++++++ .../webapp/admin/users/user_role_users.xhtml | 279 +++--- src/main/webapp/admin/users/user_roles.xhtml | 19 +- 16 files changed, 3152 insertions(+), 124 deletions(-) create mode 100644 src/main/java/com/divudi/bean/common/UserRoleManagementController.java create mode 100644 src/main/java/com/divudi/core/entity/WebUserDefaultLoginPage.java create mode 100644 src/main/java/com/divudi/core/facade/WebUserDefaultLoginPageFacade.java create mode 100644 src/main/java/com/divudi/service/UserRoleApplicationService.java create mode 100644 src/main/webapp/admin/users/user_role_bulk_operations.xhtml diff --git a/developer_docs/testing/playwright-e2e-workflow.md b/developer_docs/testing/playwright-e2e-workflow.md index b21ef6e10a7..130aaa23c67 100644 --- a/developer_docs/testing/playwright-e2e-workflow.md +++ b/developer_docs/testing/playwright-e2e-workflow.md @@ -514,6 +514,33 @@ with a `Finance` API-key header — it runs the identical `fetchInwardMargin(... 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}"`. + ## Quick checklist - [ ] Confirmed environment + URL with the developer; credentials kept out of the repo. 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/UserRoleManagementController.java b/src/main/java/com/divudi/bean/common/UserRoleManagementController.java new file mode 100644 index 00000000000..edcb17a3325 --- /dev/null +++ b/src/main/java/com/divudi/bean/common/UserRoleManagementController.java @@ -0,0 +1,704 @@ +/* + * 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; + + // + @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 (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 (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/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/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/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/webapp/admin/users/index.xhtml b/src/main/webapp/admin/users/index.xhtml index 0110a2c3607..ec48ebb4643 100644 --- a/src/main/webapp/admin/users/index.xhtml +++ b/src/main/webapp/admin/users/index.xhtml @@ -43,13 +43,21 @@
- + + + + + + + +