@7365admin1/module-hygiene 4.28.1-staging.6 → 4.28.1-staging.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +144 -128
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +144 -128
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -2
- package/test/hygiene-dashboard-metrics.util.test.mjs +146 -0
package/dist/index.mjs
CHANGED
|
@@ -9,7 +9,7 @@ var allowedStatus = [
|
|
|
9
9
|
var allowedPeriods = ["today", "thisWeek", "thisMonth"];
|
|
10
10
|
|
|
11
11
|
// src/repositories/hygiene-dashboard.repository.ts
|
|
12
|
-
import
|
|
12
|
+
import moment2 from "moment-timezone";
|
|
13
13
|
import { ObjectId } from "mongodb";
|
|
14
14
|
import {
|
|
15
15
|
useAtlas,
|
|
@@ -20,6 +20,54 @@ import {
|
|
|
20
20
|
paginate,
|
|
21
21
|
logger
|
|
22
22
|
} from "@7365admin1/node-server-utils";
|
|
23
|
+
|
|
24
|
+
// src/utils/hygiene-dashboard-metrics.util.ts
|
|
25
|
+
import moment from "moment-timezone";
|
|
26
|
+
var DASHBOARD_TIMEZONE = "Asia/Singapore";
|
|
27
|
+
var PERIOD_UNITS = {
|
|
28
|
+
thisWeek: { boundary: "isoWeek", step: "week" },
|
|
29
|
+
thisMonth: { boundary: "month", step: "month" }
|
|
30
|
+
};
|
|
31
|
+
var DAY_UNIT = {
|
|
32
|
+
boundary: "day",
|
|
33
|
+
step: "day"
|
|
34
|
+
};
|
|
35
|
+
function unitsOf(period) {
|
|
36
|
+
return PERIOD_UNITS[period] ?? DAY_UNIT;
|
|
37
|
+
}
|
|
38
|
+
function at(now) {
|
|
39
|
+
return now === void 0 || now === null ? moment.tz(DASHBOARD_TIMEZONE) : moment.tz(now, DASHBOARD_TIMEZONE);
|
|
40
|
+
}
|
|
41
|
+
function getPeriodRange(period, now) {
|
|
42
|
+
const { boundary } = unitsOf(period);
|
|
43
|
+
const point = at(now);
|
|
44
|
+
return {
|
|
45
|
+
$gte: point.clone().startOf(boundary).toDate(),
|
|
46
|
+
$lte: point.clone().endOf(boundary).toDate()
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function getPreviousPeriodRange(period, now) {
|
|
50
|
+
const { boundary, step } = unitsOf(period);
|
|
51
|
+
const point = at(now).subtract(1, step);
|
|
52
|
+
return {
|
|
53
|
+
$gte: point.clone().startOf(boundary).toDate(),
|
|
54
|
+
$lte: point.clone().endOf(boundary).toDate()
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
function calculatePercentageChange(currentCount, previousCount) {
|
|
58
|
+
if (typeof currentCount !== "number" || !Number.isFinite(currentCount)) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
if (typeof previousCount !== "number" || !Number.isFinite(previousCount)) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
if (previousCount === 0)
|
|
65
|
+
return null;
|
|
66
|
+
return Math.round((currentCount - previousCount) / previousCount * 100 * 100) / 100;
|
|
67
|
+
}
|
|
68
|
+
var SUPPLY_ALERT_HAS_NO_PRIOR_PERIOD = "Supply alerts count what is out of stock right now, and the supply record keeps no history, so there is no earlier period to compare against.";
|
|
69
|
+
|
|
70
|
+
// src/repositories/hygiene-dashboard.repository.ts
|
|
23
71
|
function useHygieneDashboardRepository() {
|
|
24
72
|
const db = useAtlas.getDb();
|
|
25
73
|
if (!db) {
|
|
@@ -90,59 +138,16 @@ function useHygieneDashboardRepository() {
|
|
|
90
138
|
logger.info(`Cache hit for dashboard: ${dashboardCacheKey}`);
|
|
91
139
|
return cachedDashboard;
|
|
92
140
|
}
|
|
93
|
-
function formatEndDate(date) {
|
|
94
|
-
if (typeof date === "string") {
|
|
95
|
-
const dateWithoutTimezone = date.replace(/\s*\+\d{2}:\d{2}$/, "");
|
|
96
|
-
date = new Date(dateWithoutTimezone);
|
|
97
|
-
}
|
|
98
|
-
const singaporeMoment = moment.tz(date, "Asia/Singapore");
|
|
99
|
-
singaporeMoment.set({
|
|
100
|
-
hour: 23,
|
|
101
|
-
minute: 59,
|
|
102
|
-
second: 59,
|
|
103
|
-
millisecond: 999
|
|
104
|
-
});
|
|
105
|
-
return singaporeMoment.toDate();
|
|
106
|
-
}
|
|
107
|
-
function getDateRange(p) {
|
|
108
|
-
const start = /* @__PURE__ */ new Date();
|
|
109
|
-
start.setHours(0, 0, 0, 0);
|
|
110
|
-
if (p === "today") {
|
|
111
|
-
return { $gte: start, $lte: formatEndDate(start) };
|
|
112
|
-
}
|
|
113
|
-
const days = p === "thisWeek" ? 7 : 30;
|
|
114
|
-
const rangeStart = /* @__PURE__ */ new Date();
|
|
115
|
-
rangeStart.setDate(start.getDate() - days);
|
|
116
|
-
rangeStart.setHours(0, 0, 0, 0);
|
|
117
|
-
return { $gte: rangeStart, $lte: formatEndDate(/* @__PURE__ */ new Date()) };
|
|
118
|
-
}
|
|
119
|
-
const calculatePercentageChange = (currentCount, previousCount) => {
|
|
120
|
-
if (previousCount === 0) {
|
|
121
|
-
return currentCount > 0 ? 100 : 0;
|
|
122
|
-
}
|
|
123
|
-
return Math.round(
|
|
124
|
-
(currentCount - previousCount) / previousCount * 100 * 100
|
|
125
|
-
) / 100;
|
|
126
|
-
};
|
|
127
141
|
const workOrderService = workOrderServiceMap[serviceType] ?? serviceType;
|
|
128
142
|
try {
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
const yesterday = /* @__PURE__ */ new Date();
|
|
132
|
-
yesterday.setDate(today.getDate() - 1);
|
|
133
|
-
yesterday.setHours(0, 0, 0, 0);
|
|
134
|
-
const yesterdayEnd = new Date(yesterday);
|
|
135
|
-
yesterdayEnd.setHours(23, 59, 59, 999);
|
|
136
|
-
const todayEnd = formatEndDate(/* @__PURE__ */ new Date());
|
|
137
|
-
const periodRange = getDateRange(period);
|
|
143
|
+
const periodRange = getPeriodRange(period);
|
|
144
|
+
const priorPeriodRange = getPreviousPeriodRange(period);
|
|
138
145
|
const [
|
|
139
146
|
workOrderReport,
|
|
140
147
|
supplyAlertReport,
|
|
141
148
|
taskCompletedReport,
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
yesterdayTaskCompletedReport,
|
|
145
|
-
todayTaskCompletedReport
|
|
149
|
+
priorWorkOrderReport,
|
|
150
|
+
priorTaskCompletedReport
|
|
146
151
|
] = await Promise.all([
|
|
147
152
|
// 1. Open work orders (period-filtered) with inProgress breakdown
|
|
148
153
|
workOrderCollection.aggregate([
|
|
@@ -186,45 +191,28 @@ function useHygieneDashboardRepository() {
|
|
|
186
191
|
}
|
|
187
192
|
}
|
|
188
193
|
]).toArray(),
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
}
|
|
198
|
-
},
|
|
199
|
-
{ $count: "count" }
|
|
200
|
-
]).toArray(),
|
|
201
|
-
// 5. Today open work orders (for % change)
|
|
194
|
+
/*
|
|
195
|
+
4. Open work orders in the PREVIOUS period (for % change).
|
|
196
|
+
|
|
197
|
+
The current side of the comparison is no longer queried separately:
|
|
198
|
+
it is now the same range as tile 1, so `wFacet.total` below IS the
|
|
199
|
+
current count. Two aggregations dropped, not by cutting a measure but
|
|
200
|
+
because the fix made them duplicates.
|
|
201
|
+
*/
|
|
202
202
|
workOrderCollection.aggregate([
|
|
203
203
|
{
|
|
204
204
|
$match: {
|
|
205
205
|
site,
|
|
206
206
|
service: workOrderService,
|
|
207
|
-
createdAt:
|
|
207
|
+
createdAt: priorPeriodRange,
|
|
208
208
|
status: { $nin: ["completed"] }
|
|
209
209
|
}
|
|
210
210
|
},
|
|
211
211
|
{ $count: "count" }
|
|
212
212
|
]).toArray(),
|
|
213
|
-
//
|
|
213
|
+
// 5. Task completed in the PREVIOUS period — area checklists (for % change)
|
|
214
214
|
areaChecklistCollection.aggregate([
|
|
215
|
-
...areaChecklistScheduleLookup(site, serviceType,
|
|
216
|
-
$gte: yesterday,
|
|
217
|
-
$lte: yesterdayEnd
|
|
218
|
-
}),
|
|
219
|
-
{ $match: { status: "completed" } },
|
|
220
|
-
{ $count: "count" }
|
|
221
|
-
]).toArray(),
|
|
222
|
-
// 7. Today task completed — area checklists (for % change)
|
|
223
|
-
areaChecklistCollection.aggregate([
|
|
224
|
-
...areaChecklistScheduleLookup(site, serviceType, {
|
|
225
|
-
$gte: today,
|
|
226
|
-
$lte: todayEnd
|
|
227
|
-
}),
|
|
215
|
+
...areaChecklistScheduleLookup(site, serviceType, priorPeriodRange),
|
|
228
216
|
{ $match: { status: "completed" } },
|
|
229
217
|
{ $count: "count" }
|
|
230
218
|
]).toArray()
|
|
@@ -240,21 +228,26 @@ function useHygieneDashboardRepository() {
|
|
|
240
228
|
count: wFacet.total[0]?.count ?? 0,
|
|
241
229
|
inProgress: wFacet.inProgress[0]?.count ?? 0,
|
|
242
230
|
percentage: calculatePercentageChange(
|
|
243
|
-
|
|
244
|
-
|
|
231
|
+
wFacet.total[0]?.count ?? 0,
|
|
232
|
+
priorWorkOrderReport[0]?.count ?? 0
|
|
245
233
|
)
|
|
246
234
|
},
|
|
247
235
|
supplyAlert: {
|
|
236
|
+
// The count is real: active supplies at qty 0 for this site and
|
|
237
|
+
// service. The percentage beside it was the literal 0 and had never
|
|
238
|
+
// been computed from anything — see the util for why there is no
|
|
239
|
+
// prior period to compute one from.
|
|
248
240
|
count: supplyAlertReport[0]?.count ?? 0,
|
|
249
|
-
percentage:
|
|
241
|
+
percentage: null,
|
|
242
|
+
percentageUnavailableReason: SUPPLY_ALERT_HAS_NO_PRIOR_PERIOD
|
|
250
243
|
},
|
|
251
244
|
taskCompleted: {
|
|
252
245
|
completed: tFacet.completed[0]?.count ?? 0,
|
|
253
246
|
total: tFacet.total[0]?.count ?? 0,
|
|
254
247
|
inProgress: tFacet.inProgress[0]?.count ?? 0,
|
|
255
248
|
percentage: calculatePercentageChange(
|
|
256
|
-
|
|
257
|
-
|
|
249
|
+
tFacet.completed[0]?.count ?? 0,
|
|
250
|
+
priorTaskCompletedReport[0]?.count ?? 0
|
|
258
251
|
)
|
|
259
252
|
}
|
|
260
253
|
};
|
|
@@ -292,8 +285,8 @@ function useHygieneDashboardRepository() {
|
|
|
292
285
|
return cachedData;
|
|
293
286
|
}
|
|
294
287
|
const workOrderService = workOrderServiceMap[serviceType] ?? serviceType;
|
|
295
|
-
const weekStart =
|
|
296
|
-
const weekEnd =
|
|
288
|
+
const weekStart = moment2.tz("Asia/Singapore").startOf("isoWeek").toDate();
|
|
289
|
+
const weekEnd = moment2.tz("Asia/Singapore").endOf("isoWeek").toDate();
|
|
297
290
|
const weekRange = { $gte: weekStart, $lte: weekEnd };
|
|
298
291
|
const dayLabels = {
|
|
299
292
|
1: "Sun",
|
|
@@ -373,8 +366,8 @@ function useHygieneDashboardRepository() {
|
|
|
373
366
|
logger.info(`Cache hit for todayTaskSchedule: ${cacheKey}`);
|
|
374
367
|
return cachedData;
|
|
375
368
|
}
|
|
376
|
-
const todayStart =
|
|
377
|
-
const todayEnd =
|
|
369
|
+
const todayStart = moment2.tz("Asia/Singapore").startOf("day").toDate();
|
|
370
|
+
const todayEnd = moment2.tz("Asia/Singapore").endOf("day").toDate();
|
|
378
371
|
const todayRange = { $gte: todayStart, $lte: todayEnd };
|
|
379
372
|
try {
|
|
380
373
|
const [items, countResult] = await Promise.all([
|
|
@@ -478,8 +471,8 @@ function useHygieneDashboardRepository() {
|
|
|
478
471
|
logger.info(`Cache hit for staffAttendance: ${cacheKey}`);
|
|
479
472
|
return cachedData;
|
|
480
473
|
}
|
|
481
|
-
const todayStart =
|
|
482
|
-
const todayEnd =
|
|
474
|
+
const todayStart = moment2.tz("Asia/Singapore").startOf("day").toDate();
|
|
475
|
+
const todayEnd = moment2.tz("Asia/Singapore").endOf("day").toDate();
|
|
483
476
|
const todayStartStr = todayStart.toISOString();
|
|
484
477
|
const todayEndStr = todayEnd.toISOString();
|
|
485
478
|
try {
|
|
@@ -1608,7 +1601,7 @@ function useUnitRepository() {
|
|
|
1608
1601
|
function useAreaService() {
|
|
1609
1602
|
const { createArea: _createArea, getAreasForChecklist } = useAreaRepo();
|
|
1610
1603
|
const { generateAreaExcel } = useAreaExportService();
|
|
1611
|
-
const { getUnits: _getUnits } = useUnitRepository();
|
|
1604
|
+
const { createUnit: _createUnit, getUnits: _getUnits } = useUnitRepository();
|
|
1612
1605
|
async function importArea({
|
|
1613
1606
|
dataJson,
|
|
1614
1607
|
site,
|
|
@@ -1690,23 +1683,40 @@ function useAreaService() {
|
|
|
1690
1683
|
areaData.set = setNumber;
|
|
1691
1684
|
}
|
|
1692
1685
|
}
|
|
1693
|
-
if (row.UNITS
|
|
1686
|
+
if (row.UNITS) {
|
|
1694
1687
|
const unitNames = String(row.UNITS).split(",").map((u) => u.trim()).filter((u) => u);
|
|
1695
1688
|
if (unitNames.length > 0) {
|
|
1696
1689
|
const areaUnits = [];
|
|
1697
1690
|
for (const unitName of unitNames) {
|
|
1698
|
-
|
|
1691
|
+
let foundUnit = availableUnits.find(
|
|
1699
1692
|
(u) => u.name.toLowerCase() === unitName.toLowerCase()
|
|
1700
1693
|
);
|
|
1694
|
+
if (!foundUnit) {
|
|
1695
|
+
try {
|
|
1696
|
+
const insertedUnitId = await _createUnit({
|
|
1697
|
+
name: unitName,
|
|
1698
|
+
site,
|
|
1699
|
+
serviceType
|
|
1700
|
+
});
|
|
1701
|
+
foundUnit = {
|
|
1702
|
+
_id: insertedUnitId,
|
|
1703
|
+
name: unitName
|
|
1704
|
+
};
|
|
1705
|
+
availableUnits.push(foundUnit);
|
|
1706
|
+
logger8.info(
|
|
1707
|
+
`Successfully created missing unit "${unitName}" for area "${areaName}"`
|
|
1708
|
+
);
|
|
1709
|
+
} catch (error) {
|
|
1710
|
+
logger8.warn(
|
|
1711
|
+
`Unit "${unitName}" could not be created for area "${areaName}": ${error.message}`
|
|
1712
|
+
);
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1701
1715
|
if (foundUnit) {
|
|
1702
1716
|
areaUnits.push({
|
|
1703
1717
|
unit: foundUnit._id,
|
|
1704
1718
|
name: foundUnit.name
|
|
1705
1719
|
});
|
|
1706
|
-
} else {
|
|
1707
|
-
logger8.warn(
|
|
1708
|
-
`Unit "${unitName}" not found in site for area "${areaName}"`
|
|
1709
|
-
);
|
|
1710
1720
|
}
|
|
1711
1721
|
}
|
|
1712
1722
|
if (areaUnits.length > 0) {
|
|
@@ -2098,6 +2108,13 @@ function useUnitService() {
|
|
|
2098
2108
|
updateUnit: _updateUnit,
|
|
2099
2109
|
deleteUnit: _deleteUnit
|
|
2100
2110
|
} = useUnitRepository();
|
|
2111
|
+
function getUnitNamesFromRow(row) {
|
|
2112
|
+
const unitValue = row?.UNIT ?? row?.UNITS;
|
|
2113
|
+
if (!unitValue) {
|
|
2114
|
+
return [];
|
|
2115
|
+
}
|
|
2116
|
+
return String(unitValue).split(",").map((unit) => unit.trim()).filter((unit) => unit && !unit.startsWith("Sample:"));
|
|
2117
|
+
}
|
|
2101
2118
|
async function importUnit({
|
|
2102
2119
|
dataJson,
|
|
2103
2120
|
site,
|
|
@@ -2123,36 +2140,35 @@ function useUnitService() {
|
|
|
2123
2140
|
try {
|
|
2124
2141
|
for (let i = 0; i < dataArray.length; i++) {
|
|
2125
2142
|
const row = dataArray[i];
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
continue;
|
|
2130
|
-
}
|
|
2131
|
-
const unitName = String(row.UNIT).trim();
|
|
2132
|
-
if (!unitName) {
|
|
2133
|
-
logger11.warn(`Skipping row ${i + 1} with empty unit name`);
|
|
2143
|
+
const unitNames = getUnitNamesFromRow(row);
|
|
2144
|
+
if (unitNames.length === 0) {
|
|
2145
|
+
logger11.warn(`Skipping row ${i + 1} with missing UNIT/UNITS:`, row);
|
|
2134
2146
|
skippedRows.push(i + 1);
|
|
2135
2147
|
continue;
|
|
2136
2148
|
}
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2149
|
+
const uniqueUnitNames = Array.from(
|
|
2150
|
+
new Set(unitNames.map((unitName) => unitName.toLowerCase()))
|
|
2151
|
+
).map(
|
|
2152
|
+
(lowerUnitName) => unitNames.find(
|
|
2153
|
+
(unitName) => unitName.toLowerCase() === lowerUnitName
|
|
2154
|
+
)
|
|
2155
|
+
);
|
|
2156
|
+
for (const unitName of uniqueUnitNames) {
|
|
2157
|
+
try {
|
|
2158
|
+
const insertedId = await _createUnit({
|
|
2159
|
+
name: unitName,
|
|
2160
|
+
site,
|
|
2161
|
+
serviceType
|
|
2162
|
+
});
|
|
2163
|
+
insertedUnitIds.push(insertedId);
|
|
2164
|
+
logger11.info(`Successfully created unit: ${unitName}`);
|
|
2165
|
+
} catch (error) {
|
|
2166
|
+
logger11.error(`Error creating unit "${unitName}": ${error.message}`);
|
|
2167
|
+
if (error.message.includes("Unit already exists")) {
|
|
2168
|
+
duplicateUnits.push(unitName);
|
|
2169
|
+
} else {
|
|
2170
|
+
failedUnits.push(unitName);
|
|
2171
|
+
}
|
|
2156
2172
|
}
|
|
2157
2173
|
}
|
|
2158
2174
|
}
|
|
@@ -2505,7 +2521,7 @@ function MParentChecklist(value) {
|
|
|
2505
2521
|
|
|
2506
2522
|
// src/repositories/hygiene-parent-checklist.repository.ts
|
|
2507
2523
|
import { ObjectId as ObjectId7 } from "mongodb";
|
|
2508
|
-
import
|
|
2524
|
+
import moment3 from "moment-timezone";
|
|
2509
2525
|
import {
|
|
2510
2526
|
useAtlas as useAtlas5,
|
|
2511
2527
|
InternalServerError as InternalServerError4,
|
|
@@ -2809,11 +2825,11 @@ function useParentChecklistRepo() {
|
|
|
2809
2825
|
cacheOptions.search = search;
|
|
2810
2826
|
}
|
|
2811
2827
|
const singaporeTz = "Asia/Singapore";
|
|
2812
|
-
const normalizedStartDate = typeof startDate === "string" ? startDate :
|
|
2813
|
-
const normalizedEndDate = typeof endDate === "string" ? endDate :
|
|
2828
|
+
const normalizedStartDate = typeof startDate === "string" ? startDate : moment3(startDate).tz(singaporeTz).format("YYYY-MM-DD");
|
|
2829
|
+
const normalizedEndDate = typeof endDate === "string" ? endDate : moment3(endDate).tz(singaporeTz).format("YYYY-MM-DD");
|
|
2814
2830
|
if (startDate && endDate) {
|
|
2815
|
-
const startOfDay =
|
|
2816
|
-
const endOfDay =
|
|
2831
|
+
const startOfDay = moment3.tz(normalizedStartDate, "YYYY-MM-DD", singaporeTz).startOf("day").toDate();
|
|
2832
|
+
const endOfDay = moment3.tz(normalizedEndDate, "YYYY-MM-DD", singaporeTz).endOf("day").toDate();
|
|
2817
2833
|
query.createdAt = {
|
|
2818
2834
|
$gte: startOfDay,
|
|
2819
2835
|
$lte: endOfDay
|
|
@@ -2822,12 +2838,12 @@ function useParentChecklistRepo() {
|
|
|
2822
2838
|
cacheOptions.endDate = normalizedEndDate;
|
|
2823
2839
|
} else if (startDate) {
|
|
2824
2840
|
query.createdAt = {
|
|
2825
|
-
$gte:
|
|
2841
|
+
$gte: moment3.tz(normalizedStartDate, "YYYY-MM-DD", singaporeTz).startOf("day").toDate()
|
|
2826
2842
|
};
|
|
2827
2843
|
cacheOptions.startDate = normalizedStartDate;
|
|
2828
2844
|
} else if (endDate) {
|
|
2829
2845
|
query.createdAt = {
|
|
2830
|
-
$lte:
|
|
2846
|
+
$lte: moment3.tz(normalizedEndDate, "YYYY-MM-DD", singaporeTz).endOf("day").toDate()
|
|
2831
2847
|
};
|
|
2832
2848
|
cacheOptions.endDate = normalizedEndDate;
|
|
2833
2849
|
}
|