@devx-retailos/cms 0.0.1 → 0.0.2
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/.medusa/server/src/api/admin/retailos/cms/export/route.js +2 -2
- package/.medusa/server/src/api/admin/retailos/cms/handovers/[id]/route.js +2 -2
- package/.medusa/server/src/api/admin/retailos/cms/handovers/route.js +72 -7
- package/.medusa/server/src/api/admin/retailos/cms/petty-cash/route.js +35 -4
- package/.medusa/server/src/api/admin/retailos/cms/reconciliation/route.js +42 -0
- package/.medusa/server/src/modules/cms/migrations/Migration20260626000000.js +16 -0
- package/.medusa/server/src/modules/cms/models/cms-handover.js +3 -1
- package/.medusa/server/src/modules/cms/permissions.js +6 -1
- package/.medusa/server/src/modules/cms/services/cms-module-service.js +108 -38
- package/README.md +116 -0
- package/package.json +1 -1
- package/src/api/admin/retailos/cms/export/__tests__/route.test.ts +9 -9
- package/src/api/admin/retailos/cms/export/route.ts +1 -1
- package/src/api/admin/retailos/cms/handovers/[id]/__tests__/route.test.ts +5 -5
- package/src/api/admin/retailos/cms/handovers/[id]/route.ts +1 -1
- package/src/api/admin/retailos/cms/handovers/__tests__/route.test.ts +22 -11
- package/src/api/admin/retailos/cms/handovers/route.ts +78 -6
- package/src/api/admin/retailos/cms/petty-cash/__tests__/route.test.ts +18 -12
- package/src/api/admin/retailos/cms/petty-cash/route.ts +46 -4
- package/src/api/admin/retailos/cms/reconciliation/__tests__/route.test.ts +124 -0
- package/src/api/admin/retailos/cms/reconciliation/route.ts +47 -0
- package/src/modules/cms/__tests__/cms-module-service.test.ts +219 -78
- package/src/modules/cms/__tests__/permissions.test.ts +3 -2
- package/src/modules/cms/migrations/Migration20260626000000.ts +17 -0
- package/src/modules/cms/models/cms-handover.ts +2 -0
- package/src/modules/cms/permissions.ts +5 -0
- package/src/modules/cms/services/cms-module-service.ts +163 -37
package/README.md
CHANGED
|
@@ -126,8 +126,21 @@ const logs = await cmsService.getShiftLogs({
|
|
|
126
126
|
to: new Date("2024-01-31"),
|
|
127
127
|
limit: 50,
|
|
128
128
|
})
|
|
129
|
+
|
|
130
|
+
// Daily cash reconciliation report
|
|
131
|
+
const report = await cmsService.getReconciliationReport({
|
|
132
|
+
store_id: "store_01",
|
|
133
|
+
date: "2026-06-23", // YYYY-MM-DD (UTC)
|
|
134
|
+
net_cash_sales: 28450, // optional — cash collected from orders (default 0)
|
|
135
|
+
})
|
|
136
|
+
// report.expected_closing_balance = opening + net_cash_sales
|
|
137
|
+
// + petty_cash_in - petty_cash_out + total_handovers_in - total_handovers_out
|
|
138
|
+
// report.variance = closing_balance - expected (null until day_end runs)
|
|
139
|
+
// report.is_balanced = variance === 0
|
|
129
140
|
```
|
|
130
141
|
|
|
142
|
+
> **`net_cash_sales`** comes from your order module. Pass it from the brand's order query or via `sdk-client`. When omitted the report uses 0 and computes everything from CMS data only.
|
|
143
|
+
|
|
131
144
|
---
|
|
132
145
|
|
|
133
146
|
## Permissions
|
|
@@ -140,6 +153,109 @@ const logs = await cmsService.getShiftLogs({
|
|
|
140
153
|
| `cms.petty_cash.read` | View petty cash balance and transactions |
|
|
141
154
|
| `cms.petty_cash.operate` | Add petty cash or record expenses |
|
|
142
155
|
| `cms.accumulation.read` | View current cash accumulation for a store |
|
|
156
|
+
| `cms.reconciliation.read` | View daily cash reconciliation report with variance analysis |
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## HTTP API
|
|
161
|
+
|
|
162
|
+
All routes are mounted under `/admin/retailos/cms` and require a valid Medusa admin session.
|
|
163
|
+
|
|
164
|
+
### Handovers
|
|
165
|
+
|
|
166
|
+
| Method | Path | Description |
|
|
167
|
+
|--------|------|-------------|
|
|
168
|
+
| `GET` | `/admin/retailos/cms/handovers` | List handovers with filtering, pagination, and linked employee/store data |
|
|
169
|
+
| `POST` | `/admin/retailos/cms/handovers` | Create a cash handover |
|
|
170
|
+
| `GET` | `/admin/retailos/cms/handovers/:id` | Retrieve a single handover by ID |
|
|
171
|
+
|
|
172
|
+
**GET /admin/retailos/cms/handovers** query params:
|
|
173
|
+
|
|
174
|
+
| Param | Type | Description |
|
|
175
|
+
|-------|------|-------------|
|
|
176
|
+
| `store_id` | string | Filter by store |
|
|
177
|
+
| `employee_id` | string | Filter by employee |
|
|
178
|
+
| `type` | `CR` \| `DB` | Filter by direction |
|
|
179
|
+
| `handover_id` | string | Exact match on handover_id |
|
|
180
|
+
| `q` | string | Full-text search on handover_id (`ILIKE`) |
|
|
181
|
+
| `created_at` | string / object | Date range filter |
|
|
182
|
+
| `limit` | number | Max 500, default 20 |
|
|
183
|
+
| `offset` | number | Default 0 |
|
|
184
|
+
| `order` | string | Field name; prefix `-` for DESC (e.g. `-created_at`) |
|
|
185
|
+
|
|
186
|
+
Response includes `handovers[]` and `count`. Each handover carries an embedded `employee` (`id`, `first_name`, `last_name`) and `store` (`id`, `name`, `store_code`) when module links resolve.
|
|
187
|
+
|
|
188
|
+
**POST /admin/retailos/cms/handovers** body (Zod-validated):
|
|
189
|
+
|
|
190
|
+
```json
|
|
191
|
+
{
|
|
192
|
+
"store_id": "store_01",
|
|
193
|
+
"employee_id": "emp_01",
|
|
194
|
+
"handover_amount": 2000,
|
|
195
|
+
"type": "CR",
|
|
196
|
+
"remark": "shift change",
|
|
197
|
+
"handover_id": null,
|
|
198
|
+
"image_url": null,
|
|
199
|
+
"metadata": null
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
### Petty cash
|
|
206
|
+
|
|
207
|
+
| Method | Path | Description |
|
|
208
|
+
|--------|------|-------------|
|
|
209
|
+
| `GET` | `/admin/retailos/cms/petty-cash` | List petty cash transactions with employee enrichment |
|
|
210
|
+
|
|
211
|
+
Query params: `store_id`, `limit` (max 500, default 100), `offset`.
|
|
212
|
+
|
|
213
|
+
Each transaction includes an embedded `employee` object when the `employee` module is available.
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
### Reconciliation
|
|
218
|
+
|
|
219
|
+
| Method | Path | Description |
|
|
220
|
+
|--------|------|-------------|
|
|
221
|
+
| `GET` | `/admin/retailos/cms/reconciliation` | Daily cash reconciliation report |
|
|
222
|
+
|
|
223
|
+
Required params: `store_id`, `date` (YYYY-MM-DD). Optional: `net_cash_sales` (number).
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
### Export
|
|
228
|
+
|
|
229
|
+
| Method | Path | Description |
|
|
230
|
+
|--------|------|-------------|
|
|
231
|
+
| `GET` | `/admin/retailos/cms/export` | Download all handovers as a CSV file |
|
|
232
|
+
|
|
233
|
+
Query params: `store_id` (optional). Returns `text/csv` with `Content-Disposition: attachment`.
|
|
234
|
+
|
|
235
|
+
---
|
|
236
|
+
|
|
237
|
+
## sdk-client
|
|
238
|
+
|
|
239
|
+
The `cms` namespace on `RetailOSClient` exposes typed wrappers for all routes above. Key methods:
|
|
240
|
+
|
|
241
|
+
```ts
|
|
242
|
+
// Reconciliation report
|
|
243
|
+
const report = await client.cms.getReconciliationReport({
|
|
244
|
+
store_id: "store_01",
|
|
245
|
+
date: "2026-06-26",
|
|
246
|
+
net_cash_sales: 28450,
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
// List handovers (paginated)
|
|
250
|
+
const { handovers, count } = await client.cms.listHandovers({
|
|
251
|
+
store_id: "store_01",
|
|
252
|
+
limit: 20,
|
|
253
|
+
offset: 0,
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
// Export CSV (returns raw text)
|
|
257
|
+
const csv = await client.cms.exportHandovers({ store_id: "store_01" })
|
|
258
|
+
```
|
|
143
259
|
|
|
144
260
|
---
|
|
145
261
|
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@ import { GET } from "../route"
|
|
|
3
3
|
|
|
4
4
|
function makeService() {
|
|
5
5
|
return {
|
|
6
|
-
|
|
6
|
+
listAndCountRetailosCmsHandovers: vi.fn().mockResolvedValue([[], 0]),
|
|
7
7
|
}
|
|
8
8
|
}
|
|
9
9
|
|
|
@@ -34,7 +34,7 @@ function makeRes() {
|
|
|
34
34
|
describe("GET /export", () => {
|
|
35
35
|
it("returns CSV with correct headers row", async () => {
|
|
36
36
|
const service = makeService()
|
|
37
|
-
service.
|
|
37
|
+
service.listAndCountRetailosCmsHandovers.mockResolvedValue([[], 0])
|
|
38
38
|
const req = makeReq({}, service)
|
|
39
39
|
const res = makeRes()
|
|
40
40
|
|
|
@@ -55,7 +55,7 @@ describe("GET /export", () => {
|
|
|
55
55
|
const handovers = [
|
|
56
56
|
{ id: "cmsh_1", store_id: "s1", employee_id: "e1", handover_id: null, handover_amount: 500, total_cash: null, type: "CR", remark: null, created_at: "2026-06-22T00:00:00Z" },
|
|
57
57
|
]
|
|
58
|
-
service.
|
|
58
|
+
service.listAndCountRetailosCmsHandovers.mockResolvedValue([handovers, 1])
|
|
59
59
|
const req = makeReq({}, service)
|
|
60
60
|
const res = makeRes()
|
|
61
61
|
|
|
@@ -74,7 +74,7 @@ describe("GET /export", () => {
|
|
|
74
74
|
const handovers = [
|
|
75
75
|
{ id: "cmsh_2", store_id: "s1", employee_id: "e1", handover_id: null, handover_amount: 100, total_cash: null, type: "DB", remark: 'cash, "extra"', created_at: "2026-06-22T00:00:00Z" },
|
|
76
76
|
]
|
|
77
|
-
service.
|
|
77
|
+
service.listAndCountRetailosCmsHandovers.mockResolvedValue([handovers, 1])
|
|
78
78
|
const req = makeReq({}, service)
|
|
79
79
|
const res = makeRes()
|
|
80
80
|
|
|
@@ -86,13 +86,13 @@ describe("GET /export", () => {
|
|
|
86
86
|
|
|
87
87
|
it("filters by store_id when provided in query", async () => {
|
|
88
88
|
const service = makeService()
|
|
89
|
-
service.
|
|
89
|
+
service.listAndCountRetailosCmsHandovers.mockResolvedValue([[], 0])
|
|
90
90
|
const req = makeReq({ store_id: "s1" }, service)
|
|
91
91
|
const res = makeRes()
|
|
92
92
|
|
|
93
93
|
await GET(req as any, res as any)
|
|
94
94
|
|
|
95
|
-
expect(service.
|
|
95
|
+
expect(service.listAndCountRetailosCmsHandovers).toHaveBeenCalledWith(
|
|
96
96
|
expect.objectContaining({ store_id: ["s1"] }),
|
|
97
97
|
expect.any(Object)
|
|
98
98
|
)
|
|
@@ -100,13 +100,13 @@ describe("GET /export", () => {
|
|
|
100
100
|
|
|
101
101
|
it("does not filter by store_id when not in query", async () => {
|
|
102
102
|
const service = makeService()
|
|
103
|
-
service.
|
|
103
|
+
service.listAndCountRetailosCmsHandovers.mockResolvedValue([[], 0])
|
|
104
104
|
const req = makeReq({}, service)
|
|
105
105
|
const res = makeRes()
|
|
106
106
|
|
|
107
107
|
await GET(req as any, res as any)
|
|
108
108
|
|
|
109
|
-
expect(service.
|
|
109
|
+
expect(service.listAndCountRetailosCmsHandovers).toHaveBeenCalledWith(
|
|
110
110
|
expect.not.objectContaining({ store_id: expect.anything() }),
|
|
111
111
|
expect.any(Object)
|
|
112
112
|
)
|
|
@@ -114,7 +114,7 @@ describe("GET /export", () => {
|
|
|
114
114
|
|
|
115
115
|
it("returns 500 when service throws", async () => {
|
|
116
116
|
const service = makeService()
|
|
117
|
-
service.
|
|
117
|
+
service.listAndCountRetailosCmsHandovers.mockRejectedValue(new Error("db error"))
|
|
118
118
|
const req = makeReq({}, service)
|
|
119
119
|
const res = makeRes()
|
|
120
120
|
|
|
@@ -35,7 +35,7 @@ export const GET = async (req: AuthenticatedMedusaRequest, res: MedusaResponse)
|
|
|
35
35
|
const filters: Record<string, unknown> = {}
|
|
36
36
|
if (store_id) filters.store_id = [store_id]
|
|
37
37
|
|
|
38
|
-
const [handovers] = await service.
|
|
38
|
+
const [handovers] = await service.listAndCountRetailosCmsHandovers(filters, {
|
|
39
39
|
take: 10000,
|
|
40
40
|
order: { created_at: "DESC" },
|
|
41
41
|
})
|
|
@@ -3,7 +3,7 @@ import { GET } from "../route"
|
|
|
3
3
|
|
|
4
4
|
function makeService() {
|
|
5
5
|
return {
|
|
6
|
-
|
|
6
|
+
retrieveRetailosCmsHandover: vi.fn(),
|
|
7
7
|
}
|
|
8
8
|
}
|
|
9
9
|
|
|
@@ -31,20 +31,20 @@ describe("GET /handovers/[id]", () => {
|
|
|
31
31
|
it("returns handover when found", async () => {
|
|
32
32
|
const service = makeService()
|
|
33
33
|
const handover = { id: "cmsh_1", store_id: "s1", handover_amount: 500 }
|
|
34
|
-
service.
|
|
34
|
+
service.retrieveRetailosCmsHandover.mockResolvedValue(handover)
|
|
35
35
|
const req = makeReq({ id: "cmsh_1" }, service)
|
|
36
36
|
const res = makeRes()
|
|
37
37
|
|
|
38
38
|
await GET(req as any, res as any)
|
|
39
39
|
|
|
40
|
-
expect(service.
|
|
40
|
+
expect(service.retrieveRetailosCmsHandover).toHaveBeenCalledWith("cmsh_1")
|
|
41
41
|
expect(res.status).toHaveBeenCalledWith(200)
|
|
42
42
|
expect(res.json).toHaveBeenCalledWith({ handover })
|
|
43
43
|
})
|
|
44
44
|
|
|
45
45
|
it("returns 404 when handover is null", async () => {
|
|
46
46
|
const service = makeService()
|
|
47
|
-
service.
|
|
47
|
+
service.retrieveRetailosCmsHandover.mockResolvedValue(null)
|
|
48
48
|
const req = makeReq({ id: "cmsh_missing" }, service)
|
|
49
49
|
const res = makeRes()
|
|
50
50
|
|
|
@@ -56,7 +56,7 @@ describe("GET /handovers/[id]", () => {
|
|
|
56
56
|
|
|
57
57
|
it("returns 500 when service throws", async () => {
|
|
58
58
|
const service = makeService()
|
|
59
|
-
service.
|
|
59
|
+
service.retrieveRetailosCmsHandover.mockRejectedValue(new Error("db error"))
|
|
60
60
|
const req = makeReq({ id: "cmsh_1" }, service)
|
|
61
61
|
const res = makeRes()
|
|
62
62
|
|
|
@@ -21,7 +21,7 @@ export const GET = async (req: AuthenticatedMedusaRequest, res: MedusaResponse)
|
|
|
21
21
|
const { id } = req.params
|
|
22
22
|
try {
|
|
23
23
|
const service = getService(req)
|
|
24
|
-
const handover = await service.
|
|
24
|
+
const handover = await service.retrieveRetailosCmsHandover(id)
|
|
25
25
|
if (!handover) {
|
|
26
26
|
return res.status(404).json({ code: "NOT_FOUND", message: "Handover not found" })
|
|
27
27
|
}
|
|
@@ -3,18 +3,24 @@ import { GET, POST } from "../route"
|
|
|
3
3
|
|
|
4
4
|
function makeService() {
|
|
5
5
|
return {
|
|
6
|
-
listAndCountCmsHandovers: vi.fn().mockResolvedValue([[], 0]),
|
|
7
6
|
handover: vi.fn().mockResolvedValue({ id: "cmsh_1", store_id: "s1", handover_amount: 100 }),
|
|
8
7
|
}
|
|
9
8
|
}
|
|
10
9
|
|
|
11
|
-
function
|
|
10
|
+
function makeQueryModule(data: unknown[] = [], count = 0) {
|
|
11
|
+
return {
|
|
12
|
+
graph: vi.fn().mockResolvedValue({ data, metadata: { count } }),
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function makeReq(overrides: Record<string, unknown> = {}, service = makeService(), queryModule = makeQueryModule()) {
|
|
12
17
|
return {
|
|
13
18
|
body: {},
|
|
14
|
-
|
|
19
|
+
query: {},
|
|
15
20
|
scope: {
|
|
16
21
|
resolve: vi.fn().mockImplementation((key: string) => {
|
|
17
22
|
if (key === "cms") return service
|
|
23
|
+
if (key === "query") return queryModule
|
|
18
24
|
throw new Error("not found")
|
|
19
25
|
}),
|
|
20
26
|
},
|
|
@@ -32,22 +38,27 @@ function makeRes() {
|
|
|
32
38
|
|
|
33
39
|
describe("GET /handovers", () => {
|
|
34
40
|
it("returns handover list with count", async () => {
|
|
41
|
+
const data = [{ id: "cmsh_1" }, { id: "cmsh_2" }]
|
|
42
|
+
const queryModule = makeQueryModule(data, 2)
|
|
35
43
|
const service = makeService()
|
|
36
|
-
const
|
|
37
|
-
service.listAndCountCmsHandovers.mockResolvedValue([handovers, 2])
|
|
38
|
-
const req = makeReq({}, service)
|
|
44
|
+
const req = makeReq({}, service, queryModule)
|
|
39
45
|
const res = makeRes()
|
|
40
46
|
|
|
41
47
|
await GET(req as any, res as any)
|
|
42
48
|
|
|
43
49
|
expect(res.status).toHaveBeenCalledWith(200)
|
|
44
|
-
expect(res.json).toHaveBeenCalledWith({
|
|
50
|
+
expect(res.json).toHaveBeenCalledWith({
|
|
51
|
+
handovers: [
|
|
52
|
+
{ id: "cmsh_1", employee: null, store: null },
|
|
53
|
+
{ id: "cmsh_2", employee: null, store: null },
|
|
54
|
+
],
|
|
55
|
+
count: 2,
|
|
56
|
+
})
|
|
45
57
|
})
|
|
46
58
|
|
|
47
|
-
it("returns 500 when
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
const req = makeReq({}, service)
|
|
59
|
+
it("returns 500 when query.graph throws", async () => {
|
|
60
|
+
const queryModule = { graph: vi.fn().mockRejectedValue(new Error("db error")) }
|
|
61
|
+
const req = makeReq({}, makeService(), queryModule)
|
|
51
62
|
const res = makeRes()
|
|
52
63
|
|
|
53
64
|
await GET(req as any, res as any)
|
|
@@ -2,6 +2,7 @@ import type {
|
|
|
2
2
|
AuthenticatedMedusaRequest,
|
|
3
3
|
MedusaResponse,
|
|
4
4
|
} from "@medusajs/framework"
|
|
5
|
+
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
|
|
5
6
|
import { z } from "zod"
|
|
6
7
|
import { CMS_MODULE } from "../../../../../modules/cms/constants"
|
|
7
8
|
import type CmsModuleService from "../../../../../modules/cms/services/cms-module-service"
|
|
@@ -32,13 +33,84 @@ function getLogger(req: AuthenticatedMedusaRequest) {
|
|
|
32
33
|
|
|
33
34
|
export const GET = async (req: AuthenticatedMedusaRequest, res: MedusaResponse) => {
|
|
34
35
|
try {
|
|
35
|
-
const
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
const query = (req as any).scope.resolve(ContainerRegistrationKeys.QUERY)
|
|
37
|
+
const q = req.query as Record<string, any>
|
|
38
|
+
|
|
39
|
+
const filters: Record<string, unknown> = {}
|
|
40
|
+
|
|
41
|
+
// Direct equality filters from query params
|
|
42
|
+
const DIRECT = ["store_id", "employee_id", "type"] as const
|
|
43
|
+
for (const key of DIRECT) {
|
|
44
|
+
if (q[key] !== undefined && q[key] !== "") filters[key] = q[key]
|
|
45
|
+
}
|
|
46
|
+
if (q.handover_id) filters.handover_id = q.handover_id
|
|
47
|
+
|
|
48
|
+
// q: full-text search on handover_id
|
|
49
|
+
if (q.q) {
|
|
50
|
+
filters.handover_id = { $ilike: `%${q.q}%` }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Date range filters
|
|
54
|
+
if (q.created_at) {
|
|
55
|
+
filters.created_at = typeof q.created_at === "object" ? q.created_at : { $gte: new Date(q.created_at as string) }
|
|
56
|
+
}
|
|
57
|
+
if (q.updated_at) {
|
|
58
|
+
filters.updated_at = typeof q.updated_at === "object" ? q.updated_at : { $gte: new Date(q.updated_at as string) }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Pagination
|
|
62
|
+
const limit = q.limit ? Math.min(parseInt(q.limit as string, 10), 500) : 20
|
|
63
|
+
const offset = q.offset ? parseInt(q.offset as string, 10) : 0
|
|
64
|
+
|
|
65
|
+
// Ordering: "field" = ASC, "-field" = DESC
|
|
66
|
+
let order: Record<string, "ASC" | "DESC"> = { created_at: "DESC" }
|
|
67
|
+
if (q.order) {
|
|
68
|
+
const raw = q.order as string
|
|
69
|
+
const isDesc = raw.startsWith("-")
|
|
70
|
+
const field = isDesc ? raw.slice(1) : raw
|
|
71
|
+
const SORTABLE = ["created_at", "updated_at", "handover_amount", "total_cash"]
|
|
72
|
+
if (SORTABLE.includes(field)) {
|
|
73
|
+
order = { [field]: isDesc ? "DESC" : "ASC" }
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const { data, metadata } = await query.graph({
|
|
78
|
+
entity: "retailos_cms_handover",
|
|
79
|
+
fields: [
|
|
80
|
+
"*",
|
|
81
|
+
"retailos_employee.id",
|
|
82
|
+
"retailos_employee.first_name",
|
|
83
|
+
"retailos_employee.last_name",
|
|
84
|
+
"retailos_employee.email",
|
|
85
|
+
"retailos_store.id",
|
|
86
|
+
"retailos_store.name",
|
|
87
|
+
"retailos_store.store_code",
|
|
88
|
+
],
|
|
89
|
+
filters: filters as any,
|
|
90
|
+
pagination: { skip: offset, take: limit },
|
|
91
|
+
// @ts-ignore
|
|
92
|
+
order,
|
|
40
93
|
})
|
|
41
|
-
|
|
94
|
+
|
|
95
|
+
const handovers = (data as any[]).map((h: any) => ({
|
|
96
|
+
...h,
|
|
97
|
+
employee: h.retailos_employee
|
|
98
|
+
? {
|
|
99
|
+
id: h.retailos_employee.id,
|
|
100
|
+
first_name: h.retailos_employee.first_name,
|
|
101
|
+
last_name: h.retailos_employee.last_name,
|
|
102
|
+
}
|
|
103
|
+
: null,
|
|
104
|
+
store: h.retailos_store
|
|
105
|
+
? {
|
|
106
|
+
id: h.retailos_store.id,
|
|
107
|
+
name: h.retailos_store.name,
|
|
108
|
+
store_code: h.retailos_store.store_code,
|
|
109
|
+
}
|
|
110
|
+
: null,
|
|
111
|
+
}))
|
|
112
|
+
|
|
113
|
+
return res.status(200).json({ handovers, count: (metadata as any)?.count ?? handovers.length })
|
|
42
114
|
} catch (err) {
|
|
43
115
|
getLogger(req).error("[retailos/cms] list handovers failed", {
|
|
44
116
|
error: err instanceof Error ? err.message : String(err),
|
|
@@ -3,13 +3,13 @@ import { GET } from "../route"
|
|
|
3
3
|
|
|
4
4
|
function makeService() {
|
|
5
5
|
return {
|
|
6
|
-
|
|
6
|
+
listAndCountRetailosPettyCashTransactions: vi.fn().mockResolvedValue([[], 0]),
|
|
7
7
|
}
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
function makeReq(
|
|
10
|
+
function makeReq(query: Record<string, unknown> = {}, service = makeService()) {
|
|
11
11
|
return {
|
|
12
|
-
|
|
12
|
+
query,
|
|
13
13
|
scope: {
|
|
14
14
|
resolve: vi.fn().mockImplementation((key: string) => {
|
|
15
15
|
if (key === "cms") return service
|
|
@@ -28,32 +28,38 @@ function makeRes() {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
describe("GET /petty-cash", () => {
|
|
31
|
-
it("returns transactions and count", async () => {
|
|
31
|
+
it("returns petty cash transactions and count", async () => {
|
|
32
32
|
const service = makeService()
|
|
33
33
|
const transactions = [{ id: "pct_1" }, { id: "pct_2" }]
|
|
34
|
-
service.
|
|
34
|
+
service.listAndCountRetailosPettyCashTransactions.mockResolvedValue([transactions, 2])
|
|
35
35
|
const req = makeReq({}, service)
|
|
36
36
|
const res = makeRes()
|
|
37
37
|
|
|
38
38
|
await GET(req as any, res as any)
|
|
39
39
|
|
|
40
|
-
expect(service.
|
|
40
|
+
expect(service.listAndCountRetailosPettyCashTransactions).toHaveBeenCalledWith(
|
|
41
41
|
expect.objectContaining({ transaction_type: ["open", "close", "petty_cash"] }),
|
|
42
42
|
expect.objectContaining({ take: 100 })
|
|
43
43
|
)
|
|
44
44
|
expect(res.status).toHaveBeenCalledWith(200)
|
|
45
|
-
expect(res.json).toHaveBeenCalledWith({
|
|
45
|
+
expect(res.json).toHaveBeenCalledWith({
|
|
46
|
+
petty_cash: [
|
|
47
|
+
{ id: "pct_1", employee: null },
|
|
48
|
+
{ id: "pct_2", employee: null },
|
|
49
|
+
],
|
|
50
|
+
count: 2,
|
|
51
|
+
})
|
|
46
52
|
})
|
|
47
53
|
|
|
48
|
-
it("
|
|
54
|
+
it("includes store_id in filter when provided", async () => {
|
|
49
55
|
const service = makeService()
|
|
50
|
-
service.
|
|
51
|
-
const req = makeReq({ store_id:
|
|
56
|
+
service.listAndCountRetailosPettyCashTransactions.mockResolvedValue([[], 0])
|
|
57
|
+
const req = makeReq({ store_id: "s1" }, service)
|
|
52
58
|
const res = makeRes()
|
|
53
59
|
|
|
54
60
|
await GET(req as any, res as any)
|
|
55
61
|
|
|
56
|
-
expect(service.
|
|
62
|
+
expect(service.listAndCountRetailosPettyCashTransactions).toHaveBeenCalledWith(
|
|
57
63
|
expect.objectContaining({ store_id: ["s1"], transaction_type: ["open", "close", "petty_cash"] }),
|
|
58
64
|
expect.any(Object)
|
|
59
65
|
)
|
|
@@ -61,7 +67,7 @@ describe("GET /petty-cash", () => {
|
|
|
61
67
|
|
|
62
68
|
it("returns 500 when service throws", async () => {
|
|
63
69
|
const service = makeService()
|
|
64
|
-
service.
|
|
70
|
+
service.listAndCountRetailosPettyCashTransactions.mockRejectedValue(new Error("db error"))
|
|
65
71
|
const req = makeReq({}, service)
|
|
66
72
|
const res = makeRes()
|
|
67
73
|
|
|
@@ -5,6 +5,8 @@ import type {
|
|
|
5
5
|
import { CMS_MODULE } from "../../../../../modules/cms/constants"
|
|
6
6
|
import type CmsModuleService from "../../../../../modules/cms/services/cms-module-service"
|
|
7
7
|
|
|
8
|
+
const EMPLOYEE_MODULE = "employee"
|
|
9
|
+
|
|
8
10
|
function getService(req: AuthenticatedMedusaRequest): CmsModuleService {
|
|
9
11
|
return (req as any).scope.resolve(CMS_MODULE)
|
|
10
12
|
}
|
|
@@ -20,12 +22,52 @@ function getLogger(req: AuthenticatedMedusaRequest) {
|
|
|
20
22
|
export const GET = async (req: AuthenticatedMedusaRequest, res: MedusaResponse) => {
|
|
21
23
|
try {
|
|
22
24
|
const service = getService(req)
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
+
const q = req.query as Record<string, any>
|
|
26
|
+
|
|
27
|
+
const filters: Record<string, unknown> = {}
|
|
28
|
+
if (q.store_id) filters.store_id = [q.store_id]
|
|
29
|
+
|
|
30
|
+
const limit = q.limit ? Math.min(parseInt(q.limit as string, 10), 500) : 100
|
|
31
|
+
const offset = q.offset ? parseInt(q.offset as string, 10) : 0
|
|
32
|
+
|
|
33
|
+
const [transactions, count] = await service.listAndCountRetailosPettyCashTransactions(
|
|
25
34
|
{ ...filters, transaction_type: ["open", "close", "petty_cash"] },
|
|
26
|
-
{ take:
|
|
35
|
+
{ take: limit, skip: offset, order: { created_at: "DESC" } } as any
|
|
27
36
|
)
|
|
28
|
-
|
|
37
|
+
|
|
38
|
+
// Enrich with employee data via bulk lookup
|
|
39
|
+
const employeeIds = [...new Set(
|
|
40
|
+
transactions.map((t: any) => t.employee_id).filter(Boolean)
|
|
41
|
+
)] as string[]
|
|
42
|
+
|
|
43
|
+
let employeeMap = new Map<string, { id: string; first_name: string; last_name: string }>()
|
|
44
|
+
if (employeeIds.length) {
|
|
45
|
+
try {
|
|
46
|
+
const employeeService = (req as any).scope.resolve(EMPLOYEE_MODULE)
|
|
47
|
+
const employees = await employeeService.listEmployees(
|
|
48
|
+
{ id: employeeIds },
|
|
49
|
+
{ select: ["id", "first_name", "last_name"] }
|
|
50
|
+
)
|
|
51
|
+
employeeMap = new Map(employees.map((e: any) => [e.id, e]))
|
|
52
|
+
} catch {
|
|
53
|
+
// employee module unavailable — return without enrichment
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const petty_cash = transactions.map((t: any) => ({
|
|
58
|
+
...t,
|
|
59
|
+
employee: t.employee_id
|
|
60
|
+
? (employeeMap.get(t.employee_id)
|
|
61
|
+
? {
|
|
62
|
+
id: employeeMap.get(t.employee_id)!.id,
|
|
63
|
+
first_name: employeeMap.get(t.employee_id)!.first_name,
|
|
64
|
+
last_name: employeeMap.get(t.employee_id)!.last_name,
|
|
65
|
+
}
|
|
66
|
+
: null)
|
|
67
|
+
: null,
|
|
68
|
+
}))
|
|
69
|
+
|
|
70
|
+
return res.status(200).json({ petty_cash, count })
|
|
29
71
|
} catch (err) {
|
|
30
72
|
getLogger(req).error("[retailos/cms] list petty cash failed", {
|
|
31
73
|
error: err instanceof Error ? err.message : String(err),
|