370 lines
13 KiB
TypeScript
370 lines
13 KiB
TypeScript
import { Elysia, t } from "elysia";
|
|
import prisma from "../lib/prisma";
|
|
import {
|
|
sendOrderCreatedNotification,
|
|
sendPaymentConfirmedNotification,
|
|
sendOrderShippedNotification,
|
|
sendOrderCancelledNotification,
|
|
markOrderAsPaid
|
|
} from "../lib/notifications";
|
|
|
|
export const ordersRoute = new Elysia({ prefix: "/orders" })
|
|
.get("/", async () => {
|
|
return await prisma.order.findMany({
|
|
orderBy: { createdAt: 'desc' },
|
|
include: { items: true, customer: true }
|
|
});
|
|
})
|
|
.get("/:id", async ({ params: { id }, query, set }) => {
|
|
const order = await prisma.order.findUnique({
|
|
where: { id: Number(id) },
|
|
include: { items: true, customer: true }
|
|
});
|
|
if (!order) {
|
|
set.status = 404;
|
|
return { error: "Order not found" };
|
|
}
|
|
|
|
const lang = (query as any)?.lang;
|
|
if (lang && lang !== 'all') {
|
|
const missingProductIds = order.items
|
|
.filter((item: any) => !item.translations || !(item.translations as any)?.name)
|
|
.map((item: any) => item.productId)
|
|
.filter(Boolean);
|
|
|
|
let legacyTranslationMap = new Map<number, Record<string, string>>();
|
|
if (missingProductIds.length > 0) {
|
|
const legacyTranslations = await prisma.productTranslation.findMany({
|
|
where: { productId: { in: missingProductIds } },
|
|
select: { productId: true, langCode: true, name: true }
|
|
});
|
|
for (const pt of legacyTranslations) {
|
|
if (!legacyTranslationMap.has(pt.productId)) legacyTranslationMap.set(pt.productId, {});
|
|
legacyTranslationMap.get(pt.productId)![pt.langCode] = pt.name;
|
|
}
|
|
}
|
|
|
|
const localizedItems = order.items.map((item: any) => {
|
|
const snapshotName = (item.translations as any)?.name?.[lang];
|
|
const legacyName = legacyTranslationMap.get(item.productId)?.[lang];
|
|
const localizedName = snapshotName || legacyName || item.productName;
|
|
return {
|
|
...item,
|
|
productName: localizedName,
|
|
};
|
|
});
|
|
|
|
return {
|
|
...order,
|
|
items: localizedItems,
|
|
};
|
|
}
|
|
|
|
return order;
|
|
})
|
|
.post("/", async ({ body, set }) => {
|
|
try {
|
|
const data = body as any;
|
|
const settings = await prisma.storeSettings.findFirst()
|
|
const earnRate = settings?.pointsEarnRate || 1.0
|
|
const shippingFee = data.shippingFee || 0
|
|
const netAmount = (data.totalAmount || 0) - shippingFee
|
|
const pointsEarned = Number.isFinite(netAmount) ? Math.floor(Math.max(0, netAmount) * earnRate) : 0
|
|
|
|
if (data.customerId && data.pointsUsed && data.pointsUsed > 0) {
|
|
// Verify customer has enough points
|
|
const customer = await prisma.customer.findUnique({ where: { id: data.customerId } })
|
|
if (!customer || customer.points < data.pointsUsed) {
|
|
set.status = 400
|
|
return { error: "Insufficient points" }
|
|
}
|
|
// Deduct points immediately
|
|
await prisma.customer.update({
|
|
where: { id: data.customerId },
|
|
data: { points: { decrement: data.pointsUsed } }
|
|
})
|
|
}
|
|
|
|
const isPos = data.channel === "POS";
|
|
const initialStatus = isPos ? "COMPLETED" : "PENDING_PAYMENT";
|
|
|
|
// Pre-fetch product names across all languages for immutable snapshotting
|
|
const productIds = Array.from(new Set((data.items || []).map((item: any) => Number(item.productId)).filter(Boolean))) as number[];
|
|
const [productRecords, productTranslations] = await Promise.all([
|
|
productIds.length > 0 ? prisma.product.findMany({
|
|
where: { id: { in: productIds } },
|
|
select: { id: true, name: true }
|
|
}) : [],
|
|
productIds.length > 0 ? prisma.productTranslation.findMany({
|
|
where: { productId: { in: productIds } },
|
|
select: { productId: true, langCode: true, name: true }
|
|
}) : []
|
|
]);
|
|
|
|
const translationMap = new Map<number, Record<string, string>>();
|
|
for (const p of productRecords) {
|
|
if (p.name) {
|
|
if (!translationMap.has(p.id)) translationMap.set(p.id, {});
|
|
translationMap.get(p.id)!['th'] = p.name;
|
|
}
|
|
}
|
|
for (const pt of productTranslations) {
|
|
if (pt.name) {
|
|
if (!translationMap.has(pt.productId)) translationMap.set(pt.productId, {});
|
|
translationMap.get(pt.productId)![pt.langCode] = pt.name;
|
|
}
|
|
}
|
|
|
|
const order = await prisma.order.create({
|
|
data: {
|
|
channel: data.channel || "ONLINE",
|
|
customerId: data.customerId,
|
|
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,
|
|
promoLevel2Name: data.promoLevel2Name,
|
|
promoLevel2Disc: data.promoLevel2Disc || 0,
|
|
couponCode: data.couponCode || null,
|
|
discountAmount: data.discountAmount || 0,
|
|
pointsUsed: data.pointsUsed || 0,
|
|
pointsDiscount: data.pointsDiscount || 0,
|
|
pointsEarned: pointsEarned,
|
|
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) => {
|
|
const names = translationMap.get(Number(item.productId)) || {};
|
|
const translationsSnapshot = Object.keys(names).length > 0 ? { name: names } : undefined;
|
|
return {
|
|
productId: item.productId,
|
|
variantId: item.variantId,
|
|
quantity: item.quantity,
|
|
priceAtPurchase: item.priceAtPurchase,
|
|
productName: item.productName,
|
|
variantName: item.variantName,
|
|
productImage: item.productImage,
|
|
translations: translationsSnapshot,
|
|
};
|
|
})
|
|
}
|
|
},
|
|
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) {
|
|
try {
|
|
await prisma.coupon.update({
|
|
where: { code: data.couponCode.toUpperCase() },
|
|
data: { usedCount: { increment: 1 } }
|
|
});
|
|
} catch (e) {
|
|
console.error("Failed to increment coupon usedCount:", e);
|
|
}
|
|
}
|
|
|
|
// Trigger LINE notification asynchronously
|
|
sendOrderCreatedNotification(order.id).catch(e => {
|
|
console.error("Failed to trigger sendOrderCreatedNotification:", e);
|
|
});
|
|
|
|
return order;
|
|
} catch (err) {
|
|
console.error("[Orders] Error:", err);
|
|
set.status = 500;
|
|
return { error: "Failed to create order" };
|
|
}
|
|
}, {
|
|
body: t.Object({
|
|
channel: t.Optional(t.String()),
|
|
customerId: t.Optional(t.Nullable(t.Number())),
|
|
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.Optional(t.Number()),
|
|
subtotal: t.Optional(t.Number()),
|
|
promoLevel1Name: t.Optional(t.Nullable(t.String())),
|
|
promoLevel1Disc: t.Optional(t.Number()),
|
|
promoLevel2Name: t.Optional(t.Nullable(t.String())),
|
|
promoLevel2Disc: t.Optional(t.Number()),
|
|
couponCode: t.Optional(t.Nullable(t.String())),
|
|
discountAmount: t.Optional(t.Number()),
|
|
pointsUsed: t.Optional(t.Number()),
|
|
pointsDiscount: t.Optional(t.Number()),
|
|
items: t.Array(t.Object({
|
|
productId: t.Number(),
|
|
variantId: t.Optional(t.Number()),
|
|
quantity: t.Number(),
|
|
priceAtPurchase: t.Number(),
|
|
productName: t.String(),
|
|
variantName: t.Optional(t.String()),
|
|
productImage: t.Optional(t.String())
|
|
}))
|
|
})
|
|
})
|
|
.patch("/:id/status", async ({ params: { id }, body, set }) => {
|
|
try {
|
|
const { status } = body as any;
|
|
const order = await prisma.order.findUnique({ where: { id: Number(id) } });
|
|
|
|
if (!order) {
|
|
set.status = 404;
|
|
return { error: "Order not found" };
|
|
}
|
|
|
|
if (status === 'PAID') {
|
|
await markOrderAsPaid(order.id);
|
|
} else {
|
|
await prisma.order.update({
|
|
where: { id: order.id },
|
|
data: { status }
|
|
});
|
|
|
|
// If status becomes CANCELLED, refund the points used and notify
|
|
if (status === 'CANCELLED' && order.status !== 'CANCELLED') {
|
|
if (order.customerId && order.pointsUsed > 0) {
|
|
await prisma.customer.update({
|
|
where: { id: order.customerId },
|
|
data: { points: { increment: order.pointsUsed } }
|
|
});
|
|
}
|
|
sendOrderCancelledNotification(order.id).catch(e => console.error(e));
|
|
}
|
|
|
|
// If status becomes SHIPPED, notify
|
|
if (status === 'SHIPPED' && order.status !== 'SHIPPED') {
|
|
sendOrderShippedNotification(order.id).catch(e => console.error(e));
|
|
}
|
|
}
|
|
|
|
return await prisma.order.findUnique({
|
|
where: { id: order.id },
|
|
include: { items: true, customer: true }
|
|
});
|
|
} catch (err) {
|
|
set.status = 500;
|
|
return { error: "Failed to update order status" };
|
|
}
|
|
}, {
|
|
body: t.Object({
|
|
status: t.String()
|
|
})
|
|
})
|
|
.patch("/:id/slip", async ({ params: { id }, body, set }) => {
|
|
try {
|
|
const { paymentSlipUrl } = body as any;
|
|
return await prisma.order.update({
|
|
where: { id: Number(id) },
|
|
data: { paymentSlipUrl },
|
|
include: { items: true }
|
|
});
|
|
} catch (err) {
|
|
set.status = 500;
|
|
return { error: "Failed to upload slip" };
|
|
}
|
|
}, {
|
|
body: t.Object({
|
|
paymentSlipUrl: t.String()
|
|
})
|
|
})
|
|
.patch("/:id/tracking", async ({ params: { id }, body, set }) => {
|
|
try {
|
|
const { trackingNo } = body as any;
|
|
const order = await prisma.order.update({
|
|
where: { id: Number(id) },
|
|
data: { trackingNo, status: "SHIPPED" },
|
|
include: { items: true }
|
|
});
|
|
// Trigger notification asynchronously
|
|
sendOrderShippedNotification(order.id).catch(e => {
|
|
console.error("Failed to trigger sendOrderShippedNotification:", e);
|
|
});
|
|
return order;
|
|
} catch (err) {
|
|
set.status = 500;
|
|
return { error: "Failed to update tracking number" };
|
|
}
|
|
}, {
|
|
body: t.Object({
|
|
trackingNo: t.String()
|
|
})
|
|
})
|
|
.get("/customer/:customerId", async ({ params: { customerId } }) => {
|
|
return await prisma.order.findMany({
|
|
where: { customerId: Number(customerId) },
|
|
orderBy: { createdAt: 'desc' },
|
|
include: { items: true }
|
|
});
|
|
})
|
|
.post("/track", async ({ body, set }) => {
|
|
try {
|
|
const { orderId, phone } = body as any;
|
|
const order = await prisma.order.findUnique({
|
|
where: { id: Number(orderId) }
|
|
});
|
|
if (!order) {
|
|
set.status = 404;
|
|
return { error: "Order not found" };
|
|
}
|
|
|
|
const sanitizePhone = (p: string) => p.replace(/[^0-9]/g, '');
|
|
if (sanitizePhone(order.customerPhone) !== sanitizePhone(phone)) {
|
|
set.status = 401;
|
|
return { error: "Authentication failed. Phone number does not match." };
|
|
}
|
|
|
|
return { success: true, orderId: order.id };
|
|
} catch (err) {
|
|
set.status = 500;
|
|
return { error: "Invalid tracking request" };
|
|
}
|
|
}, {
|
|
body: t.Object({
|
|
orderId: t.Number(),
|
|
phone: t.String()
|
|
})
|
|
});
|