wip(freight): migration 0017 + model/column-list groundwork for add-freight-templates

This commit is contained in:
Chengdong Zhang
2026-09-23 17:39:45 +08:00
parent a83e857bdf
commit 5b426486ac
105 changed files with 6193 additions and 1566 deletions
+188 -79
View File
@@ -103,9 +103,20 @@ const STORAGE_KEY = "vmall.mock.state.v5";
type PersistedState = Pick<
MockState,
"cart" | "orders" | "shipments" | "invoices" | "addresses" | "favorites" |
"aftersales" | "aftersaleMessages" | "orderSeq" | "invoiceSeq" | "addressSeq" |
"favoriteSeq" | "aftersaleSeq" | "aftersaleMessageSeq"
| "cart"
| "orders"
| "shipments"
| "invoices"
| "addresses"
| "favorites"
| "aftersales"
| "aftersaleMessages"
| "orderSeq"
| "invoiceSeq"
| "addressSeq"
| "favoriteSeq"
| "aftersaleSeq"
| "aftersaleMessageSeq"
>;
// Load cart/order session state persisted by a previous page load (client only).
@@ -123,7 +134,8 @@ function loadPersisted(): PersistedState | null {
if (!Array.isArray(p.addresses) || typeof p.addressSeq !== "number") return null;
if (!Array.isArray(p.favorites) || typeof p.favoriteSeq !== "number") return null;
if (!Array.isArray(p.aftersales) || !Array.isArray(p.aftersaleMessages)) return null;
if (typeof p.aftersaleSeq !== "number" || typeof p.aftersaleMessageSeq !== "number") return null;
if (typeof p.aftersaleSeq !== "number" || typeof p.aftersaleMessageSeq !== "number")
return null;
return p as PersistedState;
} catch {
return null;
@@ -337,48 +349,100 @@ function seedAftersales(orders: Order[]): Aftersale[] {
const approved = item("o1", "o1-it2");
const refunded = item("o2", "o2-it1");
const rejected = item("o2", "o2-it2");
if (pending) rows.push({
id: "as-demo-pending", order_id: pending.order.id, order_item_id: pending.line.id,
shop_id: pending.order.shop_id, user_id: MOCK_USER.id, kind: "refund_only", status: "pending",
reason: { en: "The item arrived with a visible issue.", zh: "商品到货后发现明显问题。" },
amount_minor: Math.min(500, pending.line.unit_price_minor * pending.line.qty), currency: pending.order.currency,
evidence: ["https://example.com/aftersale/demo-evidence.jpg"], reopened: false,
return_carrier: null, return_tracking_no: null, created_at: "2026-09-10T12:00:00.000Z", updated_at: "2026-09-10T12:00:00.000Z",
});
if (approved) rows.push({
id: "as-demo-approved", order_id: approved.order.id, order_item_id: approved.line.id,
shop_id: approved.order.shop_id, user_id: MOCK_USER.id, kind: "return_refund", status: "approved",
reason: { en: "The item needs to be returned.", zh: "商品需要退回。" },
amount_minor: approved.line.unit_price_minor * approved.line.qty, currency: approved.order.currency,
evidence: [], reopened: false, return_carrier: null, return_tracking_no: null,
created_at: "2026-09-10T13:00:00.000Z", updated_at: "2026-09-11T09:00:00.000Z",
});
if (refunded) rows.push({
id: "as-demo-refunded", order_id: refunded.order.id, order_item_id: refunded.line.id,
shop_id: refunded.order.shop_id, user_id: MOCK_USER.id, kind: "refund_only", status: "refunded",
reason: { en: "The product did not match the listing.", zh: "商品与描述不符。" },
amount_minor: refunded.line.unit_price_minor * refunded.line.qty, currency: refunded.order.currency,
evidence: [], reopened: false, return_carrier: null, return_tracking_no: null,
created_at: "2026-09-12T17:00:00.000Z", updated_at: "2026-09-14T09:00:00.000Z",
});
if (rejected) rows.push({
id: "as-demo-rejected", order_id: rejected.order.id, order_item_id: rejected.line.id,
shop_id: rejected.order.shop_id, user_id: MOCK_USER.id, kind: "return_refund", status: "rejected",
reason: { en: "Please review this return request.", zh: "请重新审核本次退货申请。" },
amount_minor: rejected.line.unit_price_minor * rejected.line.qty, currency: rejected.order.currency,
evidence: [], reopened: false, return_carrier: null, return_tracking_no: null,
created_at: "2026-09-12T18:00:00.000Z", updated_at: "2026-09-13T09:00:00.000Z",
});
if (pending)
rows.push({
id: "as-demo-pending",
order_id: pending.order.id,
order_item_id: pending.line.id,
shop_id: pending.order.shop_id,
user_id: MOCK_USER.id,
kind: "refund_only",
status: "pending",
reason: { en: "The item arrived with a visible issue.", zh: "商品到货后发现明显问题。" },
amount_minor: Math.min(500, pending.line.unit_price_minor * pending.line.qty),
currency: pending.order.currency,
evidence: ["https://example.com/aftersale/demo-evidence.jpg"],
reopened: false,
return_carrier: null,
return_tracking_no: null,
created_at: "2026-09-10T12:00:00.000Z",
updated_at: "2026-09-10T12:00:00.000Z",
});
if (approved)
rows.push({
id: "as-demo-approved",
order_id: approved.order.id,
order_item_id: approved.line.id,
shop_id: approved.order.shop_id,
user_id: MOCK_USER.id,
kind: "return_refund",
status: "approved",
reason: { en: "The item needs to be returned.", zh: "商品需要退回。" },
amount_minor: approved.line.unit_price_minor * approved.line.qty,
currency: approved.order.currency,
evidence: [],
reopened: false,
return_carrier: null,
return_tracking_no: null,
created_at: "2026-09-10T13:00:00.000Z",
updated_at: "2026-09-11T09:00:00.000Z",
});
if (refunded)
rows.push({
id: "as-demo-refunded",
order_id: refunded.order.id,
order_item_id: refunded.line.id,
shop_id: refunded.order.shop_id,
user_id: MOCK_USER.id,
kind: "refund_only",
status: "refunded",
reason: { en: "The product did not match the listing.", zh: "商品与描述不符。" },
amount_minor: refunded.line.unit_price_minor * refunded.line.qty,
currency: refunded.order.currency,
evidence: [],
reopened: false,
return_carrier: null,
return_tracking_no: null,
created_at: "2026-09-12T17:00:00.000Z",
updated_at: "2026-09-14T09:00:00.000Z",
});
if (rejected)
rows.push({
id: "as-demo-rejected",
order_id: rejected.order.id,
order_item_id: rejected.line.id,
shop_id: rejected.order.shop_id,
user_id: MOCK_USER.id,
kind: "return_refund",
status: "rejected",
reason: { en: "Please review this return request.", zh: "请重新审核本次退货申请。" },
amount_minor: rejected.line.unit_price_minor * rejected.line.qty,
currency: rejected.order.currency,
evidence: [],
reopened: false,
return_carrier: null,
return_tracking_no: null,
created_at: "2026-09-12T18:00:00.000Z",
updated_at: "2026-09-13T09:00:00.000Z",
});
return rows;
}
function seedAftersaleMessages(aftersales: Aftersale[]): AftersaleMessage[] {
const refunded = aftersales.find((row) => row.id === "as-demo-refunded");
return refunded ? [{
id: "asm-demo-1", aftersale_id: refunded.id, author_role: "buyer", author_id: MOCK_USER.id,
content: { en: "Please help review this refund.", zh: "请帮忙审核退款。" }, evidence: [],
created_at: "2026-09-12T17:30:00.000Z",
}] : [];
return refunded
? [
{
id: "asm-demo-1",
aftersale_id: refunded.id,
author_role: "buyer",
author_id: MOCK_USER.id,
content: { en: "Please help review this refund.", zh: "请帮忙审核退款。" },
evidence: [],
created_at: "2026-09-12T17:30:00.000Z",
},
]
: [];
}
function initialState(): MockState {
@@ -438,7 +502,11 @@ function initialState(): MockState {
}
function unsupported(): never {
throw new ApiError(501, "MOCK_UNSUPPORTED", "This admin/shop endpoint is not part of the mall mock.");
throw new ApiError(
501,
"MOCK_UNSUPPORTED",
"This admin/shop endpoint is not part of the mall mock.",
);
}
type MockStoreRecord = NonNullable<ReturnType<typeof storeById>>;
@@ -463,7 +531,6 @@ function toShopProfile(store: MockStoreRecord): ShopProfile {
};
}
function skuIndex(): Record<string, { product: Product; skuId: string }> {
const index: Record<string, { product: Product; skuId: string }> = {};
for (const p of searchMockProducts({ perPage: 1000 }).items) {
@@ -518,7 +585,9 @@ export function createMockApi(): ApiClient {
return row;
}
function findOrderItem(orderItemId: string): { order: Order; item: Order["items"][number] } | null {
function findOrderItem(
orderItemId: string,
): { order: Order; item: Order["items"][number] } | null {
for (const order of state.orders) {
const item = order.items.find((entry) => entry.id === orderItemId);
if (item) return { order, item };
@@ -546,7 +615,8 @@ export function createMockApi(): ApiClient {
function aftersaleDetail(id: string): AftersaleDetail {
const row = findAftersale(id);
const found = findOrderItem(row.order_item_id);
if (!found || found.order.id !== row.order_id) throw new ApiError(404, "NOT_FOUND", "Aftersale item not found");
if (!found || found.order.id !== row.order_id)
throw new ApiError(404, "NOT_FOUND", "Aftersale item not found");
return {
...copyAftersale(row),
item: {
@@ -559,8 +629,15 @@ export function createMockApi(): ApiClient {
messages: state.aftersaleMessages
.filter((message) => message.aftersale_id === row.id)
.sort((a, b) => a.created_at.localeCompare(b.created_at))
.map((message) => ({ ...message, content: { ...message.content }, evidence: [...message.evidence] })),
remaining_refundable_minor: remainingFor(row.order_item_id, found.item.unit_price_minor * found.item.qty),
.map((message) => ({
...message,
content: { ...message.content },
evidence: [...message.evidence],
})),
remaining_refundable_minor: remainingFor(
row.order_item_id,
found.item.unit_price_minor * found.item.qty,
),
};
}
@@ -608,10 +685,12 @@ export function createMockApi(): ApiClient {
const entry = skuIndex()[skuId];
if (!entry) return Promise.reject(new ApiError(404, "NOT_FOUND", "SKU not found"));
const sku = entry.product.skus?.find((s) => s.id === skuId);
if (!sku || !sku.active) return Promise.reject(new ApiError(400, "SKU_INACTIVE", "SKU is not available"));
if (!sku || !sku.active)
return Promise.reject(new ApiError(400, "SKU_INACTIVE", "SKU is not available"));
const existing = state.cart.find((i) => i.sku_id === skuId);
const nextQty = (existing?.qty ?? 0) + qty;
if (nextQty > sku.stock) return Promise.reject(new ApiError(400, "OUT_OF_STOCK", "Not enough stock"));
if (nextQty > sku.stock)
return Promise.reject(new ApiError(400, "OUT_OF_STOCK", "Not enough stock"));
if (existing) existing.qty = nextQty;
else {
state.cart.push({
@@ -761,7 +840,9 @@ export function createMockApi(): ApiClient {
const order = state.orders.find((o) => o.id === id);
if (!order) return Promise.reject(new ApiError(404, "NOT_FOUND", "Order not found"));
if (order.status !== "pending_payment") {
return Promise.reject(new ApiError(409, "INVALID_STATE", "Only unpaid orders can be cancelled"));
return Promise.reject(
new ApiError(409, "INVALID_STATE", "Only unpaid orders can be cancelled"),
);
}
order.status = "cancelled";
const coupon = state.coupons.find((c) => c.order_id === order.id);
@@ -833,15 +914,26 @@ export function createMockApi(): ApiClient {
throw new ApiError(400, "BAD_REQUEST", "amount_minor must be positive");
}
const evidence = (body.evidence ?? []).map((url) => url.trim());
if (evidence.some((url) => !url)) throw new ApiError(400, "BAD_REQUEST", "evidence URLs must be non-empty");
if (evidence.some((url) => !url))
throw new ApiError(400, "BAD_REQUEST", "evidence URLs must be non-empty");
const found = findOrderItem(body.order_item_id);
if (!found || found.order.user_id !== MOCK_USER.id) throw new ApiError(404, "NOT_FOUND", "order item");
if (!eligibleOrder(found.order)) throw new ApiError(409, "CONFLICT", "order item is not eligible for after-sale");
const active = state.aftersales.some((row) => row.order_item_id === body.order_item_id &&
["pending", "approved", "buyer_shipping", "merchant_confirmed"].includes(row.status));
if (active) throw new ApiError(409, "CONFLICT", "an active aftersale already exists for this item");
const remaining = remainingFor(body.order_item_id, found.item.unit_price_minor * found.item.qty);
if (body.amount_minor > remaining) throw new ApiError(409, "CONFLICT", "amount exceeds the remaining refundable balance");
if (!found || found.order.user_id !== MOCK_USER.id)
throw new ApiError(404, "NOT_FOUND", "order item");
if (!eligibleOrder(found.order))
throw new ApiError(409, "CONFLICT", "order item is not eligible for after-sale");
const active = state.aftersales.some(
(row) =>
row.order_item_id === body.order_item_id &&
["pending", "approved", "buyer_shipping", "merchant_confirmed"].includes(row.status),
);
if (active)
throw new ApiError(409, "CONFLICT", "an active aftersale already exists for this item");
const remaining = remainingFor(
body.order_item_id,
found.item.unit_price_minor * found.item.qty,
);
if (body.amount_minor > remaining)
throw new ApiError(409, "CONFLICT", "amount exceeds the remaining refundable balance");
state.aftersaleSeq += 1;
const now = new Date().toISOString();
const row: Aftersale = {
@@ -867,10 +959,11 @@ export function createMockApi(): ApiClient {
return aftersaleDetail(row.id);
},
listMyAftersales: async (): Promise<Aftersale[]> => state.aftersales
.filter((row) => row.user_id === MOCK_USER.id)
.sort((a, b) => b.created_at.localeCompare(a.created_at))
.map(copyAftersale),
listMyAftersales: async (): Promise<Aftersale[]> =>
state.aftersales
.filter((row) => row.user_id === MOCK_USER.id)
.sort((a, b) => b.created_at.localeCompare(a.created_at))
.map(copyAftersale),
getAftersale: async (id: string): Promise<AftersaleDetail> => aftersaleDetail(id),
@@ -897,13 +990,19 @@ export function createMockApi(): ApiClient {
return copyAftersale(row);
},
submitAftersaleReturnTracking: async (id: string, body: AftersaleReturnTrackingBody): Promise<Aftersale> => {
submitAftersaleReturnTracking: async (
id: string,
body: AftersaleReturnTrackingBody,
): Promise<Aftersale> => {
const carrier = body.carrier.trim();
const trackingNo = body.tracking_no.trim();
if (!carrier || !trackingNo) throw new ApiError(400, "BAD_REQUEST", "carrier and tracking_no are required");
if (!carrier || !trackingNo)
throw new ApiError(400, "BAD_REQUEST", "carrier and tracking_no are required");
const row = findAftersale(id);
if (row.kind !== "return_refund") throw new ApiError(409, "CONFLICT", "not a return-refund aftersale");
if (row.status !== "approved") throw new ApiError(409, "CONFLICT", "aftersale is not awaiting return shipping");
if (row.kind !== "return_refund")
throw new ApiError(409, "CONFLICT", "not a return-refund aftersale");
if (row.status !== "approved")
throw new ApiError(409, "CONFLICT", "aftersale is not awaiting return shipping");
row.status = "buyer_shipping";
row.return_carrier = carrier;
row.return_tracking_no = trackingNo;
@@ -912,13 +1011,17 @@ export function createMockApi(): ApiClient {
return copyAftersale(row);
},
addAftersaleMessage: async (id: string, body: AftersaleMessageBody): Promise<AftersaleMessage> => {
addAftersaleMessage: async (
id: string,
body: AftersaleMessageBody,
): Promise<AftersaleMessage> => {
const row = findAftersale(id);
const en = body.content.en?.trim() ?? "";
const zh = body.content.zh?.trim() ?? "";
if (!en && !zh) throw new ApiError(400, "BAD_REQUEST", "content needs text");
const evidence = (body.evidence ?? []).map((url) => url.trim());
if (evidence.some((url) => !url)) throw new ApiError(400, "BAD_REQUEST", "evidence URLs must be non-empty");
if (evidence.some((url) => !url))
throw new ApiError(400, "BAD_REQUEST", "evidence URLs must be non-empty");
state.aftersaleMessageSeq += 1;
const now = new Date().toISOString();
const message: AftersaleMessage = {
@@ -970,7 +1073,8 @@ export function createMockApi(): ApiClient {
})),
}),
listShops: (): Promise<ShopProfile[]> => Promise.resolve(MOCK_STORES.map(toShopProfile)), getShop: (slug) => {
listShops: (): Promise<ShopProfile[]> => Promise.resolve(MOCK_STORES.map(toShopProfile)),
getShop: (slug) => {
const store = storeById(slug);
if (!store) return Promise.reject(new ApiError(404, "NOT_FOUND", "Shop not found"));
return Promise.resolve(toShopProfile(store));
@@ -979,7 +1083,8 @@ export function createMockApi(): ApiClient {
listMyAddresses: () =>
Promise.resolve(
[...state.addresses].sort(
(a, b) => Number(b.is_default) - Number(a.is_default) || b.created_at.localeCompare(a.created_at),
(a, b) =>
Number(b.is_default) - Number(a.is_default) || b.created_at.localeCompare(a.created_at),
),
),
@@ -1062,7 +1167,9 @@ export function createMockApi(): ApiClient {
.filter((row) => row.kind === q.kind)
.filter((row) => {
if (!q.target_id) return true;
return row.kind === "product" ? row.product.id === q.target_id : row.shop.id === q.target_id;
return row.kind === "product"
? row.product.id === q.target_id
: row.shop.id === q.target_id;
})
.sort((a, b) => b.created_at.localeCompare(a.created_at));
const total = visible.length;
@@ -1087,7 +1194,11 @@ export function createMockApi(): ApiClient {
return Promise.resolve(hydrateProductFavorite(existing.id, existing.created_at, product));
}
state.favoriteSeq += 1;
const row = hydrateProductFavorite(`f-${state.favoriteSeq}`, new Date().toISOString(), product);
const row = hydrateProductFavorite(
`f-${state.favoriteSeq}`,
new Date().toISOString(),
product,
);
state.favorites.push(row);
persist();
return Promise.resolve(row);
@@ -1116,7 +1227,9 @@ export function createMockApi(): ApiClient {
},
removeShopFavorite: (shopId: string) => {
state.favorites = state.favorites.filter((row) => !(row.kind === "shop" && row.shop.id === shopId));
state.favorites = state.favorites.filter(
(row) => !(row.kind === "shop" && row.shop.id === shopId),
);
persist();
return Promise.resolve();
},
@@ -1139,9 +1252,7 @@ export function createMockApi(): ApiClient {
},
listPointsProducts: () =>
Promise.resolve(
state.pointsProducts.filter((p) => p.published).map((p) => ({ ...p })),
),
Promise.resolve(state.pointsProducts.filter((p) => p.published).map((p) => ({ ...p }))),
listFlashSales: () => Promise.resolve(mockFlashSales()),
@@ -1150,9 +1261,7 @@ export function createMockApi(): ApiClient {
listMyRedemptions: () => Promise.resolve(state.redemptions.map((o) => ({ ...o }))),
redeemPoints: (body: RedeemPointsBody) => {
const product = state.pointsProducts.find(
(p) => p.id === body.product_id && p.published,
);
const product = state.pointsProducts.find((p) => p.id === body.product_id && p.published);
if (!product) {
return Promise.reject(new ApiError(404, "NOT_FOUND", "Points product not found"));
}