merge: refactor/v2 完全并入 develop(v2 为准,v2 血统 20+ 提交收敛为主干)
This commit is contained in:
@@ -92,10 +92,19 @@ Prisma Postgres 最佳实践: prisma-postgres 技能
|
|||||||
|
|
||||||
### 经验沉淀(跨项目通用)
|
### 经验沉淀(跨项目通用)
|
||||||
|
|
||||||
|
- **入口 nginx 的真实挂载源在 v1 仓库**:线上 official.inkreach.cc 的 nginx 容器由 `/opt/inkreach/deploy` 创建,挂载的是那边的 `nginx/admin.conf` 与 `certbot/acme` 证书;v2 仓库 `deploy/nginx/admin.conf` 只是同步副本。改线上路由必须改 v1 的文件,再在该目录 `docker compose up -d --force-recreate admin`。v1/v2 目录同名 → compose 项目名相同 → pgdata/uploads 数据卷共享,重建不丢数据。
|
||||||
|
- **文件型 bind mount 的 inode 陷阱**:编辑器保存常替换 inode,容器内仍看到旧内容(`:ro` 挂载连 docker cp 都被拒);`docker compose restart` 也不会重新解析挂载。要让新文件内容生效只能 `--force-recreate`,新服务的挂载尽量挂目录而非单个文件。
|
||||||
- **数据库漂移处理**:连共享开发库时先跑 `prisma migrate status`;若报"列已存在"类错误,说明有人用 `db push` 带外改过库,用 `prisma migrate resolve --applied <name>` 把已存在的迁移标记为已应用,再 `migrate deploy` 应用真正缺的部分。切勿盲目 reset 共享库。
|
- **数据库漂移处理**:连共享开发库时先跑 `prisma migrate status`;若报"列已存在"类错误,说明有人用 `db push` 带外改过库,用 `prisma migrate resolve --applied <name>` 把已存在的迁移标记为已应用,再 `migrate deploy` 应用真正缺的部分。切勿盲目 reset 共享库。
|
||||||
- **连真实库的集成测试隔离**:jest 并行套件共用一个数据库时,(a) 夹具的天然键(sdsCategoryId、名称等)必须带运行时间戳唯一化,禁止跨运行共享字面量;(b) 全量型操作(如 auto-group 扫全库)会顺带扫到其他并行套件的夹具,其写入路径必须对"成员中途消失"宽容(跳过而非抛错),否则会随机挂测试。
|
- **连真实库的集成测试隔离**:jest 并行套件共用一个数据库时,(a) 夹具的天然键(sdsCategoryId、名称等)必须带运行时间戳唯一化,禁止跨运行共享字面量;(b) 全量型操作(如 auto-group 扫全库)会顺带扫到其他并行套件的夹具,其写入路径必须对"成员中途消失"宽容(跳过而非抛错),否则会随机挂测试。
|
||||||
- **跑全量 jest 前先停 dev server**:`nest start --watch` 等常驻进程与测试共用数据库时,其重编译窗口/后台钩子会与测试写入竞争,造成"单跑绿、全量偶发红"的假阳性;验证基线前先停掉所有 watch 进程再跑。
|
- **跑全量 jest 前先停 dev server**:`nest start --watch` 等常驻进程与测试共用数据库时,其重编译窗口/后台钩子会与测试写入竞争,造成"单跑绿、全量偶发红"的假阳性;验证基线前先停掉所有 watch 进程再跑。
|
||||||
- **中文断言勿手写字面量排序**:JS `Array.sort()` 对中文按 UTF-16 码位排(如 烫 U+70EB < 直 U+76F4),手写期望序列容易按拼音/习惯顺序写反;比较选项集合时用 `expect.arrayContaining` + 长度,或对两侧统一 `.sort()` 后再比较。
|
- **中文断言勿手写字面量排序**:JS `Array.sort()` 对中文按 UTF-16 码位排(如 烫 U+70EB < 直 U+76F4),手写期望序列容易按拼音/习惯顺序写反;比较选项集合时用 `expect.arrayContaining` + 长度,或从同一排序函数生成期望。
|
||||||
|
- **pnpm 仓库容器化执行要挂仓库根**:pnpm 的 node_modules 是相对符号链接指向根 `.pnpm` store,容器里只挂子包目录(如 `apps/api:/app`)会断链报 "Cannot find module";必须挂整个仓库根并 `-w` 到子包。另外 Prisma 引擎与系统 libssl 版本强绑定:node:20-alpine 缺 libssl1.1 会报 engine 加载失败,直接复用项目自身的运行镜像(如 deploy-v2-api)跑 prisma/jest 最稳。
|
||||||
|
- **Prisma 唯一查询用字段名而非列名**:`where: { id }` 而非 `@map("category_id")` 映射后的 `categoryId`;schema `@map` 只影响 SQL 列名,Prisma Client 的唯一输入类型永远用 model 字段名。
|
||||||
|
- **跨套件分页断言要圈定夹具**:真实库上测"列表排序"时全库数据可能远超 pageSize,夹具根本进不了第一页;给夹具商品名加唯一前缀 + `keyword` 过滤圈定,断言既稳定又能看到完整顺序。
|
||||||
|
- **"绝对排序键 + 子集过滤"模式**:需要"任意筛选组合下顺序都正确"时,给每条数据算好一组绝对排序键(如 国家→二级→款→priority,缺失沉底),筛选只做子集过滤不做特殊排序分支——比每个筛选组合写一套 orderBy 逻辑可靠得多。
|
||||||
|
- **"代表行选取收敛为单一方法"**:多端点共享同一实体视图(如族化商品对外的代表行)时,代表行选取规则必须收敛为一个私有方法供所有端点复用,禁止各端点各自 `goods[0]`——否则排序参数不同会导致 goodName/主图等字段在列表与详情间漂移。
|
||||||
|
- **"物化数组的顺序必须有确定性来源"**:把聚合结果物化进 JSONB(如 price_matrix 的 printCounts/crafts/logistics)时,"Set 去重保首现序"不是顺序保证——源查询没有 orderBy 时 Postgres 不承诺返回序,同一数据两次重算顺序可能漂移。必须:① 聚合后按业务词表序稳定排序(词表外自由文本沉底);② 源查询显式 `orderBy` 固定遍历序。测试用"打乱输入顺序断言输出全等"覆盖顺序确定性。
|
||||||
|
- **"新增维度取值 = 标签行 + 两处词表常量,缺一不可"**:工艺/物流/印花数量这类封闭维度新增可选值时,只往 `tags` 表插行不够——矩阵维度词表 `DIM_VALUES` 和展示序 `OPTION_DISPLAY_ORDER` 是硬编码常量,必须同步追加,否则打了新标签的链接会被词表过滤导致价格格子静默消失(比报错更危险)。同时确认自动派生规则(auto-tag-rules / admin origin-name)是否要认识新值;存量数据零影响的前提是"只放开准入,不主动打标"。
|
||||||
- **测试夹具必须自包含**:断言库内"存在某类数据"的用例(如标签组过滤)在全新/一次性数据库上必挂;夹具自己种下断言所依赖的数据,不依赖共享库的既有状态。
|
- **测试夹具必须自包含**:断言库内"存在某类数据"的用例(如标签组过滤)在全新/一次性数据库上必挂;夹具自己种下断言所依赖的数据,不依赖共享库的既有状态。
|
||||||
- **严禁直接热修运行容器内的代码**:本次线上 v2 镜像内被塞过未提交的词表改动(热转印),仓库重建镜像即复发且难排查;所有修复必须落在仓库并重建部署。排查"线上行为与代码不符"时先 `md5sum` 比对容器内源文件与仓库。
|
- **严禁直接热修运行容器内的代码**:本次线上 v2 镜像内被塞过未提交的词表改动(热转印),仓库重建镜像即复发且难排查;所有修复必须落在仓库并重建部署。排查"线上行为与代码不符"时先 `md5sum` 比对容器内源文件与仓库。
|
||||||
- **两端"同构"解析器必须连测试用例也同构**:admin 与 api 各有一份链接名解析器,本次展示 bug(`品名(DTG180) SKU` 显示成光款号)正是两端取括号策略不一致(api 首括号对 vs admin 末括号)所致。改任何一端解析行为时,另一端同样输入的用例必须同步补上(origin-name.spec ↔ origin-name.parser.spec/goods.service.spec)。
|
- **两端"同构"解析器必须连测试用例也同构**:admin 与 api 各有一份链接名解析器,本次展示 bug(`品名(DTG180) SKU` 显示成光款号)正是两端取括号策略不一致(api 首括号对 vs admin 末括号)所致。改任何一端解析行为时,另一端同样输入的用例必须同步补上(origin-name.spec ↔ origin-name.parser.spec/goods.service.spec)。
|
||||||
|
|||||||
@@ -24,9 +24,11 @@
|
|||||||
"configure:product-center-icons": "ts-node prisma/configure-product-center-icons.ts",
|
"configure:product-center-icons": "ts-node prisma/configure-product-center-icons.ts",
|
||||||
"import:product-detail": "ts-node prisma/import-product-detail.ts",
|
"import:product-detail": "ts-node prisma/import-product-detail.ts",
|
||||||
"backfill:product-families": "ts-node prisma/backfill-product-families.ts",
|
"backfill:product-families": "ts-node prisma/backfill-product-families.ts",
|
||||||
|
"backfill:product-families": "ts-node prisma/backfill-product-families.ts",
|
||||||
"fix:good-names": "ts-node prisma/fix-pure-sku-good-names.ts",
|
"fix:good-names": "ts-node prisma/fix-pure-sku-good-names.ts",
|
||||||
"fix:category-names": "ts-node prisma/fix-category-parens.ts",
|
"fix:category-names": "ts-node prisma/fix-category-parens.ts",
|
||||||
"organize": "ts-node prisma/backfill-product-families.ts"
|
"organize": "ts-node prisma/backfill-product-families.ts",
|
||||||
|
"recompute:families": "ts-node prisma/recompute-all-families.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/axios": "^3.0.1",
|
"@nestjs/axios": "^3.0.1",
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* 一次性回填:解析 排序表.md → categories.sort_order(二级/款)+ countries.sort_order
|
||||||
|
* + 沙特国家行 + 中东根更名
|
||||||
|
* 幂等:可重复执行;SDS 同步若覆盖根名称,重跑本脚本即可恢复
|
||||||
|
*/
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
// 排序表国家名 → 新树根分类名(与库内 category_name 精确对应)
|
||||||
|
const ROOT_MAP: Record<string, string> = {
|
||||||
|
'美国': '美国工厂直发',
|
||||||
|
'英国': '英国本地直发',
|
||||||
|
'日本': '日本本地工厂直发',
|
||||||
|
'墨西哥': '墨西哥工厂本地直发',
|
||||||
|
'巴西': '巴西本地工厂直发',
|
||||||
|
'中东': '中东本地工厂直发', // 同时更名沙特
|
||||||
|
'波兰': '欧洲波兰工厂直发',
|
||||||
|
'西班牙': '欧洲西班牙工厂直发',
|
||||||
|
'德国': '欧洲德国工厂本地直发',
|
||||||
|
'意大利': '欧洲意大利工厂直发',
|
||||||
|
'加拿大': '加拿大本地工厂直发',
|
||||||
|
'澳大利亚': '澳大利亚本地工厂直发',
|
||||||
|
'韩国': '韩国本地直发',
|
||||||
|
'中国(国内工厂)': '国内工厂',
|
||||||
|
};
|
||||||
|
|
||||||
|
const norm = (s: string) => s.trim().replace(/\s+/g, '');
|
||||||
|
const codeOf = (s: string) => (s.trim().match(/^[A-Za-z0-9]+/) ?? [''])[0];
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const raw = readFileSync(process.env.SORT_TABLE_PATH ?? '/repo/排序表.md', 'utf8');
|
||||||
|
let country: string | null = null;
|
||||||
|
let l2: string | null = null;
|
||||||
|
const tree: Array<{ country: string; l2: string; l3: string }> = [];
|
||||||
|
const countryOrder: string[] = [];
|
||||||
|
for (const line of raw.split('\n')) {
|
||||||
|
const t = line.trim();
|
||||||
|
if (t.startsWith('# ')) {
|
||||||
|
const name = t.slice(2).trim();
|
||||||
|
if (name === '全部' || name.includes('工厂直发国家')) continue;
|
||||||
|
country = name;
|
||||||
|
if (!countryOrder.includes(name)) countryOrder.push(name);
|
||||||
|
} else if (t.startsWith('## ') && country) {
|
||||||
|
l2 = t.slice(3).trim();
|
||||||
|
} else if (t.startsWith('### ') && country && l2) {
|
||||||
|
tree.push({ country, l2, l3: t.slice(4).trim() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`parsed: ${countryOrder.length} countries, ${tree.length} leaves`);
|
||||||
|
|
||||||
|
const roots = await prisma.category.findMany({
|
||||||
|
where: { parentCategoryId: null, sdsCategoryId: { not: null } },
|
||||||
|
include: { children: { include: { children: true } } },
|
||||||
|
});
|
||||||
|
const rootByName = new Map(roots.map((r) => [r.categoryName, r]));
|
||||||
|
const unmatched: string[] = [];
|
||||||
|
|
||||||
|
// 1) countries:沙特 upsert + 顺序重写(中国无国家行,跳过)
|
||||||
|
for (let i = 0; i < countryOrder.length; i++) {
|
||||||
|
const name = countryOrder[i];
|
||||||
|
const dbCountry = name === '中东' ? '沙特' : name;
|
||||||
|
if (dbCountry === '中国(国内工厂)') continue;
|
||||||
|
const sortOrder = i + 1;
|
||||||
|
const existing = await prisma.country.findUnique({ where: { countryName: dbCountry } });
|
||||||
|
if (existing) {
|
||||||
|
await prisma.country.update({ where: { id: existing.id }, data: { sortOrder } });
|
||||||
|
} else if (dbCountry === '沙特') {
|
||||||
|
await prisma.country.create({ data: { countryName: '沙特', sortOrder } });
|
||||||
|
console.log('created country: 沙特');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 中东根 → 沙特
|
||||||
|
const meRoot = rootByName.get('中东本地工厂直发');
|
||||||
|
if (meRoot) {
|
||||||
|
await prisma.category.update({ where: { id: meRoot.id }, data: { categoryName: '沙特本地工厂直发' } });
|
||||||
|
rootByName.set('沙特本地工厂直发', meRoot);
|
||||||
|
console.log('renamed root: 中东本地工厂直发 -> 沙特本地工厂直发');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 二级/款 sort_order
|
||||||
|
for (const countryName of countryOrder) {
|
||||||
|
const rootName = countryName === '中东' ? '沙特本地工厂直发' : ROOT_MAP[countryName];
|
||||||
|
const root = rootByName.get(rootName);
|
||||||
|
if (!root) {
|
||||||
|
unmatched.push(`ROOT MISS: ${countryName} (expect root "${rootName}")`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const l2s = root.children;
|
||||||
|
const l2NamesInOrder: string[] = [];
|
||||||
|
for (const row of tree) {
|
||||||
|
if (row.country === countryName && !l2NamesInOrder.includes(row.l2)) l2NamesInOrder.push(row.l2);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < l2NamesInOrder.length; i++) {
|
||||||
|
const target = l2NamesInOrder[i];
|
||||||
|
const mid = l2s.find((m) => norm(m.categoryName) === norm(target));
|
||||||
|
if (!mid) {
|
||||||
|
unmatched.push(`L2 MISS: ${countryName} / ${target}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await prisma.category.update({ where: { id: mid.id }, data: { sortOrder: i + 1 } });
|
||||||
|
const leaves = mid.children;
|
||||||
|
const l3Names = tree.filter((r) => r.country === countryName && r.l2 === target).map((r) => r.l3);
|
||||||
|
for (let j = 0; j < l3Names.length; j++) {
|
||||||
|
const want = norm(l3Names[j]);
|
||||||
|
const code = norm(codeOf(l3Names[j]));
|
||||||
|
const leaf =
|
||||||
|
leaves.find((l) => norm(l.categoryName) === want) ??
|
||||||
|
(code ? leaves.find((l) => norm(l.categoryName).startsWith(code)) : undefined);
|
||||||
|
if (!leaf) {
|
||||||
|
unmatched.push(`L3 MISS: ${countryName} / ${target} / ${l3Names[j]}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await prisma.category.update({ where: { id: leaf.id }, data: { sortOrder: j + 1 } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unmatched.length) {
|
||||||
|
console.error(`UNMATCHED (${unmatched.length}):\n` + unmatched.join('\n'));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('backfill done');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect());
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "categories" ADD COLUMN "sort_order" INTEGER NOT NULL DEFAULT 0;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* 全量族重算(一次性维护脚本):
|
||||||
|
* 逐族调用 FamilyRecomputeService.recomputeFamily,刷新物化字段
|
||||||
|
* (并集尺码表/包装规则 + 五维价格矩阵,含选项组词表序排序)。
|
||||||
|
* autoManaged=false 的族内部只置 stale,不会覆盖人工物化字段。
|
||||||
|
*
|
||||||
|
* 运行:pnpm --filter @inkreach/api recompute:families
|
||||||
|
*/
|
||||||
|
import { PrismaService } from '../src/prisma/prisma.service';
|
||||||
|
import { FamilyRecomputeService } from '../src/product-families/family-recompute.service';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const prisma = new PrismaService();
|
||||||
|
await prisma.onModuleInit();
|
||||||
|
const recompute = new FamilyRecomputeService(prisma);
|
||||||
|
|
||||||
|
const families = await prisma.productFamily.findMany({
|
||||||
|
select: { id: true },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
});
|
||||||
|
let ok = 0;
|
||||||
|
let failed = 0;
|
||||||
|
for (const { id } of families) {
|
||||||
|
try {
|
||||||
|
await recompute.recomputeFamily(id);
|
||||||
|
ok++;
|
||||||
|
} catch (error) {
|
||||||
|
failed++;
|
||||||
|
console.error(`family ${id} recompute failed: ${String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`[recompute:families] total=${families.length} ok=${ok} failed=${failed}`);
|
||||||
|
await prisma.onModuleDestroy();
|
||||||
|
if (failed > 0) process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -161,6 +161,7 @@ model Category {
|
|||||||
categoryName String @map("category_name")
|
categoryName String @map("category_name")
|
||||||
categoryIcon String? @map("category_icon")
|
categoryIcon String? @map("category_icon")
|
||||||
sdsCategoryId String? @unique @map("sds_category_id")
|
sdsCategoryId String? @unique @map("sds_category_id")
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
@@ -1,6 +1,10 @@
|
|||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { FamilyRecomputeService } from './family-recompute.service';
|
import {
|
||||||
|
FamilyRecomputeService,
|
||||||
|
derivePriceMatrix,
|
||||||
|
memberMatrixCombos,
|
||||||
|
} from './family-recompute.service';
|
||||||
import { ProductFamiliesService } from './product-families.service';
|
import { ProductFamiliesService } from './product-families.service';
|
||||||
import { OrganizeService } from './organize.service';
|
import { OrganizeService } from './organize.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
@@ -400,3 +404,130 @@ describe('FamilyRecomputeService', () => {
|
|||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* derivePriceMatrix 纯函数单测:选项组排序(词表序 + 未知值沉底)。
|
||||||
|
* 业务确认的展示顺序:printCount 单面→双面;craft 烫画→直喷→不打印;logistics 不包邮→包邮。
|
||||||
|
* 词表外自由文本(CUSTOM craftLabel/logisticsLabel,如"海运")沉底,相互间保持首次遇到序。
|
||||||
|
*/
|
||||||
|
describe('derivePriceMatrix 选项组排序', () => {
|
||||||
|
const mkMember = (
|
||||||
|
id: number,
|
||||||
|
over: { tags?: string[]; source?: 'SDS' | 'CUSTOM'; craftLabel?: string | null; logisticsLabel?: string | null },
|
||||||
|
) =>
|
||||||
|
({
|
||||||
|
id: BigInt(id),
|
||||||
|
source: over.source ?? 'SDS',
|
||||||
|
originGoodTags: (over.tags ?? []).map((t) => ({ tag: { tagName: t } })),
|
||||||
|
variants: [],
|
||||||
|
craftLabel: over.craftLabel ?? null,
|
||||||
|
logisticsLabel: over.logisticsLabel ?? null,
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
it('成员顺序无关:三个维度恒为词表序', () => {
|
||||||
|
// 故意让"遇到序"与目标词表序全部相反(双面在前/直喷在前/不包邮在前)
|
||||||
|
const members = [
|
||||||
|
mkMember(1, { tags: ['直喷', '双面印花', '不包邮'] }),
|
||||||
|
mkMember(2, { tags: ['烫画', '单面印花', '包邮'] }),
|
||||||
|
];
|
||||||
|
const m = derivePriceMatrix(members, []);
|
||||||
|
expect(m.printCounts).toEqual(['单面印花', '双面印花']);
|
||||||
|
expect(m.crafts).toEqual(['烫画', '直喷']);
|
||||||
|
expect(m.logistics).toEqual(['不包邮', '包邮']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('子集保序:只出现部分取值时相对顺序不变', () => {
|
||||||
|
const m = derivePriceMatrix([mkMember(1, { tags: ['直喷', '双面印花', '不包邮'] })], []);
|
||||||
|
expect(m.printCounts).toEqual(['双面印花']);
|
||||||
|
expect(m.crafts).toEqual(['直喷']);
|
||||||
|
expect(m.logistics).toEqual(['不包邮']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('未知自由文本沉底且相互保持首次遇到序', () => {
|
||||||
|
const members = [
|
||||||
|
// CUSTOM:自由文本标签 + tags 提供印花数量(无印花维度不进矩阵的既有语义)
|
||||||
|
mkMember(1, { source: 'CUSTOM', tags: ['双面印花'], craftLabel: '丝印', logisticsLabel: '海运' }),
|
||||||
|
mkMember(2, { source: 'CUSTOM', tags: ['双面印花'], craftLabel: '水洗', logisticsLabel: '空运' }),
|
||||||
|
mkMember(3, { tags: ['烫画', '单面印花', '包邮'] }),
|
||||||
|
];
|
||||||
|
const m = derivePriceMatrix(members, []);
|
||||||
|
expect(m.printCounts).toEqual(['单面印花', '双面印花']);
|
||||||
|
expect(m.crafts).toEqual(['烫画', '丝印', '水洗']);
|
||||||
|
expect(m.logistics).toEqual(['包邮', '海运', '空运']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('人工覆盖新增取值参与同一排序', () => {
|
||||||
|
const members = [mkMember(1, { tags: ['烫画', '单面印花', '包邮'] })];
|
||||||
|
const overrides = [
|
||||||
|
{
|
||||||
|
sizeId: 'size_M',
|
||||||
|
colorId: 'color_red',
|
||||||
|
printCount: '四面印花',
|
||||||
|
craft: '丝印',
|
||||||
|
logistics: '海运',
|
||||||
|
price: '40',
|
||||||
|
},
|
||||||
|
] as any;
|
||||||
|
const m = derivePriceMatrix(members, overrides);
|
||||||
|
expect(m.printCounts).toEqual(['单面印花', '四面印花']);
|
||||||
|
expect(m.crafts).toEqual(['烫画', '丝印']);
|
||||||
|
expect(m.logistics).toEqual(['包邮', '海运']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('幂等:同输入多跑两遍输出全等(含顺序),与成员遍历顺序无关', () => {
|
||||||
|
const members = [
|
||||||
|
mkMember(1, { tags: ['直喷', '双面印花', '不包邮'] }),
|
||||||
|
mkMember(2, { source: 'CUSTOM', tags: ['双面印花'], craftLabel: '丝印', logisticsLabel: '海运' }),
|
||||||
|
mkMember(3, { tags: ['烫画', '单面印花', '包邮'] }),
|
||||||
|
];
|
||||||
|
const first = derivePriceMatrix(members, []);
|
||||||
|
const second = derivePriceMatrix(members, []);
|
||||||
|
const reversed = derivePriceMatrix([...members].reverse(), []);
|
||||||
|
expect(JSON.stringify(second)).toBe(JSON.stringify(first));
|
||||||
|
expect(JSON.stringify(reversed)).toBe(JSON.stringify(first));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 印刷工艺新增"热转印":矩阵维度词表放开准入 + 展示序追加末尾。
|
||||||
|
* 自动派生规则不改 —— SDS 链接只能人工打标;不打标的链接/族零影响。
|
||||||
|
*/
|
||||||
|
describe('热转印工艺', () => {
|
||||||
|
const mkMember = (
|
||||||
|
id: number,
|
||||||
|
over: { tags?: string[]; source?: 'SDS' | 'CUSTOM'; craftLabel?: string | null; logisticsLabel?: string | null },
|
||||||
|
) =>
|
||||||
|
({
|
||||||
|
id: BigInt(id),
|
||||||
|
source: over.source ?? 'SDS',
|
||||||
|
originGoodTags: (over.tags ?? []).map((t) => ({ tag: { tagName: t } })),
|
||||||
|
variants: [],
|
||||||
|
craftLabel: over.craftLabel ?? null,
|
||||||
|
logisticsLabel: over.logisticsLabel ?? null,
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
it('memberMatrixCombos:打标热转印的链接产出矩阵组合(此前被词表过滤为空)', () => {
|
||||||
|
const combos = memberMatrixCombos({ tagNames: ['单面印花', '热转印', '包邮'] });
|
||||||
|
expect(combos).toEqual([{ printCount: '单面印花', craft: '热转印', logistics: '包邮' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derivePriceMatrix:crafts 展示序为 烫画→直喷→不打印→热转印(词表内,不再沉底)', () => {
|
||||||
|
const members = [
|
||||||
|
mkMember(1, { tags: ['热转印', '单面印花', '包邮'] }),
|
||||||
|
mkMember(2, { tags: ['烫画', '单面印花', '包邮'] }),
|
||||||
|
mkMember(3, { tags: ['直喷', '单面印花', '不包邮'] }),
|
||||||
|
mkMember(4, { tags: ['不打印', '单面印花', '包邮'] }),
|
||||||
|
];
|
||||||
|
const m = derivePriceMatrix(members, []);
|
||||||
|
expect(m.crafts).toEqual(['烫画', '直喷', '不打印', '热转印']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('词表外自由文本(丝印/水洗)仍然沉底,与热转印区分', () => {
|
||||||
|
const members = [
|
||||||
|
mkMember(1, { tags: ['热转印', '单面印花', '包邮'] }),
|
||||||
|
mkMember(2, { source: 'CUSTOM', tags: ['单面印花'], craftLabel: '丝印', logisticsLabel: '包邮' }),
|
||||||
|
];
|
||||||
|
const m = derivePriceMatrix(members, []);
|
||||||
|
expect(m.crafts).toEqual(['热转印', '丝印']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -66,11 +66,31 @@ type OverrideRow = Prisma.FamilyPriceOverrideGetPayload<{}>;
|
|||||||
/** 矩阵三个归因维度的合法取值(封闭集合,与派生标签组一致) */
|
/** 矩阵三个归因维度的合法取值(封闭集合,与派生标签组一致) */
|
||||||
const DIM_VALUES = {
|
const DIM_VALUES = {
|
||||||
printCount: ['单面印花', '双面印花'],
|
printCount: ['单面印花', '双面印花'],
|
||||||
craft: ['烫画', '直喷', '不打印'],
|
craft: ['烫画', '直喷', '不打印', '热转印'],
|
||||||
logistics: ['包邮', '不包邮'],
|
logistics: ['包邮', '不包邮'],
|
||||||
} as const satisfies Record<string, readonly string[]>;
|
} as const satisfies Record<string, readonly string[]>;
|
||||||
|
|
||||||
type DimKey = keyof typeof DIM_VALUES;
|
type DimKey = keyof typeof DIM_VALUES;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 选项组展示顺序(业务确认):printCount 单面→双面;craft 烫画→直喷→不打印→热转印;logistics 不包邮→包邮。
|
||||||
|
* 与 DIM_VALUES 的"组合生成序"是两回事,物化前对聚合数组按此稳定排序;
|
||||||
|
* 词表外自由文本(CUSTOM 标签,如"海运")沉底,相互间保持首次遇到序。
|
||||||
|
*/
|
||||||
|
const OPTION_DISPLAY_ORDER = {
|
||||||
|
printCount: ['单面印花', '双面印花'],
|
||||||
|
craft: ['烫画', '直喷', '不打印', '热转印'],
|
||||||
|
logistics: ['不包邮', '包邮'],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function optionRank(value: string, canon: readonly string[]): number {
|
||||||
|
const i = canon.indexOf(value);
|
||||||
|
return i === -1 ? canon.length : i;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortOptions(values: string[], canon: readonly string[]): string[] {
|
||||||
|
return [...values].sort((a, b) => optionRank(a, canon) - optionRank(b, canon));
|
||||||
|
}
|
||||||
export interface MatrixCombo {
|
export interface MatrixCombo {
|
||||||
printCount: string;
|
printCount: string;
|
||||||
craft: string;
|
craft: string;
|
||||||
@@ -270,9 +290,9 @@ export function derivePriceMatrix(members: Member[], overrides: OverrideRow[]):
|
|||||||
return {
|
return {
|
||||||
sizes: [...sizes.entries()].map(([key, name]) => ({ key, name })),
|
sizes: [...sizes.entries()].map(([key, name]) => ({ key, name })),
|
||||||
colors: [...colors.entries()].map(([key, v]) => ({ key, ...v })),
|
colors: [...colors.entries()].map(([key, v]) => ({ key, ...v })),
|
||||||
printCounts,
|
printCounts: sortOptions(printCounts, OPTION_DISPLAY_ORDER.printCount),
|
||||||
crafts,
|
crafts: sortOptions(crafts, OPTION_DISPLAY_ORDER.craft),
|
||||||
logistics: logisticsOptions,
|
logistics: sortOptions(logisticsOptions, OPTION_DISPLAY_ORDER.logistics),
|
||||||
rows,
|
rows,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -330,6 +350,8 @@ export class FamilyRecomputeService {
|
|||||||
include: {
|
include: {
|
||||||
originGoods: {
|
originGoods: {
|
||||||
where: { delisted: false },
|
where: { delisted: false },
|
||||||
|
// 固定遍历序:保证"首次遇到序"(未知自由文本取值的相对顺序)确定性,重算幂等
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
include: {
|
include: {
|
||||||
detail: true,
|
detail: true,
|
||||||
variants: { where: { enabled: true }, orderBy: { sortOrder: 'asc' } },
|
variants: { where: { enabled: true }, orderBy: { sortOrder: 'asc' } },
|
||||||
|
|||||||
@@ -650,4 +650,225 @@ describe('PublicService', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getGoods tree-order sorting (国家→二级→款→priority)', () => {
|
||||||
|
// 结构: 国家A(sort=1)>MidA>LeafA1(sort=1, 2条goods)、LeafA2(sort=2);国家B(sort=2)>MidB>LeafB1
|
||||||
|
// 期望默认顺序: A款1(priority desc) -> A款2 -> B款1;B 的 priority=99 也不能越级
|
||||||
|
const stamp2 = `${stamp}-treeorder`;
|
||||||
|
const sdsA1 = `la1-${stamp2}`;
|
||||||
|
const sdsA2 = `la2-${stamp2}`;
|
||||||
|
const sdsB1 = `lb1-${stamp2}`;
|
||||||
|
const trash = {
|
||||||
|
goodIds: [] as bigint[],
|
||||||
|
familyIds: [] as bigint[],
|
||||||
|
originGoodIds: [] as bigint[],
|
||||||
|
categoryIds: [] as bigint[],
|
||||||
|
countryIds: [] as bigint[],
|
||||||
|
};
|
||||||
|
let orderedFamilyIds: string[] = [];
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const cA = await prisma.country.create({
|
||||||
|
data: { countryName: `TreeOrder A ${stamp2}`, sortOrder: 1 },
|
||||||
|
});
|
||||||
|
const cB = await prisma.country.create({
|
||||||
|
data: { countryName: `TreeOrder B ${stamp2}`, sortOrder: 2 },
|
||||||
|
});
|
||||||
|
trash.countryIds = [cA.id, cB.id];
|
||||||
|
const midA = await prisma.category.create({
|
||||||
|
data: { categoryName: `TreeOrder MidA ${stamp2}`, sdsCategoryId: `ma-${stamp2}`, sortOrder: 1 },
|
||||||
|
});
|
||||||
|
const leafA1 = await prisma.category.create({
|
||||||
|
data: { categoryName: `TreeOrder LeafA1 ${stamp2}`, parentCategoryId: midA.id, sdsCategoryId: sdsA1, sortOrder: 1 },
|
||||||
|
});
|
||||||
|
const leafA2 = await prisma.category.create({
|
||||||
|
data: { categoryName: `TreeOrder LeafA2 ${stamp2}`, parentCategoryId: midA.id, sdsCategoryId: sdsA2, sortOrder: 2 },
|
||||||
|
});
|
||||||
|
const midB = await prisma.category.create({
|
||||||
|
data: { categoryName: `TreeOrder MidB ${stamp2}`, sdsCategoryId: `mb-${stamp2}`, sortOrder: 2 },
|
||||||
|
});
|
||||||
|
const leafB1 = await prisma.category.create({
|
||||||
|
data: { categoryName: `TreeOrder LeafB1 ${stamp2}`, parentCategoryId: midB.id, sdsCategoryId: sdsB1, sortOrder: 1 },
|
||||||
|
});
|
||||||
|
trash.categoryIds = [leafA1.id, leafA2.id, leafB1.id, midA.id, midB.id];
|
||||||
|
|
||||||
|
const mk = async (
|
||||||
|
countryId: bigint,
|
||||||
|
sdsCategoryId: string,
|
||||||
|
name: string,
|
||||||
|
priority: number,
|
||||||
|
) => {
|
||||||
|
const og = await prisma.originGood.create({
|
||||||
|
data: { sdsGoodId: `to-${name}-${stamp2}`, goodName: name, sdsCategoryId },
|
||||||
|
});
|
||||||
|
trash.originGoodIds.push(og.id);
|
||||||
|
const fam = await prisma.productFamily.create({
|
||||||
|
data: { familyName: `to-fam-${name}-${stamp2}`, primaryOriginGoodId: og.id },
|
||||||
|
});
|
||||||
|
trash.familyIds.push(fam.id);
|
||||||
|
await prisma.originGood.update({ where: { id: og.id }, data: { familyId: fam.id } });
|
||||||
|
const good = await prisma.good.create({
|
||||||
|
data: {
|
||||||
|
goodName: `TO${stamp2}-${name}`,
|
||||||
|
originGoodId: og.id,
|
||||||
|
familyId: fam.id,
|
||||||
|
countryId,
|
||||||
|
categoryId: sdsCategoryId === sdsA1 ? leafA1.id : sdsCategoryId === sdsA2 ? leafA2.id : leafB1.id,
|
||||||
|
goodPriority: priority,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
trash.goodIds.push(good.id);
|
||||||
|
return fam.id.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const a1Low = await mk(cA.id, sdsA1, 'A1Low', 1);
|
||||||
|
const a1High = await mk(cA.id, sdsA1, 'A1High', 9);
|
||||||
|
const a2 = await mk(cA.id, sdsA2, 'A2', 0);
|
||||||
|
const b1 = await mk(cB.id, sdsB1, 'B1', 99);
|
||||||
|
orderedFamilyIds = [a1High, a1Low, a2, b1];
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.good.deleteMany({ where: { id: { in: trash.goodIds } } }).catch(() => undefined);
|
||||||
|
await prisma.productFamily.deleteMany({ where: { id: { in: trash.familyIds } } }).catch(() => undefined);
|
||||||
|
await prisma.originGood.deleteMany({ where: { id: { in: trash.originGoodIds } } }).catch(() => undefined);
|
||||||
|
for (const id of trash.categoryIds) {
|
||||||
|
await prisma.category.delete({ where: { id } }).catch(() => undefined);
|
||||||
|
}
|
||||||
|
await prisma.country.deleteMany({ where: { id: { in: trash.countryIds } } }).catch(() => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DEFAULT: country > mid > leaf > priority (cross-country priority cannot jump the queue)', async () => {
|
||||||
|
const res = await service.getGoods({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 100,
|
||||||
|
keyword: `TO${stamp2}`, // 唯一前缀圈定本夹具 4 条,避免全库分页截断
|
||||||
|
});
|
||||||
|
expect(res.total).toBe(4);
|
||||||
|
const idx = res.items.map((i) => i.goodId);
|
||||||
|
const pos = orderedFamilyIds.map((id) => idx.indexOf(id));
|
||||||
|
expect(pos.every((p) => p >= 0)).toBe(true); // 全部命中
|
||||||
|
expect(pos).toEqual([...pos].sort((a, b) => a - b)); // 相对有序
|
||||||
|
// 同款内 priority desc
|
||||||
|
expect(idx.indexOf(orderedFamilyIds[0])).toBeLessThan(idx.indexOf(orderedFamilyIds[1]));
|
||||||
|
// 款顺序:LeafA1 -> LeafA2
|
||||||
|
expect(idx.indexOf(orderedFamilyIds[1])).toBeLessThan(idx.indexOf(orderedFamilyIds[2]));
|
||||||
|
// 国家/款顺序优先于 priority:B1(99) 不能排到 A2(0) 前面
|
||||||
|
expect(idx.indexOf(orderedFamilyIds[2])).toBeLessThan(idx.indexOf(orderedFamilyIds[3]));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('family representative row consistency (列表/首页代表行对齐详情)', () => {
|
||||||
|
// 同族两条 Good:同 priority=10,Low 的 id 更小/createdAt 更早/价格更低/位置更好,
|
||||||
|
// High 的 createdAt 更新。详情代表行规则 = priority desc → createdAt desc → id asc
|
||||||
|
// → 详情永远取 High;列表/首页必须与详情一致,而不是随排序参数漂移到 Low。
|
||||||
|
const stamp3 = `${stamp}-rep`;
|
||||||
|
let repFamilyId: bigint;
|
||||||
|
let trash = {
|
||||||
|
goodIds: [] as bigint[],
|
||||||
|
positionIds: [] as bigint[],
|
||||||
|
originGoodIds: [] as bigint[],
|
||||||
|
};
|
||||||
|
const repLowName = `Rep Low ${stamp3}`;
|
||||||
|
const repHighName = `Rep High ${stamp3}`;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const posLow = await prisma.position.create({
|
||||||
|
data: { indexVal: 1, countryId, categoryId },
|
||||||
|
});
|
||||||
|
const posHigh = await prisma.position.create({
|
||||||
|
data: { indexVal: 5, countryId, categoryId },
|
||||||
|
});
|
||||||
|
trash.positionIds = [posLow.id, posHigh.id];
|
||||||
|
|
||||||
|
const ogLow = await prisma.originGood.create({
|
||||||
|
data: { sdsGoodId: `rep-low-${stamp3}`, goodName: repLowName, goodPrice: 10 },
|
||||||
|
});
|
||||||
|
const ogHigh = await prisma.originGood.create({
|
||||||
|
data: { sdsGoodId: `rep-high-${stamp3}`, goodName: repHighName, goodPrice: 20 },
|
||||||
|
});
|
||||||
|
trash.originGoodIds = [ogLow.id, ogHigh.id];
|
||||||
|
|
||||||
|
const family = await prisma.productFamily.create({
|
||||||
|
data: { familyName: `rep-fam-${stamp3}`, primaryOriginGoodId: ogLow.id },
|
||||||
|
});
|
||||||
|
repFamilyId = family.id;
|
||||||
|
await prisma.originGood.updateMany({
|
||||||
|
where: { id: { in: [ogLow.id, ogHigh.id] } },
|
||||||
|
data: { familyId: family.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const gLow = await prisma.good.create({
|
||||||
|
data: {
|
||||||
|
goodName: repLowName,
|
||||||
|
originGoodId: ogLow.id,
|
||||||
|
familyId: family.id,
|
||||||
|
countryId,
|
||||||
|
categoryId,
|
||||||
|
goodPriority: 10,
|
||||||
|
positionId: posLow.id,
|
||||||
|
createdAt: new Date(stamp),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const gHigh = await prisma.good.create({
|
||||||
|
data: {
|
||||||
|
goodName: repHighName,
|
||||||
|
originGoodId: ogHigh.id,
|
||||||
|
familyId: family.id,
|
||||||
|
countryId,
|
||||||
|
categoryId,
|
||||||
|
goodPriority: 10,
|
||||||
|
positionId: posHigh.id,
|
||||||
|
createdAt: new Date(stamp + 60_000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
trash.goodIds = [gLow.id, gHigh.id];
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.good.deleteMany({ where: { id: { in: trash.goodIds } } }).catch(() => undefined);
|
||||||
|
await prisma.position
|
||||||
|
.deleteMany({ where: { id: { in: trash.positionIds } } })
|
||||||
|
.catch(() => undefined);
|
||||||
|
await prisma.productFamily.delete({ where: { id: repFamilyId } }).catch(() => undefined);
|
||||||
|
await prisma.originGood
|
||||||
|
.deleteMany({ where: { id: { in: trash.originGoodIds } } })
|
||||||
|
.catch(() => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DEFAULT 列表代表行与详情一致(priority 并列时取 createdAt 最新,而非 id 最小)', async () => {
|
||||||
|
const detail = await service.getGood(repFamilyId.toString());
|
||||||
|
expect(detail.goodName).toBe(repHighName);
|
||||||
|
|
||||||
|
const list = await service.getGoods({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 50,
|
||||||
|
keyword: 'Rep ', // 本文件夹具唯一前缀,圈定本族(goodName: Rep Low/High)
|
||||||
|
});
|
||||||
|
const ours = list.items.filter((i) => i.goodId === repFamilyId.toString());
|
||||||
|
expect(ours).toHaveLength(1);
|
||||||
|
expect(ours[0].goodName).toBe(detail.goodName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PRICE_ASC 列表代表行不漂移到价格更低的成员', async () => {
|
||||||
|
const detail = await service.getGood(repFamilyId.toString());
|
||||||
|
const list = await service.getGoods({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 50,
|
||||||
|
keyword: 'Rep ',
|
||||||
|
sort: 'PRICE_ASC',
|
||||||
|
});
|
||||||
|
const ours = list.items.filter((i) => i.goodId === repFamilyId.toString());
|
||||||
|
expect(ours).toHaveLength(1);
|
||||||
|
expect(ours[0].goodName).toBe(detail.goodName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('home-goods 代表行与详情一致(不取位置更好的成员)', async () => {
|
||||||
|
const detail = await service.getGood(repFamilyId.toString());
|
||||||
|
const home = await service.getHomeGoods({ limit: 50, countryId: countryId.toString() });
|
||||||
|
const ours = home.filter((h) => h.goodId === repFamilyId.toString());
|
||||||
|
expect(ours).toHaveLength(1);
|
||||||
|
expect(ours[0].goodName).toBe(detail.goodName);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -71,13 +71,19 @@ const PUBLIC_GOOD_LIST_INCLUDE = {
|
|||||||
tag: { include: { tagGroup: true } },
|
tag: { include: { tagGroup: true } },
|
||||||
position: true,
|
position: true,
|
||||||
originGood: {
|
originGood: {
|
||||||
select: { sdsGoodId: true, goodImage: true, goodPrice: true },
|
select: { sdsGoodId: true, goodImage: true, goodPrice: true, sdsCategoryId: true },
|
||||||
},
|
},
|
||||||
goodTags: { include: { tag: { include: { tagGroup: true } } } },
|
goodTags: { include: { tag: { include: { tagGroup: true } } } },
|
||||||
} satisfies Prisma.GoodInclude;
|
} satisfies Prisma.GoodInclude;
|
||||||
|
|
||||||
type PublicGoodListRow = Prisma.GoodGetPayload<{ include: typeof PUBLIC_GOOD_LIST_INCLUDE }>;
|
type PublicGoodListRow = Prisma.GoodGetPayload<{ include: typeof PUBLIC_GOOD_LIST_INCLUDE }>;
|
||||||
|
|
||||||
|
interface TreeOrderMeta {
|
||||||
|
countryOrder: Map<string, number>;
|
||||||
|
/** key: origin_goods.sds_category_id → 款所属二级(c2)/款(c3) 的顺序值 */
|
||||||
|
leafOrder: Map<string, { c2: number; c3: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PublicService {
|
export class PublicService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
@@ -220,12 +226,7 @@ export class PublicService {
|
|||||||
? [{ originGood: { goodPrice: 'desc' } }, { id: 'asc' }]
|
? [{ originGood: { goodPrice: 'desc' } }, { id: 'asc' }]
|
||||||
: query.sort === 'NEWEST'
|
: query.sort === 'NEWEST'
|
||||||
? [{ createdAt: 'desc' }, { id: 'asc' }]
|
? [{ createdAt: 'desc' }, { id: 'asc' }]
|
||||||
: [
|
: [{ id: 'asc' }]; // DEFAULT:排序移到内存做(树序,见下)
|
||||||
{ goodPriority: 'desc' },
|
|
||||||
{ position: { indexVal: 'asc' } },
|
|
||||||
{ createdAt: 'desc' },
|
|
||||||
{ id: 'asc' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// 契约族化:一族对外只暴露一条(代表行=排序第一条,goodId=族ID);
|
// 契约族化:一族对外只暴露一条(代表行=排序第一条,goodId=族ID);
|
||||||
// 无族 Good(自定义商品)各自成一条。商品量级为百级,先取全量匹配
|
// 无族 Good(自定义商品)各自成一条。商品量级为百级,先取全量匹配
|
||||||
@@ -235,6 +236,15 @@ export class PublicService {
|
|||||||
include: PUBLIC_GOOD_LIST_INCLUDE,
|
include: PUBLIC_GOOD_LIST_INCLUDE,
|
||||||
orderBy,
|
orderBy,
|
||||||
});
|
});
|
||||||
|
if (!query.sort || query.sort === 'DEFAULT') {
|
||||||
|
// 默认排序 = 款序树:国家 → 款所属二级 → 款 → 优先级。
|
||||||
|
// 款顺序存于新树 categories.sort_order(回填自排序表),商品经
|
||||||
|
// origin_goods.sds_category_id 定位到款;同一款下多条 Good 共享
|
||||||
|
// 前三层键,仅按 goodPriority 分先后。缺键(如款不在树中)沉到
|
||||||
|
// 所属国家分组末尾。
|
||||||
|
const meta = await this.loadTreeOrderMeta();
|
||||||
|
rows.sort((a, b) => this.compareByTreeOrder(meta, a, b));
|
||||||
|
}
|
||||||
const familyMinPrices = await this.loadFamilyMinPrices();
|
const familyMinPrices = await this.loadFamilyMinPrices();
|
||||||
const grouped = new Map<string, PublicGoodListRow[]>();
|
const grouped = new Map<string, PublicGoodListRow[]>();
|
||||||
for (const good of rows) {
|
for (const good of rows) {
|
||||||
@@ -244,7 +254,7 @@ export class PublicService {
|
|||||||
else grouped.set(key, [good]);
|
else grouped.set(key, [good]);
|
||||||
}
|
}
|
||||||
let items = [...grouped.values()].map((goods) => {
|
let items = [...grouped.values()].map((goods) => {
|
||||||
const rep = goods[0];
|
const rep = this.pickFamilyRepresentative(goods);
|
||||||
const dto = this.toPublicGood(rep);
|
const dto = this.toPublicGood(rep);
|
||||||
// 列表价 = 族矩阵最低价("这个款之下有哪些价格"的起价);无矩阵回退链接价
|
// 列表价 = 族矩阵最低价("这个款之下有哪些价格"的起价);无矩阵回退链接价
|
||||||
const familyMin = rep.familyId
|
const familyMin = rep.familyId
|
||||||
@@ -270,6 +280,52 @@ export class PublicService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 款序元数据:countries.sort_order(一级)+ 新树二/三级 categories.sort_order
|
||||||
|
* (款顺序,回填自排序表)。key 用 origin_goods.sds_category_id 关联商品→款。
|
||||||
|
*/
|
||||||
|
private async loadTreeOrderMeta(): Promise<TreeOrderMeta> {
|
||||||
|
const [countries, leaves] = await Promise.all([
|
||||||
|
this.prisma.country.findMany({ select: { id: true, sortOrder: true } }),
|
||||||
|
this.prisma.$queryRaw<
|
||||||
|
Array<{ sds_category_id: string; c2: number; c3: number }>
|
||||||
|
>`
|
||||||
|
SELECT leaf.sds_category_id,
|
||||||
|
COALESCE(mid.sort_order, 2147483647) AS c2,
|
||||||
|
COALESCE(leaf.sort_order, 2147483647) AS c3
|
||||||
|
FROM categories leaf
|
||||||
|
JOIN categories mid ON mid.category_id = leaf.parent_category_id
|
||||||
|
WHERE leaf.sds_category_id IS NOT NULL AND leaf.sds_category_id <> ''
|
||||||
|
`,
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
countryOrder: new Map(countries.map((c) => [c.id.toString(), c.sortOrder])),
|
||||||
|
leafOrder: new Map(
|
||||||
|
leaves.map((l) => [l.sds_category_id, { c2: Number(l.c2), c3: Number(l.c3) }]),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private compareByTreeOrder(meta: TreeOrderMeta, a: PublicGoodListRow, b: PublicGoodListRow): number {
|
||||||
|
const MAX = Number.MAX_SAFE_INTEGER;
|
||||||
|
const key = (g: PublicGoodListRow): [number, number, number, number, number] => {
|
||||||
|
const leaf = meta.leafOrder.get(g.originGood.sdsCategoryId ?? '');
|
||||||
|
return [
|
||||||
|
meta.countryOrder.get(g.countryId.toString()) ?? MAX,
|
||||||
|
leaf?.c2 ?? MAX,
|
||||||
|
leaf?.c3 ?? MAX,
|
||||||
|
-(g.goodPriority ?? 0),
|
||||||
|
Number(g.id),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
const ka = key(a);
|
||||||
|
const kb = key(b);
|
||||||
|
for (let i = 0; i < ka.length; i++) {
|
||||||
|
if (ka[i] !== kb[i]) return ka[i] - kb[i];
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 族最低价一次 SQL 聚合:price_matrix 是每族 ~11KB 的 JSONB,按行 include
|
* 族最低价一次 SQL 聚合:price_matrix 是每族 ~11KB 的 JSONB,按行 include
|
||||||
* 会让每个商品都携带整份矩阵(实测全量 ~330ms);PG 端展开聚合只回传
|
* 会让每个商品都携带整份矩阵(实测全量 ~330ms);PG 端展开聚合只回传
|
||||||
@@ -365,16 +421,20 @@ export class PublicService {
|
|||||||
take: query.limit,
|
take: query.limit,
|
||||||
});
|
});
|
||||||
const familyMinPrices = await this.loadFamilyMinPrices();
|
const familyMinPrices = await this.loadFamilyMinPrices();
|
||||||
// 首页同样按族去重(同族多条位置配置只保留排序最前一条),再截取 limit
|
// 首页同样按族去重(一族只出一条),代表行选取与详情/列表一致
|
||||||
const seen = new Set<string>();
|
const grouped = new Map<string, PublicGoodListRow[]>();
|
||||||
const items: PublicGoodDto[] = [];
|
|
||||||
for (const good of rows) {
|
for (const good of rows) {
|
||||||
const key = good.familyId ? `f:${good.familyId}` : `g:${good.id}`;
|
const key = good.familyId ? `f:${good.familyId}` : `g:${good.id}`;
|
||||||
if (seen.has(key)) continue;
|
const bucket = grouped.get(key);
|
||||||
seen.add(key);
|
if (bucket) bucket.push(good);
|
||||||
const dto = this.toPublicGood(good);
|
else grouped.set(key, [good]);
|
||||||
const familyMin = good.familyId
|
}
|
||||||
? familyMinPrices.get(good.familyId.toString())
|
const items: PublicGoodDto[] = [];
|
||||||
|
for (const goods of grouped.values()) {
|
||||||
|
const rep = this.pickFamilyRepresentative(goods);
|
||||||
|
const dto = this.toPublicGood(rep);
|
||||||
|
const familyMin = rep.familyId
|
||||||
|
? familyMinPrices.get(rep.familyId.toString())
|
||||||
: undefined;
|
: undefined;
|
||||||
if (familyMin !== undefined) dto.price = familyMin;
|
if (familyMin !== undefined) dto.price = familyMin;
|
||||||
items.push(dto);
|
items.push(dto);
|
||||||
@@ -382,6 +442,22 @@ export class PublicService {
|
|||||||
return items.slice(0, query.limit);
|
return items.slice(0, query.limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 族代表行选取:与详情 getGoodByFamilyId 的 orderBy 保持一致
|
||||||
|
* (goodPriority desc → createdAt desc → id asc),
|
||||||
|
* 保证列表/首页与详情返回的 goodName、主图、分类等代表行字段一致。
|
||||||
|
*/
|
||||||
|
private pickFamilyRepresentative<T extends { goodPriority: number | null; createdAt: Date; id: bigint }>(
|
||||||
|
goods: T[],
|
||||||
|
): T {
|
||||||
|
return [...goods].sort(
|
||||||
|
(a, b) =>
|
||||||
|
(b.goodPriority ?? 0) - (a.goodPriority ?? 0) ||
|
||||||
|
b.createdAt.getTime() - a.createdAt.getTime() ||
|
||||||
|
(a.id < b.id ? -1 : a.id > b.id ? 1 : 0),
|
||||||
|
)[0];
|
||||||
|
}
|
||||||
|
|
||||||
/** 入参用精简行类型:列表(PublicGoodListRow)与详情(PublicGoodRow,字段超集)都能传 */
|
/** 入参用精简行类型:列表(PublicGoodListRow)与详情(PublicGoodRow,字段超集)都能传 */
|
||||||
private toPublicGood(good: PublicGoodListRow): PublicGoodDto {
|
private toPublicGood(good: PublicGoodListRow): PublicGoodDto {
|
||||||
const formatGroup = (group: { id: bigint; groupName: string; sortOrder: number } | null) =>
|
const formatGroup = (group: { id: bigint; groupName: string; sortOrder: number } | null) =>
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
<!DOCTYPE html><html lang=zh-CN><head><meta charset=utf-8><meta http-equiv=X-UA-Compatible content="IE=edge"><title>印美达POD供应链</title><script>var coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(a)') || CSS.supports('top: constant(a)'))
|
<!DOCTYPE html><html lang=zh-CN><head><meta charset=utf-8><meta http-equiv=X-UA-Compatible content="IE=edge"><title>印美达POD供应链</title><script>var coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(a)') || CSS.supports('top: constant(a)'))
|
||||||
document.write('<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' + (coverSupport ? ', viewport-fit=cover' : '') + '" />')</script><link rel=stylesheet href=/v2/h5/static/index.149e085d.css></head><body><noscript><strong>Please enable JavaScript to continue.</strong></noscript><div id=app></div><script src=/v2/h5/static/js/chunk-vendors.6234e216.js></script><script src=/v2/h5/static/js/index.e79d1a25.js></script></body></html>
|
document.write('<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' + (coverSupport ? ', viewport-fit=cover' : '') + '" />')</script><link rel=stylesheet href=/v2/h5/static/index.149e085d.css></head><body><noscript><strong>Please enable JavaScript to continue.</strong></noscript><div id=app></div><script src=/v2/h5/static/js/chunk-vendors.6234e216.js></script><script src=/v2/h5/static/js/index.f402cc83.js></script></body></html>
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -115,7 +115,7 @@ apps/api/
|
|||||||
| `/public/countries` `GET` | 公开国家列表(仅含已挂商品的国家) | 公开 |
|
| `/public/countries` `GET` | 公开国家列表(仅含已挂商品的国家) | 公开 |
|
||||||
| `/public/tags` `GET` | 公开标签列表(带 `group` 字段,按 group 排序) | 公开 |
|
| `/public/tags` `GET` | 公开标签列表(带 `group` 字段,按 group 排序) | 公开 |
|
||||||
| `/public/tag-groups` `GET` | 公开标签分组列表 | 公开 |
|
| `/public/tag-groups` `GET` | 公开标签分组列表 | 公开 |
|
||||||
| `/public/goods` `GET` | 分页商品(**族化契约:一族一条**,`goodId`=族ID,`price`=族起价;无族商品不返回;支持 `countryId/categoryId/tags(JSON)/keyword/minPrice/maxPrice/sort/page/pageSize`) | 公开 |
|
| `/public/goods` `GET` | 分页商品(**族化契约:一族一条**,`goodId`=族ID,`price`=族起价;无族商品不返回;支持 `countryId/categoryId/tags(JSON)/keyword/minPrice/maxPrice/sort/page/pageSize`)。**默认排序 = 款序树**:国家(`countries.sort_order`) → 款所属二级(`categories.sort_order`) → 款(`categories.sort_order`) → `good_priority`;商品经 `origin_goods.sds_category_id` 定位到款(新树三级节点=合并后的款,顺序值由 `排序表.md` 经回填脚本写入),同一款下多条 Good 聚在一起、款内按优先级分先后;`sort=PRICE_ASC/PRICE_DESC/NEWEST` 不受影响 | 公开 |
|
||||||
| `/public/goods/:id` `GET` | 款级详情,`:id` = **族 ID**(唯一公开键,SDS 链接 ID 404);公共字段取代表 Good,`variants` = 全体族成员 ∪ 旧副源(去重),`sizeChart/packageSpecs` = 族并集;默认输出 `family` 块(并集尺码表/包装 + 严格五维价格矩阵 尺码×颜色×印花数量×工艺×物流 + 族起价);`PUBLIC_DETAIL_FROM_FAMILY=false` 应急回退 | 公开 |
|
| `/public/goods/:id` `GET` | 款级详情,`:id` = **族 ID**(唯一公开键,SDS 链接 ID 404);公共字段取代表 Good,`variants` = 全体族成员 ∪ 旧副源(去重),`sizeChart/packageSpecs` = 族并集;默认输出 `family` 块(并集尺码表/包装 + 严格五维价格矩阵 尺码×颜色×印花数量×工艺×物流 + 族起价);`PUBLIC_DETAIL_FROM_FAMILY=false` 应急回退 | 公开 |
|
||||||
| `/categories` `/tags` `/tag-groups` `/countries` `/positions` | 后台 CRUD | JWT |
|
| `/categories` `/tags` `/tag-groups` `/countries` `/positions` | 后台 CRUD | JWT |
|
||||||
| `/countries/sort` `PATCH` | 批量保存国家拖拽排序(`items=[{id,sortOrder}]` 全量提交;公开/后台国家列表均按 sortOrder 排序) | JWT |
|
| `/countries/sort` `PATCH` | 批量保存国家拖拽排序(`items=[{id,sortOrder}]` 全量提交;公开/后台国家列表均按 sortOrder 排序) | JWT |
|
||||||
|
|||||||
@@ -0,0 +1,510 @@
|
|||||||
|
# 分类排序(category-tree-sort)实施计划
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** `GET /public/goods` 默认排序变为「国家 → 分类(款所属二级)→ 款 → good_priority」,把排序表中每个"款"下的 1~N 个已配置商品排到该款的位置上。**分类树保持老树完全不动**,接口输入输出 DTO 结构完全不变。
|
||||||
|
|
||||||
|
**Architecture:** 领域模型:老树(男士服装→T恤)是正式分类体系,小程序导航与 `categoryId` 过滤全部照旧;新树(国家根→男士T恤→DG001…)的二级/三级节点**只作为"款排序元数据层"**——"款"是合并后的具体衣服(一个款经 family 合并映射 1~N 个源商品/已配置商品)。`categories` 新增 `sort_order` 承载款顺序;`getGoods` 内存中按四层键排序(量级 295,分组/分页本就内存执行),商品经 `origin_goods.sds_category_id` 找到所属款。数据一次性回填脚本解析 `排序表.md`。
|
||||||
|
|
||||||
|
**Tech Stack:** NestJS + Prisma + PostgreSQL + Jest(真实库集成测试)
|
||||||
|
|
||||||
|
**已确认的决策:**
|
||||||
|
1. **分类树不动**:`/public/categories` 继续返回老树,老树分类是对的
|
||||||
|
2. **款 = 排序单元**:排序表的三级(DG001…)是款,不是分类;一个款在"已配置"里有 1~多个商品(同款不同工艺/物流拆分的,经 family 合并),它们排在一起、占该款的位置(如 DG004 的商品整体排在 DG001 后面)
|
||||||
|
3. **前端调用形态(真实 URL 已确认)**:`categoryId` 永远只传一个老树节点 id,可选伴随 `countryId`,DTO 不动:
|
||||||
|
- 只选分类:`?categoryId=659`(女士服装,跨国家)→ ①国家→②款顺序→③priority("美国的女士T恤各款排完到下个国家")
|
||||||
|
- 选到叶子:`?categoryId=666` → 同上
|
||||||
|
- 国家 + 分类:`?countryId=49&categoryId=658` → 该国内按 ②③
|
||||||
|
- 什么都不选 → 全局 ①②③
|
||||||
|
(全部由"绝对排序键 + 子集过滤"自然满足,无特殊分支)
|
||||||
|
4. 「中东」改为「沙特」写入 countries(需求方口径);新树根改名「沙特本地工厂直发」;防晒衣(good_id=137) 的款不在树中(sds_category_id=2393 无对应节点),排序在日本组内沉底,不修复
|
||||||
|
|
||||||
|
**硬性约束:**
|
||||||
|
- 所有 public 接口入参、出参 DTO 字段**一个都不能变**,只改内部逻辑
|
||||||
|
- 已有测试必须全部通过,不允许跳过
|
||||||
|
- 共享开发库(deploy-v2-postgres-1):禁止 reset;迁移用「手写 SQL + db execute + migrate resolve」,先跑 `prisma migrate status`
|
||||||
|
- 回滚保障(均已就绪,见文末「备份与回滚」):
|
||||||
|
- DB 全库快照:`backups/inkreach_snapshot_20260901_185233.dump`(pg_dump -Fc)
|
||||||
|
- 运行容器快照:镜像 `inkreach-api-snapshot:20260901`(docker commit 自 deploy-v2-api-1,含当前代码与依赖)
|
||||||
|
- 代码回滚 = 删 feature 分支
|
||||||
|
|
||||||
|
**执行环境(宿主机无 node;已验证的正确方式——挂仓库根 + 复用运行镜像):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ⚠️ 两个坑(实测踩过):
|
||||||
|
# 1) pnpm 的 node_modules 是相对符号链接指向根 .pnpm store,
|
||||||
|
# 只挂 apps/api:/app 会断链(Cannot find module)——必须挂整个仓库根
|
||||||
|
# 2) node:20-alpine 缺 libssl1.1,Prisma engine 加载失败——
|
||||||
|
# 直接用项目运行镜像 deploy-v2-api(libssl 已匹配)
|
||||||
|
docker run --rm --network deploy-v2_default \
|
||||||
|
-e DATABASE_URL='postgresql://inkreach:2628adbdf875727ae1b5556b08cb00452bb4e1f80490f6b6@postgres:5432/inkreach' \
|
||||||
|
-v /opt/inkreach-v2:/repo -w /repo/apps/api deploy-v2-api npx <prisma|jest|tsc ...>
|
||||||
|
# 只读 prisma 元数据(migrate status 等)也可轻量挂载:
|
||||||
|
# -v /opt/inkreach-v2/apps/api/prisma:/app/prisma deploy-v2-api npx prisma migrate status
|
||||||
|
```
|
||||||
|
|
||||||
|
**排序键定义(getGoods DEFAULT,商品经 origin_goods.sds_category_id 定位到款):**
|
||||||
|
|
||||||
|
```
|
||||||
|
① countries.sort_order(经 goods.country_id) 缺失 → MAX_SAFE_INTEGER
|
||||||
|
② 款所属二级(如"男士T恤")的 sort_order 缺失 → MAX_SAFE_INTEGER
|
||||||
|
③ 款自身的 sort_order 缺失 → MAX_SAFE_INTEGER
|
||||||
|
④ good_priority desc, id asc(现有字段,不变)
|
||||||
|
```
|
||||||
|
(缺失沉底发生在「所属国家分组内」:如防晒衣(137) 的 sds_category_id=2393 不在新树,
|
||||||
|
它会排到日本组末尾,不会破坏国家间顺序,可照常售卖/搜索。
|
||||||
|
同一款下 1~N 个商品(单面/双面印花等)共享 ②③,仅靠 ④ 分先后,自然聚在一起。)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Schema 迁移 — categories 加 sort_order
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `apps/api/prisma/schema.prisma`(Category model 约 L157-174)
|
||||||
|
- Create: `apps/api/prisma/migrations/20260901000000_add_category_sort_order/migration.sql`
|
||||||
|
|
||||||
|
- [ ] **Step 1.1: 先检查迁移状态(共享库防漂移)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm --network deploy-v2_default \
|
||||||
|
-e DATABASE_URL='postgresql://inkreach:2628adbdf875727ae1b5556b08cb00452bb4e1f80490f6b6@postgres:5432/inkreach' \
|
||||||
|
-v /opt/inkreach-v2/apps/api:/app -w /app node:20-alpine npx prisma migrate status
|
||||||
|
```
|
||||||
|
Expected: `Database schema is up to date!`。若报 drift/已存在类错误,按 AGENTS.md 经验用 `migrate resolve` 处理,禁止 reset。
|
||||||
|
|
||||||
|
- [ ] **Step 1.2: 修改 schema.prisma**
|
||||||
|
|
||||||
|
Category model 的 `sdsCategoryId` 行后新增:
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 1.3: 手写迁移 SQL(应用与登记分离,避免 shadow DB)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p apps/api/prisma/migrations/20260901000000_add_category_sort_order
|
||||||
|
```
|
||||||
|
|
||||||
|
创建 `apps/api/prisma/migrations/20260901000000_add_category_sort_order/migration.sql`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "categories" ADD COLUMN "sort_order" INTEGER NOT NULL DEFAULT 0;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 1.4: 应用 SQL、登记迁移、重新生成 client**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm --network deploy-v2_default \
|
||||||
|
-e DATABASE_URL='postgresql://inkreach:2628adbdf875727ae1b5556b08cb00452bb4e1f80490f6b6@postgres:5432/inkreach' \
|
||||||
|
-v /opt/inkreach-v2/apps/api:/app -w /app node:20-alpine \
|
||||||
|
npx prisma db execute --file prisma/migrations/20260901000000_add_category_sort_order/migration.sql
|
||||||
|
|
||||||
|
docker run --rm --network deploy-v2_default \
|
||||||
|
-e DATABASE_URL='postgresql://inkreach:2628adbdf875727ae1b5556b08cb00452bb4e1f80490f6b6@postgres:5432/inkreach' \
|
||||||
|
-v /opt/inkreach-v2/apps/api:/app -w /app node:20-alpine \
|
||||||
|
npx prisma migrate resolve --applied 20260901000000_add_category_sort_order
|
||||||
|
|
||||||
|
docker run --rm --network deploy-v2_default \
|
||||||
|
-e DATABASE_URL='postgresql://inkreach:2628adbdf875727ae1b5556b08cb00452bb4e1f80490f6b6@postgres:5432/inkreach' \
|
||||||
|
-v /opt/inkreach-v2/apps/api:/app -w /app node:20-alpine npx prisma generate
|
||||||
|
```
|
||||||
|
Expected: 三条命令均成功。
|
||||||
|
|
||||||
|
- [ ] **Step 1.5: 验证列存在**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec deploy-v2-postgres-1 psql -U inkreach -d inkreach -c "\d categories" | grep sort_order
|
||||||
|
```
|
||||||
|
Expected: `sort_order | integer | | not null | 0`
|
||||||
|
|
||||||
|
- [ ] **Step 1.6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/api/prisma/schema.prisma apps/api/prisma/migrations/20260901000000_add_category_sort_order
|
||||||
|
git commit -m "feat(api): add sort_order column to categories"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: 数据回填脚本 — 排序表 → sort_order + 沙特
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `apps/api/prisma/backfill-category-sort-order.ts`
|
||||||
|
|
||||||
|
**行为(幂等,可重复执行):**
|
||||||
|
1. 解析 `排序表.md`:`# ` = 一级国家(跳过「全部」「# 工厂直发国家/地区列表」)、`## ` = 二级(男士T恤等)、`### ` = 款
|
||||||
|
2. 国家名 → 新树根 category 映射(写死映射表);`中东` 条目:countries upsert「沙特」(sort_order=表内顺序 6)+ 根重命名「沙特本地工厂直发」
|
||||||
|
3. 每个根内:二级按出现顺序写 `sort_order=1..n`;款在所属二级范围内写 `sort_order=1..n`
|
||||||
|
4. 匹配规则:先按规范化名称(trim+压缩空白)精确匹配;失败再按「首个货号 token」前缀匹配(如 `GBTM011长袖T` 命中 `GBTM011长袖T`);**跨根禁止匹配**
|
||||||
|
5. 未匹配条目打印 `UNMATCHED:` 清单并退出码 1(人工对齐排序表/库名后重跑);`中国(国内工厂)` 跳过 countries 部分,sort_order 照常回填
|
||||||
|
6. countries.sort_order 按表内顺序 1..13 重写(美国 英国 日本 墨西哥 巴西 沙特 波兰 西班牙 德国 意大利 加拿大 澳大利亚 韩国)
|
||||||
|
|
||||||
|
- [ ] **Step 2.1: 编写脚本**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/**
|
||||||
|
* 一次性回填:解析 排序表.md → categories.sort_order(二级/款)+ countries.sort_order
|
||||||
|
* + 沙特国家行 + 中东根更名
|
||||||
|
* 幂等:可重复执行;SDS 同步若覆盖根名称,重跑本脚本即可恢复
|
||||||
|
*/
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
// 排序表国家名 → 新树根分类名
|
||||||
|
const ROOT_MAP: Record<string, string> = {
|
||||||
|
'美国': '美国工厂直发',
|
||||||
|
'英国': '英国本地直发',
|
||||||
|
'日本': '日本本地工厂直发',
|
||||||
|
'墨西哥': '墨西哥工厂本地直发',
|
||||||
|
'巴西': '巴西本地工厂直发',
|
||||||
|
'中东': '中东本地工厂直发', // 同时更名沙特
|
||||||
|
'波兰': '欧洲波兰工厂直发',
|
||||||
|
'西班牙': '欧洲西班牙工厂直发',
|
||||||
|
'德国': '欧洲德国工厂本地直发',
|
||||||
|
'意大利': '欧洲意大利工厂直发',
|
||||||
|
'加拿大': '加拿大本地工厂直发',
|
||||||
|
'澳大利亚': '澳大利亚本地工厂直发',
|
||||||
|
'韩国': '韩国本地直发',
|
||||||
|
'中国(国内工厂)': '国内工厂',
|
||||||
|
};
|
||||||
|
|
||||||
|
const norm = (s: string) => s.trim().replace(/\s+/g, '');
|
||||||
|
const codeOf = (s: string) => (s.trim().match(/^[A-Za-z0-9]+/) ?? [''])[0];
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const raw = readFileSync(process.env.SORT_TABLE_PATH ?? '/repo/排序表.md', 'utf8');
|
||||||
|
let country: string | null = null;
|
||||||
|
let l2: string | null = null;
|
||||||
|
const tree: Array<{ country: string; l2: string; l3: string }> = [];
|
||||||
|
const countryOrder: string[] = [];
|
||||||
|
for (const line of raw.split('\n')) {
|
||||||
|
const t = line.trim();
|
||||||
|
if (t.startsWith('# ')) {
|
||||||
|
const name = t.slice(2).trim();
|
||||||
|
if (name === '全部' || name.includes('工厂直发国家')) continue;
|
||||||
|
country = name;
|
||||||
|
if (!countryOrder.includes(name)) countryOrder.push(name);
|
||||||
|
} else if (t.startsWith('## ') && country) {
|
||||||
|
l2 = t.slice(3).trim();
|
||||||
|
} else if (t.startsWith('### ') && country && l2) {
|
||||||
|
tree.push({ country, l2, l3: t.slice(4).trim() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const roots = await prisma.category.findMany({
|
||||||
|
where: { parentCategoryId: null, sdsCategoryId: { not: null } },
|
||||||
|
include: { children: { include: { children: true } } },
|
||||||
|
});
|
||||||
|
const rootByName = new Map(roots.map((r) => [r.categoryName, r]));
|
||||||
|
const unmatched: string[] = [];
|
||||||
|
|
||||||
|
// 1) countries:沙特 upsert + 顺序重写
|
||||||
|
for (let i = 0; i < countryOrder.length; i++) {
|
||||||
|
const name = countryOrder[i];
|
||||||
|
const dbCountry = name === '中东' ? '沙特' : name;
|
||||||
|
const sortOrder = i + 1;
|
||||||
|
const existing = await prisma.country.findUnique({ where: { countryName: dbCountry } });
|
||||||
|
if (existing) {
|
||||||
|
await prisma.country.update({ where: { id: existing.id }, data: { sortOrder } });
|
||||||
|
} else if (dbCountry === '沙特') {
|
||||||
|
await prisma.country.create({ data: { countryName: '沙特', sortOrder } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 中东根 → 沙特
|
||||||
|
const meRoot = rootByName.get('中东本地工厂直发');
|
||||||
|
if (meRoot) {
|
||||||
|
await prisma.category.update({ where: { id: meRoot.id }, data: { categoryName: '沙特本地工厂直发' } });
|
||||||
|
rootByName.set('沙特本地工厂直发', meRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 二级/款 sort_order
|
||||||
|
for (const countryName of countryOrder) {
|
||||||
|
const rootName = ROOT_MAP[countryName];
|
||||||
|
const root = rootByName.get(countryName === '中东' ? '沙特本地工厂直发' : rootName);
|
||||||
|
if (!root) { unmatched.push(`ROOT MISS: ${countryName}`); continue; }
|
||||||
|
const l2s = root.children;
|
||||||
|
const l2NamesInOrder: string[] = [];
|
||||||
|
for (const row of tree) if (row.country === countryName && !l2NamesInOrder.includes(row.l2)) l2NamesInOrder.push(row.l2);
|
||||||
|
for (let i = 0; i < l2NamesInOrder.length; i++) {
|
||||||
|
const target = l2NamesInOrder[i];
|
||||||
|
const mid = l2s.find((m) => norm(m.categoryName) === norm(target));
|
||||||
|
if (!mid) { unmatched.push(`L2 MISS: ${countryName} / ${target}`); continue; }
|
||||||
|
await prisma.category.update({ where: { id: mid.id }, data: { sortOrder: i + 1 } });
|
||||||
|
const leaves = mid.children;
|
||||||
|
const l3Names = tree.filter((r) => r.country === countryName && r.l2 === target).map((r) => r.l3);
|
||||||
|
for (let j = 0; j < l3Names.length; j++) {
|
||||||
|
const want = norm(l3Names[j]);
|
||||||
|
const code = norm(codeOf(l3Names[j]));
|
||||||
|
const leaf =
|
||||||
|
leaves.find((l) => norm(l.categoryName) === want) ??
|
||||||
|
(code ? leaves.find((l) => norm(l.categoryName).startsWith(code)) : undefined);
|
||||||
|
if (!leaf) { unmatched.push(`L3 MISS: ${countryName} / ${target} / ${l3Names[j]}`); continue; }
|
||||||
|
await prisma.category.update({ where: { id: leaf.id }, data: { sortOrder: j + 1 } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unmatched.length) {
|
||||||
|
console.error(`UNMATCHED (${unmatched.length}):\n` + unmatched.join('\n'));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('backfill done');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().finally(() => prisma.$disconnect());
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2.2: 执行脚本**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ts-node 不在 apps/api 依赖中,用 tsc 编译后以 node 运行;
|
||||||
|
# 仓库根只读挂载到 /repo 以读取排序表.md
|
||||||
|
docker run --rm --network deploy-v2_default \
|
||||||
|
-e DATABASE_URL='postgresql://inkreach:2628adbdf875727ae1b5556b08cb00452bb4e1f80490f6b6@postgres:5432/inkreach' \
|
||||||
|
-e SORT_TABLE_PATH=/repo/排序表.md \
|
||||||
|
-v /opt/inkreach-v2:/repo:ro -v /opt/inkreach-v2/apps/api:/app -w /app node:20-alpine \
|
||||||
|
sh -c "npx tsc prisma/backfill-category-sort-order.ts --module commonjs --target es2020 --esModuleInterop --skipLibCheck --outDir /tmp/bf && node /tmp/bf/backfill-category-sort-order.js"
|
||||||
|
```
|
||||||
|
Expected: `backfill done`,退出码 0。已知候选 UNMATCHED:美国/内衣 `DG170G170G女士无痕三角内裤`(库内为 `DG701 170G女士无痕三角内裤`,货号前缀 `DG170G170G` 不匹配)→ 将排序表.md 该行改为与库名一致后重跑;其余逐条人工核对。
|
||||||
|
|
||||||
|
- [ ] **Step 2.3: SQL 验证回填结果**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec deploy-v2-postgres-1 psql -U inkreach -d inkreach -c "
|
||||||
|
select country_name, sort_order from countries order by sort_order;" -c "
|
||||||
|
select root.category_name, mid.category_name, mid.sort_order, count(leaf.category_id) leaves
|
||||||
|
from categories root
|
||||||
|
join categories mid on mid.parent_category_id = root.category_id
|
||||||
|
left join categories leaf on leaf.parent_category_id = mid.category_id
|
||||||
|
where root.parent_category_id is null and root.sds_category_id is not null
|
||||||
|
group by 1,2,3 order by 1, mid.sort_order;" | head -70
|
||||||
|
```
|
||||||
|
Expected: countries 13 行(沙特=6);美国根下 男士T恤=1、女士T恤=2…;各二级下款 sort_order 连续 1..n。
|
||||||
|
|
||||||
|
抽查美国/男士T恤款顺序:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec deploy-v2-postgres-1 psql -U inkreach -d inkreach -tAc "
|
||||||
|
select category_name || ' | ' || sort_order from categories
|
||||||
|
where parent_category_id = (select category_id from categories where category_name='男士T恤' and parent_category_id=(select category_id from categories where category_name='美国工厂直发'))
|
||||||
|
order by sort_order;" | head -8
|
||||||
|
```
|
||||||
|
Expected: 第一行 `DG001 180G纯棉T恤 (JSA002) | 1`,第二行 `DG004 230G水洗T恤(JSA003) | 2`。
|
||||||
|
|
||||||
|
- [ ] **Step 2.4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/api/prisma/backfill-category-sort-order.ts
|
||||||
|
git commit -m "feat(api): backfill category/country sort order from reference table"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: getGoods 默认排序(TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `apps/api/src/public/public.service.ts`(getGoods 约 L186-271、PUBLIC_GOOD_LIST_INCLUDE 约 L68-77)
|
||||||
|
- Test: `apps/api/src/public/public.service.spec.ts`(追加 describe,遵循现有真实库集成测试风格,fixture 带 `stamp` 唯一化)
|
||||||
|
|
||||||
|
- [ ] **Step 3.1: 写失败测试**
|
||||||
|
|
||||||
|
在 `public.service.spec.ts` 追加:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
describe('getGoods tree-order sorting', () => {
|
||||||
|
// 结构: 国家A(sort=1)>MidA>LeafA1(sort=1,2条goods)、LeafA2(sort=2);国家B(sort=2)>MidB>LeafB1
|
||||||
|
// 期望默认顺序: A款1 -> A款2 -> B款1,同款内 priority desc;B 的 priority=99 也不能越级
|
||||||
|
const stamp2 = `treeorder-${Date.now()}`;
|
||||||
|
let orderedGoodIds: bigint[] = [];
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const cA = await prisma.country.create({ data: { countryName: `TA ${stamp2}`, sortOrder: 1 } });
|
||||||
|
const cB = await prisma.country.create({ data: { countryName: `TB ${stamp2}`, sortOrder: 2 } });
|
||||||
|
const midA = await prisma.category.create({ data: { categoryName: `MidA ${stamp2}`, sdsCategoryId: `ma-${stamp2}`, sortOrder: 1 } });
|
||||||
|
const leafA1 = await prisma.category.create({ data: { categoryName: `LeafA1 ${stamp2}`, parentCategoryId: midA.id, sdsCategoryId: `la1-${stamp2}`, sortOrder: 1 } });
|
||||||
|
const leafA2 = await prisma.category.create({ data: { categoryName: `LeafA2 ${stamp2}`, parentCategoryId: midA.id, sdsCategoryId: `la2-${stamp2}`, sortOrder: 2 } });
|
||||||
|
const midB = await prisma.category.create({ data: { categoryName: `MidB ${stamp2}`, sdsCategoryId: `mb-${stamp2}`, sortOrder: 2 } });
|
||||||
|
const leafB1 = await prisma.category.create({ data: { categoryName: `LeafB1 ${stamp2}`, parentCategoryId: midB.id, sdsCategoryId: `lb1-${stamp2}`, sortOrder: 2 } });
|
||||||
|
|
||||||
|
const mk = async (countryId: bigint, sdsCat: string, name: string, priority: number) => {
|
||||||
|
const og = await prisma.originGood.create({ data: { sdsGoodId: `${name}-${stamp2}`, goodName: name, sdsCategoryId: sdsCat } });
|
||||||
|
const fam = await prisma.productFamily.create({ data: { familyName: `f-${name}-${stamp2}`, primaryOriginGoodId: og.id } });
|
||||||
|
await prisma.originGood.update({ where: { id: og.id }, data: { familyId: fam.id } });
|
||||||
|
return prisma.good.create({ data: { goodName: name, originGoodId: og.id, familyId: fam.id, countryId, categoryId: leafA1.id, goodPriority: priority } });
|
||||||
|
};
|
||||||
|
const a1Low = await mk(cA.id, `la1-${stamp2}`, 'A1Low', 1);
|
||||||
|
const a1High = await mk(cA.id, `la1-${stamp2}`, 'A1High', 9);
|
||||||
|
const a2 = await mk(cA.id, `la2-${stamp2}`, 'A2', 0);
|
||||||
|
const b1 = await mk(cB.id, `lb1-${stamp2}`, 'B1', 99);
|
||||||
|
orderedGoodIds = [a1High.id, a1Low.id, a2.id, b1.id];
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DEFAULT: country > mid > leaf > priority', async () => {
|
||||||
|
const res = await service.getGoods({ page: 1, pageSize: 50 });
|
||||||
|
const idx = res.items.map((i) => BigInt(i.goodId));
|
||||||
|
const pos = orderedGoodIds.map((id) => idx.indexOf(id));
|
||||||
|
expect(pos.every((p) => p >= 0)).toBe(true); // 全部命中
|
||||||
|
expect(pos).toEqual([...pos].sort((a, b) => a - b)); // 相对有序
|
||||||
|
expect(idx.indexOf(orderedGoodIds[0])).toBeLessThan(idx.indexOf(orderedGoodIds[1])); // 同款内 priority desc
|
||||||
|
expect(idx.indexOf(orderedGoodIds[1])).toBeLessThan(idx.indexOf(orderedGoodIds[2])); // 款顺序
|
||||||
|
expect(idx.indexOf(orderedGoodIds[2])).toBeLessThan(idx.indexOf(orderedGoodIds[3])); // 国家/款顺序优先于 priority
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3.2: 跑测试确认失败**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm --network deploy-v2_default -e DATABASE_URL='postgresql://inkreach:2628adbdf875727ae1b5556b08cb00452bb4e1f80490f6b6@postgres:5432/inkreach' -v /opt/inkreach-v2/apps/api:/app -w /app node:20-alpine npx jest src/public/public.service.spec.ts -t 'tree-order'
|
||||||
|
```
|
||||||
|
Expected: FAIL(现顺序按 goodPriority desc,B1 会排最前)。
|
||||||
|
|
||||||
|
- [ ] **Step 3.3: 实现**
|
||||||
|
|
||||||
|
`public.service.ts`:
|
||||||
|
|
||||||
|
1) `PUBLIC_GOOD_LIST_INCLUDE.originGood.select` 增加 `sdsCategoryId: true`
|
||||||
|
|
||||||
|
2) 新增类型与私有方法:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface TreeOrderMeta {
|
||||||
|
countryOrder: Map<string, number>;
|
||||||
|
leafOrder: Map<string, { c2: number; c3: number }>; // key: origin_goods.sds_category_id
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadTreeOrderMeta(): Promise<TreeOrderMeta> {
|
||||||
|
const [countries, leaves] = await Promise.all([
|
||||||
|
this.prisma.country.findMany({ select: { id: true, sortOrder: true } }),
|
||||||
|
this.prisma.$queryRaw<Array<{ sds_category_id: string; c2: bigint | number; c3: bigint | number }>>`
|
||||||
|
SELECT leaf.sds_category_id,
|
||||||
|
COALESCE(mid.sort_order, 2147483647) AS c2,
|
||||||
|
COALESCE(leaf.sort_order, 2147483647) AS c3
|
||||||
|
FROM categories leaf
|
||||||
|
JOIN categories mid ON mid.category_id = leaf.parent_category_id
|
||||||
|
WHERE leaf.sds_category_id IS NOT NULL AND leaf.sds_category_id <> ''`,
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
countryOrder: new Map(countries.map((c) => [c.id.toString(), c.sortOrder])),
|
||||||
|
leafOrder: new Map(leaves.map((l) => [l.sds_category_id, { c2: Number(l.c2), c3: Number(l.c3) }])),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private compareByTreeOrder(meta: TreeOrderMeta, a: PublicGoodListRow, b: PublicGoodListRow): number {
|
||||||
|
const MAX = Number.MAX_SAFE_INTEGER;
|
||||||
|
const key = (g: PublicGoodListRow): [number, number, number, number, number] => [
|
||||||
|
meta.countryOrder.get(g.countryId.toString()) ?? MAX,
|
||||||
|
meta.leafOrder.get(g.originGood.sdsCategoryId)?.c2 ?? MAX,
|
||||||
|
meta.leafOrder.get(g.originGood.sdsCategoryId)?.c3 ?? MAX,
|
||||||
|
-g.goodPriority,
|
||||||
|
Number(g.id),
|
||||||
|
];
|
||||||
|
const ka = key(a);
|
||||||
|
const kb = key(b);
|
||||||
|
for (let i = 0; i < ka.length; i++) if (ka[i] !== kb[i]) return ka[i] - kb[i];
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3) `getGoods`:DEFAULT 分支的 DB `orderBy` 改为 `[{ id: 'asc' }]`(排序移内存),`findMany` 之后、分组之前插入:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
if (!query.sort || query.sort === 'DEFAULT') {
|
||||||
|
const meta = await this.loadTreeOrderMeta();
|
||||||
|
rows.sort((a, b) => this.compareByTreeOrder(meta, a, b));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
(分组代表行 = 树序第一条,与现有「排序最前为代表」契约一致;同一款下 1~N 个商品共享 ②③ 键,仅靠 ④ 分先后。)
|
||||||
|
|
||||||
|
- [ ] **Step 3.4: 跑测试确认通过(含既有用例)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm ... npx jest src/public/public.service.spec.ts
|
||||||
|
```
|
||||||
|
Expected: 全部 PASS(现有 getGoods 断言若依赖 DEFAULT 全局顺序按新规则修正期望;fixture 多为同国家同分类,一般不受影响)。
|
||||||
|
|
||||||
|
- [ ] **Step 3.5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/api/src/public/public.service.ts apps/api/src/public/public.service.spec.ts
|
||||||
|
git commit -m "feat(api): order public goods by country > mid-category > style > priority"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: 全量验证 + 交付
|
||||||
|
|
||||||
|
- [ ] **Step 4.1: api 全量测试**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm --network deploy-v2_default -e DATABASE_URL='postgresql://inkreach:2628adbdf875727ae1b5556b08cb00452bb4e1f80490f6b6@postgres:5432/inkreach' -v /opt/inkreach-v2/apps/api:/app -w /app node:20-alpine npx jest
|
||||||
|
```
|
||||||
|
Expected: 全部 PASS。任何失败必须修复(含其他模块被 schema 变更波及的用例)。
|
||||||
|
|
||||||
|
- [ ] **Step 4.2: verification-before-completion 自检清单**
|
||||||
|
|
||||||
|
- [ ] `prisma migrate status` 干净(无未应用/未登记迁移)
|
||||||
|
- [ ] 回填 UNMATCHED 清单为空(或已逐条人工处理并记录)
|
||||||
|
- [ ] 冒烟(真实调用形态,先完成 Step 4.4 重建容器):`/public/goods?pageSize=10` DEFAULT = 美国 DG001 族 → DG004 族 …;`/public/goods?categoryId=659`(女士服装,跨国)按国家顺序排;`/public/goods?categoryId=666` 同上;`/public/goods?countryId=49&categoryId=658` 只返回英国男装且按 ②③④;`/public/categories` 输出与改造前完全一致(老树,结构内容均不变)
|
||||||
|
- [ ] DTO 字段逐个对比改动前后(PublicGoodDto / 分页结构)无增删
|
||||||
|
- [ ] `git status` 干净,全部提交
|
||||||
|
|
||||||
|
- [ ] **Step 4.3: 合并回 refactor/v2**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout refactor/v2 && git merge --no-ff feature/category-tree-sort -m "merge: feature/category-tree-sort (public goods style-order sorting)"
|
||||||
|
git branch -d feature/category-tree-sort
|
||||||
|
```
|
||||||
|
(本仓库 v2 工作线为 `refactor/v2`;不推远端,部署时机由用户确认。)
|
||||||
|
|
||||||
|
- [ ] **Step 4.4: 重建 v2-api 容器使新逻辑生效(部署步骤,执行前向用户确认)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/inkreach-v2/deploy && docker compose -f docker-compose.v2.yml up -d --build api
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4.5: 按项目规则沉淀**
|
||||||
|
|
||||||
|
- 更新 `docs/references/structs.md`(categories.sort_order 新字段、public 排序语义、款/family 领域说明)
|
||||||
|
- 更新 `docs/references/` 使用文档与 `README.md`(新排序规则)
|
||||||
|
- 通用经验追加到 `AGENTS.md`(如:共享库迁移「手写 SQL + db execute + resolve」通道、「绝对排序键 + 子集过滤」模式、排序表驱动回填的幂等脚本设计)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 范围外(明确不做)
|
||||||
|
|
||||||
|
- **分类树接口与导航**(老树不动;新树仅作款排序元数据层)
|
||||||
|
- `getHomeGoods`(首页位次排序维持 position.indexVal 优先,另行需求再调)
|
||||||
|
- 防晒衣(good_id=137) 的 sds_category_id=2393 修复(排序中日本组内沉底)
|
||||||
|
- admin 后台款顺序拖拽管理(本期用脚本回填;后续如需可视化调整再立项)
|
||||||
|
- 中东根更名后 SDS 同步覆盖名称的持久对抗(重跑回填脚本即可恢复)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 备份与回滚(已就绪)
|
||||||
|
|
||||||
|
| 资产 | 位置 | 时间 |
|
||||||
|
|---|---|---|
|
||||||
|
| DB 全库快照 | `backups/inkreach_snapshot_20260901_185233.dump`(pg_dump -Fc,3.4M) | 2026-09-01 |
|
||||||
|
| 运行容器快照 | docker 镜像 `inkreach-api-snapshot:20260901`(711MB,commit 自 deploy-v2-api-1) | 2026-09-01 |
|
||||||
|
| 代码 | 分支 `feature/category-tree-sort`(refactor/v2 未动) | 实时 |
|
||||||
|
|
||||||
|
**DB 恢复(覆盖式,先停 api 容器避免写入竞争):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/inkreach-v2/deploy && docker compose -f docker-compose.v2.yml stop api
|
||||||
|
docker exec -i deploy-v2-postgres-1 pg_restore -U inkreach -d inkreach --clean --if-exists \
|
||||||
|
< /opt/inkreach-v2/backups/inkreach_snapshot_20260901_185233.dump
|
||||||
|
cd /opt/inkreach-v2/deploy && docker compose -f docker-compose.v2.yml start api
|
||||||
|
```
|
||||||
|
|
||||||
|
**api 容器恢复(快照镜像另起实例做比对/应急):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d --name deploy-v2-api-snapshot --network deploy-v2_default inkreach-api-snapshot:20260901
|
||||||
|
```
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Feature:印刷工艺新增"热转印"选项
|
||||||
|
|
||||||
|
日期:2026-09-02
|
||||||
|
类型:新功能(feature)
|
||||||
|
影响面:`apps/api/src/product-families/family-recompute.service.ts`(两处词表常量)+ 测试 + `tags` 表新增一行
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
业务需要新增印刷工艺"热转印"。标签由 `tags` 表驱动(`GET /tags` 全量返回,管理端打标用),但价格矩阵的维度词表在后端硬编码,需同步放开准入。
|
||||||
|
|
||||||
|
## 现状(2026-09-02 分析确认)
|
||||||
|
|
||||||
|
- `tags` 表:印刷工艺组(tag_group_id=3)现有 烫画(1)/直喷(2)/不打印(3),有 `POST /tags` 接口(JwtAuthGuard,重名 409)
|
||||||
|
- 矩阵维度词表 [DIM_VALUES.craft](file:///opt/inkreach-v2/apps/api/src/product-families/family-recompute.service.ts) = `['烫画','直喷','不打印']` 硬编码,`memberMatrixCombos` 按它过滤链接标签——词表外的标签会导致链接不进矩阵
|
||||||
|
- 展示序词表 `OPTION_DISPLAY_ORDER.craft` 同样硬编码,词表外沉底
|
||||||
|
- 名称自动派生规则(`auto-tag-rules.ts` / admin `origin-name.ts`)**本次不改**:SDS 链接不会自动派生热转印,只能人工打标
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
1. `DIM_VALUES.craft` 追加 `'热转印'`(末尾)——矩阵认可该工艺标签,SDS/CUSTOM 链接人工打标后均可进矩阵
|
||||||
|
2. `OPTION_DISPLAY_ORDER.craft` 追加 `'热转印'`(末尾)——按钮组显示在 不打印 之后(业务确认排序表先不改,放最后)
|
||||||
|
3. `POST /tags { tagName: '热转印', tagGroupId: '3', sortOrder: 4 }` 创建标签行(管理端标签栏立即可见/可选)
|
||||||
|
4. **不给任何存量链接打标**——纯增量能力,存量族矩阵零变化
|
||||||
|
|
||||||
|
## 明确不做
|
||||||
|
|
||||||
|
- 不改自动派生规则(名称含"热转印"仍派生默认烫画,如需后续再议)
|
||||||
|
- 不给存量链接打标、不重算存量族
|
||||||
|
- 不动 自建商品 craftLabel 维度填错的历史数据
|
||||||
|
|
||||||
|
## 测试计划(TDD)
|
||||||
|
|
||||||
|
测试文件:`family-recompute.service.spec.ts`(memberMatrixCombos / derivePriceMatrix 纯函数)
|
||||||
|
|
||||||
|
1. `memberMatrixCombos`:链接标签 [单面印花, 热转印, 包邮] → 产出 `{ printCount:'单面印花', craft:'热转印', logistics:'包邮' }`(修复前被词表过滤 → combos 为空,RED)
|
||||||
|
2. `derivePriceMatrix`:crafts 输出 `烫画→直喷→不打印→热转印`(热转印作为词表内值参与排序,不再沉底)
|
||||||
|
3. 既有"未知自由文本沉底"用例(丝印/水洗)仍应沉底——热转印是词表内,丝印/水洗仍是词表外
|
||||||
|
4. 全量 jest + tsc 通过
|
||||||
|
|
||||||
|
## 线上验证
|
||||||
|
|
||||||
|
1. `GET /tags` 返回 热转印(tagGroupId=3, sortOrder=4)
|
||||||
|
2. 抽查存量族 priceMatrix.crafts 与改动前一致(无新增值)
|
||||||
|
3. 无链接打标 → 公开详情 crafts 不含 热转印
|
||||||
|
|
||||||
|
## 回滚
|
||||||
|
|
||||||
|
- 代码:revert 两行常量(未打标状态零影响)
|
||||||
|
- 标签:无引用可直接删(`origin_good_tags` 无该行)
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Fix:价格矩阵选项组按词表序输出(印花/工艺/物流按钮顺序稳定)
|
||||||
|
|
||||||
|
日期:2026-09-02
|
||||||
|
类型:Bug 修复(fix)
|
||||||
|
影响面:`apps/api/src/product-families/family-recompute.service.ts` 及其测试、一次性全库重算
|
||||||
|
|
||||||
|
## 背景与问题
|
||||||
|
|
||||||
|
详情接口 `family.priceMatrix` 的 `printCounts / crafts / logistics` 是物化进 JSONB 的字符串数组,H5 按数组原序渲染印花/工艺/物流按钮组(无前端排序)。数组顺序 = 重算时 Set 去重的"首次遇到序",且成员查询 `originGoods` 无 `orderBy`,导致:
|
||||||
|
|
||||||
|
1. 每个族的按钮顺序不一致(实测 goodId=1 为 `['双面印花','单面印花']`、`['不包邮','包邮']`,与词表相反);
|
||||||
|
2. 同一族两次重算顺序理论上可能漂移(Postgres 无顺序保证)。
|
||||||
|
|
||||||
|
对比:尺码/颜色按钮组有 `variants.sortOrder`,顺序稳定;本修复把印花/工艺/物流对齐到同样可预期。
|
||||||
|
|
||||||
|
## 目标顺序(业务确认)
|
||||||
|
|
||||||
|
| 维度 | 展示顺序 |
|
||||||
|
| --- | --- |
|
||||||
|
| printCount | 单面印花 → 双面印花 |
|
||||||
|
| craft | 烫画 → 直喷 → 不打印 |
|
||||||
|
| logistics | 不包邮 → 包邮 |
|
||||||
|
|
||||||
|
无论族内实际出现哪个子集,相对顺序恒定;词表外自由文本(如 CUSTOM 的"海运")沉到最后,相互间保持首次遇到序。
|
||||||
|
|
||||||
|
## 数据边界(2026-09-02 全库实测)
|
||||||
|
|
||||||
|
- crafts 中出现 `双面印花`×2、logistics 中出现 `海运`×2:来自 3 条 CUSTOM 链接 craftLabel/logisticsLabel 自由文本(含维度填错,属数据清理问题,本修复只保证其沉底,不改数据)。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
1. `derivePriceMatrix`:聚合(Set 去重)+ 人工覆盖 append 之后,对三个数组做稳定排序:
|
||||||
|
- 新增 `OPTION_DISPLAY_ORDER` 常量(printCount/craft/logistics 三组词表序,logistics 与 `DIM_VALUES` 顺序相反是业务要求;`DIM_VALUES` 组合生成语义不动);
|
||||||
|
- 排序键 = 词表下标,未知值 = `canon.length`(沉底),稳定排序保持未知值间首次遇到序。
|
||||||
|
2. `recomputeFamily` 成员查询加 `orderBy: { id: 'asc' }`,使"遇到序"确定(未知值之间顺序也确定)。
|
||||||
|
3. 只影响 `autoManaged` 族物化;非自动族人工字段照旧不碰(现有不变量)。
|
||||||
|
4. 一次性脚本:逐族调用 `recomputeFamily` 全库刷新存量(166 个族)。
|
||||||
|
|
||||||
|
## 非目标
|
||||||
|
|
||||||
|
- 不改 API 输出结构(仍是 `string[]`),前端零改动;
|
||||||
|
- 不清理 CUSTOM 维度填错数据(另行处理)。
|
||||||
|
|
||||||
|
## 测试计划(TDD)
|
||||||
|
|
||||||
|
测试文件:`apps/api/src/product-families/family-recompute.service.spec.ts`(`derivePriceMatrix` 为纯函数,可直接单测)。
|
||||||
|
|
||||||
|
1. **打乱输入仍输出词表序**:成员顺序颠倒,crafts 输出仍 `烫画→直喷→不打印`(修复前为遇到序,RED)。
|
||||||
|
2. **子集保序**:只含 `直喷+烫画` → 输出 `烫画,直喷`;只含 `双面` → 单元素。
|
||||||
|
3. **未知值沉底**:logistics 含 `海运` → `不包邮,包邮,海运`;未知值相互保持遇到序。
|
||||||
|
4. **覆盖 append 值参与同一排序**:override 新增取值后数组仍词表序。
|
||||||
|
5. **重算幂等(含顺序)**:同输入跑两遍输出 deep-equal。
|
||||||
|
6. 既有用例全绿;全量 jest + tsc 通过。
|
||||||
|
|
||||||
|
## 风险与回滚
|
||||||
|
|
||||||
|
- 纯物化顺序变化,无 schema/契约变更;回滚 = revert commit + 再跑一次全库重算(旧逻辑会把顺序"洗回"遇到序,仅顺序变化无数据损坏)。
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Fix:公开接口族代表行选取规则统一(列表/首页对齐详情)
|
||||||
|
|
||||||
|
日期:2026-09-02
|
||||||
|
类型:Bug 修复(fix)
|
||||||
|
影响面:`apps/api/src/public/public.service.ts` 及其测试
|
||||||
|
|
||||||
|
## 背景与问题
|
||||||
|
|
||||||
|
公开接口中,族(productFamily)对外只暴露一条"代表行",`goodName`、主图、国家、分类、标签、`goodPriority`、`createdAt` 等字段全部来自代表行。但三个接口的代表行选取规则不一致:
|
||||||
|
|
||||||
|
| 接口 | 代表行选取规则 | 位置 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 详情 `GET /public/goods/{goodId}` | `goodPriority desc → createdAt desc → id asc` | `getGoodByFamilyId` |
|
||||||
|
| 列表 `GET /public/goods` | `goods[0]`(随列表排序参数变化:DEFAULT 平局取 id 最小;价格排序取价格极值行;NEWEST 取最新行) | `getGoods` 分组处 |
|
||||||
|
| 首页 `GET /public/home-goods` | `goods[0]`(按 position 顺序的第一条) | `getHomeGoods` |
|
||||||
|
|
||||||
|
后果:同一族内多条 Good 名称不同时,列表返回的 `goodName` 与详情不一致;且列表换排序参数后名称还会变。
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
- 列表与首页的族代表行选取规则与详情完全一致:`goodPriority desc → createdAt desc → id asc`。
|
||||||
|
- 列表排序逻辑(DEFAULT 树序 / PRICE / NEWEST)与分组顺序完全不变。
|
||||||
|
- 公开 API 输入输出数据结构不变(硬性约束)。
|
||||||
|
|
||||||
|
## 非目标
|
||||||
|
|
||||||
|
- 不改动详情逻辑、族去重契约、树序排序实现。
|
||||||
|
- 不处理无族(自定义商品)——其分组内只有一条,不受影响。
|
||||||
|
|
||||||
|
## 方案(已确认:方案 A + 首页一起改)
|
||||||
|
|
||||||
|
在 `public.service.ts` 中新增私有方法,组内显式选取代表行,`getGoods` 与 `getHomeGoods` 分组处统一调用:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/** 族代表行:与详情 getGoodByFamilyId 的 orderBy 保持一致
|
||||||
|
* (goodPriority desc → createdAt desc → id asc),保证列表/首页/详情字段一致 */
|
||||||
|
private pickFamilyRepresentative<T extends { goodPriority: number | null; createdAt: Date; id: bigint }>(
|
||||||
|
goods: T[],
|
||||||
|
): T {
|
||||||
|
return [...goods].sort(
|
||||||
|
(a, b) =>
|
||||||
|
(b.goodPriority ?? 0) - (a.goodPriority ?? 0) ||
|
||||||
|
b.createdAt.getTime() - a.createdAt.getTime() ||
|
||||||
|
(a.id < b.id ? -1 : a.id > b.id ? 1 : 0),
|
||||||
|
)[0];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `getGoods`:`const rep = this.pickFamilyRepresentative(goods);`
|
||||||
|
- `getHomeGoods`:同上替换 `goods[0]`。
|
||||||
|
- 组序不受影响:同族成员共享国家/款,最高 `goodPriority` 相同,仅平局细则变化,树序键 c1/c2/c3 与 max(priority) 均不变。
|
||||||
|
|
||||||
|
## 测试计划(TDD)
|
||||||
|
|
||||||
|
测试文件:`apps/api/src/public/public.service.spec.ts`(沿用现有夹具风格)。
|
||||||
|
|
||||||
|
1. **列表 vs 详情一致性**:构造同族两条 Good,`goodPriority` 相同、`createdAt` 不同、名称不同 →
|
||||||
|
`GET /public/goods` 列表项的 `goodName` === 详情接口返回的 `goodName`。
|
||||||
|
2. **优先级优先**:族内两条 Good 优先级不同 → 列表代表行取优先级最高的那条(即使它 id 更大/创建更早)。
|
||||||
|
3. **价格排序下名称不漂移**:`sort=PRICE_ASC` 时,价格较低但优先级低的成员不是代表行,列表 `goodName` 与 DEFAULT 排序一致。
|
||||||
|
4. **首页一致性**:位置配置同族多条 → 首页返回的 `goodName` 与详情一致。
|
||||||
|
5. 既有用例全部保持通过。
|
||||||
|
|
||||||
|
## 风险与回滚
|
||||||
|
|
||||||
|
- 纯内存选取逻辑变更,无 schema/数据变更,无部署数据风险。
|
||||||
|
- 回滚:还原 commit 即可。
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
全部
|
||||||
|
# 工厂直发国家/地区列表
|
||||||
|
|
||||||
|
# 美国
|
||||||
|
## 男士T恤
|
||||||
|
### DG001 180G纯棉T恤 (JSA002)
|
||||||
|
### DG004 230G水洗T恤(JSA003)
|
||||||
|
### DG013 230G脏洗T恤
|
||||||
|
### JSA006 全棉重磅T恤
|
||||||
|
### JSS001 美国足球球衣T恤
|
||||||
|
### DG015 180G纯棉T恤
|
||||||
|
### C1717 207G重磅纯棉T恤
|
||||||
|
## 女士T恤
|
||||||
|
### DG100 180G女士彩棉T恤
|
||||||
|
### DG101 180G纯棉女士露脐T恤
|
||||||
|
### DG102 高弹力女款露脐
|
||||||
|
### DG110 180G大码彩棉女T恤
|
||||||
|
### DG120 牛奶丝T恤
|
||||||
|
### DG150 180G纯棉女士T恤
|
||||||
|
### DG502 180G女士圆领长袖T恤
|
||||||
|
### DG601 牛奶丝裙子
|
||||||
|
### JSA008 180G女士短装T恤
|
||||||
|
### JSA011 冰丝螺纹V领女士T恤
|
||||||
|
### JSE008 180g女士水洗露脐装
|
||||||
|
## 童装T恤
|
||||||
|
### DG012 180G青少年纯棉T恤
|
||||||
|
### DG014 190G小童T恤
|
||||||
|
### DG301 190G纯棉哈哈衣
|
||||||
|
### DG503 190G小童长袖
|
||||||
|
### 5000B 180G吉尔丹童装T恤
|
||||||
|
### JSA004 190G牛奶丝小童T恤
|
||||||
|
## 男士短裤
|
||||||
|
### DG201 300G脏洗短裤
|
||||||
|
### DG202 260G水洗短裤(JSD007)
|
||||||
|
### DG206 240G涤纶休闲短裤
|
||||||
|
### JSD001 180G高弹纤维男士短裤
|
||||||
|
## 女士短裤
|
||||||
|
### DG205 牛奶丝短裤
|
||||||
|
### JSD002 180G高弹纤维女士短裤
|
||||||
|
### JSD003 女士短款瑜伽运动裤
|
||||||
|
### JSD004 女士高弹中长款瑜伽运动裤
|
||||||
|
### JSD005 女士高弹瑜伽长裤
|
||||||
|
## 童装短裤
|
||||||
|
### DG204 190G小童短裤
|
||||||
|
## 男士背心
|
||||||
|
### VS001 180G男款背心(JSA005)
|
||||||
|
### VS003 210G水洗背心 (JSA007)
|
||||||
|
## 女士背心
|
||||||
|
### VS002 180G女款彩棉背心
|
||||||
|
### VS004 V领彩棉背心
|
||||||
|
### JSA009 180G女士镂空背心
|
||||||
|
## 男士卫衣
|
||||||
|
### DF009 270G连帽卫衣
|
||||||
|
### DF010 400G水洗做旧连帽卫衣
|
||||||
|
### JSB001 280G薄绒男士带帽卫衣
|
||||||
|
### JSB002 380G薄绒男士迷彩卫衣
|
||||||
|
### JSB003 380G男士薄绒圆领卫衣
|
||||||
|
## 女士卫衣
|
||||||
|
### DG501 250G女士圆领卫衣
|
||||||
|
## 童装卫衣
|
||||||
|
### DG504 280G小童圆领卫衣
|
||||||
|
## 男士长裤
|
||||||
|
### DG210 280G高级抓绒慢跑卫裤
|
||||||
|
## 内衣
|
||||||
|
### DG701 170G女士无痕三角内裤
|
||||||
|
### JSA010 270G高弹力女士抹胸
|
||||||
|
### JSE001 180G女士无痕内衣
|
||||||
|
## 家居
|
||||||
|
### USIU002 280G法兰绒毛毯
|
||||||
|
### USIU005 法兰绒地垫/科技布地垫
|
||||||
|
### USIU012 天鹅绒地毯
|
||||||
|
## 配饰
|
||||||
|
### YSM01 7X7全棉磨毛帽子
|
||||||
|
### YSM02 全棉斜纹拼色帽子(黑+卡其)
|
||||||
|
# 英国
|
||||||
|
## 男士T恤
|
||||||
|
### GBTM001 180g纯棉T恤
|
||||||
|
### GBTM010 成人POLO衫
|
||||||
|
### GBTM011长袖T
|
||||||
|
### GBTM013 230G水洗T恤
|
||||||
|
## 女士T恤
|
||||||
|
### GBTF004 180g女装纯棉T恤
|
||||||
|
## 男士卫衣
|
||||||
|
### GBHM002 280g圆领卫衣
|
||||||
|
### GBHM003 280g连帽卫衣
|
||||||
|
## 童装T恤
|
||||||
|
### GBTK005 180g童装纯棉T恤
|
||||||
|
### GBTK008 190g童装POLO短袖
|
||||||
|
### GBTK012 婴儿长袖哈哈衣
|
||||||
|
## 童装卫衣
|
||||||
|
### GBHK006 280g童装圆领卫衣
|
||||||
|
### GBHK007 280g童装连帽卫衣
|
||||||
|
## 家居配饰
|
||||||
|
### GBIU014 280g法兰绒毛毯
|
||||||
|
### GBIU015 超细纤维沙滩巾
|
||||||
|
### GBIU016 条形桌垫地垫
|
||||||
|
## 印花烫贴
|
||||||
|
### GBIU009 定制立体印花烫画贴
|
||||||
|
## 桌面用品
|
||||||
|
### GBIU017 鼠标垫
|
||||||
|
# 日本
|
||||||
|
## 男士T恤
|
||||||
|
### JPTM001 200g纯棉T恤
|
||||||
|
### JPTM004 250g纯棉渐变T恤
|
||||||
|
### JPTM006 200g纯棉长袖
|
||||||
|
### JPTM007 230g水洗T恤
|
||||||
|
## 童装T恤
|
||||||
|
### JPTK015 200g童装纯棉T恤
|
||||||
|
## 男士背心
|
||||||
|
### JPTM008 纯棉背心
|
||||||
|
### JPTM002 230g水洗背心
|
||||||
|
## 男士卫衣
|
||||||
|
### JPHM009 280g连帽卫衣
|
||||||
|
### JPHM010 280g圆领卫衣
|
||||||
|
### JPPX012 280g连帽卫衣+卫裤套装
|
||||||
|
### JPPX013 圆领卫衣+卫裤套装
|
||||||
|
## 下装(短裤 & 长裤)
|
||||||
|
### JPSM018 240G华棉短裤
|
||||||
|
### JPPM011 男士/女士320克华棉卫裤
|
||||||
|
## 家居配饰
|
||||||
|
### JPIU020 沙滩巾
|
||||||
|
### JPIU021 280G法兰绒毛毯
|
||||||
|
### JPIU014 帆布袋
|
||||||
|
# 墨西哥
|
||||||
|
## 男士T恤
|
||||||
|
### METB001 180g纯棉男士T恤
|
||||||
|
### PET001 男士涤纶T恤
|
||||||
|
### MESXT001 230g水洗T恤
|
||||||
|
## 女士T恤
|
||||||
|
### METP001 180g纯棉女装T恤
|
||||||
|
## 童装T恤
|
||||||
|
### METN001 180g纯棉童装T恤
|
||||||
|
## 男士卫衣
|
||||||
|
### HM01 280g连帽卫衣
|
||||||
|
## 男士背心
|
||||||
|
### TBB001 篮球圆领背心
|
||||||
|
## 下装(短裤 & 长裤)
|
||||||
|
### SFB 运动短裤
|
||||||
|
# 巴西
|
||||||
|
## 男士T恤
|
||||||
|
### BRTM001 180g纯棉T恤
|
||||||
|
### BRTM002 水洗T恤
|
||||||
|
## 女士T恤
|
||||||
|
### BRTF004 女款紧身露背T恤
|
||||||
|
### BRTF007 女士短款露脐T恤
|
||||||
|
# 中东
|
||||||
|
## 男士T恤
|
||||||
|
### SATU001 180g男装纯棉T恤
|
||||||
|
## 女士T恤
|
||||||
|
### SATU001 180g女装纯棉T恤
|
||||||
|
## 男士卫衣
|
||||||
|
### SAHM003 270g连帽卫衣
|
||||||
|
## 童装T恤
|
||||||
|
### SATK004 180g童装纯棉T恤
|
||||||
|
# 波兰
|
||||||
|
## 男士T恤
|
||||||
|
### PLTM001 190g纯棉T恤(QLTC9000)
|
||||||
|
### PLTM031 大码纯棉T恤(QLTC9000)
|
||||||
|
### PLTM008 190G纯棉彩色T恤(QLTC9000)
|
||||||
|
### PLTM006 230G水洗T恤(PLTM006)
|
||||||
|
### PLTM024 水洗破洞T恤(SS508)
|
||||||
|
## 女士T恤
|
||||||
|
### PLTF004 女款紧身T恤(SS5015)
|
||||||
|
### PLTF026 女款修身长袖T恤(WT01180)
|
||||||
|
### PLTF027 女款插肩袖短款(SST507)
|
||||||
|
### PLTF028 女款露脐T恤(SST508)
|
||||||
|
## 男士卫衣
|
||||||
|
### PLHM002 圆领卫衣(TWZ360)
|
||||||
|
### PLHM003 连帽卫衣(MWZKD360)
|
||||||
|
## 童装T恤
|
||||||
|
### PLTK005 童装纯棉T恤(KT01180)
|
||||||
|
### PLHM012 女士童装短款纯棉T恤(SST506)
|
||||||
|
### PLHM013 女士童插肩短袖T恤(SST5010)
|
||||||
|
### PLTK015 女士童短款落肩纯棉T恤(DTG180)
|
||||||
|
### PLTK016 童装纯色插肩短袖T恤(DTG180)
|
||||||
|
## 女士卫衣
|
||||||
|
### PLHF029 女士短款长袖帽衫卫衣(SS513)
|
||||||
|
## 下装(短裤 & 长裤)
|
||||||
|
### PLHM011 女款短裤(SS510250)
|
||||||
|
### PLPM014 男式卫裤(WKC260)
|
||||||
|
### PLSU025 男女同款五分裤(QP01280)
|
||||||
|
## 童装卫衣
|
||||||
|
### PLHM009 男女童同款抓毛圆领卫衣(TGZ360)
|
||||||
|
### PLHM010 男女童同款抓毛连帽卫衣(MGZ360)
|
||||||
|
## 男士夹克
|
||||||
|
### PLJM030 牛仔外套(DKJKT0010)
|
||||||
|
# 西班牙
|
||||||
|
## 男士T恤
|
||||||
|
### ESTM002 190g纯棉T恤
|
||||||
|
## 童装T恤
|
||||||
|
### ESTK005 童装纯棉T恤
|
||||||
|
## 男士卫衣
|
||||||
|
### ESHM003 圆领卫衣
|
||||||
|
### ESHM004 连帽卫衣
|
||||||
|
# 德国
|
||||||
|
## 男士T恤
|
||||||
|
### DETM001 180g纯棉T恤
|
||||||
|
### DETM002 230水洗T恤
|
||||||
|
## 男士卫衣
|
||||||
|
### DEHM003 250g连帽有绳卫衣
|
||||||
|
## 下装(短裤 & 长裤)
|
||||||
|
### DEPM004 255g卫裤
|
||||||
|
# 意大利
|
||||||
|
## 男士T恤
|
||||||
|
### ITTM001 180g纯棉T恤
|
||||||
|
## 女士T恤
|
||||||
|
### ITFM005 180G女士纯棉短袖T恤
|
||||||
|
## 童装T恤
|
||||||
|
### ITTK002 150G童装圆领短袖T恤
|
||||||
|
# 加拿大
|
||||||
|
## 男士T恤
|
||||||
|
### CATM001 180g纯棉T恤
|
||||||
|
### CATM006 180G成人足球运动服
|
||||||
|
### CATM007 230G纯棉水洗T恤
|
||||||
|
## 男士卫衣
|
||||||
|
### CAHM002 280g连帽卫衣
|
||||||
|
# 澳大利亚
|
||||||
|
## 男士T恤
|
||||||
|
### AUTM001 180g纯棉T恤
|
||||||
|
### AUTM002 220g水洗T恤
|
||||||
|
## 男士背心
|
||||||
|
### AUTM003 200g纯棉背心
|
||||||
|
# 韩国
|
||||||
|
## 男士T恤
|
||||||
|
### KRTM001 200g纯棉T恤
|
||||||
|
### KRTM004 200G纯棉长袖
|
||||||
|
## 男士卫衣
|
||||||
|
### KRHM002 圆领卫衣
|
||||||
|
### KRHM003 连帽卫衣
|
||||||
|
# 中国(国内工厂)
|
||||||
|
## 男士T恤
|
||||||
|
### 180g纯棉T恤
|
||||||
|
### 230g水洗T恤
|
||||||
Reference in New Issue
Block a user