feat(pos): implement integrated full-screen POS point-of-sale system with inventory deduction and thermal receipt
This commit is contained in:
1 parent
b585277214
commit
5796e4d868
10 files changed
+1549
-23
No files matched your search
@@ -313,11 +313,12 @@ model NewsletterSubscriber {
|
||||
}
|
||||
|
||||
model Order {
|
||||
id Int @id @default(autoincrement())
|
||||
customerName String
|
||||
customerEmail String
|
||||
customerPhone String
|
||||
shippingAddress String
|
||||
id Int @id @default(autoincrement())
|
||||
channel String @default("ONLINE") // "ONLINE" | "POS"
|
||||
customerName String?
|
||||
customerEmail String?
|
||||
customerPhone String?
|
||||
shippingAddress String?
|
||||
|
||||
subtotal Float @default(0)
|
||||
promoLevel1Name String?
|
||||
@@ -332,9 +333,13 @@ model Order {
|
||||
pointsDiscount Float @default(0)
|
||||
pointsEarned Int @default(0)
|
||||
|
||||
shippingFee Float @default(35)
|
||||
shippingFee Float @default(0)
|
||||
totalAmount Float
|
||||
status String @default("PENDING_PAYMENT")
|
||||
paymentMethod String? @default("CASH")
|
||||
receivedAmount Float?
|
||||
changeAmount Float?
|
||||
cashierId Int?
|
||||
paymentSlipUrl String?
|
||||
trackingNo String?
|
||||
|
||||
@@ -344,6 +349,9 @@ model Order {
|
||||
items OrderItem[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([channel])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model OrderItem {
|
||||
|
||||
@@ -66,4 +66,34 @@ export const customersRoute = new Elysia({ prefix: "/customers" })
|
||||
pointsDelta: t.Number(),
|
||||
reason: t.Optional(t.String())
|
||||
})
|
||||
})
|
||||
.post("/", async ({ body, set }) => {
|
||||
try {
|
||||
const { displayName, phone, email } = body as { displayName?: string, phone?: string, email?: string };
|
||||
|
||||
// Auto-generate a unique lineUserId placeholder for in-store/POS customers
|
||||
const lineUserId = `POS_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`;
|
||||
|
||||
const newCustomer = await prisma.customer.create({
|
||||
data: {
|
||||
lineUserId,
|
||||
displayName: displayName || "ลูกค้าหน้าร้าน",
|
||||
phone: phone || null,
|
||||
email: email || null,
|
||||
points: 0
|
||||
}
|
||||
});
|
||||
return newCustomer;
|
||||
} catch (err: any) {
|
||||
console.error("[Customers] Create error:", err);
|
||||
set.status = 400;
|
||||
return { error: err.message || "Failed to create customer" };
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
displayName: t.Optional(t.String()),
|
||||
phone: t.Optional(t.String()),
|
||||
email: t.Optional(t.String())
|
||||
})
|
||||
});
|
||||
|
||||
@@ -48,13 +48,17 @@ export const ordersRoute = new Elysia({ prefix: "/orders" })
|
||||
})
|
||||
}
|
||||
|
||||
const isPos = data.channel === "POS";
|
||||
const initialStatus = isPos ? "COMPLETED" : "PENDING_PAYMENT";
|
||||
|
||||
const order = await prisma.order.create({
|
||||
data: {
|
||||
channel: data.channel || "ONLINE",
|
||||
customerId: data.customerId,
|
||||
customerName: data.customerName,
|
||||
customerEmail: data.customerEmail,
|
||||
customerPhone: data.customerPhone,
|
||||
shippingAddress: data.shippingAddress,
|
||||
customerName: data.customerName || (isPos ? "ลูกค้าหน้าร้าน (Walk-in)" : null),
|
||||
customerEmail: data.customerEmail || null,
|
||||
customerPhone: data.customerPhone || null,
|
||||
shippingAddress: data.shippingAddress || (isPos ? "หน้าร้าน (Takeaway)" : null),
|
||||
subtotal: data.subtotal || 0,
|
||||
promoLevel1Name: data.promoLevel1Name,
|
||||
promoLevel1Disc: data.promoLevel1Disc || 0,
|
||||
@@ -65,8 +69,13 @@ export const ordersRoute = new Elysia({ prefix: "/orders" })
|
||||
pointsUsed: data.pointsUsed || 0,
|
||||
pointsDiscount: data.pointsDiscount || 0,
|
||||
pointsEarned: pointsEarned,
|
||||
shippingFee: data.shippingFee,
|
||||
shippingFee: data.shippingFee || 0,
|
||||
totalAmount: data.totalAmount,
|
||||
status: initialStatus,
|
||||
paymentMethod: data.paymentMethod || (isPos ? "CASH" : null),
|
||||
receivedAmount: data.receivedAmount,
|
||||
changeAmount: data.changeAmount,
|
||||
cashierId: data.cashierId,
|
||||
items: {
|
||||
create: data.items.map((item: any) => ({
|
||||
productId: item.productId,
|
||||
@@ -81,6 +90,33 @@ export const ordersRoute = new Elysia({ prefix: "/orders" })
|
||||
},
|
||||
include: { items: true }
|
||||
});
|
||||
|
||||
// For POS orders: Deduct stock immediately and credit points
|
||||
if (isPos) {
|
||||
for (const item of data.items) {
|
||||
if (item.variantId) {
|
||||
try {
|
||||
await prisma.productVariant.update({
|
||||
where: { id: item.variantId },
|
||||
data: { stock: { decrement: item.quantity } }
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn(`[POS] Failed to decrement stock for variant ${item.variantId}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (data.customerId && pointsEarned > 0) {
|
||||
try {
|
||||
await prisma.customer.update({
|
||||
where: { id: data.customerId },
|
||||
data: { points: { increment: pointsEarned } }
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn(`[POS] Failed to credit points for customer ${data.customerId}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If a coupon was used, increment its usedCount
|
||||
if (data.couponCode) {
|
||||
@@ -107,13 +143,18 @@ export const ordersRoute = new Elysia({ prefix: "/orders" })
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
channel: t.Optional(t.String()),
|
||||
customerId: t.Optional(t.Nullable(t.Number())),
|
||||
customerName: t.String(),
|
||||
customerEmail: t.String(),
|
||||
customerPhone: t.String(),
|
||||
shippingAddress: t.String(),
|
||||
customerName: t.Optional(t.Nullable(t.String())),
|
||||
customerEmail: t.Optional(t.Nullable(t.String())),
|
||||
customerPhone: t.Optional(t.Nullable(t.String())),
|
||||
shippingAddress: t.Optional(t.Nullable(t.String())),
|
||||
paymentMethod: t.Optional(t.Nullable(t.String())),
|
||||
receivedAmount: t.Optional(t.Nullable(t.Number())),
|
||||
changeAmount: t.Optional(t.Nullable(t.Number())),
|
||||
cashierId: t.Optional(t.Nullable(t.Number())),
|
||||
totalAmount: t.Number(),
|
||||
shippingFee: t.Number(),
|
||||
shippingFee: t.Optional(t.Number()),
|
||||
subtotal: t.Optional(t.Number()),
|
||||
promoLevel1Name: t.Optional(t.Nullable(t.String())),
|
||||
promoLevel1Disc: t.Optional(t.Number()),
|
||||
|
||||
@@ -133,8 +133,13 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
{ translations: { some: { name: { contains: search, mode: "insensitive" as const } } } },
|
||||
{ brand: { name: { contains: search, mode: "insensitive" as const } } },
|
||||
{ category: { name: { contains: search, mode: "insensitive" as const } } },
|
||||
{ variants: { some: { OR: [
|
||||
{ barcode: { contains: search, mode: "insensitive" as const } },
|
||||
{ sku: { contains: search, mode: "insensitive" as const } }
|
||||
] } } },
|
||||
],
|
||||
}),
|
||||
|
||||
} as const;
|
||||
|
||||
const [products, total] = await Promise.all([
|
||||
|
||||
Reference in new issue
Block a user