@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,144 @@
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 { eq, and, sql } from "@porulle/core/drizzle";
18
+ import { Ok } from "@porulle/core";
19
+ import { posRecipes, posRecipeIngredients } from "../schema.js";
20
+ export class RecipeDeductionService {
21
+ db;
22
+ inventorySvc;
23
+ constructor(db, services) {
24
+ this.db = db;
25
+ // Extract inventory service if available
26
+ if (services?.inventory && typeof services.inventory.adjust === "function") {
27
+ this.inventorySvc = services.inventory;
28
+ }
29
+ }
30
+ /**
31
+ * Resolve all recipe ingredient deductions for a list of sold items.
32
+ * Pure query -- no mutations. Returns a flat list to pass to applyDeductions().
33
+ */
34
+ async resolveDeductions(orgId, items) {
35
+ const deductions = [];
36
+ for (const item of items) {
37
+ const recipes = await this.db
38
+ .select()
39
+ .from(posRecipes)
40
+ .where(and(eq(posRecipes.organizationId, orgId), eq(posRecipes.entityId, item.entityId), eq(posRecipes.isActive, true)));
41
+ if (recipes.length === 0)
42
+ continue;
43
+ const recipe = recipes[0];
44
+ const ingredients = await this.db
45
+ .select()
46
+ .from(posRecipeIngredients)
47
+ .where(eq(posRecipeIngredients.recipeId, recipe.id));
48
+ for (const ing of ingredients) {
49
+ if (ing.entityId == null)
50
+ continue;
51
+ const deductQty = Math.ceil((ing.quantity * item.quantity) / recipe.yieldQuantity);
52
+ deductions.push({
53
+ entityId: ing.entityId,
54
+ variantId: ing.variantId ?? null,
55
+ quantity: deductQty,
56
+ unit: ing.unit,
57
+ itemName: ing.ingredientName,
58
+ reason: `Recipe: ${deductQty}${ing.unit} ${ing.ingredientName} for ${item.quantity}x ${recipe.name}`,
59
+ });
60
+ }
61
+ }
62
+ return Ok(deductions);
63
+ }
64
+ /**
65
+ * Apply deductions to inventory.
66
+ *
67
+ * Strategy 1 (preferred): Use inventory.adjust() via ServiceRegistry.
68
+ * - Creates proper inventory_movements with audit trail
69
+ * - Fires hooks (e.g., low-stock alerts)
70
+ * - Respects the inventory service's stock guard logic
71
+ *
72
+ * Strategy 2 (fallback): Raw SQL with atomic WHERE guard.
73
+ * - Used when services are not available (standalone scripts, tests)
74
+ * - Same oversell protection via UPDATE ... WHERE quantity_on_hand >= N
75
+ */
76
+ async applyDeductions(tx, deductions, warehouseId, referenceType, referenceId, performedBy) {
77
+ if (this.inventorySvc) {
78
+ return this.applyViaService(deductions, warehouseId, performedBy);
79
+ }
80
+ return this.applyViaRawSQL(tx, deductions, warehouseId, referenceType, referenceId, performedBy);
81
+ }
82
+ /**
83
+ * Strategy 1: Deduct via kernel.services.inventory.adjust().
84
+ * Each deduction becomes a negative adjustment with a reason.
85
+ */
86
+ async applyViaService(deductions, warehouseId, performedBy) {
87
+ const inventory = this.inventorySvc;
88
+ const systemActor = {
89
+ type: "system",
90
+ userId: performedBy,
91
+ email: null,
92
+ name: "Recipe Deduction",
93
+ vendorId: null,
94
+ organizationId: null,
95
+ role: "system",
96
+ permissions: ["inventory:adjust"],
97
+ };
98
+ let applied = 0;
99
+ for (const d of deductions) {
100
+ const result = await inventory.adjust({
101
+ entityId: d.entityId,
102
+ warehouseId,
103
+ adjustment: -d.quantity,
104
+ reason: d.reason,
105
+ }, systemActor);
106
+ if (result.ok) {
107
+ applied++;
108
+ }
109
+ // If adjust fails (insufficient stock), it returns ok: false
110
+ // and the deduction is skipped -- same behavior as raw SQL guard
111
+ }
112
+ return Ok(applied);
113
+ }
114
+ /**
115
+ * Strategy 2: Raw SQL fallback with atomic oversell guard.
116
+ * Used when ServiceRegistry is not available.
117
+ */
118
+ async applyViaRawSQL(tx, deductions, warehouseId, referenceType, referenceId, performedBy) {
119
+ let applied = 0;
120
+ // PluginDb (PgDatabase) has .execute() — no cast needed
121
+ const exec = tx;
122
+ for (const d of deductions) {
123
+ const variantClause = d.variantId != null
124
+ ? sql `variant_id = ${d.variantId}`
125
+ : sql `variant_id IS NULL`;
126
+ // Atomic guard: only deduct if sufficient stock
127
+ const updateResult = await exec.execute(sql `UPDATE inventory_levels
128
+ SET quantity_on_hand = quantity_on_hand - ${d.quantity}, updated_at = NOW()
129
+ WHERE entity_id = ${d.entityId} AND warehouse_id = ${warehouseId} AND ${variantClause}
130
+ AND quantity_on_hand >= ${d.quantity}
131
+ RETURNING quantity_on_hand`);
132
+ const rows = Array.isArray(updateResult) ? updateResult : updateResult.rows;
133
+ if (rows.length === 0) {
134
+ await exec.execute(sql `INSERT INTO inventory_movements (entity_id, variant_id, warehouse_id, type, quantity, reference_type, reference_id, reason, performed_by)
135
+ VALUES (${d.entityId}, ${d.variantId}, ${warehouseId}, 'sale', ${0}, ${referenceType}, ${referenceId}, ${"SKIPPED: insufficient stock for " + d.reason}, ${performedBy})`);
136
+ continue;
137
+ }
138
+ await exec.execute(sql `INSERT INTO inventory_movements (entity_id, variant_id, warehouse_id, type, quantity, reference_type, reference_id, reason, performed_by)
139
+ VALUES (${d.entityId}, ${d.variantId}, ${warehouseId}, 'sale', ${-d.quantity}, ${referenceType}, ${referenceId}, ${d.reason}, ${performedBy})`);
140
+ applied++;
141
+ }
142
+ return Ok(applied);
143
+ }
144
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * RecipeService — Recipe/BOM management and COGS calculation.
3
+ *
4
+ * URY: Recipe mapping using Bill of Materials (BOM). Links menu items
5
+ * to raw ingredients for COGS calculation in Daily P&L.
6
+ * URY uses ERPNext's BOM doctype + Item Price for buying cost.
7
+ *
8
+ * Our implementation: pos_recipes + pos_recipe_ingredients tables.
9
+ * COGS = sum(ingredient.quantity * ingredient.costPerUnit) / yieldQuantity.
10
+ */
11
+ import type { PluginResult } from "@porulle/core";
12
+ import { posRecipes, posRecipeIngredients } from "../schema.js";
13
+ import type { Db } from "../types.js";
14
+ export declare class RecipeService {
15
+ private db;
16
+ constructor(db: Db);
17
+ createRecipe(orgId: string, input: {
18
+ entityId: string;
19
+ name: string;
20
+ yieldQuantity?: number;
21
+ ingredients: Array<{
22
+ ingredientName: string;
23
+ quantity: number;
24
+ unit: string;
25
+ costPerUnit: number;
26
+ entityId?: string;
27
+ variantId?: string;
28
+ }>;
29
+ }): Promise<PluginResult<{
30
+ id: string;
31
+ name: string;
32
+ costPerUnit: number;
33
+ }>>;
34
+ getRecipeWithIngredients(recipeId: string): Promise<PluginResult<{
35
+ recipe: typeof posRecipes.$inferSelect;
36
+ ingredients: Array<typeof posRecipeIngredients.$inferSelect>;
37
+ totalCost: number;
38
+ costPerUnit: number;
39
+ }>>;
40
+ calculateCOGS(orgId: string, entityId: string, quantity: number): Promise<number>;
41
+ listRecipes(orgId: string): Promise<PluginResult<Array<typeof posRecipes.$inferSelect>>>;
42
+ }
43
+ //# sourceMappingURL=recipe-service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"recipe-service.d.ts","sourceRoot":"","sources":["../../src/services/recipe-service.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAClD,OAAO,EAAE,UAAU,EAAE,oBAAoB,EAAE,MAAM,cAAc,CAAC;AAChE,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,aAAa,CAAC;AAEtC,qBAAa,aAAa;IACZ,OAAO,CAAC,EAAE;gBAAF,EAAE,EAAE,EAAE;IAEpB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE;QACvC,QAAQ,EAAE,MAAM,CAAC;QACjB,IAAI,EAAE,MAAM,CAAC;QACb,aAAa,CAAC,EAAE,MAAM,CAAC;QACvB,WAAW,EAAE,KAAK,CAAC;YACjB,cAAc,EAAE,MAAM,CAAC;YACvB,QAAQ,EAAE,MAAM,CAAC;YACjB,IAAI,EAAE,MAAM,CAAC;YACb,WAAW,EAAE,MAAM,CAAC;YACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;YAClB,SAAS,CAAC,EAAE,MAAM,CAAC;SACpB,CAAC,CAAC;KACJ,GAAG,OAAO,CAAC,YAAY,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAkCtE,wBAAwB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC;QACrE,MAAM,EAAE,OAAO,UAAU,CAAC,YAAY,CAAC;QACvC,WAAW,EAAE,KAAK,CAAC,OAAO,oBAAoB,CAAC,YAAY,CAAC,CAAC;QAC7D,SAAS,EAAE,MAAM,CAAC;QAClB,WAAW,EAAE,MAAM,CAAC;KACrB,CAAC,CAAC;IAiBG,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAmBjF,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,KAAK,CAAC,OAAO,UAAU,CAAC,YAAY,CAAC,CAAC,CAAC;CAO/F"}
@@ -0,0 +1,85 @@
1
+ /**
2
+ * RecipeService — Recipe/BOM management and COGS calculation.
3
+ *
4
+ * URY: Recipe mapping using Bill of Materials (BOM). Links menu items
5
+ * to raw ingredients for COGS calculation in Daily P&L.
6
+ * URY uses ERPNext's BOM doctype + Item Price for buying cost.
7
+ *
8
+ * Our implementation: pos_recipes + pos_recipe_ingredients tables.
9
+ * COGS = sum(ingredient.quantity * ingredient.costPerUnit) / yieldQuantity.
10
+ */
11
+ import { eq, and } from "@porulle/core/drizzle";
12
+ import { Ok, Err } from "@porulle/core";
13
+ import { posRecipes, posRecipeIngredients } from "../schema.js";
14
+ export class RecipeService {
15
+ db;
16
+ constructor(db) {
17
+ this.db = db;
18
+ }
19
+ async createRecipe(orgId, input) {
20
+ const rows = await this.db
21
+ .insert(posRecipes)
22
+ .values({
23
+ organizationId: orgId,
24
+ entityId: input.entityId,
25
+ name: input.name,
26
+ yieldQuantity: input.yieldQuantity ?? 1,
27
+ })
28
+ .returning();
29
+ const recipe = rows[0];
30
+ let totalCost = 0;
31
+ for (let i = 0; i < input.ingredients.length; i++) {
32
+ const ing = input.ingredients[i];
33
+ await this.db.insert(posRecipeIngredients).values({
34
+ recipeId: recipe.id,
35
+ ingredientName: ing.ingredientName,
36
+ quantity: ing.quantity,
37
+ unit: ing.unit,
38
+ costPerUnit: ing.costPerUnit,
39
+ entityId: ing.entityId,
40
+ variantId: ing.variantId,
41
+ sortOrder: i,
42
+ });
43
+ totalCost += ing.quantity * ing.costPerUnit;
44
+ }
45
+ const costPerUnit = Math.round(totalCost / (input.yieldQuantity ?? 1));
46
+ return Ok({ id: recipe.id, name: recipe.name, costPerUnit });
47
+ }
48
+ async getRecipeWithIngredients(recipeId) {
49
+ const recipes = await this.db.select().from(posRecipes).where(eq(posRecipes.id, recipeId));
50
+ if (recipes.length === 0)
51
+ return Err("Recipe not found");
52
+ const recipe = recipes[0];
53
+ const ingredients = await this.db
54
+ .select()
55
+ .from(posRecipeIngredients)
56
+ .where(eq(posRecipeIngredients.recipeId, recipeId))
57
+ .orderBy(posRecipeIngredients.sortOrder);
58
+ const totalCost = ingredients.reduce((sum, i) => sum + i.quantity * i.costPerUnit, 0);
59
+ const costPerUnit = Math.round(totalCost / recipe.yieldQuantity);
60
+ return Ok({ recipe, ingredients, totalCost, costPerUnit });
61
+ }
62
+ async calculateCOGS(orgId, entityId, quantity) {
63
+ const recipes = await this.db
64
+ .select()
65
+ .from(posRecipes)
66
+ .where(and(eq(posRecipes.organizationId, orgId), eq(posRecipes.entityId, entityId), eq(posRecipes.isActive, true)));
67
+ if (recipes.length === 0)
68
+ return 0;
69
+ const recipe = recipes[0];
70
+ const ingredients = await this.db
71
+ .select()
72
+ .from(posRecipeIngredients)
73
+ .where(eq(posRecipeIngredients.recipeId, recipe.id));
74
+ const costPerYield = ingredients.reduce((sum, i) => sum + i.quantity * i.costPerUnit, 0);
75
+ const costPerUnit = costPerYield / recipe.yieldQuantity;
76
+ return Math.round(costPerUnit * quantity);
77
+ }
78
+ async listRecipes(orgId) {
79
+ const rows = await this.db
80
+ .select()
81
+ .from(posRecipes)
82
+ .where(eq(posRecipes.organizationId, orgId));
83
+ return Ok(rows);
84
+ }
85
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * TableService — Table CRUD, status management, assignment, and transfer.
3
+ *
4
+ * Informed by URY's table management patterns:
5
+ * - URY Table: room-based (restaurant_room), binary occupied flag, floor plan layout,
6
+ * shape (Circle/Square/Rectangle), is_take_away
7
+ * - table_transfer(): validates same room, target not occupied, updates KOT links
8
+ * - captain_transfer(): reassigns waiter, validates room access in multi-cashier mode
9
+ * - restrict_existing_order(): prevents double-booking (no 2 draft invoices on 1 table)
10
+ *
11
+ * Improvements over URY:
12
+ * - 4-state status machine (available -> occupied -> bill_requested -> cleaning)
13
+ * instead of binary occupied flag
14
+ * - Multi-table assignments (large party across 2+ tables) via pos_table_assignments
15
+ * - Server section assignment (assignedOperatorId) as first-class field
16
+ * - Zone-based grouping instead of room doctype FK
17
+ */
18
+ import type { PluginResult } from "@porulle/core";
19
+ import type { Db, Table, TableAssignment, TableStatus } from "../types.js";
20
+ export declare class TableService {
21
+ private db;
22
+ constructor(db: Db);
23
+ create(orgId: string, input: {
24
+ number: string;
25
+ zone: string;
26
+ capacity?: number;
27
+ minimumSeats?: number;
28
+ shape?: "rectangle" | "square" | "circle";
29
+ isTakeaway?: boolean;
30
+ layoutX?: number;
31
+ layoutY?: number;
32
+ }): Promise<PluginResult<Table>>;
33
+ list(orgId: string, zone?: string): Promise<PluginResult<Table[]>>;
34
+ getById(orgId: string, id: string): Promise<PluginResult<Table>>;
35
+ update(orgId: string, id: string, input: {
36
+ number?: string;
37
+ zone?: string;
38
+ capacity?: number;
39
+ shape?: "rectangle" | "square" | "circle";
40
+ assignedOperatorId?: string | null;
41
+ metadata?: Record<string, unknown>;
42
+ }): Promise<PluginResult<Table>>;
43
+ setStatus(orgId: string, id: string, newStatus: TableStatus): Promise<PluginResult<Table>>;
44
+ assignToTransaction(orgId: string, tableId: string, transactionId: string): Promise<PluginResult<TableAssignment>>;
45
+ clear(orgId: string, tableId: string): Promise<PluginResult<Table>>;
46
+ transfer(orgId: string, fromTableId: string, toTableId: string): Promise<PluginResult<{
47
+ from: Table;
48
+ to: Table;
49
+ }>>;
50
+ updateLayout(orgId: string, id: string, layout: {
51
+ layoutX?: number;
52
+ layoutY?: number;
53
+ layoutWidth?: number;
54
+ layoutHeight?: number;
55
+ }): Promise<PluginResult<Table>>;
56
+ listZones(orgId: string): Promise<PluginResult<Array<{
57
+ zone: string;
58
+ count: number;
59
+ }>>>;
60
+ }
61
+ //# sourceMappingURL=table-service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"table-service.d.ts","sourceRoot":"","sources":["../../src/services/table-service.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAIH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAElD,OAAO,KAAK,EAAE,EAAE,EAAE,KAAK,EAAE,eAAe,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAS3E,qBAAa,YAAY;IACX,OAAO,CAAC,EAAE;gBAAF,EAAE,EAAE,EAAE;IAIpB,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE;QACjC,MAAM,EAAE,MAAM,CAAC;QACf,IAAI,EAAE,MAAM,CAAC;QACb,QAAQ,CAAC,EAAE,MAAM,CAAC;QAClB,YAAY,CAAC,EAAE,MAAM,CAAC;QACtB,KAAK,CAAC,EAAE,WAAW,GAAG,QAAQ,GAAG,QAAQ,CAAC;QAC1C,UAAU,CAAC,EAAE,OAAO,CAAC;QACrB,OAAO,CAAC,EAAE,MAAM,CAAC;QACjB,OAAO,CAAC,EAAE,MAAM,CAAC;KAClB,GAAG,OAAO,CAAC,YAAY,CAAC,KAAK,CAAC,CAAC;IA2B1B,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,KAAK,EAAE,CAAC,CAAC;IAalE,OAAO,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,KAAK,CAAC,CAAC;IAUhE,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE;QAC7C,MAAM,CAAC,EAAE,MAAM,CAAC;QAChB,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,QAAQ,CAAC,EAAE,MAAM,CAAC;QAClB,KAAK,CAAC,EAAE,WAAW,GAAG,QAAQ,GAAG,QAAQ,CAAC;QAC1C,kBAAkB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;QACnC,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;KACpC,GAAG,OAAO,CAAC,YAAY,CAAC,KAAK,CAAC,CAAC;IAa1B,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,SAAS,EAAE,WAAW,GAAG,OAAO,CAAC,YAAY,CAAC,KAAK,CAAC,CAAC;IAuB1F,mBAAmB,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,eAAe,CAAC,CAAC;IAgClH,KAAK,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,KAAK,CAAC,CAAC;IAsBnE,QAAQ,CAAC,KAAK,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC;QAAE,IAAI,EAAE,KAAK,CAAC;QAAC,EAAE,EAAE,KAAK,CAAA;KAAE,CAAC,CAAC;IAqClH,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE;QACpD,OAAO,CAAC,EAAE,MAAM,CAAC;QACjB,OAAO,CAAC,EAAE,MAAM,CAAC;QACjB,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,YAAY,CAAC,EAAE,MAAM,CAAC;KACvB,GAAG,OAAO,CAAC,YAAY,CAAC,KAAK,CAAC,CAAC;IAa1B,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC,CAAC;CAY9F"}
@@ -0,0 +1,206 @@
1
+ /**
2
+ * TableService — Table CRUD, status management, assignment, and transfer.
3
+ *
4
+ * Informed by URY's table management patterns:
5
+ * - URY Table: room-based (restaurant_room), binary occupied flag, floor plan layout,
6
+ * shape (Circle/Square/Rectangle), is_take_away
7
+ * - table_transfer(): validates same room, target not occupied, updates KOT links
8
+ * - captain_transfer(): reassigns waiter, validates room access in multi-cashier mode
9
+ * - restrict_existing_order(): prevents double-booking (no 2 draft invoices on 1 table)
10
+ *
11
+ * Improvements over URY:
12
+ * - 4-state status machine (available -> occupied -> bill_requested -> cleaning)
13
+ * instead of binary occupied flag
14
+ * - Multi-table assignments (large party across 2+ tables) via pos_table_assignments
15
+ * - Server section assignment (assignedOperatorId) as first-class field
16
+ * - Zone-based grouping instead of room doctype FK
17
+ */
18
+ import { eq, and, sql } from "@porulle/core/drizzle";
19
+ import { Ok, Err } from "@porulle/core";
20
+ import { posTables, posTableAssignments } from "../schema.js";
21
+ const VALID_TRANSITIONS = {
22
+ available: ["occupied"],
23
+ occupied: ["bill_requested", "available"],
24
+ bill_requested: ["cleaning", "available"],
25
+ cleaning: ["available"],
26
+ };
27
+ export class TableService {
28
+ db;
29
+ constructor(db) {
30
+ this.db = db;
31
+ }
32
+ // ─── CRUD ──────────────────────────────────────────────────────────
33
+ async create(orgId, input) {
34
+ // Check for duplicate number in org
35
+ const existing = await this.db
36
+ .select()
37
+ .from(posTables)
38
+ .where(and(eq(posTables.organizationId, orgId), eq(posTables.number, input.number)));
39
+ if (existing.length > 0)
40
+ return Err(`Table '${input.number}' already exists`);
41
+ const rows = await this.db
42
+ .insert(posTables)
43
+ .values({
44
+ organizationId: orgId,
45
+ number: input.number,
46
+ zone: input.zone,
47
+ capacity: input.capacity ?? 4,
48
+ minimumSeats: input.minimumSeats ?? 1,
49
+ shape: input.shape ?? "rectangle",
50
+ isTakeaway: input.isTakeaway ?? false,
51
+ layoutX: input.layoutX ?? 0,
52
+ layoutY: input.layoutY ?? 0,
53
+ })
54
+ .returning();
55
+ return Ok(rows[0]);
56
+ }
57
+ async list(orgId, zone) {
58
+ const conditions = [eq(posTables.organizationId, orgId)];
59
+ if (zone)
60
+ conditions.push(eq(posTables.zone, zone));
61
+ const rows = await this.db
62
+ .select()
63
+ .from(posTables)
64
+ .where(and(...conditions))
65
+ .orderBy(posTables.number);
66
+ return Ok(rows);
67
+ }
68
+ async getById(orgId, id) {
69
+ const rows = await this.db
70
+ .select()
71
+ .from(posTables)
72
+ .where(and(eq(posTables.id, id), eq(posTables.organizationId, orgId)));
73
+ if (rows.length === 0)
74
+ return Err("Table not found");
75
+ return Ok(rows[0]);
76
+ }
77
+ async update(orgId, id, input) {
78
+ const rows = await this.db
79
+ .update(posTables)
80
+ .set({ ...input, updatedAt: new Date() })
81
+ .where(and(eq(posTables.id, id), eq(posTables.organizationId, orgId)))
82
+ .returning();
83
+ if (rows.length === 0)
84
+ return Err("Table not found");
85
+ return Ok(rows[0]);
86
+ }
87
+ // ─── Status Management ─────────────────────────────────────────────
88
+ async setStatus(orgId, id, newStatus) {
89
+ const table = await this.getById(orgId, id);
90
+ if (!table.ok)
91
+ return table;
92
+ const current = table.value.status;
93
+ const allowed = VALID_TRANSITIONS[current];
94
+ if (!allowed?.includes(newStatus)) {
95
+ return Err(`Cannot transition table from '${current}' to '${newStatus}'`);
96
+ }
97
+ const rows = await this.db
98
+ .update(posTables)
99
+ .set({ status: newStatus, updatedAt: new Date() })
100
+ .where(eq(posTables.id, id))
101
+ .returning();
102
+ return Ok(rows[0]);
103
+ }
104
+ // ─── Assignment ────────────────────────────────────────────────────
105
+ // Links a table to a POS transaction. Sets table status to "occupied".
106
+ // URY equivalent: sync_order() setting occupied=1 + latest_invoice_time.
107
+ async assignToTransaction(orgId, tableId, transactionId) {
108
+ // Lock the table row to prevent concurrent double-seating
109
+ const locked = await this.db
110
+ .select()
111
+ .from(posTables)
112
+ .where(and(eq(posTables.id, tableId), eq(posTables.organizationId, orgId)))
113
+ .for("update");
114
+ if (locked.length === 0)
115
+ return Err("Table not found");
116
+ const table = locked[0];
117
+ if (table.status !== "available") {
118
+ return Err(`Table '${table.number}' is not available (current: ${table.status})`);
119
+ }
120
+ // Set table to occupied (under lock — no race condition)
121
+ await this.db
122
+ .update(posTables)
123
+ .set({ status: "occupied", updatedAt: new Date() })
124
+ .where(eq(posTables.id, tableId));
125
+ // Create assignment
126
+ const rows = await this.db
127
+ .insert(posTableAssignments)
128
+ .values({ tableId, transactionId, seatedAt: new Date() })
129
+ .returning();
130
+ return Ok(rows[0]);
131
+ }
132
+ // ─── Clear ─────────────────────────────────────────────────────────
133
+ // Clears a table: removes assignments and sets status to "available".
134
+ async clear(orgId, tableId) {
135
+ // Delete assignments
136
+ await this.db
137
+ .delete(posTableAssignments)
138
+ .where(eq(posTableAssignments.tableId, tableId));
139
+ // Set available
140
+ const rows = await this.db
141
+ .update(posTables)
142
+ .set({ status: "available", updatedAt: new Date() })
143
+ .where(and(eq(posTables.id, tableId), eq(posTables.organizationId, orgId)))
144
+ .returning();
145
+ if (rows.length === 0)
146
+ return Err("Table not found");
147
+ return Ok(rows[0]);
148
+ }
149
+ // ─── Transfer ──────────────────────────────────────────────────────
150
+ // Moves a transaction from one table to another.
151
+ // URY equivalent: table_transfer() — validates same room, target not occupied.
152
+ // We validate same zone (our equivalent of URY's room).
153
+ async transfer(orgId, fromTableId, toTableId) {
154
+ const fromTable = await this.getById(orgId, fromTableId);
155
+ if (!fromTable.ok)
156
+ return fromTable;
157
+ const toTable = await this.getById(orgId, toTableId);
158
+ if (!toTable.ok)
159
+ return toTable;
160
+ // Same zone required (URY: same room)
161
+ if (fromTable.value.zone !== toTable.value.zone) {
162
+ return Err(`Cannot transfer between different zones ('${fromTable.value.zone}' -> '${toTable.value.zone}')`);
163
+ }
164
+ // Target must be available
165
+ if (toTable.value.status !== "available") {
166
+ return Err(`Target table '${toTable.value.number}' is not available`);
167
+ }
168
+ // Move assignments
169
+ await this.db
170
+ .update(posTableAssignments)
171
+ .set({ tableId: toTableId })
172
+ .where(eq(posTableAssignments.tableId, fromTableId));
173
+ // Update statuses
174
+ await this.db.update(posTables).set({ status: "available", updatedAt: new Date() }).where(eq(posTables.id, fromTableId));
175
+ await this.db.update(posTables).set({ status: "occupied", updatedAt: new Date() }).where(eq(posTables.id, toTableId));
176
+ const updatedFrom = (await this.getById(orgId, fromTableId)).ok ? (await this.getById(orgId, fromTableId)) : fromTable;
177
+ const updatedTo = (await this.getById(orgId, toTableId)).ok ? (await this.getById(orgId, toTableId)) : toTable;
178
+ if (!updatedFrom.ok || !updatedTo.ok)
179
+ return Err("Transfer failed");
180
+ return Ok({ from: updatedFrom.value, to: updatedTo.value });
181
+ }
182
+ // ─── Layout ────────────────────────────────────────────────────────
183
+ // Updates floor plan position. URY equivalent: updateTableLayout() in table-api.ts.
184
+ async updateLayout(orgId, id, layout) {
185
+ const rows = await this.db
186
+ .update(posTables)
187
+ .set({ ...layout, updatedAt: new Date() })
188
+ .where(and(eq(posTables.id, id), eq(posTables.organizationId, orgId)))
189
+ .returning();
190
+ if (rows.length === 0)
191
+ return Err("Table not found");
192
+ return Ok(rows[0]);
193
+ }
194
+ // ─── Zones ─────────────────────────────────────────────────────────
195
+ async listZones(orgId) {
196
+ const rows = await this.db
197
+ .select({
198
+ zone: posTables.zone,
199
+ count: sql `COUNT(*)`.as("count"),
200
+ })
201
+ .from(posTables)
202
+ .where(eq(posTables.organizationId, orgId))
203
+ .groupBy(posTables.zone);
204
+ return Ok(rows.map((r) => ({ zone: r.zone, count: Number(r.count) })));
205
+ }
206
+ }
@@ -0,0 +1,35 @@
1
+ export type { PluginDb as Db } from "@porulle/core";
2
+ import type { posModifierGroups, posModifierOptions, posTables, posTableAssignments, kdsStations, kdsStationItemGroups, kdsTickets, kdsTicketItems } from "./schema.js";
3
+ export type ModifierGroup = typeof posModifierGroups.$inferSelect;
4
+ export type ModifierGroupInsert = typeof posModifierGroups.$inferInsert;
5
+ export type ModifierOption = typeof posModifierOptions.$inferSelect;
6
+ export type ModifierOptionInsert = typeof posModifierOptions.$inferInsert;
7
+ export type Table = typeof posTables.$inferSelect;
8
+ export type TableInsert = typeof posTables.$inferInsert;
9
+ export type TableAssignment = typeof posTableAssignments.$inferSelect;
10
+ export type TableAssignmentInsert = typeof posTableAssignments.$inferInsert;
11
+ export type TableStatus = "available" | "occupied" | "bill_requested" | "cleaning";
12
+ export type TableShape = "rectangle" | "square" | "circle";
13
+ export type KDSStation = typeof kdsStations.$inferSelect;
14
+ export type KDSStationInsert = typeof kdsStations.$inferInsert;
15
+ export type KDSStationItemGroup = typeof kdsStationItemGroups.$inferSelect;
16
+ export type KDSTicket = typeof kdsTickets.$inferSelect;
17
+ export type KDSTicketInsert = typeof kdsTickets.$inferInsert;
18
+ export type KDSTicketItem = typeof kdsTicketItems.$inferSelect;
19
+ export type KDSTicketItemInsert = typeof kdsTicketItems.$inferInsert;
20
+ export type TicketType = "new_order" | "modified" | "cancelled" | "partially_cancelled";
21
+ export type TicketStatus = "pending" | "preparing" | "ready" | "served";
22
+ export type TicketItemStatus = "pending" | "preparing" | "done";
23
+ export type OrderType = "dine_in" | "takeaway" | "delivery";
24
+ export interface POSRestaurantPluginOptions {
25
+ /** Enable kitchen display system. Default: true */
26
+ enableKDS?: boolean;
27
+ /** Enable tip collection on payments. Default: true */
28
+ enableTips?: boolean;
29
+ /** Enable item modifiers. Default: true */
30
+ enableModifiers?: boolean;
31
+ /** Minutes before KDS ticket turns red. Default: 15 */
32
+ kdsAlertMinutes?: number;
33
+ }
34
+ export declare const DEFAULT_RESTAURANT_OPTIONS: Required<POSRestaurantPluginOptions>;
35
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,QAAQ,IAAI,EAAE,EAAE,MAAM,eAAe,CAAC;AACpD,OAAO,KAAK,EACV,iBAAiB,EACjB,kBAAkB,EAClB,SAAS,EACT,mBAAmB,EACnB,WAAW,EACX,oBAAoB,EACpB,UAAU,EACV,cAAc,EACf,MAAM,aAAa,CAAC;AAIrB,MAAM,MAAM,aAAa,GAAG,OAAO,iBAAiB,CAAC,YAAY,CAAC;AAClE,MAAM,MAAM,mBAAmB,GAAG,OAAO,iBAAiB,CAAC,YAAY,CAAC;AACxE,MAAM,MAAM,cAAc,GAAG,OAAO,kBAAkB,CAAC,YAAY,CAAC;AACpE,MAAM,MAAM,oBAAoB,GAAG,OAAO,kBAAkB,CAAC,YAAY,CAAC;AAI1E,MAAM,MAAM,KAAK,GAAG,OAAO,SAAS,CAAC,YAAY,CAAC;AAClD,MAAM,MAAM,WAAW,GAAG,OAAO,SAAS,CAAC,YAAY,CAAC;AACxD,MAAM,MAAM,eAAe,GAAG,OAAO,mBAAmB,CAAC,YAAY,CAAC;AACtE,MAAM,MAAM,qBAAqB,GAAG,OAAO,mBAAmB,CAAC,YAAY,CAAC;AAE5E,MAAM,MAAM,WAAW,GAAG,WAAW,GAAG,UAAU,GAAG,gBAAgB,GAAG,UAAU,CAAC;AACnF,MAAM,MAAM,UAAU,GAAG,WAAW,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAI3D,MAAM,MAAM,UAAU,GAAG,OAAO,WAAW,CAAC,YAAY,CAAC;AACzD,MAAM,MAAM,gBAAgB,GAAG,OAAO,WAAW,CAAC,YAAY,CAAC;AAC/D,MAAM,MAAM,mBAAmB,GAAG,OAAO,oBAAoB,CAAC,YAAY,CAAC;AAC3E,MAAM,MAAM,SAAS,GAAG,OAAO,UAAU,CAAC,YAAY,CAAC;AACvD,MAAM,MAAM,eAAe,GAAG,OAAO,UAAU,CAAC,YAAY,CAAC;AAC7D,MAAM,MAAM,aAAa,GAAG,OAAO,cAAc,CAAC,YAAY,CAAC;AAC/D,MAAM,MAAM,mBAAmB,GAAG,OAAO,cAAc,CAAC,YAAY,CAAC;AAErE,MAAM,MAAM,UAAU,GAAG,WAAW,GAAG,UAAU,GAAG,WAAW,GAAG,qBAAqB,CAAC;AACxF,MAAM,MAAM,YAAY,GAAG,SAAS,GAAG,WAAW,GAAG,OAAO,GAAG,QAAQ,CAAC;AACxE,MAAM,MAAM,gBAAgB,GAAG,SAAS,GAAG,WAAW,GAAG,MAAM,CAAC;AAChE,MAAM,MAAM,SAAS,GAAG,SAAS,GAAG,UAAU,GAAG,UAAU,CAAC;AAI5D,MAAM,WAAW,0BAA0B;IACzC,mDAAmD;IACnD,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,uDAAuD;IACvD,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,2CAA2C;IAC3C,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,uDAAuD;IACvD,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,eAAO,MAAM,0BAA0B,EAAE,QAAQ,CAAC,0BAA0B,CAK3E,CAAC"}
package/dist/types.js ADDED
@@ -0,0 +1,6 @@
1
+ export const DEFAULT_RESTAURANT_OPTIONS = {
2
+ enableKDS: true,
3
+ enableTips: true,
4
+ enableModifiers: true,
5
+ kdsAlertMinutes: 15,
6
+ };
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@porulle/plugin-pos-restaurant",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "bun": "./src/index.ts",
9
+ "import": "./dist/index.js",
10
+ "types": "./src/index.ts"
11
+ },
12
+ "./schema": {
13
+ "bun": "./src/schema.ts",
14
+ "import": "./dist/schema.js",
15
+ "require": "./dist/schema.js",
16
+ "types": "./src/schema.ts"
17
+ }
18
+ },
19
+ "scripts": {
20
+ "build": "rm -rf dist tsconfig.build.tsbuildinfo && tsc -p tsconfig.build.json",
21
+ "check-types": "tsc --noEmit",
22
+ "lint": "eslint . --max-warnings 1000",
23
+ "test": "vitest run"
24
+ },
25
+ "dependencies": {
26
+ "@hono/zod-openapi": "^1.2.2",
27
+ "@porulle/core": "workspace:*",
28
+ "@porulle/plugin-pos": "workspace:*",
29
+ "hono": "^4.12.5"
30
+ },
31
+ "devDependencies": {
32
+ "@repo/eslint-config": "*",
33
+ "@repo/typescript-config": "*",
34
+ "@types/node": "^24.5.2",
35
+ "eslint": "^9.39.1",
36
+ "typescript": "5.9.2",
37
+ "vitest": "^3.2.4"
38
+ },
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "files": [
43
+ "src",
44
+ "dist",
45
+ "README.md"
46
+ ],
47
+ "peerDependencies": {
48
+ "zod": ">=4.0.0"
49
+ },
50
+ "description": "Restaurant layer on top of @porulle/plugin-pos: tables, floor zones, optional modifiers and KDS, checklists, alerts, recipes (food BOM), and analytics.",
51
+ "homepage": "https://porulle-docs.vercel.app",
52
+ "bugs": {
53
+ "url": "https://github.com/asyncdotengineering/porulle/issues"
54
+ },
55
+ "repository": {
56
+ "type": "git",
57
+ "url": "git+https://github.com/asyncdotengineering/porulle.git",
58
+ "directory": "packages/plugins/plugin-pos-restaurant"
59
+ },
60
+ "author": "Porulle contributors"
61
+ }