@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.
- package/README.md +61 -0
- package/dist/hooks/modifier-validation.d.ts +21 -0
- package/dist/hooks/modifier-validation.d.ts.map +1 -0
- package/dist/hooks/modifier-validation.js +40 -0
- package/dist/hooks/table-lifecycle.d.ts +15 -0
- package/dist/hooks/table-lifecycle.d.ts.map +1 -0
- package/dist/hooks/table-lifecycle.js +40 -0
- package/dist/index.d.ts +60 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +169 -0
- package/dist/routes/kds.d.ts +9 -0
- package/dist/routes/kds.d.ts.map +1 -0
- package/dist/routes/kds.js +112 -0
- package/dist/routes/modifiers.d.ts +15 -0
- package/dist/routes/modifiers.d.ts.map +1 -0
- package/dist/routes/modifiers.js +117 -0
- package/dist/routes/operations.d.ts +34 -0
- package/dist/routes/operations.d.ts.map +1 -0
- package/dist/routes/operations.js +249 -0
- package/dist/routes/tables.d.ts +9 -0
- package/dist/routes/tables.d.ts.map +1 -0
- package/dist/routes/tables.js +110 -0
- package/dist/schema.d.ts +3831 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +418 -0
- package/dist/services/alert-service.d.ts +56 -0
- package/dist/services/alert-service.d.ts.map +1 -0
- package/dist/services/alert-service.js +173 -0
- package/dist/services/analytics-service.d.ts +60 -0
- package/dist/services/analytics-service.d.ts.map +1 -0
- package/dist/services/analytics-service.js +178 -0
- package/dist/services/checklist-service.d.ts +66 -0
- package/dist/services/checklist-service.d.ts.map +1 -0
- package/dist/services/checklist-service.js +106 -0
- package/dist/services/kds-service.d.ts +77 -0
- package/dist/services/kds-service.d.ts.map +1 -0
- package/dist/services/kds-service.js +246 -0
- package/dist/services/modifier-service.d.ts +74 -0
- package/dist/services/modifier-service.d.ts.map +1 -0
- package/dist/services/modifier-service.js +180 -0
- package/dist/services/recipe-deduction-service.d.ts +64 -0
- package/dist/services/recipe-deduction-service.d.ts.map +1 -0
- package/dist/services/recipe-deduction-service.js +144 -0
- package/dist/services/recipe-service.d.ts +43 -0
- package/dist/services/recipe-service.d.ts.map +1 -0
- package/dist/services/recipe-service.js +85 -0
- package/dist/services/table-service.d.ts +61 -0
- package/dist/services/table-service.d.ts.map +1 -0
- package/dist/services/table-service.js +206 -0
- package/dist/types.d.ts +35 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +6 -0
- package/package.json +61 -0
- package/src/hooks/modifier-validation.ts +60 -0
- package/src/hooks/table-lifecycle.ts +50 -0
- package/src/index.ts +218 -0
- package/src/routes/kds.ts +121 -0
- package/src/routes/modifiers.ts +129 -0
- package/src/routes/operations.ts +276 -0
- package/src/routes/tables.ts +117 -0
- package/src/schema.ts +462 -0
- package/src/services/alert-service.ts +234 -0
- package/src/services/analytics-service.ts +242 -0
- package/src/services/checklist-service.ts +139 -0
- package/src/services/kds-service.ts +340 -0
- package/src/services/modifier-service.ts +251 -0
- package/src/services/recipe-deduction-service.ts +221 -0
- package/src/services/recipe-service.ts +115 -0
- package/src/services/table-service.ts +260 -0
- package/src/types.ts +63 -0
|
@@ -0,0 +1,221 @@
|
|
|
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
|
+
|
|
18
|
+
import { eq, and, sql } from "@porulle/core/drizzle";
|
|
19
|
+
import { Ok } from "@porulle/core";
|
|
20
|
+
import type { PluginResult } from "@porulle/core";
|
|
21
|
+
import { posRecipes, posRecipeIngredients } from "../schema.js";
|
|
22
|
+
import type { Db } from "../types.js";
|
|
23
|
+
|
|
24
|
+
/** Minimal interface for the inventory service methods we need. */
|
|
25
|
+
interface InventoryAdjustFn {
|
|
26
|
+
adjust(
|
|
27
|
+
input: { entityId: string; warehouseId?: string; adjustment: number; reason?: string },
|
|
28
|
+
actor?: unknown,
|
|
29
|
+
): Promise<{ ok: boolean; value?: unknown; error?: unknown }>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface DeductionItem {
|
|
33
|
+
entityId: string;
|
|
34
|
+
variantId: string | null;
|
|
35
|
+
quantity: number;
|
|
36
|
+
unit: string;
|
|
37
|
+
itemName: string;
|
|
38
|
+
reason: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class RecipeDeductionService {
|
|
42
|
+
private inventorySvc?: InventoryAdjustFn;
|
|
43
|
+
|
|
44
|
+
constructor(
|
|
45
|
+
private db: Db,
|
|
46
|
+
services?: Record<string, unknown>,
|
|
47
|
+
) {
|
|
48
|
+
// Extract inventory service if available
|
|
49
|
+
if (services?.inventory && typeof (services.inventory as Record<string, unknown>).adjust === "function") {
|
|
50
|
+
this.inventorySvc = services.inventory as InventoryAdjustFn;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Resolve all recipe ingredient deductions for a list of sold items.
|
|
56
|
+
* Pure query -- no mutations. Returns a flat list to pass to applyDeductions().
|
|
57
|
+
*/
|
|
58
|
+
async resolveDeductions(
|
|
59
|
+
orgId: string,
|
|
60
|
+
items: Array<{ entityId: string; quantity: number }>,
|
|
61
|
+
): Promise<PluginResult<DeductionItem[]>> {
|
|
62
|
+
const deductions: DeductionItem[] = [];
|
|
63
|
+
|
|
64
|
+
for (const item of items) {
|
|
65
|
+
const recipes = await this.db
|
|
66
|
+
.select()
|
|
67
|
+
.from(posRecipes)
|
|
68
|
+
.where(and(
|
|
69
|
+
eq(posRecipes.organizationId, orgId),
|
|
70
|
+
eq(posRecipes.entityId, item.entityId),
|
|
71
|
+
eq(posRecipes.isActive, true),
|
|
72
|
+
));
|
|
73
|
+
|
|
74
|
+
if (recipes.length === 0) continue;
|
|
75
|
+
const recipe = recipes[0]!;
|
|
76
|
+
|
|
77
|
+
const ingredients = await this.db
|
|
78
|
+
.select()
|
|
79
|
+
.from(posRecipeIngredients)
|
|
80
|
+
.where(eq(posRecipeIngredients.recipeId, recipe.id));
|
|
81
|
+
|
|
82
|
+
for (const ing of ingredients) {
|
|
83
|
+
if (ing.entityId == null) continue;
|
|
84
|
+
|
|
85
|
+
const deductQty = Math.ceil((ing.quantity * item.quantity) / recipe.yieldQuantity);
|
|
86
|
+
|
|
87
|
+
deductions.push({
|
|
88
|
+
entityId: ing.entityId,
|
|
89
|
+
variantId: ing.variantId ?? null,
|
|
90
|
+
quantity: deductQty,
|
|
91
|
+
unit: ing.unit,
|
|
92
|
+
itemName: ing.ingredientName,
|
|
93
|
+
reason: `Recipe: ${deductQty}${ing.unit} ${ing.ingredientName} for ${item.quantity}x ${recipe.name}`,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return Ok(deductions);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Apply deductions to inventory.
|
|
103
|
+
*
|
|
104
|
+
* Strategy 1 (preferred): Use inventory.adjust() via ServiceRegistry.
|
|
105
|
+
* - Creates proper inventory_movements with audit trail
|
|
106
|
+
* - Fires hooks (e.g., low-stock alerts)
|
|
107
|
+
* - Respects the inventory service's stock guard logic
|
|
108
|
+
*
|
|
109
|
+
* Strategy 2 (fallback): Raw SQL with atomic WHERE guard.
|
|
110
|
+
* - Used when services are not available (standalone scripts, tests)
|
|
111
|
+
* - Same oversell protection via UPDATE ... WHERE quantity_on_hand >= N
|
|
112
|
+
*/
|
|
113
|
+
async applyDeductions(
|
|
114
|
+
tx: Db,
|
|
115
|
+
deductions: DeductionItem[],
|
|
116
|
+
warehouseId: string,
|
|
117
|
+
referenceType: string,
|
|
118
|
+
referenceId: string,
|
|
119
|
+
performedBy: string,
|
|
120
|
+
): Promise<PluginResult<number>> {
|
|
121
|
+
if (this.inventorySvc) {
|
|
122
|
+
return this.applyViaService(deductions, warehouseId, performedBy);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return this.applyViaRawSQL(tx, deductions, warehouseId, referenceType, referenceId, performedBy);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Strategy 1: Deduct via kernel.services.inventory.adjust().
|
|
130
|
+
* Each deduction becomes a negative adjustment with a reason.
|
|
131
|
+
*/
|
|
132
|
+
private async applyViaService(
|
|
133
|
+
deductions: DeductionItem[],
|
|
134
|
+
warehouseId: string,
|
|
135
|
+
performedBy: string,
|
|
136
|
+
): Promise<PluginResult<number>> {
|
|
137
|
+
const inventory = this.inventorySvc!;
|
|
138
|
+
const systemActor = {
|
|
139
|
+
type: "system" as const,
|
|
140
|
+
userId: performedBy,
|
|
141
|
+
email: null,
|
|
142
|
+
name: "Recipe Deduction",
|
|
143
|
+
vendorId: null,
|
|
144
|
+
organizationId: null,
|
|
145
|
+
role: "system",
|
|
146
|
+
permissions: ["inventory:adjust"],
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
let applied = 0;
|
|
150
|
+
|
|
151
|
+
for (const d of deductions) {
|
|
152
|
+
const result = await inventory.adjust(
|
|
153
|
+
{
|
|
154
|
+
entityId: d.entityId,
|
|
155
|
+
warehouseId,
|
|
156
|
+
adjustment: -d.quantity,
|
|
157
|
+
reason: d.reason,
|
|
158
|
+
},
|
|
159
|
+
systemActor,
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
if (result.ok) {
|
|
163
|
+
applied++;
|
|
164
|
+
}
|
|
165
|
+
// If adjust fails (insufficient stock), it returns ok: false
|
|
166
|
+
// and the deduction is skipped -- same behavior as raw SQL guard
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return Ok(applied);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Strategy 2: Raw SQL fallback with atomic oversell guard.
|
|
174
|
+
* Used when ServiceRegistry is not available.
|
|
175
|
+
*/
|
|
176
|
+
private async applyViaRawSQL(
|
|
177
|
+
tx: Db,
|
|
178
|
+
deductions: DeductionItem[],
|
|
179
|
+
warehouseId: string,
|
|
180
|
+
referenceType: string,
|
|
181
|
+
referenceId: string,
|
|
182
|
+
performedBy: string,
|
|
183
|
+
): Promise<PluginResult<number>> {
|
|
184
|
+
let applied = 0;
|
|
185
|
+
// PluginDb (PgDatabase) has .execute() — no cast needed
|
|
186
|
+
const exec = tx;
|
|
187
|
+
|
|
188
|
+
for (const d of deductions) {
|
|
189
|
+
const variantClause = d.variantId != null
|
|
190
|
+
? sql`variant_id = ${d.variantId}`
|
|
191
|
+
: sql`variant_id IS NULL`;
|
|
192
|
+
|
|
193
|
+
// Atomic guard: only deduct if sufficient stock
|
|
194
|
+
const updateResult = await exec.execute(
|
|
195
|
+
sql`UPDATE inventory_levels
|
|
196
|
+
SET quantity_on_hand = quantity_on_hand - ${d.quantity}, updated_at = NOW()
|
|
197
|
+
WHERE entity_id = ${d.entityId} AND warehouse_id = ${warehouseId} AND ${variantClause}
|
|
198
|
+
AND quantity_on_hand >= ${d.quantity}
|
|
199
|
+
RETURNING quantity_on_hand`,
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
const rows = Array.isArray(updateResult) ? updateResult : (updateResult as { rows: unknown[] }).rows;
|
|
203
|
+
if (rows.length === 0) {
|
|
204
|
+
await exec.execute(
|
|
205
|
+
sql`INSERT INTO inventory_movements (entity_id, variant_id, warehouse_id, type, quantity, reference_type, reference_id, reason, performed_by)
|
|
206
|
+
VALUES (${d.entityId}, ${d.variantId}, ${warehouseId}, 'sale', ${0}, ${referenceType}, ${referenceId}, ${"SKIPPED: insufficient stock for " + d.reason}, ${performedBy})`,
|
|
207
|
+
);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
await exec.execute(
|
|
212
|
+
sql`INSERT INTO inventory_movements (entity_id, variant_id, warehouse_id, type, quantity, reference_type, reference_id, reason, performed_by)
|
|
213
|
+
VALUES (${d.entityId}, ${d.variantId}, ${warehouseId}, 'sale', ${-d.quantity}, ${referenceType}, ${referenceId}, ${d.reason}, ${performedBy})`,
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
applied++;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return Ok(applied);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
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
|
+
|
|
12
|
+
import { eq, and } from "@porulle/core/drizzle";
|
|
13
|
+
import { Ok, Err } from "@porulle/core";
|
|
14
|
+
import type { PluginResult } from "@porulle/core";
|
|
15
|
+
import { posRecipes, posRecipeIngredients } from "../schema.js";
|
|
16
|
+
import type { Db } from "../types.js";
|
|
17
|
+
|
|
18
|
+
export class RecipeService {
|
|
19
|
+
constructor(private db: Db) {}
|
|
20
|
+
|
|
21
|
+
async createRecipe(orgId: string, input: {
|
|
22
|
+
entityId: string;
|
|
23
|
+
name: string;
|
|
24
|
+
yieldQuantity?: number;
|
|
25
|
+
ingredients: Array<{
|
|
26
|
+
ingredientName: string;
|
|
27
|
+
quantity: number;
|
|
28
|
+
unit: string;
|
|
29
|
+
costPerUnit: number;
|
|
30
|
+
entityId?: string; // optional: link to inventory entity for deduction
|
|
31
|
+
variantId?: string; // optional: specific variant to deduct
|
|
32
|
+
}>;
|
|
33
|
+
}): Promise<PluginResult<{ id: string; name: string; costPerUnit: number }>> {
|
|
34
|
+
const rows = await this.db
|
|
35
|
+
.insert(posRecipes)
|
|
36
|
+
.values({
|
|
37
|
+
organizationId: orgId,
|
|
38
|
+
entityId: input.entityId,
|
|
39
|
+
name: input.name,
|
|
40
|
+
yieldQuantity: input.yieldQuantity ?? 1,
|
|
41
|
+
})
|
|
42
|
+
.returning();
|
|
43
|
+
|
|
44
|
+
const recipe = rows[0]!;
|
|
45
|
+
|
|
46
|
+
let totalCost = 0;
|
|
47
|
+
for (let i = 0; i < input.ingredients.length; i++) {
|
|
48
|
+
const ing = input.ingredients[i]!;
|
|
49
|
+
await this.db.insert(posRecipeIngredients).values({
|
|
50
|
+
recipeId: recipe.id,
|
|
51
|
+
ingredientName: ing.ingredientName,
|
|
52
|
+
quantity: ing.quantity,
|
|
53
|
+
unit: ing.unit,
|
|
54
|
+
costPerUnit: ing.costPerUnit,
|
|
55
|
+
entityId: ing.entityId,
|
|
56
|
+
variantId: ing.variantId,
|
|
57
|
+
sortOrder: i,
|
|
58
|
+
});
|
|
59
|
+
totalCost += ing.quantity * ing.costPerUnit;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const costPerUnit = Math.round(totalCost / (input.yieldQuantity ?? 1));
|
|
63
|
+
|
|
64
|
+
return Ok({ id: recipe.id, name: recipe.name, costPerUnit });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async getRecipeWithIngredients(recipeId: string): Promise<PluginResult<{
|
|
68
|
+
recipe: typeof posRecipes.$inferSelect;
|
|
69
|
+
ingredients: Array<typeof posRecipeIngredients.$inferSelect>;
|
|
70
|
+
totalCost: number;
|
|
71
|
+
costPerUnit: number;
|
|
72
|
+
}>> {
|
|
73
|
+
const recipes = await this.db.select().from(posRecipes).where(eq(posRecipes.id, recipeId));
|
|
74
|
+
if (recipes.length === 0) return Err("Recipe not found");
|
|
75
|
+
const recipe = recipes[0]!;
|
|
76
|
+
|
|
77
|
+
const ingredients = await this.db
|
|
78
|
+
.select()
|
|
79
|
+
.from(posRecipeIngredients)
|
|
80
|
+
.where(eq(posRecipeIngredients.recipeId, recipeId))
|
|
81
|
+
.orderBy(posRecipeIngredients.sortOrder);
|
|
82
|
+
|
|
83
|
+
const totalCost = ingredients.reduce((sum, i) => sum + i.quantity * i.costPerUnit, 0);
|
|
84
|
+
const costPerUnit = Math.round(totalCost / recipe.yieldQuantity);
|
|
85
|
+
|
|
86
|
+
return Ok({ recipe, ingredients, totalCost, costPerUnit });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async calculateCOGS(orgId: string, entityId: string, quantity: number): Promise<number> {
|
|
90
|
+
const recipes = await this.db
|
|
91
|
+
.select()
|
|
92
|
+
.from(posRecipes)
|
|
93
|
+
.where(and(eq(posRecipes.organizationId, orgId), eq(posRecipes.entityId, entityId), eq(posRecipes.isActive, true)));
|
|
94
|
+
|
|
95
|
+
if (recipes.length === 0) return 0;
|
|
96
|
+
const recipe = recipes[0]!;
|
|
97
|
+
|
|
98
|
+
const ingredients = await this.db
|
|
99
|
+
.select()
|
|
100
|
+
.from(posRecipeIngredients)
|
|
101
|
+
.where(eq(posRecipeIngredients.recipeId, recipe.id));
|
|
102
|
+
|
|
103
|
+
const costPerYield = ingredients.reduce((sum, i) => sum + i.quantity * i.costPerUnit, 0);
|
|
104
|
+
const costPerUnit = costPerYield / recipe.yieldQuantity;
|
|
105
|
+
return Math.round(costPerUnit * quantity);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async listRecipes(orgId: string): Promise<PluginResult<Array<typeof posRecipes.$inferSelect>>> {
|
|
109
|
+
const rows = await this.db
|
|
110
|
+
.select()
|
|
111
|
+
.from(posRecipes)
|
|
112
|
+
.where(eq(posRecipes.organizationId, orgId));
|
|
113
|
+
return Ok(rows);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
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
|
+
|
|
19
|
+
import { eq, and, sql } from "@porulle/core/drizzle";
|
|
20
|
+
import { Ok, Err } from "@porulle/core";
|
|
21
|
+
import type { PluginResult } from "@porulle/core";
|
|
22
|
+
import { posTables, posTableAssignments } from "../schema.js";
|
|
23
|
+
import type { Db, Table, TableAssignment, TableStatus } from "../types.js";
|
|
24
|
+
|
|
25
|
+
const VALID_TRANSITIONS: Record<TableStatus, TableStatus[]> = {
|
|
26
|
+
available: ["occupied"],
|
|
27
|
+
occupied: ["bill_requested", "available"],
|
|
28
|
+
bill_requested: ["cleaning", "available"],
|
|
29
|
+
cleaning: ["available"],
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export class TableService {
|
|
33
|
+
constructor(private db: Db) {}
|
|
34
|
+
|
|
35
|
+
// ─── CRUD ──────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
async create(orgId: string, input: {
|
|
38
|
+
number: string;
|
|
39
|
+
zone: string;
|
|
40
|
+
capacity?: number;
|
|
41
|
+
minimumSeats?: number;
|
|
42
|
+
shape?: "rectangle" | "square" | "circle";
|
|
43
|
+
isTakeaway?: boolean;
|
|
44
|
+
layoutX?: number;
|
|
45
|
+
layoutY?: number;
|
|
46
|
+
}): Promise<PluginResult<Table>> {
|
|
47
|
+
// Check for duplicate number in org
|
|
48
|
+
const existing = await this.db
|
|
49
|
+
.select()
|
|
50
|
+
.from(posTables)
|
|
51
|
+
.where(and(eq(posTables.organizationId, orgId), eq(posTables.number, input.number)));
|
|
52
|
+
|
|
53
|
+
if (existing.length > 0) return Err(`Table '${input.number}' already exists`);
|
|
54
|
+
|
|
55
|
+
const rows = await this.db
|
|
56
|
+
.insert(posTables)
|
|
57
|
+
.values({
|
|
58
|
+
organizationId: orgId,
|
|
59
|
+
number: input.number,
|
|
60
|
+
zone: input.zone,
|
|
61
|
+
capacity: input.capacity ?? 4,
|
|
62
|
+
minimumSeats: input.minimumSeats ?? 1,
|
|
63
|
+
shape: input.shape ?? "rectangle",
|
|
64
|
+
isTakeaway: input.isTakeaway ?? false,
|
|
65
|
+
layoutX: input.layoutX ?? 0,
|
|
66
|
+
layoutY: input.layoutY ?? 0,
|
|
67
|
+
})
|
|
68
|
+
.returning();
|
|
69
|
+
|
|
70
|
+
return Ok(rows[0]!);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async list(orgId: string, zone?: string): Promise<PluginResult<Table[]>> {
|
|
74
|
+
const conditions = [eq(posTables.organizationId, orgId)];
|
|
75
|
+
if (zone) conditions.push(eq(posTables.zone, zone));
|
|
76
|
+
|
|
77
|
+
const rows = await this.db
|
|
78
|
+
.select()
|
|
79
|
+
.from(posTables)
|
|
80
|
+
.where(and(...conditions))
|
|
81
|
+
.orderBy(posTables.number);
|
|
82
|
+
|
|
83
|
+
return Ok(rows);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async getById(orgId: string, id: string): Promise<PluginResult<Table>> {
|
|
87
|
+
const rows = await this.db
|
|
88
|
+
.select()
|
|
89
|
+
.from(posTables)
|
|
90
|
+
.where(and(eq(posTables.id, id), eq(posTables.organizationId, orgId)));
|
|
91
|
+
|
|
92
|
+
if (rows.length === 0) return Err("Table not found");
|
|
93
|
+
return Ok(rows[0]!);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async update(orgId: string, id: string, input: {
|
|
97
|
+
number?: string;
|
|
98
|
+
zone?: string;
|
|
99
|
+
capacity?: number;
|
|
100
|
+
shape?: "rectangle" | "square" | "circle";
|
|
101
|
+
assignedOperatorId?: string | null;
|
|
102
|
+
metadata?: Record<string, unknown>;
|
|
103
|
+
}): Promise<PluginResult<Table>> {
|
|
104
|
+
const rows = await this.db
|
|
105
|
+
.update(posTables)
|
|
106
|
+
.set({ ...input, updatedAt: new Date() })
|
|
107
|
+
.where(and(eq(posTables.id, id), eq(posTables.organizationId, orgId)))
|
|
108
|
+
.returning();
|
|
109
|
+
|
|
110
|
+
if (rows.length === 0) return Err("Table not found");
|
|
111
|
+
return Ok(rows[0]!);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ─── Status Management ─────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
async setStatus(orgId: string, id: string, newStatus: TableStatus): Promise<PluginResult<Table>> {
|
|
117
|
+
const table = await this.getById(orgId, id);
|
|
118
|
+
if (!table.ok) return table;
|
|
119
|
+
|
|
120
|
+
const current = table.value.status as TableStatus;
|
|
121
|
+
const allowed = VALID_TRANSITIONS[current];
|
|
122
|
+
if (!allowed?.includes(newStatus)) {
|
|
123
|
+
return Err(`Cannot transition table from '${current}' to '${newStatus}'`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const rows = await this.db
|
|
127
|
+
.update(posTables)
|
|
128
|
+
.set({ status: newStatus, updatedAt: new Date() })
|
|
129
|
+
.where(eq(posTables.id, id))
|
|
130
|
+
.returning();
|
|
131
|
+
|
|
132
|
+
return Ok(rows[0]!);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ─── Assignment ────────────────────────────────────────────────────
|
|
136
|
+
// Links a table to a POS transaction. Sets table status to "occupied".
|
|
137
|
+
// URY equivalent: sync_order() setting occupied=1 + latest_invoice_time.
|
|
138
|
+
|
|
139
|
+
async assignToTransaction(orgId: string, tableId: string, transactionId: string): Promise<PluginResult<TableAssignment>> {
|
|
140
|
+
// Lock the table row to prevent concurrent double-seating
|
|
141
|
+
const locked = await this.db
|
|
142
|
+
.select()
|
|
143
|
+
.from(posTables)
|
|
144
|
+
.where(and(eq(posTables.id, tableId), eq(posTables.organizationId, orgId)))
|
|
145
|
+
.for("update");
|
|
146
|
+
|
|
147
|
+
if (locked.length === 0) return Err("Table not found");
|
|
148
|
+
const table = locked[0]!;
|
|
149
|
+
if (table.status !== "available") {
|
|
150
|
+
return Err(`Table '${table.number}' is not available (current: ${table.status})`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Set table to occupied (under lock — no race condition)
|
|
154
|
+
await this.db
|
|
155
|
+
.update(posTables)
|
|
156
|
+
.set({ status: "occupied", updatedAt: new Date() })
|
|
157
|
+
.where(eq(posTables.id, tableId));
|
|
158
|
+
|
|
159
|
+
// Create assignment
|
|
160
|
+
const rows = await this.db
|
|
161
|
+
.insert(posTableAssignments)
|
|
162
|
+
.values({ tableId, transactionId, seatedAt: new Date() })
|
|
163
|
+
.returning();
|
|
164
|
+
|
|
165
|
+
return Ok(rows[0]!);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ─── Clear ─────────────────────────────────────────────────────────
|
|
169
|
+
// Clears a table: removes assignments and sets status to "available".
|
|
170
|
+
|
|
171
|
+
async clear(orgId: string, tableId: string): Promise<PluginResult<Table>> {
|
|
172
|
+
// Delete assignments
|
|
173
|
+
await this.db
|
|
174
|
+
.delete(posTableAssignments)
|
|
175
|
+
.where(eq(posTableAssignments.tableId, tableId));
|
|
176
|
+
|
|
177
|
+
// Set available
|
|
178
|
+
const rows = await this.db
|
|
179
|
+
.update(posTables)
|
|
180
|
+
.set({ status: "available", updatedAt: new Date() })
|
|
181
|
+
.where(and(eq(posTables.id, tableId), eq(posTables.organizationId, orgId)))
|
|
182
|
+
.returning();
|
|
183
|
+
|
|
184
|
+
if (rows.length === 0) return Err("Table not found");
|
|
185
|
+
return Ok(rows[0]!);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ─── Transfer ──────────────────────────────────────────────────────
|
|
189
|
+
// Moves a transaction from one table to another.
|
|
190
|
+
// URY equivalent: table_transfer() — validates same room, target not occupied.
|
|
191
|
+
// We validate same zone (our equivalent of URY's room).
|
|
192
|
+
|
|
193
|
+
async transfer(orgId: string, fromTableId: string, toTableId: string): Promise<PluginResult<{ from: Table; to: Table }>> {
|
|
194
|
+
const fromTable = await this.getById(orgId, fromTableId);
|
|
195
|
+
if (!fromTable.ok) return fromTable;
|
|
196
|
+
|
|
197
|
+
const toTable = await this.getById(orgId, toTableId);
|
|
198
|
+
if (!toTable.ok) return toTable;
|
|
199
|
+
|
|
200
|
+
// Same zone required (URY: same room)
|
|
201
|
+
if (fromTable.value.zone !== toTable.value.zone) {
|
|
202
|
+
return Err(`Cannot transfer between different zones ('${fromTable.value.zone}' -> '${toTable.value.zone}')`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Target must be available
|
|
206
|
+
if (toTable.value.status !== "available") {
|
|
207
|
+
return Err(`Target table '${toTable.value.number}' is not available`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Move assignments
|
|
211
|
+
await this.db
|
|
212
|
+
.update(posTableAssignments)
|
|
213
|
+
.set({ tableId: toTableId })
|
|
214
|
+
.where(eq(posTableAssignments.tableId, fromTableId));
|
|
215
|
+
|
|
216
|
+
// Update statuses
|
|
217
|
+
await this.db.update(posTables).set({ status: "available", updatedAt: new Date() }).where(eq(posTables.id, fromTableId));
|
|
218
|
+
await this.db.update(posTables).set({ status: "occupied", updatedAt: new Date() }).where(eq(posTables.id, toTableId));
|
|
219
|
+
|
|
220
|
+
const updatedFrom = (await this.getById(orgId, fromTableId)).ok ? (await this.getById(orgId, fromTableId)) : fromTable;
|
|
221
|
+
const updatedTo = (await this.getById(orgId, toTableId)).ok ? (await this.getById(orgId, toTableId)) : toTable;
|
|
222
|
+
|
|
223
|
+
if (!updatedFrom.ok || !updatedTo.ok) return Err("Transfer failed");
|
|
224
|
+
return Ok({ from: updatedFrom.value, to: updatedTo.value });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ─── Layout ────────────────────────────────────────────────────────
|
|
228
|
+
// Updates floor plan position. URY equivalent: updateTableLayout() in table-api.ts.
|
|
229
|
+
|
|
230
|
+
async updateLayout(orgId: string, id: string, layout: {
|
|
231
|
+
layoutX?: number;
|
|
232
|
+
layoutY?: number;
|
|
233
|
+
layoutWidth?: number;
|
|
234
|
+
layoutHeight?: number;
|
|
235
|
+
}): Promise<PluginResult<Table>> {
|
|
236
|
+
const rows = await this.db
|
|
237
|
+
.update(posTables)
|
|
238
|
+
.set({ ...layout, updatedAt: new Date() })
|
|
239
|
+
.where(and(eq(posTables.id, id), eq(posTables.organizationId, orgId)))
|
|
240
|
+
.returning();
|
|
241
|
+
|
|
242
|
+
if (rows.length === 0) return Err("Table not found");
|
|
243
|
+
return Ok(rows[0]!);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ─── Zones ─────────────────────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
async listZones(orgId: string): Promise<PluginResult<Array<{ zone: string; count: number }>>> {
|
|
249
|
+
const rows = await this.db
|
|
250
|
+
.select({
|
|
251
|
+
zone: posTables.zone,
|
|
252
|
+
count: sql<number>`COUNT(*)`.as("count"),
|
|
253
|
+
})
|
|
254
|
+
.from(posTables)
|
|
255
|
+
.where(eq(posTables.organizationId, orgId))
|
|
256
|
+
.groupBy(posTables.zone);
|
|
257
|
+
|
|
258
|
+
return Ok(rows.map((r) => ({ zone: r.zone, count: Number(r.count) })));
|
|
259
|
+
}
|
|
260
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export type { PluginDb as Db } from "@porulle/core";
|
|
2
|
+
import type {
|
|
3
|
+
posModifierGroups,
|
|
4
|
+
posModifierOptions,
|
|
5
|
+
posTables,
|
|
6
|
+
posTableAssignments,
|
|
7
|
+
kdsStations,
|
|
8
|
+
kdsStationItemGroups,
|
|
9
|
+
kdsTickets,
|
|
10
|
+
kdsTicketItems,
|
|
11
|
+
} from "./schema.js";
|
|
12
|
+
|
|
13
|
+
// ─── Modifier Types ─────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
export type ModifierGroup = typeof posModifierGroups.$inferSelect;
|
|
16
|
+
export type ModifierGroupInsert = typeof posModifierGroups.$inferInsert;
|
|
17
|
+
export type ModifierOption = typeof posModifierOptions.$inferSelect;
|
|
18
|
+
export type ModifierOptionInsert = typeof posModifierOptions.$inferInsert;
|
|
19
|
+
|
|
20
|
+
// ─── Table Types ────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
export type Table = typeof posTables.$inferSelect;
|
|
23
|
+
export type TableInsert = typeof posTables.$inferInsert;
|
|
24
|
+
export type TableAssignment = typeof posTableAssignments.$inferSelect;
|
|
25
|
+
export type TableAssignmentInsert = typeof posTableAssignments.$inferInsert;
|
|
26
|
+
|
|
27
|
+
export type TableStatus = "available" | "occupied" | "bill_requested" | "cleaning";
|
|
28
|
+
export type TableShape = "rectangle" | "square" | "circle";
|
|
29
|
+
|
|
30
|
+
// ─── KDS Types ──────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
export type KDSStation = typeof kdsStations.$inferSelect;
|
|
33
|
+
export type KDSStationInsert = typeof kdsStations.$inferInsert;
|
|
34
|
+
export type KDSStationItemGroup = typeof kdsStationItemGroups.$inferSelect;
|
|
35
|
+
export type KDSTicket = typeof kdsTickets.$inferSelect;
|
|
36
|
+
export type KDSTicketInsert = typeof kdsTickets.$inferInsert;
|
|
37
|
+
export type KDSTicketItem = typeof kdsTicketItems.$inferSelect;
|
|
38
|
+
export type KDSTicketItemInsert = typeof kdsTicketItems.$inferInsert;
|
|
39
|
+
|
|
40
|
+
export type TicketType = "new_order" | "modified" | "cancelled" | "partially_cancelled";
|
|
41
|
+
export type TicketStatus = "pending" | "preparing" | "ready" | "served";
|
|
42
|
+
export type TicketItemStatus = "pending" | "preparing" | "done";
|
|
43
|
+
export type OrderType = "dine_in" | "takeaway" | "delivery";
|
|
44
|
+
|
|
45
|
+
// ─── Plugin Options ─────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
export interface POSRestaurantPluginOptions {
|
|
48
|
+
/** Enable kitchen display system. Default: true */
|
|
49
|
+
enableKDS?: boolean;
|
|
50
|
+
/** Enable tip collection on payments. Default: true */
|
|
51
|
+
enableTips?: boolean;
|
|
52
|
+
/** Enable item modifiers. Default: true */
|
|
53
|
+
enableModifiers?: boolean;
|
|
54
|
+
/** Minutes before KDS ticket turns red. Default: 15 */
|
|
55
|
+
kdsAlertMinutes?: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const DEFAULT_RESTAURANT_OPTIONS: Required<POSRestaurantPluginOptions> = {
|
|
59
|
+
enableKDS: true,
|
|
60
|
+
enableTips: true,
|
|
61
|
+
enableModifiers: true,
|
|
62
|
+
kdsAlertMinutes: 15,
|
|
63
|
+
};
|