Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bbd696b58 | ||
|
|
b947d88c2c | ||
|
|
1a3858d30b | ||
|
|
a1928a2050 |
@@ -8,3 +8,7 @@ dist/
|
||||
deploy/certbot/
|
||||
deploy/data-dump.json
|
||||
uploads/
|
||||
|
||||
# Data cleaning runtime artifacts (production snapshots, exports, reports)
|
||||
data-cleaning/runs/
|
||||
deploy/backups/
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
# 商品数据清洗(Goods Data Cleaning)
|
||||
|
||||
本目录存放商品数据清洗相关的全部脚本与规则文件。清洗目标:把右侧 SDS 原产品库
|
||||
(`origin_goods`)按规则批量配置为左侧官网商品(`goods`),替代后台手动逐个点击。
|
||||
|
||||
## 转换规则调查(2026-08-27,基于生产环境只读数据)
|
||||
|
||||
> 数据来源:`https://official.inkreach.cc/api` 生产接口(JWT 只读拉取):
|
||||
> `/origin-goods?page=1..3&pageSize=200`(共 552 条)、`/goods?page=1..2&pageSize=200`(共 240 条)、
|
||||
> `/tags`、`/categories`、`/countries`。未做任何写操作。
|
||||
> 原始 JSON 快照存于 `runs/survey-2026-08-27/`(已加入 .gitignore,不入库)。
|
||||
|
||||
### 1. 右侧原产品(origin_goods)命名结构
|
||||
|
||||
右侧只有**一个字符串** `goodName`,整体模式:
|
||||
|
||||
```
|
||||
国家(物流备注)品名-SKU-工艺位置[-仓库名]
|
||||
↑ ↑ ↑ ↑ ↑
|
||||
| | | | └─ 可选第4段:美西洛杉矶一仓 / 美中亚特兰大仓 等
|
||||
| | | └─ 单面印花 / 双面印花 / 直喷双面 / 直喷单面 / 不打印 ...
|
||||
| | └─ SKU 编码(字母+数字,如 DG001 / C1717 / KRHM003)
|
||||
| └─ 包邮 / 不包邮 / DHL包邮*运费结算订单时支付 / 不包邮光板 等变体
|
||||
└─ 美国/德国/韩国...(全角括号为主,少量半角混用)
|
||||
```
|
||||
|
||||
统计(552 条):
|
||||
- **541 条**匹配 `国家(备注)其余` 模式;**11 条异常**:
|
||||
- 4 条自定义商品(`Goods Origin ...`,source=CUSTOM)
|
||||
- 2 条缺国家前缀:`(不包邮)230g水洗炒雪花T恤-双面印花` / `(不包邮)180g纯棉T恤成人款-...`
|
||||
- 1 条用空格代替括号:`法国 180g纯棉T恤-FRTM001-双面印花`
|
||||
- 2 条半角左括号混用:`美国(不包邮)女士高弹中长款瑜伽运动裤-JSD004-...`
|
||||
- 2 条同上变体
|
||||
- 「备注)之后」按 `-` 分段:**387 条 = 3 段**(品名-SKU-工艺)、**154 条 = 4 段**(品名-SKU-工艺-仓库)
|
||||
- 物流备注出现的变体形态(示例):`包邮`、`不包邮`、`DHL包邮`、`DHL包邮*运费结算订单时支付`、`包邮*运费订单结算时支付`、`不包邮光板`
|
||||
- 国家前缀出现过的值:美国、德国、英国、意大利、西班牙、西班牙直发、韩国、日本、加拿大、墨西哥、巴西、波兰、澳大利亚
|
||||
|
||||
### 2. 左侧已配置商品(goods)现状 —— 人工配置的实际结果
|
||||
|
||||
240 条 goods 记录,覆盖 **209 个**不同 origin_good_id(**343 个 origin 尚未配置**)。
|
||||
|
||||
**没有系统级拆分逻辑**:前端 [GoodsView.vue](../apps/admin/src/views/goods/GoodsView.vue) 提交的是完整原名,
|
||||
后端 [goods.service.ts](../apps/api/src/goods/goods.service.ts) create/batchCreate 也原样入库。
|
||||
因此左侧名称与右端的差异(113/240 条被改过)全部是**人工在编辑弹窗里改的**,且存在多种风格并存。
|
||||
|
||||
改名风格分布(113 条改名的归类):
|
||||
|
||||
| 风格 | 数量 | 示例 |
|
||||
|------|------|------|
|
||||
| A. 全拆:去前缀去括号,品名+空格+SKU,丢弃工艺段 | 27 | `韩国(包邮)200g纯棉T恤-KRTM001-单面印花` → `200g纯棉T恤 KRTM001` |
|
||||
| B. 半改:仅把长备注缩成「X包邮」,其余原样保留 | 86 | `德国(DHL包邮*运费结算订单时支付)230g水洗T恤-DETM002-单面印花` → `德国(DHL包邮)230g水洗T恤-DETM002-单面印花` |
|
||||
| R0. 保持原名不动 | 127 | 多为后期配置(2026-06 后期 ~ 07),未加工 |
|
||||
|
||||
标签体系(生产库现有 3 组 7 个标签)与左侧使用情况:
|
||||
|
||||
| 分组 | 标签(id) | 左侧使用次数 |
|
||||
|------|----------|--------------|
|
||||
| 物流渠道 | 包邮(30) / 不包邮(31) | 87 / 153 ← 基本每个商品都挂了物流标签 |
|
||||
| 印刷位置 | 双面印(35) / 单面印(34) | 14 / 14 ← 只有少数挂了 |
|
||||
| 印刷工艺 | 烫画(32) / 直喷(33) / 不打印(42) | 11 / 3 / 1 |
|
||||
|
||||
其他字段:goodPriority 几乎全部为 5;positionId 全部为空;
|
||||
国家字段与名称里的国家前缀一致率 238/240(仅 `西班牙直发(...)` 两条归入了「西班牙」)。
|
||||
|
||||
### 3. 人工操作流程(脚本要模拟的完整动作)
|
||||
|
||||
后台右侧树有「仅未配置」筛选按钮,人工实际是**两步操作**:
|
||||
|
||||
**第一步:配置(右→左创建)**
|
||||
1. 右侧找到未配置的原产品,点击「配置」
|
||||
2. 弹窗显示:原产品名(**纯文本,不可编辑**,[L1716](../apps/admin/src/views/goods/GoodsView.vue#L1716))、预览图片(可改但默认回填)
|
||||
3. 人工选择三项:**国家、分类、标签**(名称在此步不能改)
|
||||
4. 确认提交 → `POST /goods`,goodName 直接传原产品完整原名([L403](../apps/admin/src/views/goods/GoodsView.vue#L403))
|
||||
|
||||
**第二步:编辑改名(左侧已有记录上改)**
|
||||
1. 左侧找到刚配置的商品,点击「编辑」
|
||||
2. 编辑弹窗有 `el-input` 绑定 goodName([L1821](../apps/admin/src/views/goods/GoodsView.vue#L1821)),这里才能改名
|
||||
3. 保存 → `PATCH /goods/:id`([L690](../apps/admin/src/views/goods/GoodsView.vue#L690))
|
||||
|
||||
因此脚本的**实际操作序列**是:先 `POST /goods`(用原名创建)→ 再 `PATCH /goods/:id`(改名)
|
||||
|
||||
弹窗字段与 API 参数对应:
|
||||
|
||||
| 步骤 | 弹窗字段 | API 参数 | 说明 |
|
||||
|------|----------|----------|------|
|
||||
| 配置 | 国家 | `countryId` | 必填 |
|
||||
| 配置 | 分类 | `categoryId` | 必填 |
|
||||
| 配置 | 标签 | `tagIds[]` | 写入 good_tags 中间表 |
|
||||
| 配置 | 预览图片 | `goodImage` | 默认回填 origin_good.goodImage |
|
||||
| 配置 | 名称 | `goodName` | **不可编辑**,自动传原名 |
|
||||
| 编辑 | 名称 | `goodName` | **这一步才能改**,调 PATCH |
|
||||
| 编辑 | 其余字段 | 同上 | 也可在编辑时调整 |
|
||||
|
||||
### 4. 定稿转换规则
|
||||
|
||||
> 以下规则已确认,脚本按此执行。
|
||||
|
||||
#### 4.1 整体策略
|
||||
|
||||
- **方案:API 驱动**(不导出/导入 DB,避免 BigInt 自增序列和外键约束问题)
|
||||
- 脚本调用 `POST /goods` 逐条配置,走现有业务逻辑(校验、事务、标签关联全部由后端处理)
|
||||
- 幂等:已存在的 (originGoodId, countryId) 组合跳过
|
||||
- scope:仅处理**未配置**的原产品(configuredCount === 0),已配置的不动
|
||||
|
||||
#### 4.2 名称解析与改名规则
|
||||
|
||||
原产品名格式:`国家(物流备注)品名-SKU-工艺位置[-仓库名]`
|
||||
|
||||
**改名规则**:保留到 `-` 分隔的第 2 段,用空格连接,丢弃后续段(工艺、仓库)。
|
||||
|
||||
```
|
||||
输入: 美国(包邮)180g纯棉T恤成人款-DG001-单面印花
|
||||
解析: 品名="180g纯棉T恤成人款" SKU="DG001" 工艺="单面印花"(丢弃)
|
||||
输出: goodName = "180g纯棉T恤成人款 DG001"
|
||||
```
|
||||
|
||||
- 仓库名后缀(第 4 段,如「美西洛杉矶一仓」)直接丢弃
|
||||
- 异常名称(缺国家前缀、半角括号等)单独输出到报告,不自动处理
|
||||
|
||||
#### 4.3 国家
|
||||
|
||||
- 从名称第一个 `(` 之前提取国家文本
|
||||
- 通过 `GET /countries` 拿到 countries 表,按 `countryName` 精确匹配 → `countryId`
|
||||
- 特殊映射:「西班牙直发」→ 匹配「西班牙」
|
||||
- 匹配不到的 → 输出到报告,不自动处理
|
||||
|
||||
#### 4.4 分类(品类)
|
||||
|
||||
- 右侧树已经按 categories 树分组(通过 `origin_goods.sds_category_id` ↔ `categories.sds_category_id` 桥接)
|
||||
- 同一原产品的分类就是它在右侧树中所挂的分类节点,直接取该节点的 `categoryId`
|
||||
- 未分类的(sdsCategoryId 无匹配)→ 输出到报告
|
||||
|
||||
#### 4.5 标签(3 组,从名称解析)
|
||||
|
||||
标签体系(3 组 7 个):
|
||||
|
||||
| 分组 | 标签 | id | 解析规则 |
|
||||
|------|------|----|----------|
|
||||
| 物流渠道 | 包邮 / 不包邮 | 30 / 31 | 括号内含「包邮」→ 包邮(30),否则 → 不包邮(31) |
|
||||
| 印刷位置 | 单面印 / 双面印 | 34 / 35 | 工艺段含「单面」→ 单面印(34),含「双面」→ 双面印(35) |
|
||||
| 印刷工艺 | 烫画 / 直喷 / 不打印 | 32 / 33 / 42 | 工艺段含「直喷」→ 直喷(33);含「不打印」或「光板」→ 不打印(42);其余默认 → 烫画(32) |
|
||||
|
||||
工艺段 = `-` 分段的最后一段(去仓库段后),如 `单面印花`、`直喷双面`、`不打印`、`烫画`。
|
||||
|
||||
解析示例:
|
||||
|
||||
```
|
||||
美国(不包邮)180g纯棉T恤成人款-DG001-单面印花
|
||||
→ 物流: 不包邮(31), 印刷位置: 单面印(34), 印刷工艺: 烫画(32)
|
||||
|
||||
美国(不包邮)207G重磅纯棉T恤-C1717-直喷双面
|
||||
→ 物流: 不包邮(31), 印刷位置: 双面印(35), 印刷工艺: 直喷(33)
|
||||
|
||||
美国(不包邮光板)180g纯棉T恤成人款-DG001-不打印
|
||||
→ 物流: 不包邮(31), 印刷位置: 跳过(工艺段=不打印无法判断单双面), 印刷工艺: 不打印(42)
|
||||
```
|
||||
|
||||
#### 4.6 其他字段
|
||||
|
||||
- `goodImage`:直接用 origin_good.goodImage,不改
|
||||
- `goodPriority`:默认 5(与现有 236/240 条一致)
|
||||
- `positionId`:不填(与现有 240/240 条一致)
|
||||
|
||||
---
|
||||
|
||||
## 执行流水线(API 驱动方案)
|
||||
|
||||
```
|
||||
① 拉取只读数据
|
||||
调 GET /origin-goods/tree(含 configuredCount)、/tags、/categories、/countries
|
||||
存到 runs/<date>/snapshot/
|
||||
|
||||
② 本地解析脚本 sync-plan.mjs
|
||||
读取快照 → 筛选未配置 → 逐条解析名称 → 生成待创建指令列表
|
||||
输出: runs/<date>/plan.json(每条包含 originGoodId/countryId/categoryId/goodName/tagIds/goodImage)
|
||||
+ runs/<date>/plan-report.md(人工检查点:共 N 条待创建、X 条异常需人工确认)
|
||||
|
||||
③ 人工审查 plan.json 和 plan-report.md
|
||||
|
||||
④ 执行脚本 sync-apply.mjs
|
||||
读取 plan.json → 逐条 POST /goods(带 JWT,自动刷新 token)
|
||||
每条打印 +/skip/error;失败自动重试 3 次;生成 apply-report.md
|
||||
|
||||
⑤ 验证:调 /origin-goods/tree 确认未配置数归零,调 /goods 抽查新记录
|
||||
```
|
||||
|
||||
原则:
|
||||
- 不导出/导入数据库,全部通过 API 操作
|
||||
- ② 和 ④ 分离:先生成计划供人工审查,确认后再执行
|
||||
- 每步产物存 runs/(已 gitignore)
|
||||
- token 30 分钟过期,脚本内自动用 /auth/login 刷新
|
||||
|
||||
## 目录内容规划
|
||||
|
||||
| 文件 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| `sync-rules.config.mjs` | 待创建 | 国家映射/异常处理等配置项(解析规则已定稿在上文) |
|
||||
| `sync-plan.mjs` | 待创建 | 拉取 + 解析 + 生成 plan.json |
|
||||
| `sync-apply.mjs` | 待创建 | 读取 plan.json + 逐条 POST /goods |
|
||||
| `runs/` | 已创建 | 调查快照 survey-2026-08-27/ 已存在;后续每次运行产物按日期归档 |
|
||||
|
||||
详细实施计划见 [plans/feature/goods-data-cleaning-feature.md](../plans/feature/goods-data-cleaning-feature.md)。
|
||||
@@ -0,0 +1,280 @@
|
||||
# Goods Data Cleaning Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 用「备份 → 导出 → 脚本转换 → 导入 → 验证」的流水线,把右侧 SDS 原产品库(`origin_goods`)按规则批量配置为左侧官网商品(`goods`),替代后台手动逐个点击。直接作用于生产库。
|
||||
|
||||
**Background:**
|
||||
- 后台 GoodsView 已实现手动配置能力:右侧树选原产品 → 选国家/品类/标签 → 生成 `goods` 记录。
|
||||
- 手动逐个点击效率低,需要脚本化批量处理。
|
||||
- 同步规则(品类映射 / 国家分配 / 标签分配 / 优先级策略)**尚未确定**,将在后续数据清洗计划中明确后填入 `data-cleaning/sync-rules.config.mjs`。
|
||||
|
||||
**Architecture:** 三步式流水线,复用现有 export/import 脚本,只新写转换脚本与独立规则文件。规则文件占位先行——改规则不改代码。转换脚本为纯函数式(JSON in → JSON out),不直接连接数据库;导入前输出变更摘要报告作为人工检查点;导入前必须完成全量备份保证可回滚。
|
||||
|
||||
**Tech Stack:** Node.js ESM 脚本、@prisma/client(仅 export/import 使用)、PostgreSQL。
|
||||
|
||||
**目录约定:**
|
||||
- 清洗相关脚本/规则/产物统一放根目录 `data-cleaning/`(见 [data-cleaning/README.md](../../data-cleaning/README.md))
|
||||
- 复用现有 [apps/api/scripts/export-data.mjs](../../apps/api/scripts/export-data.mjs) 与 [apps/api/scripts/import-data.mjs](../../apps/api/scripts/import-data.mjs)
|
||||
|
||||
---
|
||||
|
||||
## 流水线总览
|
||||
|
||||
```
|
||||
[生产库]
|
||||
│ ① pg_dump 全量备份
|
||||
▼
|
||||
② node scripts/export-data.mjs data-cleaning/runs/<date>/export.json
|
||||
▼
|
||||
③ node data-cleaning/sync-transform.mjs data-cleaning/runs/<date>/export.json
|
||||
→ 输出 transformed.json + change-report.md(人工检查点)
|
||||
▼
|
||||
④ node scripts/import-data.mjs data-cleaning/runs/<date>/transformed.json
|
||||
▼
|
||||
⑤ 验证:行数对比 / 抽查商品配置 / 官网公开 API 抽查
|
||||
```
|
||||
|
||||
### Task 1: 创建规则文件占位 `sync-rules.config.mjs`
|
||||
|
||||
**Files:**
|
||||
- Create: `data-cleaning/sync-rules.config.mjs`
|
||||
- Test: 无(纯数据文件,由 Task 2 的测试覆盖加载逻辑)
|
||||
|
||||
- [x] **Step 1: 创建规则文件骨架**
|
||||
|
||||
```js
|
||||
/**
|
||||
* 商品数据清洗 — 同步规则配置
|
||||
*
|
||||
* 规则尚未确定。确定后只修改本文件,不改 sync-transform.mjs。
|
||||
* 字段语义在规则确定时补充说明。
|
||||
*/
|
||||
export const rules = {
|
||||
/** 品类映射:SDS 品类 → 本地品类(待定) */
|
||||
categoryMapping: {
|
||||
// '<sds_category_id 或名称>': '<本地 category_id 或名称>',
|
||||
},
|
||||
|
||||
/** 国家分配:每条原产品生成哪些国家的 good(待定) */
|
||||
countryAssignment: {
|
||||
mode: 'none', // none | all | fixed | perCategory
|
||||
fixedCountryIds: [],
|
||||
perCategory: {},
|
||||
},
|
||||
|
||||
/** 标签分配:新 good 挂哪些 tag(待定) */
|
||||
tagAssignment: {
|
||||
mode: 'none', // none | fixed | perCategory
|
||||
fixedTagIds: [],
|
||||
perCategory: {},
|
||||
},
|
||||
|
||||
/** 优先级策略(待定) */
|
||||
priority: {
|
||||
defaultPriority: 0,
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
- [x] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add data-cleaning/sync-rules.config.mjs
|
||||
git commit -m "feat(data-cleaning): add sync rules config placeholder"
|
||||
```
|
||||
|
||||
### Task 2: 创建转换脚本 `sync-transform.mjs`(TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `data-cleaning/sync-transform.mjs`
|
||||
- Test: `data-cleaning/sync-transform.test.mjs`
|
||||
|
||||
- [x] **Step 1: 写失败测试**
|
||||
|
||||
```js
|
||||
// data-cleaning/sync-transform.test.mjs
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { transform } from './sync-transform.mjs';
|
||||
import { rules } from './sync-rules.config.mjs';
|
||||
|
||||
const baseDump = () => ({
|
||||
exportedAt: new Date().toISOString(),
|
||||
tables: {
|
||||
users: [], countries: [], categories: [], tag_groups: [], tags: [],
|
||||
positions: [],
|
||||
origin_goods: [
|
||||
{ id: '1', sds_good_id: 'SDS-A', good_name: 'A', delisted: 'false', is_custom: 'false' },
|
||||
{ id: '2', sds_good_id: 'SDS-B', good_name: 'B', delisted: 'true', is_custom: 'false' },
|
||||
],
|
||||
origin_good_variants: [], origin_good_details: [],
|
||||
goods: [], good_tags: [], sync_logs: [],
|
||||
},
|
||||
});
|
||||
|
||||
describe('transform', () => {
|
||||
it('rules 为空时原样返回且报告零变更', () => {
|
||||
const dump = baseDump();
|
||||
const { result, report } = transform(dump, rules);
|
||||
expect(result.tables.goods).toHaveLength(0);
|
||||
expect(report.created).toBe(0);
|
||||
expect(result.tables.origin_goods).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('跳过已下架原产品', () => {
|
||||
const dump = baseDump();
|
||||
const testRules = { ...rules, countryAssignment: { mode: 'all' } };
|
||||
const { report } = transform(dump, testRules);
|
||||
// 只有未下架的 id=1 会生成 good
|
||||
expect(report.created).toBe(1);
|
||||
expect(report.skippedDelisted).toBe(1);
|
||||
});
|
||||
|
||||
it('幂等:已存在的 (originGoodId,countryId) 不重复创建', () => {
|
||||
const dump = baseDump();
|
||||
dump.tables.goods = [
|
||||
{ id: '100', origin_good_id: '1', country_id: '9', good_name: 'A', good_priority: '0' },
|
||||
];
|
||||
dump.tables.countries = [{ id: '9', country_name: 'US' }];
|
||||
const testRules = {
|
||||
...rules,
|
||||
countryAssignment: { mode: 'fixed', fixedCountryIds: ['9'] },
|
||||
};
|
||||
const { report } = transform(dump, testRules);
|
||||
expect(report.created).toBe(0);
|
||||
expect(report.duplicates).toBe(1);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
> 注:若仓库未配置 vitest 运行 `.mjs`,可用 `node --test`(node:test)替代,断言等价改写。
|
||||
|
||||
- [x] **Step 2: 运行测试确认 RED**
|
||||
|
||||
Run: `cd apps/api && npx vitest run ../../data-cleaning/sync-transform.test.mjs`(或 `node --test data-cleaning/`)
|
||||
Expected: FAIL(模块不存在)
|
||||
|
||||
- [x] **Step 3: 最小实现**
|
||||
|
||||
```js
|
||||
// data-cleaning/sync-transform.mjs
|
||||
/**
|
||||
* 数据转换脚本(纯函数式):读取导出 JSON + 规则文件,
|
||||
* 输出待导入 JSON(transformed.json)与变更摘要(change-report.md)。
|
||||
*
|
||||
* Usage:
|
||||
* node data-cleaning/sync-transform.mjs <export.json>
|
||||
*/
|
||||
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import { rules } from './sync-rules.config.mjs';
|
||||
|
||||
/** 纯函数:dump + rules → { result, report } */
|
||||
export function transform(dump, rulesConfig) {
|
||||
const tables = JSON.parse(JSON.stringify(dump.tables)); // deep clone
|
||||
const countries = tables.countries;
|
||||
const existingKeys = new Set(
|
||||
tables.goods.map((g) => `${g.origin_good_id}:${g.country_id}`),
|
||||
);
|
||||
|
||||
const report = { created: 0, duplicates: 0, skippedDelisted: 0, details: [] };
|
||||
const activeCountries = countries.filter((c) => c.id);
|
||||
|
||||
for (const og of tables.origin_goods) {
|
||||
if (og.delisted === 'true' || og.delisted === true) {
|
||||
if (og.is_custom !== 'true' && og.is_custom !== true) report.skippedDelisted++;
|
||||
continue;
|
||||
}
|
||||
let targets = [];
|
||||
if (rulesConfig.countryAssignment.mode === 'all') {
|
||||
targets = activeCountries.map((c) => c.id);
|
||||
} else if (rulesConfig.countryAssignment.mode === 'fixed') {
|
||||
targets = rulesConfig.countryAssignment.fixedCountryIds;
|
||||
}
|
||||
|
||||
for (const countryId of targets) {
|
||||
const key = `${og.id}:${countryId}`;
|
||||
if (existingKeys.has(key)) { report.duplicates++; continue; }
|
||||
const newId = String(
|
||||
tables.goods.reduce((m, g) => Math.max(m, Number(g.id || 0)), 0) + 1,
|
||||
);
|
||||
tables.goods.push({
|
||||
id: newId,
|
||||
origin_good_id: og.id,
|
||||
country_id: countryId,
|
||||
category_id: rulesConfig.categoryMapping.default ?? '',
|
||||
good_name: og.good_name,
|
||||
good_priority: String(rulesConfig.priority.defaultPriority ?? 0),
|
||||
});
|
||||
existingKeys.add(key);
|
||||
report.created++;
|
||||
report.details.push(`+ good[${newId}] origin=${og.sds_good_id} country=${countryId}`);
|
||||
}
|
||||
}
|
||||
return { result: { exportedAt: dump.exportedAt, tables }, report };
|
||||
}
|
||||
|
||||
function main() {
|
||||
const input = process.argv[2];
|
||||
if (!input) {
|
||||
console.error('Usage: node data-cleaning/sync-transform.mjs <export.json>');
|
||||
process.exit(1);
|
||||
}
|
||||
const dump = JSON.parse(readFileSync(input, 'utf8'));
|
||||
const { result, report } = transform(dump, rules);
|
||||
|
||||
mkdirSync(`${dirname(input)}/out`, { recursive: true });
|
||||
writeFileSync(`${dirname(input)}/out/transformed.json`, JSON.stringify(result));
|
||||
writeFileSync(
|
||||
`${dirname(input)}/out/change-report.md`,
|
||||
['# 变更摘要', `- 新增 goods: ${report.created}`, `- 重复跳过: ${report.duplicates}`,
|
||||
`- 下架跳过: ${report.skippedDelisted}`, '', ...report.details.map((d) => `- ${d}`)].join('\n'),
|
||||
);
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
}
|
||||
|
||||
// 测试环境下不自动执行 main
|
||||
if (process.env.NODE_ENV !== 'test' && import.meta.url === `file://${process.argv[1]}`) {
|
||||
main();
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 4: 运行测试确认 GREEN**
|
||||
|
||||
Run: `cd apps/api && npx vitest run ../../data-cleaning/sync-transform.test.mjs`
|
||||
Expected: PASS(3 个用例全过)
|
||||
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add data-cleaning/sync-transform.mjs data-cleaning/sync-transform.test.mjs
|
||||
git commit -m "feat(data-cleaning): add pure transform script with tests"
|
||||
```
|
||||
|
||||
### Task 3: 生产执行手册写入 README 并联调演练
|
||||
|
||||
**Files:**
|
||||
- Modify: `data-cleaning/README.md`
|
||||
|
||||
- [x] **Step 1: 补充执行命令段**(备份 / 导出 / 转换 / 人工检查 / 导入 / 验证 六个步骤的确切命令与预期输出)
|
||||
|
||||
- [x] **Step 2: 本地或预发演练一次全流程**(规则为空应零变更),确认 change-report 为空、行数一致
|
||||
|
||||
- [x] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add data-cleaning/README.md
|
||||
git commit -m "docs(data-cleaning): add production runbook"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 待定事项(规则确定后回填)
|
||||
|
||||
| 项 | 状态 | 回填位置 |
|
||||
|----|------|----------|
|
||||
| 品类映射规则 | 未定 | `data-cleaning/sync-rules.config.mjs#categoryMapping` |
|
||||
| 国家分配策略 | 未定 | `...#countryAssignment` |
|
||||
| 标签分配策略 | 未定 | `...#tagAssignment` |
|
||||
| 优先级策略 | 未定 | `...#priority` |
|
||||
| 规则细节文档 | 未定 | 后续数据清洗计划文件夹(本目录)内新建规则说明 md |
|
||||
Reference in New Issue
Block a user