diff --git a/cod-client-astro/locales/ar/orders.json b/cod-client-astro/locales/ar/orders.json index 91f15f1c..a3252402 100644 --- a/cod-client-astro/locales/ar/orders.json +++ b/cod-client-astro/locales/ar/orders.json @@ -205,7 +205,14 @@ "tracking_encaissed": "تم تحصيل المبلغ", "tracking_payed": "تم الدفع", "custom_answers": "إجابات العميل", - "custom_answers_note": "إجابات أسئلتك الخاصة في نموذج الطلب. لا تُرسل أبداً لشركات التوصيل." + "custom_answers_note": "إجابات أسئلتك الخاصة في نموذج الطلب. لا تُرسل أبداً لشركات التوصيل.", + "customer_history": { + "new": "زبون جديد", + "pending": "طلبات سابقة قيد المعالجة", + "reliable": "زبون موثوق", + "risky": "إرجاع متكرر", + "summary": "طلبات سابقة: {total} · سُلّمت: {delivered} · أُرجعت: {returned} · أُلغيت: {cancelled}" + } }, "next_status": { "new": "تأكيد الطلب", diff --git a/cod-client-astro/locales/en/orders.json b/cod-client-astro/locales/en/orders.json index 4545a3fe..fd010f0c 100644 --- a/cod-client-astro/locales/en/orders.json +++ b/cod-client-astro/locales/en/orders.json @@ -205,7 +205,14 @@ "tracking_encaissed": "Payment collected", "tracking_payed": "Paid out", "custom_answers": "Customer answers", - "custom_answers_note": "Answers to your own checkout questions. They are never sent to carriers." + "custom_answers_note": "Answers to your own checkout questions. They are never sent to carriers.", + "customer_history": { + "new": "New customer", + "pending": "Previous orders in progress", + "reliable": "Reliable customer", + "risky": "Frequent returns", + "summary": "Previous orders: {total} · delivered: {delivered} · returned: {returned} · cancelled: {cancelled}" + } }, "next_status": { "new": "Confirm Order", diff --git a/cod-client-astro/locales/fr/orders.json b/cod-client-astro/locales/fr/orders.json index 79aad424..62618e01 100644 --- a/cod-client-astro/locales/fr/orders.json +++ b/cod-client-astro/locales/fr/orders.json @@ -205,7 +205,14 @@ "tracking_encaissed": "Montant encaissé", "tracking_payed": "Paiement effectué", "custom_answers": "Réponses du client", - "custom_answers_note": "Réponses à vos propres questions de commande. Elles ne sont jamais envoyées aux transporteurs." + "custom_answers_note": "Réponses à vos propres questions de commande. Elles ne sont jamais envoyées aux transporteurs.", + "customer_history": { + "new": "Nouveau client", + "pending": "Commandes précédentes en cours", + "reliable": "Client fiable", + "risky": "Retours fréquents", + "summary": "Commandes précédentes : {total} · livrées : {delivered} · retournées : {returned} · annulées : {cancelled}" + } }, "next_status": { "new": "Confirmer la commande", diff --git a/cod-client-astro/src/features/orders/components/CustomerHistoryBadge.tsx b/cod-client-astro/src/features/orders/components/CustomerHistoryBadge.tsx new file mode 100644 index 00000000..98116318 --- /dev/null +++ b/cod-client-astro/src/features/orders/components/CustomerHistoryBadge.tsx @@ -0,0 +1,42 @@ +import { History } from "lucide-react"; +import { Badge, type BadgeTone } from "@/components/ui"; +import { useT } from "@/i18n/react"; +import { + customerHistoryTone, + type CustomerHistoryTone, +} from "@/features/orders/model"; +import type { CustomerHistory } from "@/features/orders/types"; + +const BADGE_TONE: Record = { + new: "neutral", + pending: "info", + reliable: "success", + risky: "critical", +}; + +/** + * How this customer's other orders ended — lets the confirmation team spot + * repeat refusers (and loyal buyers) before calling or dispatching. + */ +export function CustomerHistoryBadge({ history }: { history: CustomerHistory }) { + const t = useT("orders"); + const tone = customerHistoryTone(history); + + return ( +
+ + + {t(`detail.customer_history.${tone}`)} + + {history.total > 0 && ( +

+ {t("detail.customer_history.summary") + .replace("{total}", String(history.total)) + .replace("{delivered}", String(history.delivered)) + .replace("{returned}", String(history.returned)) + .replace("{cancelled}", String(history.cancelled))} +

+ )} +
+ ); +} diff --git a/cod-client-astro/src/features/orders/components/OrderCustomerCard.tsx b/cod-client-astro/src/features/orders/components/OrderCustomerCard.tsx index 7b23a261..e47483e5 100644 --- a/cod-client-astro/src/features/orders/components/OrderCustomerCard.tsx +++ b/cod-client-astro/src/features/orders/components/OrderCustomerCard.tsx @@ -2,11 +2,18 @@ import { Mail, MapPin, Phone, UserRound } from "lucide-react"; import { Card } from "@/components/ui"; import { useT } from "@/i18n/react"; import type { OrderDetail } from "@/features/orders/types"; +import { CustomerHistoryBadge } from "@/features/orders/components/CustomerHistoryBadge"; interface OrderCustomerCardProps { order: Pick< OrderDetail, - "customerName" | "phone" | "wilaya" | "commune" | "address" | "customerEmail" + | "customerName" + | "phone" + | "wilaya" + | "commune" + | "address" + | "customerEmail" + | "customerHistory" >; } @@ -45,6 +52,9 @@ export function OrderCustomerCard({ order }: OrderCustomerCardProps) { {order.customerEmail} )} + {order.customerHistory && ( + + )}
diff --git a/cod-client-astro/src/features/orders/model.test.ts b/cod-client-astro/src/features/orders/model.test.ts index 86112537..125db718 100644 --- a/cod-client-astro/src/features/orders/model.test.ts +++ b/cod-client-astro/src/features/orders/model.test.ts @@ -16,6 +16,7 @@ import { shipmentUpdateFieldSupport, sortOrders, abandonedBasketView, + customerHistoryTone, } from "./model"; import type { OrderListItem } from "./types"; @@ -375,3 +376,32 @@ describe("abandonedBasketView", () => { }); }); }); + +describe("customerHistoryTone", () => { + const history = (delivered: number, returned: number, cancelled = 0, inProgress = 0) => ({ + total: delivered + returned + cancelled + inProgress, + delivered, + returned, + cancelled, + }); + + it("flags a first-time customer as new", () => { + expect(customerHistoryTone(history(0, 0))).toBe("new"); + }); + + it("is pending while no past order reached the carrier outcome", () => { + expect(customerHistoryTone(history(0, 0, 0, 2))).toBe("pending"); + // Cancellations may be the merchant's call — they never colour the badge. + expect(customerHistoryTone(history(0, 0, 3))).toBe("pending"); + }); + + it("is reliable when deliveries outnumber returns", () => { + expect(customerHistoryTone(history(3, 1))).toBe("reliable"); + expect(customerHistoryTone(history(1, 0, 4))).toBe("reliable"); + }); + + it("is risky when returns match or exceed deliveries", () => { + expect(customerHistoryTone(history(1, 1))).toBe("risky"); + expect(customerHistoryTone(history(0, 2))).toBe("risky"); + }); +}); diff --git a/cod-client-astro/src/features/orders/model.ts b/cod-client-astro/src/features/orders/model.ts index 4041a514..ca23ccfa 100644 --- a/cod-client-astro/src/features/orders/model.ts +++ b/cod-client-astro/src/features/orders/model.ts @@ -2,6 +2,7 @@ import type { Locale } from "@/i18n/config"; import type { AbandonedOrder, AbandonedOrderStatus, + CustomerHistory, OrderListItem, OrderStatus, } from "./types"; @@ -466,3 +467,22 @@ export function filterAbandonedOrders( return true; }); } + +// ─── Customer delivery history badge ──────────────────────────────────────── + +export type CustomerHistoryTone = "new" | "pending" | "reliable" | "risky"; + +/** + * Classify a customer's past orders for the order detail badge. + * Only carrier outcomes judge the customer: a cancellation may be the + * merchant's own call, so it is shown but never colours the badge. + * new → no other orders + * pending → other orders exist but none delivered or returned yet + * risky → returns at least match deliveries + * reliable → more deliveries than returns + */ +export function customerHistoryTone(history: CustomerHistory): CustomerHistoryTone { + if (history.total === 0) return "new"; + if (history.delivered === 0 && history.returned === 0) return "pending"; + return history.returned >= history.delivered ? "risky" : "reliable"; +} diff --git a/cod-client-astro/src/features/orders/types.ts b/cod-client-astro/src/features/orders/types.ts index b2c9fd29..1f6ab6f9 100644 --- a/cod-client-astro/src/features/orders/types.ts +++ b/cod-client-astro/src/features/orders/types.ts @@ -97,10 +97,19 @@ export interface OrderListItem extends OrderBase { lastUpdatedBy?: string | null; } +/** How the same customer's OTHER orders ended (current order excluded). */ +export interface CustomerHistory { + total: number; + delivered: number; + returned: number; + cancelled: number; +} + export interface OrderDetail extends OrderBase { products: OrderProduct[]; statusHistory: StatusHistoryItem[]; labelUrl?: string | null; + customerHistory?: CustomerHistory; } export interface Driver { diff --git a/cod-server/src/endpoints/orders/orders.customer-history-e2e.test.ts b/cod-server/src/endpoints/orders/orders.customer-history-e2e.test.ts new file mode 100644 index 00000000..928343ce --- /dev/null +++ b/cod-server/src/endpoints/orders/orders.customer-history-e2e.test.ts @@ -0,0 +1,166 @@ +/** + * getOrderById → customerHistory — real-D1 E2E + summarizer unit tests. + * + * customerHistory tells the merchant how this customer's OTHER orders ended + * (shown as a badge on the order detail page): + * • the current order is always excluded + * • outcome counts cover terminal statuses only (delivered/returned/cancelled) + * • total also counts in-progress orders + * • another customer's orders never leak in + */ + +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { Miniflare } from "miniflare"; +import { readFileSync, readdirSync } from "node:fs"; +import { resolve } from "node:path"; +import { drizzle } from "drizzle-orm/d1"; +import * as schema from "@/db/schema"; +import type { AppDb } from "@/db"; +import { + getOrderById, + summarizeCustomerHistory, +} from "../../../../cod-shared/queries/orders"; + +const registry: Miniflare[] = []; +let db: AppDb; + +type Status = (typeof schema.orders.$inferInsert)["status"]; + +beforeAll(async () => { + const mf = new Miniflare({ + script: "export default { fetch() { return new Response('ok'); } }", + modules: true, + d1Databases: { DB: "test-db" }, + }); + registry.push(mf); + const d1 = await mf.getD1Database("DB"); + const dir = resolve(__dirname, "../../db/migrations"); + const preparedStatements: D1PreparedStatement[] = []; + for (const file of readdirSync(dir).filter((f) => f.endsWith(".sql")).sort()) { + const statements = readFileSync(`${dir}/${file}`, "utf8") + .split("--> statement-breakpoint") + .flatMap((s) => s.split(/;\s*\n/)) + .map((s) => s.replace(/;+\s*$/, "").trim()) + .filter((s) => s.replace(/--[^\n]*/g, "").trim().length > 0); + for (const statement of statements) { + preparedStatements.push(d1.prepare(statement)); + } + } + for (let i = 0; i < preparedStatements.length; i += 50) { + await d1.batch(preparedStatements.slice(i, i + 50)); + } + db = drizzle(d1 as unknown as D1Database, { schema }) as unknown as AppDb; + + const now = new Date().toISOString(); + + await db.insert(schema.customers).values([ + { id: "cust-repeat", name: "Repeat Buyer", phone: "0770000101", wilaya: "الجزائر", createdAt: now }, + { id: "cust-new", name: "New Buyer", phone: "0770000102", wilaya: "الجزائر", createdAt: now }, + { id: "cust-other", name: "Other Buyer", phone: "0770000103", wilaya: "الجزائر", createdAt: now }, + ]); + + const order = (id: string, customerId: string, phone: string, status: Status) => ({ + id, + orderNumber: `ORD-${id}`, + customerId, + customerName: "Buyer", + phone, + price: 1000, + status, + deliveryMethod: "unassigned" as const, + deliveryType: "home" as const, + deliveryFee: 0, + driverFee: 0, + codAmount: 1000, + createdAt: now, + updatedAt: now, + }); + + // One insert per row — D1 caps bound variables per statement. + const seedOrders = [ + // Repeat buyer: current order + 6 others (2 delivered, 2 returned, 1 cancelled, 1 in progress) + order("rep-current", "cust-repeat", "0770000101", "new"), + order("rep-d1", "cust-repeat", "0770000101", "delivered"), + order("rep-d2", "cust-repeat", "0770000101", "delivered"), + order("rep-r1", "cust-repeat", "0770000101", "returned"), + order("rep-r2", "cust-repeat", "0770000101", "returned"), + order("rep-c1", "cust-repeat", "0770000101", "cancelled"), + order("rep-p1", "cust-repeat", "0770000101", "dispatched"), + // First-time buyer: only the current order + order("new-current", "cust-new", "0770000102", "new"), + // Another customer's history must not leak into the repeat buyer's counts + order("oth-r1", "cust-other", "0770000103", "returned"), + order("oth-r2", "cust-other", "0770000103", "returned"), + ]; + for (const row of seedOrders) { + await db.insert(schema.orders).values(row); + } +}, 120_000); + +afterAll(async () => { + for (const mf of registry) await mf.dispose(); +}); + +describe("getOrderById customerHistory — real D1", () => { + it("counts the customer's other orders by outcome, excluding the current order", async () => { + const order = await getOrderById(db, "rep-current"); + + expect(order!.customerHistory).toEqual({ + total: 6, + delivered: 2, + returned: 2, + cancelled: 1, + }); + }); + + it("excludes the viewed order even when it is terminal", async () => { + const order = await getOrderById(db, "rep-d1"); + + expect(order!.customerHistory).toEqual({ + total: 6, + delivered: 1, + returned: 2, + cancelled: 1, + }); + }); + + it("returns zeros for a first-time customer", async () => { + const order = await getOrderById(db, "new-current"); + + expect(order!.customerHistory).toEqual({ + total: 0, + delivered: 0, + returned: 0, + cancelled: 0, + }); + }); +}); + +describe("summarizeCustomerHistory", () => { + it("returns zeros for no rows", () => { + expect(summarizeCustomerHistory([])).toEqual({ + total: 0, + delivered: 0, + returned: 0, + cancelled: 0, + }); + }); + + it("counts in-progress statuses in total only", () => { + expect( + summarizeCustomerHistory([ + { status: "delivered", count: 3 }, + { status: "confirmed", count: 2 }, + { status: "out_for_delivery", count: 1 }, + ]), + ).toEqual({ total: 6, delivered: 3, returned: 0, cancelled: 0 }); + }); + + it("coerces string counts from the driver", () => { + expect( + summarizeCustomerHistory([ + { status: "returned", count: "4" as unknown as number }, + ]), + ).toEqual({ total: 4, delivered: 0, returned: 4, cancelled: 0 }); + }); +}); diff --git a/cod-server/src/openapi/schemas/orders.ts b/cod-server/src/openapi/schemas/orders.ts index 1bfb3203..e5d03787 100644 --- a/cod-server/src/openapi/schemas/orders.ts +++ b/cod-server/src/openapi/schemas/orders.ts @@ -208,6 +208,20 @@ export const OrderListItemSchema = OrderBaseSchema.extend({ // ─── Detail View (for GET /orders/{id}) ─────────────────────────────────────── +/** + * How the same customer's OTHER orders ended (the current order excluded). + * Outcome counts cover terminal orders only; total minus the three outcome + * counts = orders still in progress. + */ +export const CustomerHistorySchema = z + .object({ + total: z.number().int().openapi({ description: "Other orders by this customer", example: 5 }), + delivered: z.number().int().openapi({ example: 3 }), + returned: z.number().int().openapi({ example: 1 }), + cancelled: z.number().int().openapi({ example: 0 }), + }) + .openapi("CustomerHistory"); + /** * Full order detail - includes products and status history */ @@ -218,6 +232,10 @@ export const OrderDetailSchema = OrderBaseSchema.extend({ statusHistory: z.array(StatusHistoryItemSchema).optional().openapi({ description: "Full status change log. Included in GET /api/orders/{id} (detail view only).", }), + customerHistory: CustomerHistorySchema.optional().openapi({ + description: + "How this customer's other orders ended. Included in GET /api/orders/{id} (detail view only).", + }), }).openapi("Order", { description: "Complete order record with products and status history", }); diff --git a/cod-shared/queries/orders.ts b/cod-shared/queries/orders.ts index 85acf37f..fe7960fb 100644 --- a/cod-shared/queries/orders.ts +++ b/cod-shared/queries/orders.ts @@ -25,6 +25,7 @@ import { import type { OrderStatus } from "../db/schema"; import { eq, + ne, desc, and, like, @@ -138,6 +139,55 @@ export async function getAllOrders(db: AppDb, filters: OrderFilters = {}) { .all(); } +// ─── Customer delivery history (order detail badge) ────────────────────────── + +/** + * How this customer's OTHER orders ended. Customers are unique per phone + * (idx_customers_phone_unique), so customer_id is the phone's identity and + * the lookup rides idx_orders_customer. + * + * `total` counts every other order; the three outcome counts cover only + * terminal orders, so `total - delivered - returned - cancelled` is the + * number still in progress. + */ +export interface CustomerOrderHistory { + total: number; + delivered: number; + returned: number; + cancelled: number; +} + +/** Per-status counts of the customer's orders, excluding `excludeOrderId`. */ +export function customerOrderStatusCountsQuery( + db: AppDb, + customerId: string, + excludeOrderId: string, +) { + return db + .select({ + status: orders.status, + count: sql`COUNT(*)`, + }) + .from(orders) + .where(and(eq(orders.customerId, customerId), ne(orders.id, excludeOrderId))) + .groupBy(orders.status); +} + +/** Fold per-status count rows into the history summary. */ +export function summarizeCustomerHistory( + rows: Array<{ status: OrderStatus; count: number }>, +): CustomerOrderHistory { + const history: CustomerOrderHistory = { total: 0, delivered: 0, returned: 0, cancelled: 0 }; + for (const row of rows) { + const count = Number(row.count) || 0; + history.total += count; + if (row.status === "delivered") history.delivered += count; + else if (row.status === "returned") history.returned += count; + else if (row.status === "cancelled") history.cancelled += count; + } + return history; +} + export async function getOrderById(db: AppDb, orderId: string) { const order = await db .select({ @@ -159,7 +209,7 @@ export async function getOrderById(db: AppDb, orderId: string) { if (!order) return null; - const [orderProductsList, historyRows] = await db.batch([ + const [orderProductsList, historyRows, customerStatusRows] = await db.batch([ db.select().from(orderProducts).where(eq(orderProducts.orderId, orderId)), db .select({ @@ -174,10 +224,12 @@ export async function getOrderById(db: AppDb, orderId: string) { .leftJoin(users, eq(orderStatusHistory.by, users.id)) .where(eq(orderStatusHistory.orderId, orderId)) .orderBy(desc(orderStatusHistory.timestamp)), + customerOrderStatusCountsQuery(db, order.customerId, orderId), ]); return { ...order, + customerHistory: summarizeCustomerHistory(customerStatusRows), products: orderProductsList, statusHistory: historyRows.map((h) => ({ id: h.id,