@porulle/plugin-pos-restaurant 0.1.0

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.
Files changed (70) hide show
  1. package/README.md +61 -0
  2. package/dist/hooks/modifier-validation.d.ts +21 -0
  3. package/dist/hooks/modifier-validation.d.ts.map +1 -0
  4. package/dist/hooks/modifier-validation.js +40 -0
  5. package/dist/hooks/table-lifecycle.d.ts +15 -0
  6. package/dist/hooks/table-lifecycle.d.ts.map +1 -0
  7. package/dist/hooks/table-lifecycle.js +40 -0
  8. package/dist/index.d.ts +60 -0
  9. package/dist/index.d.ts.map +1 -0
  10. package/dist/index.js +169 -0
  11. package/dist/routes/kds.d.ts +9 -0
  12. package/dist/routes/kds.d.ts.map +1 -0
  13. package/dist/routes/kds.js +112 -0
  14. package/dist/routes/modifiers.d.ts +15 -0
  15. package/dist/routes/modifiers.d.ts.map +1 -0
  16. package/dist/routes/modifiers.js +117 -0
  17. package/dist/routes/operations.d.ts +34 -0
  18. package/dist/routes/operations.d.ts.map +1 -0
  19. package/dist/routes/operations.js +249 -0
  20. package/dist/routes/tables.d.ts +9 -0
  21. package/dist/routes/tables.d.ts.map +1 -0
  22. package/dist/routes/tables.js +110 -0
  23. package/dist/schema.d.ts +3831 -0
  24. package/dist/schema.d.ts.map +1 -0
  25. package/dist/schema.js +418 -0
  26. package/dist/services/alert-service.d.ts +56 -0
  27. package/dist/services/alert-service.d.ts.map +1 -0
  28. package/dist/services/alert-service.js +173 -0
  29. package/dist/services/analytics-service.d.ts +60 -0
  30. package/dist/services/analytics-service.d.ts.map +1 -0
  31. package/dist/services/analytics-service.js +178 -0
  32. package/dist/services/checklist-service.d.ts +66 -0
  33. package/dist/services/checklist-service.d.ts.map +1 -0
  34. package/dist/services/checklist-service.js +106 -0
  35. package/dist/services/kds-service.d.ts +77 -0
  36. package/dist/services/kds-service.d.ts.map +1 -0
  37. package/dist/services/kds-service.js +246 -0
  38. package/dist/services/modifier-service.d.ts +74 -0
  39. package/dist/services/modifier-service.d.ts.map +1 -0
  40. package/dist/services/modifier-service.js +180 -0
  41. package/dist/services/recipe-deduction-service.d.ts +64 -0
  42. package/dist/services/recipe-deduction-service.d.ts.map +1 -0
  43. package/dist/services/recipe-deduction-service.js +144 -0
  44. package/dist/services/recipe-service.d.ts +43 -0
  45. package/dist/services/recipe-service.d.ts.map +1 -0
  46. package/dist/services/recipe-service.js +85 -0
  47. package/dist/services/table-service.d.ts +61 -0
  48. package/dist/services/table-service.d.ts.map +1 -0
  49. package/dist/services/table-service.js +206 -0
  50. package/dist/types.d.ts +35 -0
  51. package/dist/types.d.ts.map +1 -0
  52. package/dist/types.js +6 -0
  53. package/package.json +61 -0
  54. package/src/hooks/modifier-validation.ts +60 -0
  55. package/src/hooks/table-lifecycle.ts +50 -0
  56. package/src/index.ts +218 -0
  57. package/src/routes/kds.ts +121 -0
  58. package/src/routes/modifiers.ts +129 -0
  59. package/src/routes/operations.ts +276 -0
  60. package/src/routes/tables.ts +117 -0
  61. package/src/schema.ts +462 -0
  62. package/src/services/alert-service.ts +234 -0
  63. package/src/services/analytics-service.ts +242 -0
  64. package/src/services/checklist-service.ts +139 -0
  65. package/src/services/kds-service.ts +340 -0
  66. package/src/services/modifier-service.ts +251 -0
  67. package/src/services/recipe-deduction-service.ts +221 -0
  68. package/src/services/recipe-service.ts +115 -0
  69. package/src/services/table-service.ts +260 -0
  70. package/src/types.ts +63 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"kds-service.d.ts","sourceRoot":"","sources":["../../src/services/kds-service.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,246 @@
1
+ /**
2
+ * KDSService — Kitchen Display System station management, ticket generation,
3
+ * and status tracking.
4
+ *
5
+ * Informed by URY's KOT system:
6
+ * - URY Production Unit: item-group-based routing, per-station printers
7
+ * - URY KOT: submittable ticket per production unit, type enum, order_status,
8
+ * production_time tracking, Socket.IO broadcasting
9
+ * - URY KOT Items: course (Menu Course link), serve_priority, indicate_course,
10
+ * cancelled_qty
11
+ * - kot_execute(): compares previous vs current items, generates New Order,
12
+ * Order Modified, Partially cancelled KOTs per production unit
13
+ * - multi_print_kot(): production printer > room printer > POS printer cascade
14
+ *
15
+ * Improvements over URY:
16
+ * - Item-level status persisted to DB (not browser localStorage)
17
+ * - 4-state ticket status (pending -> preparing -> ready -> served)
18
+ * instead of URY's 2-state (Ready For Prepare -> Served)
19
+ * - prep_duration_seconds calculated and stored for analytics
20
+ * - Structured course priority on ticket items (not just display ordering)
21
+ */
22
+ import { eq, and, sql } from "@porulle/core/drizzle";
23
+ import { kdsStations, kdsStationItemGroups, kdsTickets, kdsTicketItems, } from "../schema.js";
24
+ import { Ok, Err } from "@porulle/core";
25
+ export class KDSService {
26
+ db;
27
+ constructor(db) {
28
+ this.db = db;
29
+ }
30
+ // ─── Station CRUD ──────────────────────────────────────────────────
31
+ async createStation(orgId, input) {
32
+ const existing = await this.db
33
+ .select()
34
+ .from(kdsStations)
35
+ .where(and(eq(kdsStations.organizationId, orgId), eq(kdsStations.name, input.name)));
36
+ if (existing.length > 0)
37
+ return Err(`Station '${input.name}' already exists`);
38
+ const rows = await this.db
39
+ .insert(kdsStations)
40
+ .values({
41
+ organizationId: orgId,
42
+ name: input.name,
43
+ alertThresholdMinutes: input.alertThresholdMinutes ?? 15,
44
+ metadata: input.metadata ?? {},
45
+ })
46
+ .returning();
47
+ return Ok(rows[0]);
48
+ }
49
+ async listStations(orgId) {
50
+ const rows = await this.db
51
+ .select()
52
+ .from(kdsStations)
53
+ .where(eq(kdsStations.organizationId, orgId));
54
+ return Ok(rows);
55
+ }
56
+ async updateStation(orgId, id, input) {
57
+ const rows = await this.db
58
+ .update(kdsStations)
59
+ .set({ ...input, updatedAt: new Date() })
60
+ .where(and(eq(kdsStations.id, id), eq(kdsStations.organizationId, orgId)))
61
+ .returning();
62
+ if (rows.length === 0)
63
+ return Err("Station not found");
64
+ return Ok(rows[0]);
65
+ }
66
+ // ─── Station Item Groups ──────────────────────────────────────────
67
+ // URY equivalent: URY Production Item Groups child table.
68
+ async addItemGroup(stationId, itemGroup) {
69
+ const rows = await this.db
70
+ .insert(kdsStationItemGroups)
71
+ .values({ stationId, itemGroup })
72
+ .returning();
73
+ return Ok(rows[0]);
74
+ }
75
+ async removeItemGroup(stationId, itemGroup) {
76
+ const rows = await this.db
77
+ .delete(kdsStationItemGroups)
78
+ .where(and(eq(kdsStationItemGroups.stationId, stationId), eq(kdsStationItemGroups.itemGroup, itemGroup)))
79
+ .returning();
80
+ if (rows.length === 0)
81
+ return Err("Item group not found on station");
82
+ return Ok({ removed: true });
83
+ }
84
+ async getStationItemGroups(stationId) {
85
+ const rows = await this.db
86
+ .select({ itemGroup: kdsStationItemGroups.itemGroup })
87
+ .from(kdsStationItemGroups)
88
+ .where(eq(kdsStationItemGroups.stationId, stationId));
89
+ return rows.map((r) => r.itemGroup);
90
+ }
91
+ // ─── Ticket Generation ─────────────────────────────────────────────
92
+ // URY equivalent: kot_execute() + process_items_for_kot() + create_kot_doc().
93
+ //
94
+ // For each station, filter items matching station's item groups.
95
+ // If matching items exist, create a ticket for that station.
96
+ // If a ticket already exists for this transaction+station, set type="modified".
97
+ async generateTickets(orgId, input) {
98
+ // Get all active stations with their item groups
99
+ const stations = await this.db
100
+ .select()
101
+ .from(kdsStations)
102
+ .where(and(eq(kdsStations.organizationId, orgId), eq(kdsStations.isActive, true)));
103
+ const tickets = [];
104
+ for (const station of stations) {
105
+ const stationGroups = await this.getStationItemGroups(station.id);
106
+ const matchingItems = input.items.filter((item) => stationGroups.includes(item.itemGroup));
107
+ if (matchingItems.length === 0)
108
+ continue;
109
+ // Check if ticket already exists for this transaction+station
110
+ const existingTickets = await this.db
111
+ .select()
112
+ .from(kdsTickets)
113
+ .where(and(eq(kdsTickets.transactionId, input.transactionId), eq(kdsTickets.stationId, station.id)));
114
+ const ticketType = existingTickets.length > 0 ? "modified" : "new_order";
115
+ const ticketNumber = await this.generateTicketNumber(station.id);
116
+ const ticketRows = await this.db
117
+ .insert(kdsTickets)
118
+ .values({
119
+ organizationId: orgId,
120
+ transactionId: input.transactionId,
121
+ stationId: station.id,
122
+ type: ticketType,
123
+ status: "pending",
124
+ tableNumber: input.tableNumber,
125
+ orderType: input.orderType ?? "dine_in",
126
+ operatorName: input.operatorName,
127
+ ticketNumber,
128
+ comments: input.comments,
129
+ })
130
+ .returning();
131
+ const ticket = ticketRows[0];
132
+ // Add items to ticket
133
+ for (const item of matchingItems) {
134
+ await this.db.insert(kdsTicketItems).values({
135
+ ticketId: ticket.id,
136
+ entityId: item.entityId,
137
+ variantId: item.variantId,
138
+ itemName: item.itemName,
139
+ quantity: item.quantity,
140
+ courseName: item.courseName,
141
+ coursePriority: item.coursePriority ?? 0,
142
+ showCourseLabel: item.showCourseLabel ?? false,
143
+ modifiers: item.modifiers ?? [],
144
+ notes: item.notes,
145
+ });
146
+ }
147
+ tickets.push(ticket);
148
+ }
149
+ return Ok(tickets);
150
+ }
151
+ // ─── Ticket Status Updates ─────────────────────────────────────────
152
+ // URY equivalent: serve_kot() — sets order_status="Served", production_time.
153
+ // We add intermediate states (preparing, ready) for finer-grained tracking.
154
+ async startTicket(id) {
155
+ return this.updateTicketStatus(id, "preparing", { firedAt: new Date() });
156
+ }
157
+ async readyTicket(id) {
158
+ const ticket = await this.getTicketById(id);
159
+ if (!ticket.ok)
160
+ return ticket;
161
+ const prepDuration = ticket.value.firedAt
162
+ ? Math.round((Date.now() - ticket.value.firedAt.getTime()) / 1000)
163
+ : undefined;
164
+ return this.updateTicketStatus(id, "ready", {
165
+ readyAt: new Date(),
166
+ ...(prepDuration != null ? { prepDurationSeconds: prepDuration } : {}),
167
+ });
168
+ }
169
+ async serveTicket(id) {
170
+ return this.updateTicketStatus(id, "served", { servedAt: new Date() });
171
+ }
172
+ static VALID_TRANSITIONS = {
173
+ pending: ["preparing"],
174
+ preparing: ["ready"],
175
+ ready: ["served"],
176
+ served: [], // terminal state
177
+ };
178
+ async updateTicketStatus(id, newStatus, extra = {}) {
179
+ // Validate state transition
180
+ const existing = await this.db.select().from(kdsTickets).where(eq(kdsTickets.id, id));
181
+ if (existing.length === 0)
182
+ return Err("Ticket not found");
183
+ const current = existing[0];
184
+ const allowed = KDSService.VALID_TRANSITIONS[current.status] ?? [];
185
+ if (!allowed.includes(newStatus)) {
186
+ return Err(`Cannot transition ticket from '${current.status}' to '${newStatus}'`);
187
+ }
188
+ const rows = await this.db
189
+ .update(kdsTickets)
190
+ .set({ status: newStatus, ...extra, updatedAt: new Date() })
191
+ .where(eq(kdsTickets.id, id))
192
+ .returning();
193
+ if (rows.length === 0)
194
+ return Err("Ticket not found");
195
+ return Ok(rows[0]);
196
+ }
197
+ // ─── Item Status Updates ───────────────────────────────────────────
198
+ // URY stored item strikethrough in browser localStorage only.
199
+ // We persist item status to the database.
200
+ async markItemDone(ticketId, itemId) {
201
+ const rows = await this.db
202
+ .update(kdsTicketItems)
203
+ .set({ status: "done" })
204
+ .where(and(eq(kdsTicketItems.id, itemId), eq(kdsTicketItems.ticketId, ticketId)))
205
+ .returning();
206
+ if (rows.length === 0)
207
+ return Err("Ticket item not found");
208
+ return Ok(rows[0]);
209
+ }
210
+ // ─── Queries ───────────────────────────────────────────────────────
211
+ async getTicketById(id) {
212
+ const rows = await this.db.select().from(kdsTickets).where(eq(kdsTickets.id, id));
213
+ if (rows.length === 0)
214
+ return Err("Ticket not found");
215
+ return Ok(rows[0]);
216
+ }
217
+ async listPendingTickets(orgId, stationId) {
218
+ const tickets = await this.db
219
+ .select()
220
+ .from(kdsTickets)
221
+ .where(and(eq(kdsTickets.organizationId, orgId), eq(kdsTickets.stationId, stationId), sql `${kdsTickets.status} != 'served'`))
222
+ .orderBy(kdsTickets.createdAt);
223
+ const result = [];
224
+ for (const ticket of tickets) {
225
+ const items = await this.db
226
+ .select()
227
+ .from(kdsTicketItems)
228
+ .where(eq(kdsTicketItems.ticketId, ticket.id))
229
+ .orderBy(kdsTicketItems.coursePriority);
230
+ result.push({ ...ticket, items });
231
+ }
232
+ return Ok(result);
233
+ }
234
+ // ─── Helpers ───────────────────────────────────────────────────────
235
+ async generateTicketNumber(stationId) {
236
+ const today = new Date();
237
+ const startOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate());
238
+ const startOfDayISO = startOfDay.toISOString();
239
+ const countRows = await this.db
240
+ .select({ count: sql `COUNT(*)`.as("count") })
241
+ .from(kdsTickets)
242
+ .where(and(eq(kdsTickets.stationId, stationId), sql `${kdsTickets.createdAt} >= ${startOfDayISO}::timestamptz`));
243
+ const seq = Number(countRows[0]?.count ?? 0) + 1;
244
+ return `KDS-${String(seq).padStart(4, "0")}`;
245
+ }
246
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * ModifierService — CRUD and validation for item modifier groups and options.
3
+ *
4
+ * URY uses a flat "Item Add On" child table with no grouping, no required/optional
5
+ * flags, no min/max constraints, and no price adjustments. This service implements
6
+ * structured modifier groups with full constraint validation.
7
+ *
8
+ * Validation logic (used by cart.beforeAddItem hook):
9
+ * - Required groups must have at least minSelect selections
10
+ * - No group may exceed maxSelect selections
11
+ * - Unavailable options are rejected
12
+ * - Price adjustments are summed and returned for line item total calculation
13
+ */
14
+ import type { PluginResult } from "@porulle/core";
15
+ import type { Db, ModifierGroup, ModifierOption } from "../types.js";
16
+ export declare class ModifierService {
17
+ private db;
18
+ constructor(db: Db);
19
+ createGroup(orgId: string, input: {
20
+ name: string;
21
+ entityId?: string;
22
+ itemGroup?: string;
23
+ isRequired?: boolean;
24
+ minSelect?: number;
25
+ maxSelect?: number;
26
+ sortOrder?: number;
27
+ }): Promise<PluginResult<ModifierGroup>>;
28
+ listGroups(orgId: string, entityId?: string): Promise<PluginResult<ModifierGroup[]>>;
29
+ getGroupWithOptions(orgId: string, groupId: string): Promise<PluginResult<{
30
+ group: ModifierGroup;
31
+ options: ModifierOption[];
32
+ }>>;
33
+ updateGroup(orgId: string, groupId: string, input: {
34
+ name?: string;
35
+ isRequired?: boolean;
36
+ minSelect?: number;
37
+ maxSelect?: number;
38
+ sortOrder?: number;
39
+ }): Promise<PluginResult<ModifierGroup>>;
40
+ deleteGroup(orgId: string, groupId: string): Promise<PluginResult<{
41
+ deleted: boolean;
42
+ }>>;
43
+ addOption(groupId: string, input: {
44
+ name: string;
45
+ priceAdjustment?: number;
46
+ isDefault?: boolean;
47
+ sortOrder?: number;
48
+ }): Promise<PluginResult<ModifierOption>>;
49
+ updateOption(optionId: string, input: {
50
+ name?: string;
51
+ priceAdjustment?: number;
52
+ isDefault?: boolean;
53
+ isAvailable?: boolean;
54
+ sortOrder?: number;
55
+ }): Promise<PluginResult<ModifierOption>>;
56
+ deleteOption(optionId: string): Promise<PluginResult<{
57
+ deleted: boolean;
58
+ }>>;
59
+ validateModifiers(orgId: string, entityId: string, selections: Array<{
60
+ groupId: string;
61
+ optionIds: string[];
62
+ }>): Promise<PluginResult<{
63
+ totalAdjustment: number;
64
+ validatedModifiers: Array<{
65
+ name: string;
66
+ priceAdjustment: number;
67
+ }>;
68
+ }>>;
69
+ getModifiersForEntity(orgId: string, entityId: string): Promise<PluginResult<Array<{
70
+ group: ModifierGroup;
71
+ options: ModifierOption[];
72
+ }>>>;
73
+ }
74
+ //# sourceMappingURL=modifier-service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"modifier-service.d.ts","sourceRoot":"","sources":["../../src/services/modifier-service.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,180 @@
1
+ /**
2
+ * ModifierService — CRUD and validation for item modifier groups and options.
3
+ *
4
+ * URY uses a flat "Item Add On" child table with no grouping, no required/optional
5
+ * flags, no min/max constraints, and no price adjustments. This service implements
6
+ * structured modifier groups with full constraint validation.
7
+ *
8
+ * Validation logic (used by cart.beforeAddItem hook):
9
+ * - Required groups must have at least minSelect selections
10
+ * - No group may exceed maxSelect selections
11
+ * - Unavailable options are rejected
12
+ * - Price adjustments are summed and returned for line item total calculation
13
+ */
14
+ import { eq, and } from "@porulle/core/drizzle";
15
+ import { Ok, Err } from "@porulle/core";
16
+ import { posModifierGroups, posModifierOptions } from "../schema.js";
17
+ export class ModifierService {
18
+ db;
19
+ constructor(db) {
20
+ this.db = db;
21
+ }
22
+ // ─── Group CRUD ────────────────────────────────────────────────────
23
+ async createGroup(orgId, input) {
24
+ if (input.minSelect != null && input.maxSelect != null && input.minSelect > input.maxSelect) {
25
+ return Err("minSelect cannot exceed maxSelect");
26
+ }
27
+ const rows = await this.db
28
+ .insert(posModifierGroups)
29
+ .values({
30
+ organizationId: orgId,
31
+ name: input.name,
32
+ entityId: input.entityId,
33
+ itemGroup: input.itemGroup,
34
+ isRequired: input.isRequired ?? false,
35
+ minSelect: input.minSelect ?? 0,
36
+ maxSelect: input.maxSelect ?? 1,
37
+ sortOrder: input.sortOrder ?? 0,
38
+ })
39
+ .returning();
40
+ return Ok(rows[0]);
41
+ }
42
+ async listGroups(orgId, entityId) {
43
+ const conditions = [eq(posModifierGroups.organizationId, orgId)];
44
+ if (entityId)
45
+ conditions.push(eq(posModifierGroups.entityId, entityId));
46
+ const rows = await this.db
47
+ .select()
48
+ .from(posModifierGroups)
49
+ .where(and(...conditions))
50
+ .orderBy(posModifierGroups.sortOrder);
51
+ return Ok(rows);
52
+ }
53
+ async getGroupWithOptions(orgId, groupId) {
54
+ const groups = await this.db
55
+ .select()
56
+ .from(posModifierGroups)
57
+ .where(and(eq(posModifierGroups.id, groupId), eq(posModifierGroups.organizationId, orgId)));
58
+ if (groups.length === 0)
59
+ return Err("Modifier group not found");
60
+ const options = await this.db
61
+ .select()
62
+ .from(posModifierOptions)
63
+ .where(eq(posModifierOptions.groupId, groupId))
64
+ .orderBy(posModifierOptions.sortOrder);
65
+ return Ok({ group: groups[0], options });
66
+ }
67
+ async updateGroup(orgId, groupId, input) {
68
+ const rows = await this.db
69
+ .update(posModifierGroups)
70
+ .set({ ...input, updatedAt: new Date() })
71
+ .where(and(eq(posModifierGroups.id, groupId), eq(posModifierGroups.organizationId, orgId)))
72
+ .returning();
73
+ if (rows.length === 0)
74
+ return Err("Modifier group not found");
75
+ return Ok(rows[0]);
76
+ }
77
+ async deleteGroup(orgId, groupId) {
78
+ const rows = await this.db
79
+ .delete(posModifierGroups)
80
+ .where(and(eq(posModifierGroups.id, groupId), eq(posModifierGroups.organizationId, orgId)))
81
+ .returning();
82
+ if (rows.length === 0)
83
+ return Err("Modifier group not found");
84
+ return Ok({ deleted: true });
85
+ }
86
+ // ─── Option CRUD ───────────────────────────────────────────────────
87
+ async addOption(groupId, input) {
88
+ const rows = await this.db
89
+ .insert(posModifierOptions)
90
+ .values({
91
+ groupId,
92
+ name: input.name,
93
+ priceAdjustment: input.priceAdjustment ?? 0,
94
+ isDefault: input.isDefault ?? false,
95
+ sortOrder: input.sortOrder ?? 0,
96
+ })
97
+ .returning();
98
+ return Ok(rows[0]);
99
+ }
100
+ async updateOption(optionId, input) {
101
+ const rows = await this.db
102
+ .update(posModifierOptions)
103
+ .set(input)
104
+ .where(eq(posModifierOptions.id, optionId))
105
+ .returning();
106
+ if (rows.length === 0)
107
+ return Err("Modifier option not found");
108
+ return Ok(rows[0]);
109
+ }
110
+ async deleteOption(optionId) {
111
+ const rows = await this.db
112
+ .delete(posModifierOptions)
113
+ .where(eq(posModifierOptions.id, optionId))
114
+ .returning();
115
+ if (rows.length === 0)
116
+ return Err("Modifier option not found");
117
+ return Ok({ deleted: true });
118
+ }
119
+ // ─── Validation ────────────────────────────────────────────────────
120
+ // Called by cart.beforeAddItem hook to validate modifier selections.
121
+ async validateModifiers(orgId, entityId, selections) {
122
+ // Get all modifier groups for this entity
123
+ const groups = await this.db
124
+ .select()
125
+ .from(posModifierGroups)
126
+ .where(and(eq(posModifierGroups.organizationId, orgId), eq(posModifierGroups.entityId, entityId)));
127
+ const selectionMap = new Map(selections.map((s) => [s.groupId, s.optionIds]));
128
+ let totalAdjustment = 0;
129
+ const validatedModifiers = [];
130
+ for (const group of groups) {
131
+ const selectedOptionIds = selectionMap.get(group.id) ?? [];
132
+ // Validate required groups
133
+ if (group.isRequired && selectedOptionIds.length < group.minSelect) {
134
+ return Err(`Required: select at least ${group.minSelect} from '${group.name}'`);
135
+ }
136
+ // Validate max selections
137
+ if (selectedOptionIds.length > group.maxSelect) {
138
+ return Err(`Maximum ${group.maxSelect} selections allowed for '${group.name}'`);
139
+ }
140
+ // Validate each selected option exists and is available
141
+ if (selectedOptionIds.length > 0) {
142
+ const options = await this.db
143
+ .select()
144
+ .from(posModifierOptions)
145
+ .where(eq(posModifierOptions.groupId, group.id));
146
+ const optionMap = new Map(options.map((o) => [o.id, o]));
147
+ for (const optionId of selectedOptionIds) {
148
+ const option = optionMap.get(optionId);
149
+ if (!option)
150
+ return Err(`Modifier option '${optionId}' not found in group '${group.name}'`);
151
+ if (!option.isAvailable)
152
+ return Err(`Modifier '${option.name}' is currently unavailable`);
153
+ totalAdjustment += option.priceAdjustment;
154
+ validatedModifiers.push({ name: option.name, priceAdjustment: option.priceAdjustment });
155
+ }
156
+ }
157
+ }
158
+ return Ok({ totalAdjustment, validatedModifiers });
159
+ }
160
+ // ─── Get Modifiers for Entity ──────────────────────────────────────
161
+ // Returns all modifier groups with their options for a given entity.
162
+ // Used by the POS frontend to render modifier selection UI.
163
+ async getModifiersForEntity(orgId, entityId) {
164
+ const groups = await this.db
165
+ .select()
166
+ .from(posModifierGroups)
167
+ .where(and(eq(posModifierGroups.organizationId, orgId), eq(posModifierGroups.entityId, entityId)))
168
+ .orderBy(posModifierGroups.sortOrder);
169
+ const result = [];
170
+ for (const group of groups) {
171
+ const options = await this.db
172
+ .select()
173
+ .from(posModifierOptions)
174
+ .where(eq(posModifierOptions.groupId, group.id))
175
+ .orderBy(posModifierOptions.sortOrder);
176
+ result.push({ group, options });
177
+ }
178
+ return Ok(result);
179
+ }
180
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * RecipeDeductionService -- resolves recipe ingredient inventory deductions
3
+ * when a POS transaction completes.
4
+ *
5
+ * For each sold item:
6
+ * 1. Look up the entity's active recipe
7
+ * 2. For each ingredient with an entityId, calculate deduction quantity
8
+ * 3. Deduct via inventory service (preferred) or raw SQL (fallback)
9
+ *
10
+ * When the ServiceRegistry is provided (normal plugin boot), deductions
11
+ * go through kernel.services.inventory.adjust() which creates proper
12
+ * movements, fires hooks, and respects audit trail.
13
+ *
14
+ * When the ServiceRegistry is not available (standalone tests, scripts),
15
+ * falls back to raw SQL with the same atomic guard.
16
+ */
17
+ import type { PluginResult } from "@porulle/core";
18
+ import type { Db } from "../types.js";
19
+ interface DeductionItem {
20
+ entityId: string;
21
+ variantId: string | null;
22
+ quantity: number;
23
+ unit: string;
24
+ itemName: string;
25
+ reason: string;
26
+ }
27
+ export declare class RecipeDeductionService {
28
+ private db;
29
+ private inventorySvc?;
30
+ constructor(db: Db, services?: Record<string, unknown>);
31
+ /**
32
+ * Resolve all recipe ingredient deductions for a list of sold items.
33
+ * Pure query -- no mutations. Returns a flat list to pass to applyDeductions().
34
+ */
35
+ resolveDeductions(orgId: string, items: Array<{
36
+ entityId: string;
37
+ quantity: number;
38
+ }>): Promise<PluginResult<DeductionItem[]>>;
39
+ /**
40
+ * Apply deductions to inventory.
41
+ *
42
+ * Strategy 1 (preferred): Use inventory.adjust() via ServiceRegistry.
43
+ * - Creates proper inventory_movements with audit trail
44
+ * - Fires hooks (e.g., low-stock alerts)
45
+ * - Respects the inventory service's stock guard logic
46
+ *
47
+ * Strategy 2 (fallback): Raw SQL with atomic WHERE guard.
48
+ * - Used when services are not available (standalone scripts, tests)
49
+ * - Same oversell protection via UPDATE ... WHERE quantity_on_hand >= N
50
+ */
51
+ applyDeductions(tx: Db, deductions: DeductionItem[], warehouseId: string, referenceType: string, referenceId: string, performedBy: string): Promise<PluginResult<number>>;
52
+ /**
53
+ * Strategy 1: Deduct via kernel.services.inventory.adjust().
54
+ * Each deduction becomes a negative adjustment with a reason.
55
+ */
56
+ private applyViaService;
57
+ /**
58
+ * Strategy 2: Raw SQL fallback with atomic oversell guard.
59
+ * Used when ServiceRegistry is not available.
60
+ */
61
+ private applyViaRawSQL;
62
+ }
63
+ export {};
64
+ //# sourceMappingURL=recipe-deduction-service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"recipe-deduction-service.d.ts","sourceRoot":"","sources":["../../src/services/recipe-deduction-service.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAIH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAElD,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,aAAa,CAAC;AAUtC,UAAU,aAAa;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,qBAAa,sBAAsB;IAI/B,OAAO,CAAC,EAAE;IAHZ,OAAO,CAAC,YAAY,CAAC,CAAoB;gBAG/B,EAAE,EAAE,EAAE,EACd,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAQpC;;;OAGG;IACG,iBAAiB,CACrB,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,KAAK,CAAC;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC,GACnD,OAAO,CAAC,YAAY,CAAC,aAAa,EAAE,CAAC,CAAC;IAwCzC;;;;;;;;;;;OAWG;IACG,eAAe,CACnB,EAAE,EAAE,EAAE,EACN,UAAU,EAAE,aAAa,EAAE,EAC3B,WAAW,EAAE,MAAM,EACnB,aAAa,EAAE,MAAM,EACrB,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,YAAY,CAAC,MAAM,CAAC,CAAC;IAQhC;;;OAGG;YACW,eAAe;IAwC7B;;;OAGG;YACW,cAAc;CA6C7B"}