chore: 统一代码中的中文标点符号为英文格式
feat(site): 为站点DTO添加token字段 feat(upload): 添加文件上传配置支持CSV导入 refactor(product): 移除product实体中的stock字段并优化DTO style: 修复代码中的中文标点符号和注释格式 docs: 更新迁移指南和API文档中的标点符号格式 test: 添加标点符号替换脚本用于规范化代码格式
This commit is contained in:
parent
b8aee530e8
commit
998e1e31c7
|
|
@ -15,3 +15,4 @@ yarn.lock
|
|||
**/config.prod.ts
|
||||
**/config.local.ts
|
||||
container
|
||||
ai/products-20251202 (1).csv
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
## 概述
|
||||
|
||||
本文档详细描述了区域管理相关的API接口,包括增删改查操作以及新增的坐标功能。
|
||||
本文档详细描述了区域管理相关的API接口,包括增删改查操作以及新增的坐标功能.
|
||||
|
||||
## API 接口列表
|
||||
|
||||
|
|
@ -24,8 +24,8 @@
|
|||
|
||||
**参数说明**
|
||||
- `name`: 区域名称 (必填)
|
||||
- `latitude`: 纬度 (-90 到 90 之间,可选)
|
||||
- `longitude`: 经度 (-180 到 180 之间,可选)
|
||||
- `latitude`: 纬度 (-90 到 90 之间,可选)
|
||||
- `longitude`: 经度 (-180 到 180 之间,可选)
|
||||
|
||||
**响应示例**
|
||||
```json
|
||||
|
|
@ -61,8 +61,8 @@
|
|||
|
||||
**参数说明**
|
||||
- `name`: 区域名称 (可选)
|
||||
- `latitude`: 纬度 (-90 到 90 之间,可选)
|
||||
- `longitude`: 经度 (-180 到 180 之间,可选)
|
||||
- `latitude`: 纬度 (-90 到 90 之间,可选)
|
||||
- `longitude`: 经度 (-180 到 180 之间,可选)
|
||||
|
||||
**响应示例**
|
||||
```json
|
||||
|
|
@ -96,7 +96,7 @@
|
|||
}
|
||||
```
|
||||
|
||||
### 4. 获取区域列表(分页)
|
||||
### 4. 获取区域列表(分页)
|
||||
|
||||
**请求信息**
|
||||
- URL: `/api/area`
|
||||
|
|
@ -105,7 +105,7 @@
|
|||
- Query Parameters:
|
||||
- `currentPage`: 当前页码 (默认 1)
|
||||
- `pageSize`: 每页数量 (默认 10)
|
||||
- `name`: 区域名称(可选,用于搜索)
|
||||
- `name`: 区域名称(可选,用于搜索)
|
||||
|
||||
**响应示例**
|
||||
```json
|
||||
|
|
@ -186,11 +186,11 @@
|
|||
|
||||
## 世界地图实现建议
|
||||
|
||||
对于前端实现世界地图并显示区域坐标,推荐以下方案:
|
||||
对于前端实现世界地图并显示区域坐标,推荐以下方案:
|
||||
|
||||
### 1. 使用开源地图库
|
||||
|
||||
- **Leaflet.js**: 轻量级开源地图库,易于集成
|
||||
- **Leaflet.js**: 轻量级开源地图库,易于集成
|
||||
- **Mapbox**: 提供丰富的地图样式和交互功能
|
||||
- **Google Maps API**: 功能强大但需要API密钥
|
||||
|
||||
|
|
@ -222,15 +222,15 @@
|
|||
4. **添加交互功能**:
|
||||
- 点击标记显示区域详情
|
||||
- 搜索和筛选功能
|
||||
- 编辑坐标功能(调用更新API)
|
||||
- 编辑坐标功能(调用更新API)
|
||||
|
||||
### 3. 坐标输入建议
|
||||
|
||||
在区域管理界面,可以添加以下功能来辅助坐标输入:
|
||||
在区域管理界面,可以添加以下功能来辅助坐标输入:
|
||||
|
||||
1. 提供搜索框,根据地点名称自动获取坐标
|
||||
2. 集成小型地图,允许用户点击选择位置
|
||||
3. 添加验证,确保输入的坐标在有效范围内
|
||||
1. 提供搜索框,根据地点名称自动获取坐标
|
||||
2. 集成小型地图,允许用户点击选择位置
|
||||
3. 添加验证,确保输入的坐标在有效范围内
|
||||
|
||||
## 数据模型说明
|
||||
|
||||
|
|
@ -238,16 +238,16 @@
|
|||
|
||||
| 字段名 | 类型 | 描述 | 是否必填 |
|
||||
|--------|------|------|----------|
|
||||
| id | number | 区域ID | 否(自动生成) |
|
||||
| id | number | 区域ID | 否(自动生成) |
|
||||
| name | string | 区域名称 | 是 |
|
||||
| latitude | number | 纬度(范围:-90 到 90) | 否 |
|
||||
| longitude | number | 经度(范围:-180 到 180) | 否 |
|
||||
| createdAt | Date | 创建时间 | 否(自动生成) |
|
||||
| updatedAt | Date | 更新时间 | 否(自动生成) |
|
||||
| latitude | number | 纬度(范围:-90 到 90) | 否 |
|
||||
| longitude | number | 经度(范围:-180 到 180) | 否 |
|
||||
| createdAt | Date | 创建时间 | 否(自动生成) |
|
||||
| updatedAt | Date | 更新时间 | 否(自动生成) |
|
||||
|
||||
## 错误处理
|
||||
|
||||
API可能返回的错误信息:
|
||||
API可能返回的错误信息:
|
||||
|
||||
- `区域名称已存在`: 当尝试创建或更新区域名称与现有名称重复时
|
||||
- `区域不存在`: 当尝试更新或删除不存在的区域时
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
# 数据库迁移指南
|
||||
|
||||
为了支持区域坐标功能,需要执行数据库迁移操作,将新增的 `latitude` 和 `longitude` 字段添加到数据库表中。
|
||||
为了支持区域坐标功能,需要执行数据库迁移操作,将新增的 `latitude` 和 `longitude` 字段添加到数据库表中.
|
||||
|
||||
## 执行迁移步骤
|
||||
|
||||
### 1. 生成迁移文件
|
||||
|
||||
运行以下命令生成迁移文件:
|
||||
运行以下命令生成迁移文件:
|
||||
|
||||
```bash
|
||||
npm run migration:generate -- ./src/db/migrations/AddCoordinatesToArea
|
||||
|
|
@ -14,19 +14,19 @@ npm run migration:generate -- ./src/db/migrations/AddCoordinatesToArea
|
|||
|
||||
### 2. 检查迁移文件
|
||||
|
||||
生成的迁移文件会自动包含添加 `latitude` 和 `longitude` 字段的SQL语句。您可以检查文件内容确保迁移逻辑正确。
|
||||
生成的迁移文件会自动包含添加 `latitude` 和 `longitude` 字段的SQL语句.您可以检查文件内容确保迁移逻辑正确.
|
||||
|
||||
### 3. 执行迁移
|
||||
|
||||
运行以下命令执行迁移,将更改应用到数据库:
|
||||
运行以下命令执行迁移,将更改应用到数据库:
|
||||
|
||||
```bash
|
||||
npm run migration:run
|
||||
```
|
||||
|
||||
## 手动迁移SQL(可选)
|
||||
## 手动迁移SQL(可选)
|
||||
|
||||
如果需要手动执行SQL,可以使用以下语句:
|
||||
如果需要手动执行SQL,可以使用以下语句:
|
||||
|
||||
```sql
|
||||
ALTER TABLE `area`
|
||||
|
|
@ -34,9 +34,9 @@ ADD COLUMN `latitude` DECIMAL(10,6) NULL AFTER `name`,
|
|||
ADD COLUMN `longitude` DECIMAL(10,6) NULL AFTER `latitude`;
|
||||
```
|
||||
|
||||
## 回滚迁移(如需)
|
||||
## 回滚迁移(如需)
|
||||
|
||||
如果遇到问题,可以使用以下命令回滚迁移:
|
||||
如果遇到问题,可以使用以下命令回滚迁移:
|
||||
|
||||
```bash
|
||||
npm run typeorm -- migration:revert -d src/db/datasource.ts
|
||||
|
|
@ -45,5 +45,5 @@ npm run typeorm -- migration:revert -d src/db/datasource.ts
|
|||
## 注意事项
|
||||
|
||||
- 确保在执行迁移前备份数据库
|
||||
- 迁移不会影响现有数据,新增字段默认为 NULL
|
||||
- 迁移后,可以通过API开始为区域添加坐标信息
|
||||
- 迁移不会影响现有数据,新增字段默认为 NULL
|
||||
- 迁移后,可以通过API开始为区域添加坐标信息
|
||||
|
|
@ -20,6 +20,7 @@
|
|||
"@midwayjs/logger": "^3.1.0",
|
||||
"@midwayjs/swagger": "^3.20.2",
|
||||
"@midwayjs/typeorm": "^3.20.0",
|
||||
"@midwayjs/upload": "^3.20.16",
|
||||
"@midwayjs/validate": "^3.20.2",
|
||||
"@woocommerce/woocommerce-rest-api": "^1.0.2",
|
||||
"axios": "^1.13.2",
|
||||
|
|
@ -813,6 +814,19 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@midwayjs/upload": {
|
||||
"version": "3.20.16",
|
||||
"resolved": "https://registry.npmjs.org/@midwayjs/upload/-/upload-3.20.16.tgz",
|
||||
"integrity": "sha512-lnHDOeU4wGvABJjYjSR5dpG+6f4+hxJhU+1TsT+OsrKMDju6AyTOnTSFa1V1vMO7+VplKzkWP+ZFJkAVI0Buuw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"file-type": "16.5.4",
|
||||
"raw-body": "2.5.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@midwayjs/validate": {
|
||||
"version": "3.20.13",
|
||||
"resolved": "https://registry.npmmirror.com/@midwayjs/validate/-/validate-3.20.13.tgz",
|
||||
|
|
@ -928,6 +942,12 @@
|
|||
"integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tokenizer/token": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
|
||||
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/accepts": {
|
||||
"version": "1.3.7",
|
||||
"resolved": "https://registry.npmmirror.com/@types/accepts/-/accepts-1.3.7.tgz",
|
||||
|
|
@ -1153,6 +1173,18 @@
|
|||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/abort-controller": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"event-target-shim": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.5"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmmirror.com/accepts/-/accepts-1.3.8.tgz",
|
||||
|
|
@ -2246,6 +2278,24 @@
|
|||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/event-target-shim": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
|
||||
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/events": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8.x"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-glob": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||
|
|
@ -2278,6 +2328,23 @@
|
|||
"reusify": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/file-type": {
|
||||
"version": "16.5.4",
|
||||
"resolved": "https://registry.npmjs.org/file-type/-/file-type-16.5.4.tgz",
|
||||
"integrity": "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"readable-web-to-node-stream": "^3.0.0",
|
||||
"strtok3": "^6.2.4",
|
||||
"token-types": "^4.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sindresorhus/file-type?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz",
|
||||
|
|
@ -3607,6 +3674,19 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/peek-readable": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-4.1.0.tgz",
|
||||
"integrity": "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Borewit"
|
||||
}
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.1.tgz",
|
||||
|
|
@ -3641,6 +3721,15 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/process": {
|
||||
"version": "0.11.10",
|
||||
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
|
||||
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
|
|
@ -3777,6 +3866,47 @@
|
|||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/readable-web-to-node-stream": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.4.tgz",
|
||||
"integrity": "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"readable-stream": "^4.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Borewit"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-web-to-node-stream/node_modules/readable-stream": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
|
||||
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"abort-controller": "^3.0.0",
|
||||
"buffer": "^6.0.3",
|
||||
"events": "^3.3.0",
|
||||
"process": "^0.11.10",
|
||||
"string_decoder": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-web-to-node-stream/node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-3.6.0.tgz",
|
||||
|
|
@ -4302,6 +4432,23 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strtok3": {
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.3.0.tgz",
|
||||
"integrity": "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tokenizer/token": "^0.3.0",
|
||||
"peek-readable": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Borewit"
|
||||
}
|
||||
},
|
||||
"node_modules/superagent": {
|
||||
"version": "8.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/superagent/-/superagent-8.1.2.tgz",
|
||||
|
|
@ -4402,6 +4549,23 @@
|
|||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/token-types": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/token-types/-/token-types-4.2.1.tgz",
|
||||
"integrity": "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tokenizer/token": "^0.3.0",
|
||||
"ieee754": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Borewit"
|
||||
}
|
||||
},
|
||||
"node_modules/tsc-alias": {
|
||||
"version": "1.8.16",
|
||||
"resolved": "https://registry.npmmirror.com/tsc-alias/-/tsc-alias-1.8.16.tgz",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
"@midwayjs/logger": "^3.1.0",
|
||||
"@midwayjs/swagger": "^3.20.2",
|
||||
"@midwayjs/typeorm": "^3.20.0",
|
||||
"@midwayjs/upload": "^3.20.16",
|
||||
"@midwayjs/validate": "^3.20.2",
|
||||
"@woocommerce/woocommerce-rest-api": "^1.0.2",
|
||||
"axios": "^1.13.2",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const map = {
|
||||
',': ',',
|
||||
'.': '.',
|
||||
':': ':',
|
||||
'?': '?',
|
||||
'!': '!',
|
||||
'"': '"',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
''': "'",
|
||||
'(': '(',
|
||||
')': ')',
|
||||
'[': '[',
|
||||
']': ']',
|
||||
',': ',',
|
||||
';': ';'
|
||||
};
|
||||
|
||||
function getAllFiles(dirPath, arrayOfFiles) {
|
||||
const files = fs.readdirSync(dirPath);
|
||||
|
||||
arrayOfFiles = arrayOfFiles || [];
|
||||
|
||||
files.forEach(function(file) {
|
||||
if (fs.statSync(dirPath + "/" + file).isDirectory()) {
|
||||
arrayOfFiles = getAllFiles(dirPath + "/" + file, arrayOfFiles);
|
||||
} else {
|
||||
if (extensions.some(ext => file.endsWith(ext))) {
|
||||
arrayOfFiles.push(path.join(dirPath, "/", file));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return arrayOfFiles;
|
||||
}
|
||||
|
||||
const srcDirAPI = path.join(__dirname);
|
||||
const srcDirWEB = path.join(__dirname, '../WEB');
|
||||
|
||||
const targetDirs = [srcDirAPI, srcDirWEB];
|
||||
|
||||
const extensions = ['.ts', '.js', '.tsx', '.jsx', '.vue', '.html', '.css', '.scss', '.less', '.json', '.md'];
|
||||
|
||||
let count = 0;
|
||||
|
||||
targetDirs.forEach(dir => {
|
||||
if (fs.existsSync(dir)) {
|
||||
const files = getAllFiles(dir);
|
||||
files.forEach(file => {
|
||||
// Skip node_modules, .git, dist, build, .idea, .vscode
|
||||
if (file.includes('/node_modules/') ||
|
||||
file.includes('/.git/') ||
|
||||
file.includes('/dist/') ||
|
||||
file.includes('/build/') ||
|
||||
file.includes('/.idea/') ||
|
||||
file.includes('/.vscode/') ||
|
||||
file.includes('/coverage/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
let content = fs.readFileSync(file, 'utf8');
|
||||
let originalContent = content;
|
||||
|
||||
for (const [cn, en] of Object.entries(map)) {
|
||||
const regex = new RegExp(cn, 'g');
|
||||
content = content.replace(regex, en);
|
||||
}
|
||||
|
||||
if (content !== originalContent) {
|
||||
fs.writeFileSync(file, content, 'utf8');
|
||||
console.log(`Updated: ${file}`);
|
||||
count++;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.warn(`Directory not found: ${dir}`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`Total files updated: ${count}`);
|
||||
|
|
@ -107,7 +107,7 @@ export default {
|
|||
// origin: '*', // 允许所有来源跨域请求
|
||||
// allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], // 允许的 HTTP 方法
|
||||
// allowHeaders: ['Content-Type', 'Authorization'], // 允许的自定义请求头
|
||||
// credentials: true, // 允许携带凭据(cookies等)
|
||||
// credentials: true, // 允许携带凭据(cookies等)
|
||||
// },
|
||||
// jwt: {
|
||||
// secret: 'YOONE2024!@abc',
|
||||
|
|
@ -140,5 +140,11 @@ export default {
|
|||
user: 'info@canpouches.com',
|
||||
pass: 'WWqQ4aZq4Jrm9uwz',
|
||||
},
|
||||
}
|
||||
},
|
||||
upload: {
|
||||
// mode: 'file', // 默认为file,即上传到服务器临时目录,可以配置为 stream
|
||||
mode: 'file',
|
||||
fileSize: '10mb', // 最大支持的文件大小,默认为 10mb
|
||||
whitelist: ['.csv'], // 支持的文件后缀
|
||||
},
|
||||
} as MidwayConfig;
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import * as swagger from '@midwayjs/swagger';
|
|||
import * as crossDomain from '@midwayjs/cross-domain';
|
||||
import * as cron from '@midwayjs/cron';
|
||||
import * as jwt from '@midwayjs/jwt';
|
||||
import * as upload from '@midwayjs/upload';
|
||||
import { USER_KEY } from './decorator/user.decorator';
|
||||
import { SiteService } from './service/site.service';
|
||||
import { AuthMiddleware } from './middleware/auth.middleware';
|
||||
|
|
@ -33,6 +34,7 @@ import { AuthMiddleware } from './middleware/auth.middleware';
|
|||
crossDomain,
|
||||
cron,
|
||||
jwt,
|
||||
upload,
|
||||
],
|
||||
importConfigs: [join(__dirname, './config')],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ export class AreaController {
|
|||
}
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: '获取区域列表(分页)' })
|
||||
@ApiOperation({ summary: '获取区域列表(分页)' })
|
||||
@ApiOkResponse({ type: [Area], description: '区域列表' })
|
||||
@ApiExtension('x-pagination', { currentPage: 1, pageSize: 10, total: 100 })
|
||||
@Get('/')
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ export class DictController {
|
|||
*/
|
||||
@Get('/:id')
|
||||
async getDict(@Param('id') id: number) {
|
||||
// 调用服务层方法,并关联查询字典项
|
||||
// 调用服务层方法,并关联查询字典项
|
||||
return this.dictService.getDict({ id }, ['items']);
|
||||
}
|
||||
|
||||
|
|
@ -101,7 +101,7 @@ export class DictController {
|
|||
/**
|
||||
* 批量导入字典项
|
||||
* @param files 上传的文件
|
||||
* @param body 请求体,包含字典ID
|
||||
* @param body 请求体,包含字典ID
|
||||
*/
|
||||
@Post('/item/import')
|
||||
@Validate()
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ export class LocaleController {
|
|||
|
||||
/**
|
||||
* 根据语言获取所有翻译项
|
||||
* @param lang 语言代码,例如 zh-CN, en-US
|
||||
* @param lang 语言代码,例如 zh-CN, en-US
|
||||
* @returns 返回一个包含所有翻译键值对的 JSON 对象
|
||||
*/
|
||||
@Get('/:lang')
|
||||
|
|
@ -20,7 +20,7 @@ export class LocaleController {
|
|||
// 根据语言代码查找对应的字典
|
||||
const dict = await this.dictService.getDict({ name: lang }, ['items']);
|
||||
|
||||
// 如果字典不存在,则返回空对象
|
||||
// 如果字典不存在,则返回空对象
|
||||
if (!dict) {
|
||||
return {};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
Query,
|
||||
Controller,
|
||||
} from '@midwayjs/core';
|
||||
import * as fs from 'fs';
|
||||
import { ProductService } from '../service/product.service';
|
||||
import { errorResponse, successResponse } from '../utils/response.util';
|
||||
import { CreateProductDTO, QueryProductDTO, UpdateProductDTO, SetProductComponentsDTO } from '../dto/product.dto';
|
||||
|
|
@ -62,12 +63,14 @@ export class ProductController {
|
|||
async getProductList(
|
||||
@Query() query: QueryProductDTO
|
||||
): Promise<ProductListRes> {
|
||||
const { current = 1, pageSize = 10, name, brandId } = query;
|
||||
const { current = 1, pageSize = 10, name, brandId, sortField, sortOrder } = query;
|
||||
try {
|
||||
const data = await this.productService.getProductList(
|
||||
{ current, pageSize },
|
||||
name,
|
||||
brandId
|
||||
brandId,
|
||||
sortField,
|
||||
sortOrder
|
||||
);
|
||||
return successResponse(data);
|
||||
} catch (error) {
|
||||
|
|
@ -87,14 +90,14 @@ export class ProductController {
|
|||
}
|
||||
}
|
||||
|
||||
// 中文注释:导出所有产品 CSV
|
||||
// 导出所有产品 CSV
|
||||
@ApiOkResponse()
|
||||
@Get('/export')
|
||||
@ContentType('text/csv')
|
||||
async exportProductsCSV() {
|
||||
try {
|
||||
const csv = await this.productService.exportProductsCSV();
|
||||
// 设置下载文件名(中文注释:附件形式)
|
||||
// 设置下载文件名(附件形式)
|
||||
const date = new Date();
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
const name = `products-${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}.csv`;
|
||||
|
|
@ -105,15 +108,26 @@ export class ProductController {
|
|||
}
|
||||
}
|
||||
|
||||
// 中文注释:导入产品(CSV 文件)
|
||||
// 导入产品(CSV 文件)
|
||||
@ApiOkResponse()
|
||||
@Post('/import')
|
||||
async importProductsCSV(@Files() files: any) {
|
||||
try {
|
||||
// 条件判断:确保存在文件
|
||||
// 条件判断:确保存在文件
|
||||
const file = files?.[0];
|
||||
if (!file?.data) return errorResponse('未接收到上传文件');
|
||||
const result = await this.productService.importProductsCSV(file.data);
|
||||
|
||||
// midway/upload file 模式下,data 是临时文件路径
|
||||
let buffer = file.data;
|
||||
if (typeof file.data === 'string') {
|
||||
try {
|
||||
buffer = fs.readFileSync(file.data);
|
||||
} catch (err) {
|
||||
return errorResponse('读取上传文件失败');
|
||||
}
|
||||
}
|
||||
|
||||
const result = await this.productService.importProductsCSV(buffer);
|
||||
return successResponse(result);
|
||||
} catch (error) {
|
||||
return errorResponse(error?.message || error);
|
||||
|
|
@ -153,7 +167,7 @@ export class ProductController {
|
|||
}
|
||||
}
|
||||
|
||||
// 中文注释:获取产品的库存组成
|
||||
// 获取产品的库存组成
|
||||
@ApiOkResponse()
|
||||
@Get('/:id/components')
|
||||
async getProductComponents(@Param('id') id: number) {
|
||||
|
|
@ -165,7 +179,7 @@ export class ProductController {
|
|||
}
|
||||
}
|
||||
|
||||
// 中文注释:设置产品的库存组成(覆盖式)
|
||||
// 设置产品的库存组成(覆盖式)
|
||||
@ApiOkResponse()
|
||||
@Post('/:id/components')
|
||||
async setProductComponents(@Param('id') id: number, @Body() body: SetProductComponentsDTO) {
|
||||
|
|
@ -177,7 +191,7 @@ export class ProductController {
|
|||
}
|
||||
}
|
||||
|
||||
// 中文注释:根据 SKU 自动绑定组成(匹配所有相同 SKU 的库存)
|
||||
// 根据 SKU 自动绑定组成(匹配所有相同 SKU 的库存)
|
||||
@ApiOkResponse()
|
||||
@Post('/:id/components/auto')
|
||||
async autoBindComponents(@Param('id') id: number) {
|
||||
|
|
@ -204,7 +218,7 @@ export class ProductController {
|
|||
|
||||
|
||||
|
||||
// 通用属性接口:分页列表
|
||||
// 通用属性接口:分页列表
|
||||
@ApiOkResponse()
|
||||
@Get('/attribute')
|
||||
async getAttributeList(
|
||||
|
|
@ -225,7 +239,7 @@ export class ProductController {
|
|||
}
|
||||
}
|
||||
|
||||
// 通用属性接口:全部列表
|
||||
// 通用属性接口:全部列表
|
||||
@ApiOkResponse()
|
||||
@Get('/attributeAll')
|
||||
async getAttributeAll(@Query('dictName') dictName: string) {
|
||||
|
|
@ -237,7 +251,7 @@ export class ProductController {
|
|||
}
|
||||
}
|
||||
|
||||
// 通用属性接口:创建
|
||||
// 通用属性接口:创建
|
||||
@ApiOkResponse()
|
||||
@Post('/attribute')
|
||||
async createAttribute(
|
||||
|
|
@ -245,7 +259,7 @@ export class ProductController {
|
|||
@Body() body: { title: string; name: string }
|
||||
) {
|
||||
try {
|
||||
// 调用 getOrCreateAttribute 方法,如果不存在则创建,如果存在则返回
|
||||
// 调用 getOrCreateAttribute 方法,如果不存在则创建,如果存在则返回
|
||||
const data = await this.productService.getOrCreateAttribute(dictName, body.title, body.name);
|
||||
return successResponse(data);
|
||||
} catch (error) {
|
||||
|
|
@ -253,7 +267,7 @@ export class ProductController {
|
|||
}
|
||||
}
|
||||
|
||||
// 通用属性接口:更新
|
||||
// 通用属性接口:更新
|
||||
@ApiOkResponse()
|
||||
@Put('/attribute/:id')
|
||||
async updateAttribute(
|
||||
|
|
@ -277,7 +291,7 @@ export class ProductController {
|
|||
}
|
||||
}
|
||||
|
||||
// 通用属性接口:删除
|
||||
// 通用属性接口:删除
|
||||
@ApiOkResponse({ type: BooleanRes })
|
||||
@Del('/attribute/:id')
|
||||
async deleteAttribute(@Param('id') id: number) {
|
||||
|
|
@ -289,12 +303,12 @@ export class ProductController {
|
|||
}
|
||||
}
|
||||
|
||||
// 兼容旧接口:品牌
|
||||
// 兼容旧接口:品牌
|
||||
@ApiOkResponse()
|
||||
@Get('/brandAll')
|
||||
async compatBrandAll() {
|
||||
try {
|
||||
const data = await this.productService.getAttributeAll('brand'); // 中文注释:返回所有品牌字典项
|
||||
const data = await this.productService.getAttributeAll('brand'); // 返回所有品牌字典项
|
||||
return successResponse(data);
|
||||
} catch (error) {
|
||||
return errorResponse(error?.message || error);
|
||||
|
|
@ -305,7 +319,7 @@ export class ProductController {
|
|||
@Get('/brands')
|
||||
async compatBrands(@Query('current') current = 1, @Query('pageSize') pageSize = 10, @Query('name') name?: string) {
|
||||
try {
|
||||
const data = await this.productService.getAttributeList('brand', { current, pageSize }, name); // 中文注释:分页品牌列表
|
||||
const data = await this.productService.getAttributeList('brand', { current, pageSize }, name); // 分页品牌列表
|
||||
return successResponse(data);
|
||||
} catch (error) {
|
||||
return errorResponse(error?.message || error);
|
||||
|
|
@ -316,9 +330,9 @@ export class ProductController {
|
|||
@Post('/brand')
|
||||
async compatCreateBrand(@Body() body: { title: string; name: string }) {
|
||||
try {
|
||||
const has = await this.productService.hasAttribute('brand', body.name); // 中文注释:唯一性校验
|
||||
const has = await this.productService.hasAttribute('brand', body.name); // 唯一性校验
|
||||
if (has) return errorResponse('品牌已存在');
|
||||
const data = await this.productService.createAttribute('brand', body); // 中文注释:创建品牌字典项
|
||||
const data = await this.productService.createAttribute('brand', body); // 创建品牌字典项
|
||||
return successResponse(data);
|
||||
} catch (error) {
|
||||
return errorResponse(error?.message || error);
|
||||
|
|
@ -330,10 +344,10 @@ export class ProductController {
|
|||
async compatUpdateBrand(@Param('id') id: number, @Body() body: { title?: string; name?: string }) {
|
||||
try {
|
||||
if (body?.name) {
|
||||
const has = await this.productService.hasAttribute('brand', body.name, id); // 中文注释:唯一性校验(排除自身)
|
||||
const has = await this.productService.hasAttribute('brand', body.name, id); // 唯一性校验(排除自身)
|
||||
if (has) return errorResponse('品牌已存在');
|
||||
}
|
||||
const data = await this.productService.updateAttribute(id, body); // 中文注释:更新品牌字典项
|
||||
const data = await this.productService.updateAttribute(id, body); // 更新品牌字典项
|
||||
return successResponse(data);
|
||||
} catch (error) {
|
||||
return errorResponse(error?.message || error);
|
||||
|
|
@ -344,14 +358,14 @@ export class ProductController {
|
|||
@Del('/brand/:id')
|
||||
async compatDeleteBrand(@Param('id') id: number) {
|
||||
try {
|
||||
await this.productService.deleteAttribute(id); // 中文注释:删除品牌字典项
|
||||
await this.productService.deleteAttribute(id); // 删除品牌字典项
|
||||
return successResponse(true);
|
||||
} catch (error) {
|
||||
return errorResponse(error?.message || error);
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容旧接口:口味
|
||||
// 兼容旧接口:口味
|
||||
@ApiOkResponse()
|
||||
@Get('/flavorsAll')
|
||||
async compatFlavorsAll() {
|
||||
|
|
@ -413,7 +427,7 @@ export class ProductController {
|
|||
}
|
||||
}
|
||||
|
||||
// 兼容旧接口:规格
|
||||
// 兼容旧接口:规格
|
||||
@ApiOkResponse()
|
||||
@Get('/strengthAll')
|
||||
async compatStrengthAll() {
|
||||
|
|
@ -475,7 +489,7 @@ export class ProductController {
|
|||
}
|
||||
}
|
||||
|
||||
// 兼容旧接口:尺寸
|
||||
// 兼容旧接口:尺寸
|
||||
@ApiOkResponse()
|
||||
@Get('/sizeAll')
|
||||
async compatSizeAll() {
|
||||
|
|
|
|||
|
|
@ -176,7 +176,7 @@ export class StockController {
|
|||
}
|
||||
}
|
||||
|
||||
// 中文注释:检查某个 SKU 是否有库存(任一仓库数量大于 0)
|
||||
// 检查某个 SKU 是否有库存(任一仓库数量大于 0)
|
||||
@ApiOkResponse({ type: BooleanRes })
|
||||
@Get('/has/:sku')
|
||||
async hasStock(@Param('sku') sku: string) {
|
||||
|
|
@ -190,7 +190,7 @@ export class StockController {
|
|||
|
||||
@ApiOkResponse({
|
||||
type: BooleanRes,
|
||||
description: '更新库存(入库、出库、调整)',
|
||||
description: '更新库存(入库,出库,调整)',
|
||||
})
|
||||
@Post('/update')
|
||||
async updateStock(@Body() body: UpdateStockDTO) {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export class SubscriptionController {
|
|||
@Inject()
|
||||
subscriptionService: SubscriptionService;
|
||||
|
||||
// 同步订阅:根据站点 ID 拉取并更新本地订阅数据
|
||||
// 同步订阅:根据站点 ID 拉取并更新本地订阅数据
|
||||
@ApiOkResponse({ type: BooleanRes })
|
||||
@Post('/sync/:siteId')
|
||||
async sync(@Param('siteId') siteId: number) {
|
||||
|
|
@ -22,7 +22,7 @@ export class SubscriptionController {
|
|||
}
|
||||
}
|
||||
|
||||
// 订阅列表:分页 + 筛选
|
||||
// 订阅列表:分页 + 筛选
|
||||
@ApiOkResponse({ type: SubscriptionListRes })
|
||||
@Get('/list')
|
||||
async list(@Query() query: QuerySubscriptionDTO) {
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ export class TemplateController {
|
|||
|
||||
/**
|
||||
* @summary 创建新模板
|
||||
* @description 创建一个新的模板,用于后续的字符串生成
|
||||
* @description 创建一个新的模板,用于后续的字符串生成
|
||||
* @param templateData 模板数据
|
||||
*/
|
||||
@ApiOkResponse({ type: Template, description: '成功创建模板' })
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ export class UserController {
|
|||
})
|
||||
@Post('/logout')
|
||||
async logout() {
|
||||
// 可选:在这里处理服务端缓存的 token 或 session
|
||||
// 可选:在这里处理服务端缓存的 token 或 session
|
||||
|
||||
return successResponse(true);
|
||||
}
|
||||
|
|
@ -43,7 +43,7 @@ export class UserController {
|
|||
async addUser(@Body() body: { username: string; password: string; remark?: string }) {
|
||||
const { username, password, remark } = body;
|
||||
try {
|
||||
// 中文注释:新增用户(支持备注)
|
||||
// 新增用户(支持备注)
|
||||
await this.userService.addUser(username, password, remark);
|
||||
return successResponse(true);
|
||||
} catch (error) {
|
||||
|
|
@ -66,9 +66,9 @@ export class UserController {
|
|||
}
|
||||
) {
|
||||
const { current = 1, pageSize = 10, remark, username, isActive, isSuper, isAdmin } = query;
|
||||
// 中文注释:将字符串布尔转换为真实布尔
|
||||
// 将字符串布尔转换为真实布尔
|
||||
const toBool = (v?: string) => (v === undefined ? undefined : v === 'true');
|
||||
// 中文注释:列表移除密码字段
|
||||
// 列表移除密码字段
|
||||
const { items, total } = await this.userService.listUsers(current, pageSize, {
|
||||
remark,
|
||||
username,
|
||||
|
|
@ -86,9 +86,9 @@ export class UserController {
|
|||
@Post('/toggleActive')
|
||||
async toggleActive(@Body() body: { userId: number; isActive: boolean }) {
|
||||
try {
|
||||
// 中文注释:调用服务层更新启用状态
|
||||
// 调用服务层更新启用状态
|
||||
const data = await this.userService.toggleUserActive(body.userId, body.isActive);
|
||||
// 中文注释:移除密码字段,保证安全
|
||||
// 移除密码字段,保证安全
|
||||
const { password, ...safe } = data as any;
|
||||
return successResponse(safe);
|
||||
} catch (error) {
|
||||
|
|
@ -96,18 +96,18 @@ export class UserController {
|
|||
}
|
||||
}
|
||||
|
||||
// 中文注释:更新用户(支持用户名/密码/权限/角色更新)
|
||||
// 更新用户(支持用户名/密码/权限/角色更新)
|
||||
@Post('/update/:id')
|
||||
async updateUser(
|
||||
@Body() body: { username?: string; password?: string; isSuper?: boolean; isAdmin?: boolean; permissions?: string[]; remark?: string },
|
||||
@Query('id') id?: number
|
||||
) {
|
||||
try {
|
||||
// 条件判断:优先从路径参数获取 ID(兼容生成的 API 文件为 POST /user/update/:id)
|
||||
// 条件判断:优先从路径参数获取 ID(兼容生成的 API 文件为 POST /user/update/:id)
|
||||
const userId = Number((this.ctx?.params?.id ?? id));
|
||||
if (!userId) throw new Error('缺少用户ID');
|
||||
const data = await this.userService.updateUser(userId, body);
|
||||
// 中文注释:移除密码字段,保证安全
|
||||
// 移除密码字段,保证安全
|
||||
const { password, ...safe } = data as any;
|
||||
return successResponse(safe);
|
||||
} catch (error) {
|
||||
|
|
@ -119,7 +119,7 @@ export class UserController {
|
|||
@Get()
|
||||
async getUser(@User() user) {
|
||||
try {
|
||||
// 中文注释:详情移除密码字段
|
||||
// 详情移除密码字段
|
||||
const data = await this.userService.getUser(user.id);
|
||||
const { password, ...safe } = (data as any) || {};
|
||||
return successResponse(safe);
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ export class WebhookController {
|
|||
@Inject()
|
||||
private readonly siteService: SiteService;
|
||||
|
||||
// 移除配置中的站点数组,来源统一改为数据库
|
||||
// 移除配置中的站点数组,来源统一改为数据库
|
||||
|
||||
@Get('/')
|
||||
async test() {
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ import {
|
|||
} from '../dto/reponse.dto';
|
||||
@Controller('/wp_product')
|
||||
export class WpProductController {
|
||||
// 移除控制器内的配置站点引用,统一由服务层处理站点数据
|
||||
// 移除控制器内的配置站点引用,统一由服务层处理站点数据
|
||||
|
||||
@Inject()
|
||||
private readonly wpProductService: WpProductService;
|
||||
|
|
|
|||
|
|
@ -149,7 +149,7 @@ export default class DictSeeder implements Seeder {
|
|||
const formattedName = this.formatName(dictInfo.name);
|
||||
let dict = await repo.findOne({ where: { name: formattedName } });
|
||||
if (!dict) {
|
||||
// 如果字典不存在,则使用格式化后的 name 创建新字典
|
||||
// 如果字典不存在,则使用格式化后的 name 创建新字典
|
||||
dict = await repo.save({ name: formattedName, title: dictInfo.title, titleCn: dictInfo.titleCn });
|
||||
}
|
||||
return dict;
|
||||
|
|
@ -167,7 +167,7 @@ export default class DictSeeder implements Seeder {
|
|||
const formattedName = this.formatName(item.name);
|
||||
const existingItem = await repo.findOne({ where: { name: formattedName, dict: { id: dict.id } } });
|
||||
if (!existingItem) {
|
||||
// 如果字典项不存在,则使用格式化后的 name 创建新字典项
|
||||
// 如果字典项不存在,则使用格式化后的 name 创建新字典项
|
||||
await repo.save({ name: formattedName, title: item.title, titleCn: item.titleCn, dict });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,14 +4,14 @@ import { Template } from '../../entity/template.entity';
|
|||
|
||||
/**
|
||||
* @class TemplateSeeder
|
||||
* @description 模板数据填充器,用于在数据库初始化时插入默认的模板数据。
|
||||
* @description 模板数据填充器,用于在数据库初始化时插入默认的模板数据.
|
||||
*/
|
||||
export default class TemplateSeeder implements Seeder {
|
||||
/**
|
||||
* @method run
|
||||
* @description 执行数据填充操作。如果 product_sku 模板不存在,则创建它。
|
||||
* @param {DataSource} dataSource - 数据源实例,用于获取 repository。
|
||||
* @param {SeederFactoryManager} factoryManager - Seeder 工厂管理器。
|
||||
* @description 执行数据填充操作.如果 product_sku 模板不存在,则创建它.
|
||||
* @param {DataSource} dataSource - 数据源实例,用于获取 repository.
|
||||
* @param {SeederFactoryManager} factoryManager - Seeder 工厂管理器.
|
||||
*/
|
||||
public async run(
|
||||
dataSource: DataSource,
|
||||
|
|
@ -25,7 +25,7 @@ export default class TemplateSeeder implements Seeder {
|
|||
where: { name: 'product_sku' },
|
||||
});
|
||||
|
||||
// 如果模板不存在,则创建并保存
|
||||
// 如果模板不存在,则创建并保存
|
||||
if (!existingTemplate) {
|
||||
const template = new Template();
|
||||
template.name = 'product_sku';
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export class QueryAreaDTO {
|
|||
@Rule(RuleType.number().integer().min(1).default(10))
|
||||
pageSize?: number;
|
||||
|
||||
@ApiProperty({ description: '关键词(名称或编码)', required: false })
|
||||
@ApiProperty({ description: '关键词(名称或编码)', required: false })
|
||||
@Rule(RuleType.string())
|
||||
keyword?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export type PackagingType =
|
|||
// | PackagingCourierPak
|
||||
// | PackagingEnvelope;
|
||||
|
||||
// 定义包装类型的枚举,用于 API 文档描述
|
||||
// 定义包装类型的枚举,用于 API 文档描述
|
||||
export enum PackagingTypeEnum {
|
||||
Pallet = 'pallet',
|
||||
Package = 'package',
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ export class QueryOrderDTO {
|
|||
@Rule(RuleType.string())
|
||||
payment_method: string;
|
||||
|
||||
@ApiProperty({ description: '仅订阅订单(父订阅订单或包含订阅商品)' })
|
||||
@ApiProperty({ description: '仅订阅订单(父订阅订单或包含订阅商品)' })
|
||||
@Rule(RuleType.bool().default(false))
|
||||
isSubscriptionOnly?: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,10 @@ export class CreateProductDTO {
|
|||
@Rule(RuleType.string().required().empty({ message: '产品名称不能为空' }))
|
||||
name: string;
|
||||
|
||||
@ApiProperty({ description: '产品中文名称', required: false })
|
||||
@Rule(RuleType.string().allow('').optional())
|
||||
nameCn?: string;
|
||||
|
||||
@ApiProperty({ example: '产品描述', description: '产品描述' })
|
||||
@Rule(RuleType.string())
|
||||
description: string;
|
||||
|
|
@ -50,7 +54,7 @@ export class CreateProductDTO {
|
|||
@Rule(RuleType.number())
|
||||
categoryId?: number;
|
||||
|
||||
// 通用属性输入(中文注释:通过 attributes 统一提交品牌/口味/强度/尺寸/干湿等)
|
||||
// 通用属性输入(通过 attributes 统一提交品牌/口味/强度/尺寸/干湿等)
|
||||
@ApiProperty({ description: '属性列表', type: 'array' })
|
||||
@Rule(RuleType.array().required())
|
||||
attributes: AttributeInputDTO[];
|
||||
|
|
@ -65,12 +69,14 @@ export class CreateProductDTO {
|
|||
@Rule(RuleType.number())
|
||||
promotionPrice?: number;
|
||||
|
||||
// 中文注释:商品类型(默认 single;bundle 需手动设置组成)
|
||||
|
||||
|
||||
// 商品类型(默认 single; bundle 需手动设置组成)
|
||||
@ApiProperty({ description: '商品类型', enum: ['single', 'bundle'], default: 'single', required: false })
|
||||
@Rule(RuleType.string().valid('single', 'bundle').default('single'))
|
||||
type?: string;
|
||||
|
||||
// 中文注释:仅当 type 为 'bundle' 时,才需要提供 components
|
||||
// 仅当 type 为 'bundle' 时,才需要提供 components
|
||||
@ApiProperty({ description: '产品组成', type: 'array', required: false })
|
||||
@Rule(
|
||||
RuleType.array()
|
||||
|
|
@ -96,6 +102,10 @@ export class UpdateProductDTO {
|
|||
@Rule(RuleType.string())
|
||||
name?: string;
|
||||
|
||||
@ApiProperty({ description: '产品中文名称', required: false })
|
||||
@Rule(RuleType.string().allow('').optional())
|
||||
nameCn?: string;
|
||||
|
||||
@ApiProperty({ example: '产品描述', description: '产品描述' })
|
||||
@Rule(RuleType.string())
|
||||
description?: string;
|
||||
|
|
@ -118,12 +128,14 @@ export class UpdateProductDTO {
|
|||
@Rule(RuleType.number())
|
||||
promotionPrice?: number;
|
||||
|
||||
// 属性更新(中文注释:可选,支持增量替换指定字典的属性项)
|
||||
|
||||
|
||||
// 属性更新(可选, 支持增量替换指定字典的属性项)
|
||||
@ApiProperty({ description: '属性列表', type: 'array', required: false })
|
||||
@Rule(RuleType.array())
|
||||
attributes?: AttributeInputDTO[];
|
||||
|
||||
// 中文注释:商品类型(single 或 bundle)
|
||||
// 商品类型(single 或 bundle)
|
||||
@ApiProperty({ description: '商品类型', enum: ['single', 'bundle'], required: false })
|
||||
@Rule(RuleType.string().valid('single', 'bundle'))
|
||||
type?: string;
|
||||
|
|
@ -165,6 +177,14 @@ export class QueryProductDTO {
|
|||
@ApiProperty({ description: '品牌ID', required: false })
|
||||
@Rule(RuleType.number())
|
||||
brandId?: number;
|
||||
|
||||
@ApiProperty({ description: '排序字段', required: false })
|
||||
@Rule(RuleType.string())
|
||||
sortField?: string;
|
||||
|
||||
@ApiProperty({ description: '排序方式', required: false })
|
||||
@Rule(RuleType.string().valid('ascend', 'descend'))
|
||||
sortOrder?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -148,7 +148,7 @@ export class PaymentMethodListRes extends SuccessArrayWrapper(
|
|||
PaymentMethodDTO
|
||||
) {}
|
||||
|
||||
// 订阅分页数据(列表 + 总数等分页信息)
|
||||
// 订阅分页数据(列表 + 总数等分页信息)
|
||||
export class SubscriptionPaginatedResponse extends PaginatedWrapper(Subscription) {}
|
||||
// 订阅分页返回数据(统一成功包装)
|
||||
// 订阅分页返回数据(统一成功包装)
|
||||
export class SubscriptionListRes extends SuccessWrapper(SubscriptionPaginatedResponse) {}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ export class CreateSiteDTO {
|
|||
consumerKey?: string;
|
||||
@Rule(RuleType.string().optional())
|
||||
consumerSecret?: string;
|
||||
@Rule(RuleType.string().optional())
|
||||
token?: string;
|
||||
@Rule(RuleType.string())
|
||||
name: string;
|
||||
@Rule(RuleType.string().valid('woocommerce', 'shopyy').optional())
|
||||
|
|
@ -59,6 +61,8 @@ export class UpdateSiteDTO {
|
|||
@Rule(RuleType.string().optional())
|
||||
consumerSecret?: string;
|
||||
@Rule(RuleType.string().optional())
|
||||
token?: string;
|
||||
@Rule(RuleType.string().optional())
|
||||
name?: string;
|
||||
@Rule(RuleType.boolean().optional())
|
||||
isDisabled?: boolean;
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export class QueryStockDTO {
|
|||
@Rule(RuleType.number().allow(null))
|
||||
sortPointId?: number;
|
||||
|
||||
@ApiProperty({ description: '排序对象,格式如 { productName: "asc", sku: "desc" }', required: false })
|
||||
@ApiProperty({ description: '排序对象,格式如 { productName: "asc", sku: "desc" }', required: false })
|
||||
@Rule(RuleType.object().allow(null))
|
||||
order?: Record<string, 'asc' | 'desc'>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ import { ApiProperty } from '@midwayjs/swagger';
|
|||
import { Rule, RuleType } from '@midwayjs/validate';
|
||||
import { SubscriptionStatus } from '../enums/base.enum';
|
||||
|
||||
// 订阅列表查询参数(分页与筛选)
|
||||
// 订阅列表查询参数(分页与筛选)
|
||||
export class QuerySubscriptionDTO {
|
||||
// 当前页码(从 1 开始)
|
||||
// 当前页码(从 1 开始)
|
||||
@ApiProperty({ example: 1, description: '页码' })
|
||||
@Rule(RuleType.number().default(1))
|
||||
current: number;
|
||||
|
|
@ -14,23 +14,23 @@ export class QuerySubscriptionDTO {
|
|||
@Rule(RuleType.number().default(10))
|
||||
pageSize: number;
|
||||
|
||||
// 站点 ID(可选)
|
||||
// 站点 ID(可选)
|
||||
@ApiProperty({ description: '站点ID' })
|
||||
@Rule(RuleType.string().allow(''))
|
||||
siteId: string;
|
||||
|
||||
// 订阅状态筛选(可选),支持枚举值
|
||||
// 订阅状态筛选(可选),支持枚举值
|
||||
@ApiProperty({ description: '订阅状态', enum: SubscriptionStatus })
|
||||
@Rule(RuleType.string().valid(...Object.values(SubscriptionStatus)).allow(''))
|
||||
status: SubscriptionStatus | '';
|
||||
|
||||
// 客户邮箱(模糊匹配,可选)
|
||||
// 客户邮箱(模糊匹配,可选)
|
||||
@ApiProperty({ description: '客户邮箱' })
|
||||
@Rule(RuleType.string().allow(''))
|
||||
customer_email: string;
|
||||
|
||||
// 关键字(订阅ID、邮箱等,模糊匹配,可选)
|
||||
@ApiProperty({ description: '关键字(订阅ID、邮箱等)' })
|
||||
// 关键字(订阅ID,邮箱等,模糊匹配,可选)
|
||||
@ApiProperty({ description: '关键字(订阅ID,邮箱等)' })
|
||||
@Rule(RuleType.string().allow(''))
|
||||
keyword: string;
|
||||
}
|
||||
|
|
@ -178,7 +178,7 @@ export class Order {
|
|||
@ApiProperty()
|
||||
@Column({
|
||||
type: 'mediumtext', // 设置字段类型为 MEDIUMTEXT
|
||||
nullable: true, // 可选:是否允许为 NULL
|
||||
nullable: true, // 可选:是否允许为 NULL
|
||||
})
|
||||
@Expose()
|
||||
customer_note: string;
|
||||
|
|
|
|||
|
|
@ -79,17 +79,17 @@ export class OrderItem {
|
|||
@ApiProperty()
|
||||
@Column({ nullable: true })
|
||||
@Expose()
|
||||
tax_class?: string; // 税类(来自 line_items.tax_class)
|
||||
tax_class?: string; // 税类(来自 line_items.tax_class)
|
||||
|
||||
@ApiProperty()
|
||||
@Column({ type: 'json', nullable: true })
|
||||
@Expose()
|
||||
taxes?: any[]; // 税明细(来自 line_items.taxes,数组)
|
||||
taxes?: any[]; // 税明细(来自 line_items.taxes,数组)
|
||||
|
||||
@ApiProperty()
|
||||
@Column({ type: 'json', nullable: true })
|
||||
@Expose()
|
||||
meta_data?: any[]; // 行项目元数据(包含订阅相关键值)
|
||||
meta_data?: any[]; // 行项目元数据(包含订阅相关键值)
|
||||
|
||||
@ApiProperty()
|
||||
@Column({ nullable: true })
|
||||
|
|
@ -99,7 +99,7 @@ export class OrderItem {
|
|||
@ApiProperty()
|
||||
@Column({ nullable: true })
|
||||
@Expose()
|
||||
global_unique_id?: string; // 全局唯一ID(部分主题/插件会提供)
|
||||
global_unique_id?: string; // 全局唯一ID(部分主题/插件会提供)
|
||||
|
||||
@ApiProperty()
|
||||
@Column('decimal', { precision: 10, scale: 2 })
|
||||
|
|
@ -109,17 +109,17 @@ export class OrderItem {
|
|||
@ApiProperty()
|
||||
@Column({ type: 'json', nullable: true })
|
||||
@Expose()
|
||||
image?: { id?: string | number; src?: string }; // 商品图片(对象,包含 id/src)
|
||||
image?: { id?: string | number; src?: string }; // 商品图片(对象,包含 id/src)
|
||||
|
||||
@ApiProperty()
|
||||
@Column({ nullable: true })
|
||||
@Expose()
|
||||
parent_name?: string; // 父商品名称(组合/捆绑时可能使用)
|
||||
parent_name?: string; // 父商品名称(组合/捆绑时可能使用)
|
||||
|
||||
@ApiProperty()
|
||||
@Column({ nullable: true })
|
||||
@Expose()
|
||||
bundled_by?: string; // 捆绑来源标识(bundled_by)
|
||||
bundled_by?: string; // 捆绑来源标识(bundled_by)
|
||||
|
||||
@ApiProperty()
|
||||
@Column({ nullable: true })
|
||||
|
|
@ -129,7 +129,7 @@ export class OrderItem {
|
|||
@ApiProperty()
|
||||
@Column({ type: 'json', nullable: true })
|
||||
@Expose()
|
||||
bundled_items?: any[]; // 捆绑项列表(数组)
|
||||
bundled_items?: any[]; // 捆绑项列表(数组)
|
||||
|
||||
@ApiProperty({
|
||||
example: '2022-12-12 11:11:11',
|
||||
|
|
|
|||
|
|
@ -63,9 +63,7 @@ export class Product {
|
|||
@Column({ type: 'decimal', precision: 10, scale: 2, default: 0 })
|
||||
promotionPrice: number;
|
||||
|
||||
@ApiProperty({ description: '库存', example: 100 })
|
||||
@Column({ default: 0 })
|
||||
stock: number;
|
||||
|
||||
|
||||
|
||||
// 分类关联
|
||||
|
|
@ -79,7 +77,7 @@ export class Product {
|
|||
@JoinTable()
|
||||
attributes: DictItem[];
|
||||
|
||||
// 中文注释:产品的库存组成,一对多关系(使用独立表)
|
||||
// 产品的库存组成,一对多关系(使用独立表)
|
||||
@ApiProperty({ description: '库存组成', type: ProductStockComponent, isArray: true })
|
||||
@OneToMany(() => ProductStockComponent, (component) => component.product, { cascade: true })
|
||||
components: ProductStockComponent[];
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export class ProductStockComponent {
|
|||
@Column({ type: 'int', default: 1 })
|
||||
quantity: number;
|
||||
|
||||
// 中文注释:多对一,组件隶属于一个产品
|
||||
// 多对一,组件隶属于一个产品
|
||||
@ManyToOne(() => Product, (product) => product.components, { onDelete: 'CASCADE' })
|
||||
product: Product;
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export class Site {
|
|||
name: string;
|
||||
|
||||
@Column({ length: 32, default: 'woocommerce' })
|
||||
type: string; // 平台类型:woocommerce | shopyy
|
||||
type: string; // 平台类型:woocommerce | shopyy
|
||||
|
||||
@Column({ length: 64, nullable: true })
|
||||
skuPrefix: string;
|
||||
|
|
|
|||
|
|
@ -12,68 +12,68 @@ import { SubscriptionStatus } from '../enums/base.enum';
|
|||
@Entity('subscription')
|
||||
@Exclude()
|
||||
export class Subscription {
|
||||
// 本地主键,自增 ID
|
||||
// 本地主键,自增 ID
|
||||
@ApiProperty()
|
||||
@PrimaryGeneratedColumn()
|
||||
@Expose()
|
||||
id: number;
|
||||
|
||||
// 站点唯一标识,用于区分不同来源站点
|
||||
// 站点唯一标识,用于区分不同来源站点
|
||||
@ApiProperty({ description: '来源站点唯一标识' })
|
||||
@Column({ nullable: true })
|
||||
@Expose()
|
||||
siteId: number;
|
||||
|
||||
// WooCommerce 订阅的原始 ID(字符串化),用于幂等更新
|
||||
// WooCommerce 订阅的原始 ID(字符串化),用于幂等更新
|
||||
@ApiProperty({ description: 'WooCommerce 订阅 ID' })
|
||||
@Column()
|
||||
@Expose()
|
||||
externalSubscriptionId: string;
|
||||
|
||||
// 订阅状态(active/cancelled/on-hold 等)
|
||||
// 订阅状态(active/cancelled/on-hold 等)
|
||||
@ApiProperty({ type: SubscriptionStatus })
|
||||
@Column({ type: 'enum', enum: SubscriptionStatus })
|
||||
@Expose()
|
||||
status: SubscriptionStatus;
|
||||
|
||||
// 货币代码,例如 USD/CAD
|
||||
// 货币代码,例如 USD/CAD
|
||||
@ApiProperty()
|
||||
@Column({ default: '' })
|
||||
@Expose()
|
||||
currency: string;
|
||||
|
||||
// 总金额,保留两位小数
|
||||
// 总金额,保留两位小数
|
||||
@ApiProperty()
|
||||
@Column('decimal', { precision: 10, scale: 2, default: 0 })
|
||||
@Expose()
|
||||
total: number;
|
||||
|
||||
// 计费周期(day/week/month/year)
|
||||
// 计费周期(day/week/month/year)
|
||||
@ApiProperty({ description: '计费周期 e.g. day/week/month/year' })
|
||||
@Column({ default: '' })
|
||||
@Expose()
|
||||
billing_period: string;
|
||||
|
||||
// 计费周期间隔(例如 1/3/12)
|
||||
// 计费周期间隔(例如 1/3/12)
|
||||
@ApiProperty({ description: '计费周期间隔 e.g. 1/3/12' })
|
||||
@Column({ type: 'int', default: 0 })
|
||||
@Expose()
|
||||
billing_interval: number;
|
||||
|
||||
// 客户 ID(WooCommerce 用户 ID)
|
||||
// 客户 ID(WooCommerce 用户 ID)
|
||||
@ApiProperty()
|
||||
@Column({ type: 'int', default: 0 })
|
||||
@Expose()
|
||||
customer_id: number;
|
||||
|
||||
// 客户邮箱(从 billing.email 或 customer_email 提取)
|
||||
// 客户邮箱(从 billing.email 或 customer_email 提取)
|
||||
@ApiProperty()
|
||||
@Column({ default: '' })
|
||||
@Expose()
|
||||
customer_email: string;
|
||||
|
||||
// 父订单/订阅 ID(如有)
|
||||
@ApiProperty({ description: '父订单/父订阅ID(如有)' })
|
||||
// 父订单/订阅 ID(如有)
|
||||
@ApiProperty({ description: '父订单/父订阅ID(如有)' })
|
||||
@Column({ type: 'int', default: 0 })
|
||||
@Expose()
|
||||
parent_id: number;
|
||||
|
|
@ -102,25 +102,25 @@ export class Subscription {
|
|||
@Expose()
|
||||
end_date: Date;
|
||||
|
||||
// 商品项(订阅行项目)
|
||||
// 商品项(订阅行项目)
|
||||
@ApiProperty()
|
||||
@Column({ type: 'json', nullable: true })
|
||||
@Expose()
|
||||
line_items: any[];
|
||||
|
||||
// 额外元数据(键值对)
|
||||
// 额外元数据(键值对)
|
||||
@ApiProperty()
|
||||
@Column({ type: 'json', nullable: true })
|
||||
@Expose()
|
||||
meta_data: any[];
|
||||
|
||||
// 创建时间(数据库自动生成)
|
||||
// 创建时间(数据库自动生成)
|
||||
@ApiProperty({ example: '2022-12-12 11:11:11', description: '创建时间', required: true })
|
||||
@CreateDateColumn()
|
||||
@Expose()
|
||||
createdAt: Date;
|
||||
|
||||
// 更新时间(数据库自动生成)
|
||||
// 更新时间(数据库自动生成)
|
||||
@ApiProperty({ example: '2022-12-12 11:11:11', description: '更新时间', required: true })
|
||||
@UpdateDateColumn()
|
||||
@Expose()
|
||||
|
|
|
|||
|
|
@ -15,10 +15,10 @@ export class User {
|
|||
password: string;
|
||||
|
||||
// @Column() // 默认角色为管理员
|
||||
// roleId: number; // 角色 (如:admin, editor, viewer)
|
||||
// roleId: number; // 角色 (如:admin, editor, viewer)
|
||||
|
||||
@Column({ type: 'simple-array', nullable: true })
|
||||
permissions: string[]; // 自定义权限 (如:['user:add', 'user:edit'])
|
||||
permissions: string[]; // 自定义权限 (如:['user:add', 'user:edit'])
|
||||
|
||||
@Column({ default: false })
|
||||
isSuper: boolean; // 超级管理员
|
||||
|
|
@ -29,7 +29,7 @@ export class User {
|
|||
@Column({ default: true })
|
||||
isActive: boolean; // 用户是否启用
|
||||
|
||||
// 中文注释:备注字段(可选)
|
||||
// 备注字段(可选)
|
||||
@Column({ nullable: true })
|
||||
remark?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ export class ReportMiddleware implements IMiddleware<Context, NextFunction> {
|
|||
return async (ctx: Context, next: NextFunction) => {
|
||||
// 控制器前执行的逻辑
|
||||
const startTime = Date.now();
|
||||
// 执行下一个 Web 中间件,最后执行到控制器
|
||||
// 执行下一个 Web 中间件,最后执行到控制器
|
||||
// 这里可以拿到下一个中间件或者控制器的返回值
|
||||
const result = await next();
|
||||
// 控制器之后执行的逻辑
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ export class AreaService {
|
|||
select: 'official',
|
||||
});
|
||||
|
||||
// 如果找不到对应的国家,则抛出错误
|
||||
// 如果找不到对应的国家,则抛出错误
|
||||
if (!name) {
|
||||
throw new Error(`无效的国家代码: ${createAreaDTO.code}`);
|
||||
}
|
||||
|
|
@ -53,7 +53,7 @@ export class AreaService {
|
|||
return null;
|
||||
}
|
||||
|
||||
// 如果 code 发生变化,则更新 name
|
||||
// 如果 code 发生变化,则更新 name
|
||||
if (updateAreaDTO.code && updateAreaDTO.code !== area.code) {
|
||||
const name = countries.getName(updateAreaDTO.code, 'zh', {
|
||||
select: 'official',
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ export class CanadaPostService {
|
|||
return builder.buildObject(xmlObj);
|
||||
}
|
||||
|
||||
// 默认直接构建(用于 createShipment 这类已有完整结构)
|
||||
// 默认直接构建(用于 createShipment 这类已有完整结构)
|
||||
return builder.buildObject(data);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ export class DictService {
|
|||
}
|
||||
return this.dictModel.findOne({ where, relations });
|
||||
}
|
||||
// 获取字典列表,支持按标题搜索
|
||||
// 获取字典列表,支持按标题搜索
|
||||
async getDicts(options: { title?: string; name?: string; }) {
|
||||
const where = {
|
||||
title: options.title ? Like(`%${options.title}%`) : undefined,
|
||||
|
|
@ -135,7 +135,7 @@ export class DictService {
|
|||
return result.affected > 0;
|
||||
}
|
||||
|
||||
// 获取字典项列表,支持按 dictId 过滤
|
||||
// 获取字典项列表,支持按 dictId 过滤
|
||||
async getDictItems(params: { dictId?: number; name?: string; title?: string; }) {
|
||||
const { dictId, name, title } = params;
|
||||
const where: any = {};
|
||||
|
|
@ -150,11 +150,11 @@ export class DictService {
|
|||
where.title = Like(`%${title}%`);
|
||||
}
|
||||
|
||||
// 如果提供了 dictId,则只返回该字典下的项
|
||||
// 如果提供了 dictId,则只返回该字典下的项
|
||||
if (params.dictId) {
|
||||
return this.dictItemModel.find({ where });
|
||||
}
|
||||
// 否则,返回所有字典项
|
||||
// 否则,返回所有字典项
|
||||
return this.dictItemModel.find();
|
||||
}
|
||||
|
||||
|
|
@ -191,7 +191,7 @@ export class DictService {
|
|||
async getDictItemsByDictName(dictName: string) {
|
||||
// 查找字典
|
||||
const dict = await this.dictModel.findOne({ where: { name: dictName } });
|
||||
// 如果字典不存在,则返回空数组
|
||||
// 如果字典不存在,则返回空数组
|
||||
if (!dict) {
|
||||
return [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -228,7 +228,7 @@ export class LogisticsService {
|
|||
async removeShipment(shipmentId: number) {
|
||||
try {
|
||||
const shipment: Shipment = await this.shipmentModel.findOneBy({ id: shipmentId });
|
||||
if (shipment.state !== '190') { // todo,写常数
|
||||
if (shipment.state !== '190') { // todo,写常数
|
||||
throw new Error('订单当前状态无法删除');
|
||||
}
|
||||
const order: Order = await this.orderModel.findOneBy({ id: shipment.order_id });
|
||||
|
|
@ -347,7 +347,7 @@ export class LogisticsService {
|
|||
// 添加运单
|
||||
resShipmentOrder = await this.uniExpressService.createShipment(reqBody);
|
||||
|
||||
// 记录物流信息,并将订单状态转到完成
|
||||
// 记录物流信息,并将订单状态转到完成
|
||||
if (resShipmentOrder.status === 'SUCCESS') {
|
||||
order.orderStatus = ErpOrderStatus.COMPLETED;
|
||||
} else {
|
||||
|
|
@ -359,7 +359,7 @@ export class LogisticsService {
|
|||
await dataSource.transaction(async manager => {
|
||||
const orderRepo = manager.getRepository(Order);
|
||||
const shipmentRepo = manager.getRepository(Shipment);
|
||||
const tracking_provider = 'UniUni'; // todo: id未确定,后写进常数
|
||||
const tracking_provider = 'UniUni'; // todo: id未确定,后写进常数
|
||||
|
||||
// 同步物流信息到woocommerce
|
||||
const site = await this.siteService.get(Number(order.siteId), true);
|
||||
|
|
@ -414,7 +414,7 @@ export class LogisticsService {
|
|||
if (resShipmentOrder.status === 'SUCCESS') {
|
||||
await this.uniExpressService.deleteShipment(resShipmentOrder.data.tno);
|
||||
}
|
||||
throw new Error(`上游请求错误:${error}`);
|
||||
throw new Error(`上游请求错误:${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -558,7 +558,7 @@ export class LogisticsService {
|
|||
},
|
||||
});
|
||||
|
||||
// 从数据库批量获取站点信息,构建映射以避免 N+1 查询
|
||||
// 从数据库批量获取站点信息,构建映射以避免 N+1 查询
|
||||
const siteIds = Array.from(new Set(orders.map(o => o.siteId).filter(Boolean)));
|
||||
const { items: sites } = await this.siteService.list({ current: 1, pageSize: 1000, ids: siteIds.join(',') }, false);
|
||||
const siteMap = new Map(sites.map((s: any) => [s.id, s.name]));
|
||||
|
|
@ -602,7 +602,7 @@ export class LogisticsService {
|
|||
values.push(stockPointId);
|
||||
}
|
||||
|
||||
// todo,增加订单号搜索
|
||||
// todo,增加订单号搜索
|
||||
if (externalOrderId) {
|
||||
whereClause += ' AND o.externalOrderId = ?';
|
||||
values.push(externalOrderId);
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ export class OrderService {
|
|||
[OrderStatus.RETURN_APPROVED]: OrderStatus.ON_HOLD, // 退款申请已通过转为 on-hold
|
||||
[OrderStatus.RETURN_CANCELLED]: OrderStatus.REFUNDED // 已取消退款转为 refunded
|
||||
}
|
||||
// 由于 wordpress 订单状态和 我们的订单状态 不一致,需要做转换
|
||||
// 由于 wordpress 订单状态和 我们的订单状态 不一致,需要做转换
|
||||
async autoUpdateOrderStatus(siteId: number, order: any) {
|
||||
console.log('更新订单状态', order)
|
||||
// 其他状态保持不变
|
||||
|
|
@ -130,14 +130,14 @@ export class OrderService {
|
|||
}
|
||||
try {
|
||||
const site = await this.siteService.get(siteId);
|
||||
// 将订单状态同步到 WooCommerce,然后切换至下一状态
|
||||
// 将订单状态同步到 WooCommerce,然后切换至下一状态
|
||||
await this.wpService.updateOrder(site, String(order.id), { status: order.status });
|
||||
order.status = this.orderAutoNextStatusMap[originStatus];
|
||||
} catch (error) {
|
||||
console.error('更新订单状态失败,原因为:', error)
|
||||
console.error('更新订单状态失败,原因为:', error)
|
||||
}
|
||||
}
|
||||
// wordpress 发来,
|
||||
// wordpress 发来,
|
||||
async syncSingleOrder(siteId: number, order: any, forceUpdate = false) {
|
||||
let {
|
||||
line_items,
|
||||
|
|
@ -275,7 +275,7 @@ export class OrderService {
|
|||
'PENDING_RESHIPMENT',
|
||||
'PENDING_REFUND',
|
||||
];
|
||||
// 如果当前 ERP 状态不可覆盖,则禁止更新
|
||||
// 如果当前 ERP 状态不可覆盖,则禁止更新
|
||||
return !nonOverridableStatuses.includes(currentErpStatus);
|
||||
}
|
||||
|
||||
|
|
@ -732,7 +732,7 @@ export class OrderService {
|
|||
totalQuery += ` AND o.date_created <= ?`;
|
||||
parameters.push(endDate);
|
||||
}
|
||||
// 支付方式筛选(使用参数化,避免SQL注入)
|
||||
// 支付方式筛选(使用参数化,避免SQL注入)
|
||||
if (payment_method) {
|
||||
sqlQuery += ` AND o.payment_method LIKE ?`;
|
||||
totalQuery += ` AND o.payment_method LIKE ?`;
|
||||
|
|
@ -764,7 +764,7 @@ export class OrderService {
|
|||
}
|
||||
}
|
||||
|
||||
// 仅订阅订单过滤:父订阅订单 或 行项目包含订阅相关元数据(兼容 JSON 与字符串存储)
|
||||
// 仅订阅订单过滤:父订阅订单 或 行项目包含订阅相关元数据(兼容 JSON 与字符串存储)
|
||||
if (isSubscriptionOnly) {
|
||||
const subCond = `
|
||||
AND (
|
||||
|
|
@ -862,7 +862,7 @@ export class OrderService {
|
|||
customer_email: `%${customer_email}%`,
|
||||
});
|
||||
|
||||
// 🔥 关键字搜索:检查 order_item.name 是否包含 keyword
|
||||
// 🔥 关键字搜索:检查 order_item.name 是否包含 keyword
|
||||
if (keyword) {
|
||||
query.andWhere(
|
||||
`EXISTS (
|
||||
|
|
@ -1025,7 +1025,7 @@ export class OrderService {
|
|||
}
|
||||
|
||||
// -------------------------
|
||||
// 4. 总量统计(时间段 + siteId)
|
||||
// 4. 总量统计(时间段 + siteId)
|
||||
// -------------------------
|
||||
const totalParams: any[] = [startDate, endDate];
|
||||
const yooneParams: any[] = [startDate, endDate];
|
||||
|
|
@ -1304,7 +1304,7 @@ export class OrderService {
|
|||
|
||||
|
||||
|
||||
// 关联数据:订阅与相关订单(用于前端关联展示)
|
||||
// 关联数据:订阅与相关订单(用于前端关联展示)
|
||||
let relatedList: any[] = [];
|
||||
try {
|
||||
const related = await this.getRelatedByOrder(id);
|
||||
|
|
@ -1330,7 +1330,7 @@ export class OrderService {
|
|||
return {
|
||||
...order,
|
||||
name: site?.name,
|
||||
// Site 实体无邮箱字段,这里返回空字符串保持兼容
|
||||
// Site 实体无邮箱字段,这里返回空字符串保持兼容
|
||||
email: '',
|
||||
items,
|
||||
sales,
|
||||
|
|
@ -1394,7 +1394,7 @@ export class OrderService {
|
|||
]),
|
||||
},
|
||||
});
|
||||
// 批量获取订单涉及的站点名称,避免使用配置文件
|
||||
// 批量获取订单涉及的站点名称,避免使用配置文件
|
||||
const siteIds = Array.from(new Set(orders.map(o => o.siteId).filter(Boolean)));
|
||||
const { items: sites } = await this.siteService.list({ current: 1, pageSize: 1000, ids: siteIds.join(',') }, false);
|
||||
const siteMap = new Map(sites.map((s: any) => [String(s.id), s.name]));
|
||||
|
|
@ -1459,7 +1459,7 @@ export class OrderService {
|
|||
async createOrder(data: Record<string, any>) {
|
||||
// 从数据中解构出需要用的属性
|
||||
const { siteId, sales, total, billing, customer_email, billing_phone } = data;
|
||||
// 如果没有 siteId,则抛出错误
|
||||
// 如果没有 siteId,则抛出错误
|
||||
if (!siteId) {
|
||||
throw new Error('siteId is required');
|
||||
}
|
||||
|
|
@ -1573,11 +1573,11 @@ export class OrderService {
|
|||
});
|
||||
|
||||
if (transactionError !== undefined) {
|
||||
throw new Error(`更新物流信息错误:${transactionError.message}`);
|
||||
throw new Error(`更新物流信息错误:${transactionError.message}`);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw new Error(`更新发货产品失败:${error.message}`);
|
||||
throw new Error(`更新发货产品失败:${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1658,11 +1658,11 @@ export class OrderService {
|
|||
});
|
||||
|
||||
if (transactionError !== undefined) {
|
||||
throw new Error(`更新物流信息错误:${transactionError.message}`);
|
||||
throw new Error(`更新物流信息错误:${transactionError.message}`);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw new Error(`更新发货产品失败:${error.message}`);
|
||||
throw new Error(`更新发货产品失败:${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ export class ProductService {
|
|||
return [];
|
||||
}
|
||||
|
||||
// 格式化返回,方便前端使用
|
||||
// 格式化返回,方便前端使用
|
||||
return category.attributes.map(attr => ({
|
||||
id: attr.id,
|
||||
dictId: attr.attributeDict.id,
|
||||
|
|
@ -174,7 +174,7 @@ export class ProductService {
|
|||
// where.nameCn = Like(`%${name}%`)
|
||||
// }
|
||||
// where.sku = Not(IsNull());
|
||||
// // 查询 SKU 不为空且 name 包含关键字的产品,最多返回 50 条
|
||||
// // 查询 SKU 不为空且 name 包含关键字的产品,最多返回 50 条
|
||||
// return this.productModel.find({
|
||||
// where,
|
||||
// take: 50,
|
||||
|
|
@ -193,7 +193,7 @@ export class ProductService {
|
|||
const params: Record<string, string> = {};
|
||||
const conditions: string[] = [];
|
||||
|
||||
// 英文名关键词全部匹配(AND)
|
||||
// 英文名关键词全部匹配(AND)
|
||||
if (nameFilter.length > 0) {
|
||||
const nameConds = nameFilter.map((word, index) => {
|
||||
const key = `name${index}`;
|
||||
|
|
@ -230,7 +230,9 @@ export class ProductService {
|
|||
async getProductList(
|
||||
pagination: PaginationParams,
|
||||
name?: string,
|
||||
brandId?: number
|
||||
brandId?: number,
|
||||
sortField?: string,
|
||||
sortOrder?: string
|
||||
): Promise<ProductPaginatedResponse> {
|
||||
const qb = this.productModel
|
||||
.createQueryBuilder('product')
|
||||
|
|
@ -238,7 +240,7 @@ export class ProductService {
|
|||
.leftJoinAndSelect('attribute.dict', 'dict')
|
||||
.leftJoinAndSelect('product.category', 'category');
|
||||
|
||||
// 模糊搜索 name,支持多个关键词
|
||||
// 模糊搜索 name,支持多个关键词
|
||||
const nameFilter = name ? name.split(' ').filter(Boolean) : [];
|
||||
if (nameFilter.length > 0) {
|
||||
const nameConditions = nameFilter
|
||||
|
|
@ -266,6 +268,17 @@ export class ProductService {
|
|||
});
|
||||
}
|
||||
|
||||
// 排序
|
||||
if (sortField && sortOrder) {
|
||||
const order = sortOrder === 'ascend' ? 'ASC' : 'DESC';
|
||||
const allowedSortFields = ['price', 'promotionPrice', 'createdAt', 'updatedAt', 'sku', 'name'];
|
||||
if (allowedSortFields.includes(sortField)) {
|
||||
qb.orderBy(`product.${sortField}`, order);
|
||||
}
|
||||
} else {
|
||||
qb.orderBy('product.createdAt', 'DESC');
|
||||
}
|
||||
|
||||
// 分页
|
||||
qb.skip((pagination.current - 1) * pagination.pageSize).take(
|
||||
pagination.pageSize
|
||||
|
|
@ -273,17 +286,17 @@ export class ProductService {
|
|||
|
||||
const [items, total] = await qb.getManyAndCount();
|
||||
|
||||
// 中文注释:根据类型填充组成信息
|
||||
// 根据类型填充组成信息
|
||||
for (const product of items) {
|
||||
if (product.type === 'single') {
|
||||
// 中文注释:单品不持久化组成,这里仅返回一个基于 SKU 的虚拟组成
|
||||
// 单品不持久化组成,这里仅返回一个基于 SKU 的虚拟组成
|
||||
const component = new ProductStockComponent();
|
||||
component.productId = product.id;
|
||||
component.sku = product.sku;
|
||||
component.quantity = 1;
|
||||
product.components = [component];
|
||||
} else {
|
||||
// 中文注释:混装商品返回持久化的 SKU 组成
|
||||
// 混装商品返回持久化的 SKU 组成
|
||||
product.components = await this.productStockComponentModel.find({
|
||||
where: { productId: product.id },
|
||||
});
|
||||
|
|
@ -315,7 +328,7 @@ export class ProductService {
|
|||
where: { name: nameForLookup, dict: { id: dict.id } },
|
||||
});
|
||||
|
||||
// 如果字典项不存在,则创建
|
||||
// 如果字典项不存在,则创建
|
||||
if (!item) {
|
||||
item = new DictItem();
|
||||
item.title = itemTitle;
|
||||
|
|
@ -327,14 +340,15 @@ export class ProductService {
|
|||
return item;
|
||||
}
|
||||
|
||||
async createProduct(createProductDTO: CreateProductDTO): Promise<Product> {
|
||||
const { name, description, attributes, sku, price, categoryId } = createProductDTO;
|
||||
|
||||
// 条件判断(中文注释:校验属性输入)
|
||||
async createProduct(createProductDTO: CreateProductDTO): Promise<Product> {
|
||||
const { attributes, sku, categoryId } = createProductDTO;
|
||||
|
||||
// 条件判断(校验属性输入)
|
||||
if (!Array.isArray(attributes) || attributes.length === 0) {
|
||||
// 如果提供了 categoryId 但没有 attributes,初始化为空数组
|
||||
// 如果提供了 categoryId 但没有 attributes,初始化为空数组
|
||||
if (!attributes && categoryId) {
|
||||
// 继续执行,下面会处理 categoryId
|
||||
// 继续执行,下面会处理 categoryId
|
||||
} else {
|
||||
throw new Error('属性列表不能为空');
|
||||
}
|
||||
|
|
@ -342,34 +356,42 @@ export class ProductService {
|
|||
|
||||
const safeAttributes = attributes || [];
|
||||
|
||||
// 解析属性输入(中文注释:按 id 或 dictName 创建/关联字典项)
|
||||
// 解析属性输入(按 id 或 dictName 创建/关联字典项)
|
||||
const resolvedAttributes: DictItem[] = [];
|
||||
let categoryItem: Category | null = null;
|
||||
|
||||
// 如果提供了 categoryId,设置分类
|
||||
// 如果提供了 categoryId,设置分类
|
||||
if (categoryId) {
|
||||
categoryItem = await this.categoryModel.findOne({ where: { id: categoryId } });
|
||||
if (!categoryItem) throw new Error(`分类 ID ${categoryId} 不存在`);
|
||||
}
|
||||
|
||||
for (const attr of safeAttributes) {
|
||||
// 中文注释:如果属性是分类,特殊处理
|
||||
// 如果属性是分类,特殊处理
|
||||
if (attr.dictName === 'category') {
|
||||
if (attr.id) {
|
||||
categoryItem = await this.categoryModel.findOneBy({ id: attr.id });
|
||||
} else if (attr.name) {
|
||||
categoryItem = await this.categoryModel.findOneBy({ name: attr.name });
|
||||
} else if (attr.title) {
|
||||
// 尝试用 title 匹配 name 或 title
|
||||
categoryItem = await this.categoryModel.findOne({
|
||||
where: [
|
||||
{ name: attr.title },
|
||||
{ title: attr.title }
|
||||
]
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let item: DictItem | null = null;
|
||||
if (attr.id) {
|
||||
// 中文注释:如果传入了 id,直接查找字典项并使用,不强制要求 dictName
|
||||
// 如果传入了 id,直接查找字典项并使用,不强制要求 dictName
|
||||
item = await this.dictItemModel.findOne({ where: { id: attr.id }, relations: ['dict'] });
|
||||
if (!item) throw new Error(`字典项 ID ${attr.id} 不存在`);
|
||||
} else {
|
||||
// 中文注释:当未提供 id 时,需要 dictName 与 title/name 信息创建或获取字典项
|
||||
// 当未提供 id 时,需要 dictName 与 title/name 信息创建或获取字典项
|
||||
if (!attr?.dictName) throw new Error('属性项缺少字典名称');
|
||||
const titleOrName = attr.title || attr.name;
|
||||
if (!titleOrName) throw new Error('新建字典项需要提供 title 或 name');
|
||||
|
|
@ -378,7 +400,7 @@ export class ProductService {
|
|||
resolvedAttributes.push(item);
|
||||
}
|
||||
|
||||
// 检查完全相同属性组合是否已存在(中文注释:避免重复)
|
||||
// 检查完全相同属性组合是否已存在(避免重复)
|
||||
const qb = this.productModel.createQueryBuilder('product');
|
||||
resolvedAttributes.forEach((attr, index) => {
|
||||
qb.innerJoin(
|
||||
|
|
@ -391,18 +413,21 @@ export class ProductService {
|
|||
const isExist = await qb.getOne();
|
||||
if (isExist) throw new Error('产品已存在');
|
||||
|
||||
// 创建新产品实例(中文注释:绑定属性与基础字段)
|
||||
// 创建新产品实例(绑定属性与基础字段)
|
||||
const product = new Product();
|
||||
product.name = name;
|
||||
product.description = description;
|
||||
|
||||
// 使用 merge 填充基础字段,排除特殊处理字段
|
||||
const { attributes: _attrs, categoryId: _cid, sku: _sku, ...simpleFields } = createProductDTO;
|
||||
this.productModel.merge(product, simpleFields);
|
||||
|
||||
product.attributes = resolvedAttributes;
|
||||
if (categoryItem) {
|
||||
product.category = categoryItem;
|
||||
}
|
||||
// 条件判断(中文注释:设置商品类型,默认 single)
|
||||
product.type = (createProductDTO.type as any) || 'single';
|
||||
// 确保默认类型
|
||||
if (!product.type) product.type = 'single';
|
||||
|
||||
// 生成或设置 SKU(中文注释:基于属性字典项的 name 生成)
|
||||
// 生成或设置 SKU(基于属性字典项的 name 生成)
|
||||
if (sku) {
|
||||
product.sku = sku;
|
||||
} else {
|
||||
|
|
@ -418,15 +443,6 @@ export class ProductService {
|
|||
});
|
||||
}
|
||||
|
||||
// 价格与促销价(中文注释:可选字段)
|
||||
if (price !== undefined) {
|
||||
product.price = Number(price);
|
||||
}
|
||||
const promotionPrice = (createProductDTO as any)?.promotionPrice;
|
||||
if (promotionPrice !== undefined) {
|
||||
product.promotionPrice = Number(promotionPrice);
|
||||
}
|
||||
|
||||
return await this.productModel.save(product);
|
||||
}
|
||||
|
||||
|
|
@ -434,48 +450,42 @@ export class ProductService {
|
|||
id: number,
|
||||
updateProductDTO: UpdateProductDTO
|
||||
): Promise<Product> {
|
||||
// 检查产品是否存在(包含属性关系)
|
||||
// 检查产品是否存在(包含属性关系)
|
||||
const product = await this.productModel.findOne({ where: { id }, relations: ['attributes', 'attributes.dict', 'category'] });
|
||||
if (!product) {
|
||||
throw new Error(`产品 ID ${id} 不存在`);
|
||||
}
|
||||
|
||||
// 处理基础字段更新(若传入则更新)
|
||||
if (updateProductDTO.name !== undefined) {
|
||||
product.name = updateProductDTO.name;
|
||||
}
|
||||
if (updateProductDTO.description !== undefined) {
|
||||
product.description = updateProductDTO.description;
|
||||
}
|
||||
// 使用 merge 更新基础字段,排除特殊处理字段
|
||||
const { attributes: _attrs, categoryId: _cid, sku: _sku, ...simpleFields } = updateProductDTO;
|
||||
this.productModel.merge(product, simpleFields);
|
||||
|
||||
// 处理分类更新
|
||||
if (updateProductDTO.categoryId !== undefined) {
|
||||
if (updateProductDTO.categoryId) {
|
||||
const categoryItem = await this.categoryModel.findOne({ where: { id: updateProductDTO.categoryId } });
|
||||
if (!categoryItem) throw new Error(`分类 ID ${updateProductDTO.categoryId} 不存在`);
|
||||
product.category = categoryItem;
|
||||
} else {
|
||||
// 如果传了 0 或 null,可以清除分类(根据需求)
|
||||
// 如果传了 0 或 null,可以清除分类(根据需求)
|
||||
// product.category = null;
|
||||
}
|
||||
}
|
||||
if (updateProductDTO.price !== undefined) {
|
||||
product.price = Number(updateProductDTO.price);
|
||||
}
|
||||
if ((updateProductDTO as any).promotionPrice !== undefined) {
|
||||
product.promotionPrice = Number((updateProductDTO as any).promotionPrice);
|
||||
}
|
||||
|
||||
// 处理 SKU 更新
|
||||
if (updateProductDTO.sku !== undefined) {
|
||||
// 校验 SKU 唯一性(如变更)
|
||||
// 校验 SKU 唯一性(如变更)
|
||||
const newSku = updateProductDTO.sku;
|
||||
if (newSku && newSku !== product.sku) {
|
||||
const exist = await this.productModel.findOne({ where: { sku: newSku } });
|
||||
if (exist) {
|
||||
throw new Error('SKU 已存在,请更换后重试');
|
||||
throw new Error('SKU 已存在,请更换后重试');
|
||||
}
|
||||
product.sku = newSku;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理属性更新(中文注释:若传入 attributes 则按字典名称替换对应项)
|
||||
// 处理属性更新(若传入 attributes 则按字典名称替换对应项)
|
||||
if (Array.isArray(updateProductDTO.attributes) && updateProductDTO.attributes.length > 0) {
|
||||
const nextAttributes: DictItem[] = [...(product.attributes || [])];
|
||||
|
||||
|
|
@ -485,7 +495,7 @@ export class ProductService {
|
|||
};
|
||||
|
||||
for (const attr of updateProductDTO.attributes) {
|
||||
// 中文注释:如果属性是分类,特殊处理
|
||||
// 如果属性是分类,特殊处理
|
||||
if (attr.dictName === 'category') {
|
||||
if (attr.id) {
|
||||
const categoryItem = await this.categoryModel.findOneBy({ id: attr.id });
|
||||
|
|
@ -496,17 +506,17 @@ export class ProductService {
|
|||
|
||||
let item: DictItem | null = null;
|
||||
if (attr.id) {
|
||||
// 中文注释:当提供 id 时直接查询字典项,不强制要求 dictName
|
||||
// 当提供 id 时直接查询字典项,不强制要求 dictName
|
||||
item = await this.dictItemModel.findOne({ where: { id: attr.id }, relations: ['dict'] });
|
||||
if (!item) throw new Error(`字典项 ID ${attr.id} 不存在`);
|
||||
} else {
|
||||
// 中文注释:未提供 id 则需要 dictName 与 title/name 信息
|
||||
// 未提供 id 则需要 dictName 与 title/name 信息
|
||||
if (!attr?.dictName) throw new Error('属性项缺少字典名称');
|
||||
const titleOrName = attr.title || attr.name;
|
||||
if (!titleOrName) throw new Error('新建字典项需要提供 title 或 name');
|
||||
item = await this.getOrCreateAttribute(attr.dictName, titleOrName, attr.name);
|
||||
}
|
||||
// 中文注释:以传入的 dictName 或查询到的 item.dict.name 作为替换键
|
||||
// 以传入的 dictName 或查询到的 item.dict.name 作为替换键
|
||||
const dictKey = attr.dictName || item?.dict?.name;
|
||||
if (!dictKey) throw new Error('无法确定字典名称用于替换属性');
|
||||
replaceAttr(dictKey, item);
|
||||
|
|
@ -515,7 +525,7 @@ export class ProductService {
|
|||
product.attributes = nextAttributes;
|
||||
}
|
||||
|
||||
// 条件判断(中文注释:更新商品类型,如传入)
|
||||
// 条件判断(更新商品类型,如传入)
|
||||
if (updateProductDTO.type !== undefined) {
|
||||
product.type = updateProductDTO.type as any;
|
||||
}
|
||||
|
|
@ -525,14 +535,14 @@ export class ProductService {
|
|||
return saved;
|
||||
}
|
||||
|
||||
// 中文注释:获取产品的库存组成列表(表关联版本)
|
||||
// 获取产品的库存组成列表(表关联版本)
|
||||
async getProductComponents(productId: number): Promise<any[]> {
|
||||
// 条件判断:确保产品存在
|
||||
// 条件判断:确保产品存在
|
||||
const product = await this.productModel.findOne({ where: { id: productId } });
|
||||
if (!product) throw new Error(`产品 ID ${productId} 不存在`);
|
||||
|
||||
let components: ProductStockComponent[] = [];
|
||||
// 条件判断(中文注释:单品 simple 不持久化组成,按 SKU 动态返回单条组成)
|
||||
// 条件判断(单品 simple 不持久化组成,按 SKU 动态返回单条组成)
|
||||
if (product.type === 'single') {
|
||||
const comp = new ProductStockComponent();
|
||||
comp.productId = productId;
|
||||
|
|
@ -540,22 +550,22 @@ export class ProductService {
|
|||
comp.quantity = 1;
|
||||
components = [comp];
|
||||
} else {
|
||||
// 混装 bundle:返回已保存的 SKU 组成
|
||||
// 混装 bundle:返回已保存的 SKU 组成
|
||||
components = await this.productStockComponentModel.find({ where: { productId } });
|
||||
}
|
||||
|
||||
// 中文注释:获取所有组件的 SKU 列表
|
||||
// 获取所有组件的 SKU 列表
|
||||
const skus = components.map(c => c.sku);
|
||||
if (skus.length === 0) {
|
||||
return components;
|
||||
}
|
||||
|
||||
// 中文注释:查询这些 SKU 的库存信息
|
||||
// 查询这些 SKU 的库存信息
|
||||
const stocks = await this.stockModel.find({
|
||||
where: { sku: In(skus) },
|
||||
});
|
||||
|
||||
// 中文注释:获取所有相关的库存点 ID
|
||||
// 获取所有相关的库存点 ID
|
||||
const stockPointIds = [...new Set(stocks.map(s => s.stockPointId))];
|
||||
const stockPoints = await this.stockPointModel.find({ where: { id: In(stockPointIds) } });
|
||||
const stockPointMap = stockPoints.reduce((map, sp) => {
|
||||
|
|
@ -563,7 +573,7 @@ export class ProductService {
|
|||
return map;
|
||||
}, {});
|
||||
|
||||
// 中文注释:将库存信息按 SKU 分组
|
||||
// 将库存信息按 SKU 分组
|
||||
const stockMap = stocks.reduce((map, stock) => {
|
||||
if (!map[stock.sku]) {
|
||||
map[stock.sku] = [];
|
||||
|
|
@ -578,7 +588,7 @@ export class ProductService {
|
|||
return map;
|
||||
}, {});
|
||||
|
||||
// 中文注释:将库存信息附加到组件上
|
||||
// 将库存信息附加到组件上
|
||||
const componentsWithStock = components.map(comp => {
|
||||
return {
|
||||
...comp,
|
||||
|
|
@ -589,15 +599,15 @@ export class ProductService {
|
|||
return componentsWithStock;
|
||||
}
|
||||
|
||||
// 中文注释:设置产品的库存组成(覆盖式,表关联版本)
|
||||
// 设置产品的库存组成(覆盖式,表关联版本)
|
||||
async setProductComponents(
|
||||
productId: number,
|
||||
items: { sku: string; quantity: number }[]
|
||||
): Promise<ProductStockComponent[]> {
|
||||
// 条件判断:确保产品存在
|
||||
// 条件判断:确保产品存在
|
||||
const product = await this.productModel.findOne({ where: { id: productId } });
|
||||
if (!product) throw new Error(`产品 ID ${productId} 不存在`);
|
||||
// 条件判断(中文注释:单品 simple 不允许手动设置组成)
|
||||
// 条件判断(单品 simple 不允许手动设置组成)
|
||||
if (product.type === 'single') {
|
||||
throw new Error('单品无需设置组成');
|
||||
}
|
||||
|
|
@ -612,7 +622,7 @@ export class ProductService {
|
|||
// 插入新的组成
|
||||
const created: ProductStockComponent[] = [];
|
||||
for (const i of validItems) {
|
||||
// 中文注释:校验 SKU 格式,允许不存在库存但必须非空
|
||||
// 校验 SKU 格式,允许不存在库存但必须非空
|
||||
if (!i.sku || i.sku.trim().length === 0) {
|
||||
throw new Error('SKU 不能为空');
|
||||
}
|
||||
|
|
@ -625,13 +635,13 @@ export class ProductService {
|
|||
return created;
|
||||
}
|
||||
|
||||
// 中文注释:根据 SKU 自动绑定产品的库存组成(匹配所有相同 SKU 的库存,默认数量 1)
|
||||
// 根据 SKU 自动绑定产品的库存组成(匹配所有相同 SKU 的库存,默认数量 1)
|
||||
async autoBindComponentsBySku(productId: number): Promise<ProductStockComponent[]> {
|
||||
// 条件判断:确保产品存在
|
||||
// 条件判断:确保产品存在
|
||||
const product = await this.productModel.findOne({ where: { id: productId } });
|
||||
if (!product) throw new Error(`产品 ID ${productId} 不存在`);
|
||||
// 中文注释:按 SKU 自动绑定
|
||||
// 条件判断:simple 类型不持久化组成,直接返回单条基于 SKU 的组成
|
||||
// 按 SKU 自动绑定
|
||||
// 条件判断:simple 类型不持久化组成,直接返回单条基于 SKU 的组成
|
||||
if (product.type === 'single') {
|
||||
const comp = new ProductStockComponent();
|
||||
comp.productId = productId;
|
||||
|
|
@ -639,7 +649,7 @@ export class ProductService {
|
|||
comp.quantity = 1; // 默认数量 1
|
||||
return [comp];
|
||||
}
|
||||
// bundle 类型:若不存在则持久化一条基于 SKU 的组成
|
||||
// bundle 类型:若不存在则持久化一条基于 SKU 的组成
|
||||
const exist = await this.productStockComponentModel.findOne({ where: { productId, sku: product.sku } });
|
||||
if (!exist) {
|
||||
const comp = new ProductStockComponent();
|
||||
|
|
@ -651,7 +661,7 @@ export class ProductService {
|
|||
return await this.getProductComponents(productId);
|
||||
}
|
||||
|
||||
// 重复定义的 getProductList 已合并到前面的实现(中文注释:移除重复)
|
||||
// 重复定义的 getProductList 已合并到前面的实现(移除重复)
|
||||
|
||||
async updatenameCn(id: number, nameCn: string): Promise<Product> {
|
||||
// 确认产品是否存在
|
||||
|
|
@ -681,7 +691,7 @@ export class ProductService {
|
|||
})
|
||||
.getOne();
|
||||
if (wpProduct) {
|
||||
throw new Error('无法删除,请先删除关联的WP产品');
|
||||
throw new Error('无法删除,请先删除关联的WP产品');
|
||||
}
|
||||
|
||||
const variation = await this.variationModel
|
||||
|
|
@ -693,7 +703,7 @@ export class ProductService {
|
|||
|
||||
if (variation) {
|
||||
console.log(variation);
|
||||
throw new Error('无法删除,请先删除关联的WP变体');
|
||||
throw new Error('无法删除,请先删除关联的WP变体');
|
||||
}
|
||||
|
||||
// 删除产品
|
||||
|
|
@ -739,7 +749,7 @@ export class ProductService {
|
|||
where: { name: 'brand' },
|
||||
});
|
||||
|
||||
// 如果字典不存在,则返回空
|
||||
// 如果字典不存在,则返回空
|
||||
if (!brandDict) {
|
||||
return {
|
||||
items: [],
|
||||
|
|
@ -764,7 +774,7 @@ export class ProductService {
|
|||
where: { name: 'brand' },
|
||||
});
|
||||
|
||||
// 如果字典不存在,则返回空数组
|
||||
// 如果字典不存在,则返回空数组
|
||||
if (!brandDict) {
|
||||
return [];
|
||||
}
|
||||
|
|
@ -781,7 +791,7 @@ export class ProductService {
|
|||
where: { name: 'brand' },
|
||||
});
|
||||
|
||||
// 如果字典不存在,则抛出错误
|
||||
// 如果字典不存在,则抛出错误
|
||||
if (!brandDict) {
|
||||
throw new Error('品牌字典不存在');
|
||||
}
|
||||
|
|
@ -894,9 +904,9 @@ export class ProductService {
|
|||
pagination: PaginationParams,
|
||||
title?: string
|
||||
): Promise<SizePaginatedResponse> {
|
||||
// 查找 'size' 字典(中文注释:用于尺寸)
|
||||
// 查找 'size' 字典(用于尺寸)
|
||||
const sizeDict = await this.dictModel.findOne({ where: { name: 'size' } });
|
||||
// 条件判断(中文注释:如果字典不存在则返回空分页)
|
||||
// 条件判断(如果字典不存在则返回空分页)
|
||||
if (!sizeDict) {
|
||||
return {
|
||||
items: [],
|
||||
|
|
@ -904,19 +914,19 @@ export class ProductService {
|
|||
...pagination,
|
||||
} as any;
|
||||
}
|
||||
// 构建 where 条件(中文注释:按标题模糊搜索)
|
||||
// 构建 where 条件(按标题模糊搜索)
|
||||
const where: any = { dict: { id: sizeDict.id } };
|
||||
if (title) {
|
||||
where.title = Like(`%${title}%`);
|
||||
}
|
||||
// 分页查询(中文注释:复用通用分页工具)
|
||||
// 分页查询(复用通用分页工具)
|
||||
return await paginate(this.dictItemModel, { pagination, where });
|
||||
}
|
||||
|
||||
async getSizeAll(): Promise<SizePaginatedResponse> {
|
||||
// 查找 'size' 字典(中文注释:获取所有尺寸项)
|
||||
// 查找 'size' 字典(获取所有尺寸项)
|
||||
const sizeDict = await this.dictModel.findOne({ where: { name: 'size' } });
|
||||
// 条件判断(中文注释:如果字典不存在返回空数组)
|
||||
// 条件判断(如果字典不存在返回空数组)
|
||||
if (!sizeDict) {
|
||||
return [] as any;
|
||||
}
|
||||
|
|
@ -925,13 +935,13 @@ export class ProductService {
|
|||
|
||||
async createSize(createSizeDTO: any): Promise<DictItem> {
|
||||
const { title, name } = createSizeDTO;
|
||||
// 获取 size 字典(中文注释:用于挂载尺寸项)
|
||||
// 获取 size 字典(用于挂载尺寸项)
|
||||
const sizeDict = await this.dictModel.findOne({ where: { name: 'size' } });
|
||||
// 条件判断(中文注释:尺寸字典不存在则抛错)
|
||||
// 条件判断(尺寸字典不存在则抛错)
|
||||
if (!sizeDict) {
|
||||
throw new Error('尺寸字典不存在');
|
||||
}
|
||||
// 创建字典项(中文注释:保存尺寸名称与唯一标识)
|
||||
// 创建字典项(保存尺寸名称与唯一标识)
|
||||
const size = new DictItem();
|
||||
size.title = title;
|
||||
size.name = name;
|
||||
|
|
@ -940,26 +950,26 @@ export class ProductService {
|
|||
}
|
||||
|
||||
async updateSize(id: number, updateSize: any) {
|
||||
// 先查询(中文注释:确保尺寸项存在)
|
||||
// 先查询(确保尺寸项存在)
|
||||
const size = await this.dictItemModel.findOneBy({ id });
|
||||
// 条件判断(中文注释:不存在则报错)
|
||||
// 条件判断(不存在则报错)
|
||||
if (!size) {
|
||||
throw new Error(`尺寸 ID ${id} 不存在`);
|
||||
}
|
||||
// 更新(中文注释:写入变更字段)
|
||||
// 更新(写入变更字段)
|
||||
await this.dictItemModel.update(id, updateSize);
|
||||
// 返回最新(中文注释:再次查询返回)
|
||||
// 返回最新(再次查询返回)
|
||||
return await this.dictItemModel.findOneBy({ id });
|
||||
}
|
||||
|
||||
async deleteSize(id: number): Promise<boolean> {
|
||||
// 先查询(中文注释:确保尺寸项存在)
|
||||
// 先查询(确保尺寸项存在)
|
||||
const size = await this.dictItemModel.findOneBy({ id });
|
||||
// 条件判断(中文注释:不存在则报错)
|
||||
// 条件判断(不存在则报错)
|
||||
if (!size) {
|
||||
throw new Error(`尺寸 ID ${id} 不存在`);
|
||||
}
|
||||
// 删除(中文注释:执行删除并返回受影响行数是否>0)
|
||||
// 删除(执行删除并返回受影响行数是否>0)
|
||||
const result = await this.dictItemModel.delete(id);
|
||||
return result.affected > 0;
|
||||
}
|
||||
|
|
@ -1025,7 +1035,7 @@ export class ProductService {
|
|||
return await this.dictItemModel.save(strength);
|
||||
}
|
||||
|
||||
// 通用属性:分页获取指定字典的字典项
|
||||
// 通用属性:分页获取指定字典的字典项
|
||||
async getAttributeList(
|
||||
dictName: string,
|
||||
pagination: PaginationParams,
|
||||
|
|
@ -1045,7 +1055,7 @@ export class ProductService {
|
|||
return { items, total, ...pagination } as any;
|
||||
}
|
||||
|
||||
// 通用属性:获取指定字典的全部字典项
|
||||
// 通用属性:获取指定字典的全部字典项
|
||||
async getAttributeAll(dictName: string): Promise<DictItem[]> {
|
||||
const dict = await this.dictModel.findOne({ where: { name: dictName } });
|
||||
if (!dict) return [];
|
||||
|
|
@ -1056,7 +1066,7 @@ export class ProductService {
|
|||
});
|
||||
}
|
||||
|
||||
// 通用属性:创建字典项
|
||||
// 通用属性:创建字典项
|
||||
async createAttribute(
|
||||
dictName: string,
|
||||
payload: { title: string; name: string }
|
||||
|
|
@ -1075,7 +1085,7 @@ export class ProductService {
|
|||
return await this.dictItemModel.save(item);
|
||||
}
|
||||
|
||||
// 通用属性:更新字典项
|
||||
// 通用属性:更新字典项
|
||||
async updateAttribute(
|
||||
id: number,
|
||||
payload: { title?: string; name?: string }
|
||||
|
|
@ -1087,10 +1097,10 @@ export class ProductService {
|
|||
return await this.dictItemModel.save(item);
|
||||
}
|
||||
|
||||
// 通用属性:删除字典项(若存在产品关联则禁止删除)
|
||||
// 通用属性:删除字典项(若存在产品关联则禁止删除)
|
||||
async deleteAttribute(id: number): Promise<void> {
|
||||
const hasProducts = await this.hasProductsInAttribute(id);
|
||||
if (hasProducts) throw new Error('当前字典项存在关联产品,无法删除');
|
||||
if (hasProducts) throw new Error('当前字典项存在关联产品,无法删除');
|
||||
await this.dictItemModel.delete({ id });
|
||||
}
|
||||
|
||||
|
|
@ -1126,7 +1136,7 @@ export class ProductService {
|
|||
throw new Error(`以下 SKU 已存在: ${existingSkus.join(', ')}`);
|
||||
}
|
||||
|
||||
// 遍历检查产品 ID 是否存在,并更新 sku
|
||||
// 遍历检查产品 ID 是否存在,并更新 sku
|
||||
for (const { productId, sku } of skus) {
|
||||
const product = await this.productModel.findOne({
|
||||
where: { id: productId },
|
||||
|
|
@ -1142,31 +1152,131 @@ export class ProductService {
|
|||
return `成功更新 ${skus.length} 个 sku`;
|
||||
}
|
||||
|
||||
// 中文注释:导出所有产品为 CSV 文本
|
||||
async exportProductsCSV(): Promise<string> {
|
||||
// 查询所有产品及其属性(中文注释:包含字典关系)
|
||||
const products = await this.productModel.find({
|
||||
relations: ['attributes', 'attributes.dict'],
|
||||
order: { id: 'ASC' },
|
||||
});
|
||||
// 将单条 CSV 记录转换为数据对象
|
||||
transformCsvRecordToData(rec: any): CreateProductDTO & { sku: string } | null {
|
||||
// 必须包含 sku
|
||||
const sku: string = (rec.sku || '').trim();
|
||||
if (!sku) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 定义 CSV 表头(中文注释:与导入字段一致)
|
||||
const headers = [
|
||||
'sku',
|
||||
'name',
|
||||
'nameCn',
|
||||
'price',
|
||||
'promotionPrice',
|
||||
'type',
|
||||
'stock',
|
||||
'brand',
|
||||
'flavor',
|
||||
'strength',
|
||||
'size',
|
||||
'description',
|
||||
];
|
||||
// 辅助函数:处理空字符串为 undefined
|
||||
const val = (v: any) => {
|
||||
if (v === undefined || v === null) return undefined;
|
||||
const s = String(v).trim();
|
||||
return s === '' ? undefined : s;
|
||||
};
|
||||
|
||||
// 中文注释:CSV 字段转义,处理逗号与双引号
|
||||
// 辅助函数:处理数字
|
||||
const num = (v: any) => {
|
||||
const s = val(v);
|
||||
return s ? Number(s) : undefined;
|
||||
};
|
||||
|
||||
// 解析属性字段(分号分隔多值)
|
||||
const parseList = (v: string) => (v ? String(v).split(';').map(s => s.trim()).filter(Boolean) : []);
|
||||
|
||||
// 将属性解析为 DTO 输入
|
||||
const attributes: any[] = [];
|
||||
|
||||
// 处理动态属性字段 (attribute_*)
|
||||
for (const key of Object.keys(rec)) {
|
||||
if (key.startsWith('attribute_')) {
|
||||
const dictName = key.replace('attribute_', '');
|
||||
if (dictName) {
|
||||
const list = parseList(rec[key]);
|
||||
for (const item of list) attributes.push({ dictName, title: item });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 解析组件信息 (component_*)
|
||||
const componentsMap = new Map<string, { sku?: string; quantity?: number }>();
|
||||
for (const key of Object.keys(rec)) {
|
||||
const skuMatch = key.match(/^component_(\d+)_sku$/);
|
||||
if (skuMatch) {
|
||||
const idx = skuMatch[1];
|
||||
if (!componentsMap.has(idx)) componentsMap.set(idx, {});
|
||||
componentsMap.get(idx)!.sku = rec[key];
|
||||
}
|
||||
const qtyMatch = key.match(/^component_(\d+)_quantity$/);
|
||||
if (qtyMatch) {
|
||||
const idx = qtyMatch[1];
|
||||
if (!componentsMap.has(idx)) componentsMap.set(idx, {});
|
||||
componentsMap.get(idx)!.quantity = Number(rec[key]);
|
||||
}
|
||||
}
|
||||
const components = Array.from(componentsMap.values())
|
||||
.filter(c => c.sku && c.quantity)
|
||||
.map(c => ({ sku: c.sku!, quantity: c.quantity! }));
|
||||
|
||||
return {
|
||||
sku,
|
||||
name: val(rec.name),
|
||||
nameCn: val(rec.nameCn),
|
||||
description: val(rec.description),
|
||||
price: num(rec.price),
|
||||
promotionPrice: num(rec.promotionPrice),
|
||||
type: val(rec.type),
|
||||
|
||||
attributes: attributes.length > 0 ? attributes : undefined,
|
||||
components: components.length > 0 ? components : undefined,
|
||||
} as any;
|
||||
}
|
||||
|
||||
// 准备创建产品的 DTO, 处理类型转换和默认值
|
||||
prepareCreateProductDTO(data: any): CreateProductDTO {
|
||||
const dto = new CreateProductDTO();
|
||||
// 基础字段赋值
|
||||
dto.name = data.name;
|
||||
dto.nameCn = data.nameCn;
|
||||
dto.description = data.description;
|
||||
dto.sku = data.sku;
|
||||
|
||||
// 数值类型转换
|
||||
if (data.price !== undefined) dto.price = Number(data.price);
|
||||
if (data.promotionPrice !== undefined) dto.promotionPrice = Number(data.promotionPrice);
|
||||
|
||||
if (data.categoryId !== undefined) dto.categoryId = Number(data.categoryId);
|
||||
|
||||
// 默认值和特殊处理
|
||||
|
||||
dto.attributes = Array.isArray(data.attributes) ? data.attributes : [];
|
||||
|
||||
// 如果有组件信息,透传
|
||||
dto.type = data.type || data.components?.length? 'bundle':'single'
|
||||
if (data.components) dto.components = data.components;
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
// 准备更新产品的 DTO, 处理类型转换
|
||||
prepareUpdateProductDTO(data: any): UpdateProductDTO {
|
||||
const dto = new UpdateProductDTO();
|
||||
|
||||
if (data.name !== undefined) dto.name = data.name;
|
||||
if (data.nameCn !== undefined) dto.nameCn = data.nameCn;
|
||||
if (data.description !== undefined) dto.description = data.description;
|
||||
if (data.sku !== undefined) dto.sku = data.sku;
|
||||
|
||||
if (data.price !== undefined) dto.price = Number(data.price);
|
||||
if (data.promotionPrice !== undefined) dto.promotionPrice = Number(data.promotionPrice);
|
||||
|
||||
if (data.categoryId !== undefined) dto.categoryId = Number(data.categoryId);
|
||||
|
||||
if (data.type !== undefined) dto.type = data.type;
|
||||
if (data.attributes !== undefined) dto.attributes = data.attributes;
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
// 将单个产品转换为 CSV 行数组
|
||||
transformProductToCsvRow(
|
||||
p: Product,
|
||||
sortedDictNames: string[],
|
||||
maxComponentCount: number
|
||||
): string[] {
|
||||
// CSV 字段转义,处理逗号与双引号
|
||||
const esc = (v: any) => {
|
||||
const s = v === undefined || v === null ? '' : String(v);
|
||||
const needsQuote = /[",\n]/.test(s);
|
||||
|
|
@ -1174,7 +1284,7 @@ export class ProductService {
|
|||
return needsQuote ? `"${escaped}"` : escaped;
|
||||
};
|
||||
|
||||
// 中文注释:将属性列表转为字典名到显示值的映射
|
||||
// 将属性列表转为字典名到显示值的映射
|
||||
const pickAttr = (prod: Product, key: string) => {
|
||||
const list = (prod.attributes || []).filter(a => a?.dict?.name === key);
|
||||
if (list.length === 0) return '';
|
||||
|
|
@ -1182,33 +1292,107 @@ export class ProductService {
|
|||
return list.map(a => a.title || a.name).join(';');
|
||||
};
|
||||
|
||||
const rows: string[] = [];
|
||||
rows.push(headers.join(','));
|
||||
// 基础数据
|
||||
const rowData = [
|
||||
esc(p.sku),
|
||||
esc(p.name),
|
||||
esc(p.nameCn),
|
||||
esc(p.price),
|
||||
esc(p.promotionPrice),
|
||||
esc(p.type),
|
||||
|
||||
esc(p.description),
|
||||
];
|
||||
|
||||
// 属性数据
|
||||
for (const dictName of sortedDictNames) {
|
||||
rowData.push(esc(pickAttr(p, dictName)));
|
||||
}
|
||||
|
||||
// 组件数据
|
||||
const components = p.components || [];
|
||||
for (let i = 0; i < maxComponentCount; i++) {
|
||||
const comp = components[i];
|
||||
if (comp) {
|
||||
rowData.push(esc(comp.sku));
|
||||
rowData.push(esc(comp.quantity));
|
||||
} else {
|
||||
rowData.push('');
|
||||
rowData.push('');
|
||||
}
|
||||
}
|
||||
|
||||
return rowData;
|
||||
}
|
||||
|
||||
// 导出所有产品为 CSV 文本
|
||||
async exportProductsCSV(): Promise<string> {
|
||||
// 查询所有产品及其属性(包含字典关系)和组成
|
||||
const products = await this.productModel.find({
|
||||
relations: ['attributes', 'attributes.dict', 'components'],
|
||||
order: { id: 'ASC' },
|
||||
});
|
||||
|
||||
// 1. 收集所有动态属性的 dictName
|
||||
const dictNames = new Set<string>();
|
||||
// 2. 收集最大的组件数量
|
||||
let maxComponentCount = 0;
|
||||
|
||||
for (const p of products) {
|
||||
// 中文注释:逐行输出产品数据
|
||||
const row = [
|
||||
esc(p.sku),
|
||||
esc(p.name),
|
||||
esc(p.nameCn),
|
||||
esc(p.price),
|
||||
esc(p.promotionPrice),
|
||||
esc(p.type),
|
||||
esc(p.stock),
|
||||
esc(pickAttr(p, 'brand')),
|
||||
esc(pickAttr(p, 'flavor')),
|
||||
esc(pickAttr(p, 'strength')),
|
||||
esc(pickAttr(p, 'size')),
|
||||
esc(p.description),
|
||||
].join(',');
|
||||
rows.push(row);
|
||||
if (p.attributes) {
|
||||
for (const attr of p.attributes) {
|
||||
if (attr.dict && attr.dict.name) {
|
||||
dictNames.add(attr.dict.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (p.components) {
|
||||
if (p.components.length > maxComponentCount) {
|
||||
maxComponentCount = p.components.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sortedDictNames = Array.from(dictNames).sort();
|
||||
|
||||
// 定义 CSV 表头(与导入字段一致)
|
||||
const baseHeaders = [
|
||||
'sku',
|
||||
'name',
|
||||
'nameCn',
|
||||
'price',
|
||||
'promotionPrice',
|
||||
'type',
|
||||
|
||||
'description',
|
||||
];
|
||||
|
||||
// 动态属性表头
|
||||
const attributeHeaders = sortedDictNames.map(name => `attribute_${name}`);
|
||||
|
||||
// 动态组件表头
|
||||
const componentHeaders = [];
|
||||
for (let i = 1; i <= maxComponentCount; i++) {
|
||||
componentHeaders.push(`component_${i}_sku`);
|
||||
componentHeaders.push(`component_${i}_quantity`);
|
||||
}
|
||||
|
||||
const allHeaders = [...baseHeaders, ...attributeHeaders, ...componentHeaders];
|
||||
|
||||
const rows: string[] = [];
|
||||
rows.push(allHeaders.join(','));
|
||||
|
||||
for (const p of products) {
|
||||
const rowData = this.transformProductToCsvRow(p, sortedDictNames, maxComponentCount);
|
||||
rows.push(rowData.join(','));
|
||||
}
|
||||
|
||||
return rows.join('\n');
|
||||
}
|
||||
|
||||
// 中文注释:从 CSV 导入产品;存在则更新,不存在则创建
|
||||
// 从 CSV 导入产品;存在则更新,不存在则创建
|
||||
async importProductsCSV(buffer: Buffer): Promise<{ created: number; updated: number; errors: string[] }> {
|
||||
// 解析 CSV(中文注释:使用 csv-parse/sync 按表头解析)
|
||||
// 解析 CSV(使用 csv-parse/sync 按表头解析)
|
||||
const { parse } = await import('csv-parse/sync');
|
||||
let records: any[] = [];
|
||||
try {
|
||||
|
|
@ -1216,90 +1400,56 @@ export class ProductService {
|
|||
columns: true,
|
||||
skip_empty_lines: true,
|
||||
trim: true,
|
||||
bom: true,
|
||||
});
|
||||
console.log('Parsed records count:', records.length);
|
||||
if (records.length > 0) {
|
||||
console.log('First record keys:', Object.keys(records[0]));
|
||||
}
|
||||
} catch (e: any) {
|
||||
return { created: 0, updated: 0, errors: [`CSV 解析失败:${e?.message || e}`] };
|
||||
return { created: 0, updated: 0, errors: [`CSV 解析失败:${e?.message || e}`] };
|
||||
}
|
||||
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
// 中文注释:逐条处理记录
|
||||
// 逐条处理记录
|
||||
for (const rec of records) {
|
||||
try {
|
||||
// 条件判断:必须包含 sku
|
||||
const sku: string = (rec.sku || '').trim();
|
||||
if (!sku) {
|
||||
// 缺少 SKU 直接跳过
|
||||
const data = this.transformCsvRecordToData(rec);
|
||||
if (!data) {
|
||||
errors.push('缺少 SKU 的记录已跳过');
|
||||
continue;
|
||||
}
|
||||
const { sku, components } = data;
|
||||
|
||||
let currentProductId: number;
|
||||
let currentProductType: string = data.type || 'single';
|
||||
|
||||
// 查找现有产品
|
||||
const exist = await this.productModel.findOne({ where: { sku }, relations: ['attributes', 'attributes.dict'] });
|
||||
|
||||
// 中文注释:准备基础字段
|
||||
const base = {
|
||||
name: rec.name || '',
|
||||
nameCn: rec.nameCn || '',
|
||||
description: rec.description || '',
|
||||
price: rec.price ? Number(rec.price) : undefined,
|
||||
promotionPrice: rec.promotionPrice ? Number(rec.promotionPrice) : undefined,
|
||||
type: rec.type || '',
|
||||
stock: rec.stock ? Number(rec.stock) : undefined,
|
||||
sku,
|
||||
} as any;
|
||||
|
||||
// 中文注释:解析属性字段(分号分隔多值)
|
||||
const parseList = (v: string) => (v ? String(v).split(';').map(s => s.trim()).filter(Boolean) : []);
|
||||
const brands = parseList(rec.brand);
|
||||
const flavors = parseList(rec.flavor);
|
||||
const strengths = parseList(rec.strength);
|
||||
const sizes = parseList(rec.size);
|
||||
|
||||
// 中文注释:将属性解析为 DTO 输入
|
||||
const attrDTOs: { dictName: string; title: string }[] = [];
|
||||
for (const b of brands) attrDTOs.push({ dictName: 'brand', title: b });
|
||||
for (const f of flavors) attrDTOs.push({ dictName: 'flavor', title: f });
|
||||
for (const s of strengths) attrDTOs.push({ dictName: 'strength', title: s });
|
||||
for (const z of sizes) attrDTOs.push({ dictName: 'size', title: z });
|
||||
|
||||
if (!exist) {
|
||||
// 中文注释:创建新产品
|
||||
const dto = {
|
||||
name: base.name,
|
||||
description: base.description,
|
||||
price: base.price,
|
||||
sku: base.sku,
|
||||
attributes: attrDTOs,
|
||||
} as any;
|
||||
const createdProduct = await this.createProduct(dto);
|
||||
// 条件判断:更新可选字段
|
||||
const patch: any = {};
|
||||
if (base.nameCn) patch.nameCn = base.nameCn;
|
||||
if (base.promotionPrice !== undefined) patch.promotionPrice = base.promotionPrice;
|
||||
if (base.type) patch.type = base.type;
|
||||
if (base.stock !== undefined) patch.stock = base.stock;
|
||||
if (Object.keys(patch).length > 0) await this.productModel.update(createdProduct.id, patch);
|
||||
// 创建新产品
|
||||
const createDTO = this.prepareCreateProductDTO(data);
|
||||
const createdProduct = await this.createProduct(createDTO);
|
||||
currentProductId = createdProduct.id;
|
||||
currentProductType = createdProduct.type;
|
||||
created += 1;
|
||||
} else {
|
||||
// 中文注释:更新产品
|
||||
const updateDTO: any = {
|
||||
name: base.name || exist.name,
|
||||
description: base.description || exist.description,
|
||||
price: base.price !== undefined ? base.price : exist.price,
|
||||
sku: base.sku,
|
||||
attributes: attrDTOs,
|
||||
};
|
||||
// 条件判断:附加可选字段
|
||||
if (base.nameCn) updateDTO.nameCn = base.nameCn;
|
||||
if (base.promotionPrice !== undefined) updateDTO.promotionPrice = base.promotionPrice;
|
||||
if (base.type) updateDTO.type = base.type;
|
||||
if (base.stock !== undefined) updateDTO.stock = base.stock;
|
||||
// 更新产品
|
||||
const updateDTO = this.prepareUpdateProductDTO(data);
|
||||
await this.updateProduct(exist.id, updateDTO);
|
||||
currentProductId = exist.id;
|
||||
currentProductType = updateDTO.type || exist.type;
|
||||
updated += 1;
|
||||
}
|
||||
|
||||
// 4. 保存组件信息
|
||||
if (currentProductType !== 'single' && components && components.length > 0) {
|
||||
await this.setProductComponents(currentProductId, components);
|
||||
}
|
||||
} catch (e: any) {
|
||||
errors.push(e?.message || String(e));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export class SiteService {
|
|||
areaModel: Repository<Area>;
|
||||
|
||||
async syncFromConfig(sites: WpSite[] = []) {
|
||||
// 将配置中的 WpSite 同步到数据库 Site 表(用于一次性导入或初始化)
|
||||
// 将配置中的 WpSite 同步到数据库 Site 表(用于一次性导入或初始化)
|
||||
for (const siteConfig of sites) {
|
||||
// 按站点名称查询是否已存在记录
|
||||
const exist = await this.siteModel.findOne({
|
||||
|
|
@ -30,7 +30,7 @@ export class SiteService {
|
|||
consumerSecret: (siteConfig as any).consumerSecret,
|
||||
type: 'woocommerce',
|
||||
};
|
||||
// 存在则更新,不存在则插入新记录
|
||||
// 存在则更新,不存在则插入新记录
|
||||
if (exist) {
|
||||
await this.siteModel.update({ id: exist.id }, payload);
|
||||
} else {
|
||||
|
|
@ -45,14 +45,14 @@ export class SiteService {
|
|||
const newSite = new Site();
|
||||
Object.assign(newSite, restData);
|
||||
|
||||
// 如果传入了区域代码,则查询并关联 Area 实体
|
||||
// 如果传入了区域代码,则查询并关联 Area 实体
|
||||
if (areaCodes && areaCodes.length > 0) {
|
||||
const areas = await this.areaModel.findBy({
|
||||
code: In(areaCodes),
|
||||
});
|
||||
newSite.areas = areas;
|
||||
} else {
|
||||
// 如果没有传入区域,则关联一个空数组,代表“全局”
|
||||
// 如果没有传入区域,则关联一个空数组,代表"全局"
|
||||
newSite.areas = [];
|
||||
}
|
||||
|
||||
|
|
@ -65,12 +65,12 @@ export class SiteService {
|
|||
// 从 DTO 中分离出区域代码和其他站点数据
|
||||
const { areas: areaCodes, ...restData } = data;
|
||||
|
||||
// 首先,根据 ID 查找要更新的站点实体
|
||||
// 首先,根据 ID 查找要更新的站点实体
|
||||
const siteToUpdate = await this.siteModel.findOne({
|
||||
where: { id: Number(id) },
|
||||
});
|
||||
if (!siteToUpdate) {
|
||||
// 如果找不到站点,则操作失败
|
||||
// 如果找不到站点,则操作失败
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -86,16 +86,16 @@ export class SiteService {
|
|||
} as any;
|
||||
Object.assign(siteToUpdate, payload);
|
||||
|
||||
// 如果 DTO 中传入了 areas 字段(即使是空数组),也要更新关联关系
|
||||
// 如果 DTO 中传入了 areas 字段(即使是空数组),也要更新关联关系
|
||||
if (areaCodes !== undefined) {
|
||||
if (areaCodes.length > 0) {
|
||||
// 如果区域代码数组不为空,则查找并更新关联
|
||||
// 如果区域代码数组不为空,则查找并更新关联
|
||||
const areas = await this.areaModel.findBy({
|
||||
code: In(areaCodes),
|
||||
});
|
||||
siteToUpdate.areas = areas;
|
||||
} else {
|
||||
// 如果传入空数组,则清空所有关联,代表“全局”
|
||||
// 如果传入空数组,则清空所有关联,代表"全局"
|
||||
siteToUpdate.areas = [];
|
||||
}
|
||||
}
|
||||
|
|
@ -106,7 +106,7 @@ export class SiteService {
|
|||
}
|
||||
|
||||
async get(id: string | number, includeSecret = false) {
|
||||
// 根据主键获取站点,并使用 relations 加载关联的 areas
|
||||
// 根据主键获取站点,并使用 relations 加载关联的 areas
|
||||
const site = await this.siteModel.findOne({
|
||||
where: { id: Number(id) },
|
||||
relations: ['areas'],
|
||||
|
|
@ -114,11 +114,11 @@ export class SiteService {
|
|||
if (!site) {
|
||||
return null;
|
||||
}
|
||||
// 如果需要包含密钥,则直接返回
|
||||
// 如果需要包含密钥,则直接返回
|
||||
if (includeSecret) {
|
||||
return site;
|
||||
}
|
||||
// 默认不返回密钥,进行字段脱敏
|
||||
// 默认不返回密钥,进行字段脱敏
|
||||
const { consumerKey, consumerSecret, ...rest } = site;
|
||||
return rest;
|
||||
}
|
||||
|
|
@ -133,7 +133,7 @@ export class SiteService {
|
|||
},
|
||||
includeSecret = false
|
||||
) {
|
||||
// 分页查询站点列表,支持关键字、禁用状态与 ID 列表过滤
|
||||
// 分页查询站点列表,支持关键字,禁用状态与 ID 列表过滤
|
||||
const { current = 1, pageSize = 10, keyword, isDisabled, ids } = (param ||
|
||||
{}) as any;
|
||||
const where: any = {};
|
||||
|
|
@ -141,12 +141,12 @@ export class SiteService {
|
|||
if (keyword) {
|
||||
where.name = Like(`%${keyword}%`);
|
||||
}
|
||||
// 按禁用状态过滤(布尔转数值)
|
||||
// 按禁用状态过滤(布尔转数值)
|
||||
if (typeof isDisabled === 'boolean') {
|
||||
where.isDisabled = isDisabled ? 1 : 0;
|
||||
}
|
||||
if (ids) {
|
||||
// 解析逗号分隔的 ID 字符串为数字数组,并过滤非法值
|
||||
// 解析逗号分隔的 ID 字符串为数字数组,并过滤非法值
|
||||
const numIds = String(ids)
|
||||
.split(',')
|
||||
.filter(Boolean)
|
||||
|
|
@ -156,7 +156,7 @@ export class SiteService {
|
|||
where.id = In(numIds);
|
||||
}
|
||||
}
|
||||
// 进行分页查询,并使用 relations 加载关联的 areas
|
||||
// 进行分页查询,并使用 relations 加载关联的 areas
|
||||
const [items, total] = await this.siteModel.findAndCount({
|
||||
where,
|
||||
skip: (current - 1) * pageSize,
|
||||
|
|
@ -174,7 +174,7 @@ export class SiteService {
|
|||
}
|
||||
|
||||
async disable(id: string | number, disabled: boolean) {
|
||||
// 设置站点禁用状态(true -> 1, false -> 0)
|
||||
// 设置站点禁用状态(true -> 1, false -> 0)
|
||||
await this.siteModel.update({ id: Number(id) }, { isDisabled: disabled });
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1011,7 +1011,7 @@ export class StatisticsService {
|
|||
GROUP BY customer_email
|
||||
),
|
||||
|
||||
-- 标注每个用户每月是“新客户”还是“老客户”
|
||||
-- 标注每个用户每月是"新客户"还是"老客户"
|
||||
labeled_users AS (
|
||||
SELECT
|
||||
m.customer_email,
|
||||
|
|
@ -1056,7 +1056,7 @@ export class StatisticsService {
|
|||
GROUP BY current_month
|
||||
)
|
||||
|
||||
-- 最终结果:每月新客户、老客户、未来未复购客户
|
||||
-- 最终结果:每月新客户,老客户,未来未复购客户
|
||||
SELECT
|
||||
m.order_month,
|
||||
m.new_user_count,
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ export class StockService {
|
|||
const purchaseOrder = await this.purchaseOrderModel.findOneBy({ id });
|
||||
if (!purchaseOrder) throw new Error(`采购订单 ID ${id} 不存在`);
|
||||
if (purchaseOrder.status === 'received')
|
||||
throw new Error(`采购订单 ID ${id} 已到达,无法修改`);
|
||||
throw new Error(`采购订单 ID ${id} 已到达,无法修改`);
|
||||
const { stockPointId, expectedArrivalTime, status, items, note } = data;
|
||||
purchaseOrder.stockPointId = stockPointId;
|
||||
purchaseOrder.expectedArrivalTime = expectedArrivalTime;
|
||||
|
|
@ -208,7 +208,7 @@ export class StockService {
|
|||
);
|
||||
}
|
||||
|
||||
// 中文注释:检查指定 SKU 是否在任一仓库有库存(数量大于 0)
|
||||
// 检查指定 SKU 是否在任一仓库有库存(数量大于 0)
|
||||
async hasStockBySku(sku: string): Promise<boolean> {
|
||||
const count = await this.stockModel
|
||||
.createQueryBuilder('stock')
|
||||
|
|
@ -222,7 +222,7 @@ export class StockService {
|
|||
const purchaseOrder = await this.purchaseOrderModel.findOneBy({ id });
|
||||
if (!purchaseOrder) throw new Error(`采购订单 ID ${id} 不存在`);
|
||||
if (purchaseOrder.status === 'received')
|
||||
throw new Error(`采购订单 ID ${id} 已到达,无法删除`);
|
||||
throw new Error(`采购订单 ID ${id} 已到达,无法删除`);
|
||||
await this.purchaseOrderItemModel.delete({ purchaseOrderId: id });
|
||||
await this.purchaseOrderModel.delete({ id });
|
||||
}
|
||||
|
|
@ -231,7 +231,7 @@ export class StockService {
|
|||
const purchaseOrder = await this.purchaseOrderModel.findOneBy({ id });
|
||||
if (!purchaseOrder) throw new Error(`采购订单 ID ${id} 不存在`);
|
||||
if (purchaseOrder.status === 'received')
|
||||
throw new Error(`采购订单 ID ${id} 已到达,不要重复操作`);
|
||||
throw new Error(`采购订单 ID ${id} 已到达,不要重复操作`);
|
||||
const items = await this.purchaseOrderItemModel.find({
|
||||
where: { purchaseOrderId: id },
|
||||
});
|
||||
|
|
@ -403,7 +403,7 @@ export class StockService {
|
|||
sku,
|
||||
});
|
||||
if (!stock) {
|
||||
// 如果库存不存在,则直接新增
|
||||
// 如果库存不存在,则直接新增
|
||||
const newStock = this.stockModel.create({
|
||||
stockPointId,
|
||||
sku,
|
||||
|
|
@ -415,7 +415,7 @@ export class StockService {
|
|||
stock.quantity +=
|
||||
operationType === 'in' ? quantityChange : -quantityChange;
|
||||
// if (stock.quantity < 0) {
|
||||
// throw new Error('库存不足,无法完成操作');
|
||||
// throw new Error('库存不足,无法完成操作');
|
||||
// }
|
||||
await this.stockModel.save(stock);
|
||||
}
|
||||
|
|
@ -571,7 +571,7 @@ export class StockService {
|
|||
async cancelTransfer(id: number, userId: number) {
|
||||
const transfer = await this.transferModel.findOneBy({ id });
|
||||
if (!transfer) throw new Error(`调拨 ID ${id} 不存在`);
|
||||
if (transfer.isArrived) throw new Error(`调拨 ID ${id} 已到达,无法取消`);
|
||||
if (transfer.isArrived) throw new Error(`调拨 ID ${id} 已到达,无法取消`);
|
||||
const items = await this.transferItemModel.find({
|
||||
where: { transferId: id },
|
||||
});
|
||||
|
|
@ -594,7 +594,7 @@ export class StockService {
|
|||
if (!transfer) throw new Error(`调拨 ID ${id} 不存在`);
|
||||
if (transfer.isCancel) throw new Error(`调拨 ID ${id} 已取消`);
|
||||
if (transfer.isArrived)
|
||||
throw new Error(`调拨 ID ${id} 已到达,不要重复操作`);
|
||||
throw new Error(`调拨 ID ${id} 已到达,不要重复操作`);
|
||||
const items = await this.transferItemModel.find({
|
||||
where: { transferId: id },
|
||||
});
|
||||
|
|
@ -617,7 +617,7 @@ export class StockService {
|
|||
if (!transfer) throw new Error(`调拨 ID ${id} 不存在`);
|
||||
if (transfer.isCancel) throw new Error(`调拨 ID ${id} 已取消`);
|
||||
if (transfer.isArrived)
|
||||
throw new Error(`调拨 ID ${id} 已到达,不要重复操作`);
|
||||
throw new Error(`调拨 ID ${id} 已到达,不要重复操作`);
|
||||
transfer.isLost = true;
|
||||
await this.transferModel.save(transfer);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,8 +28,8 @@ export class SubscriptionService {
|
|||
|
||||
/**
|
||||
* 同步单条订阅
|
||||
* - 规范化字段、设置幂等键 externalSubscriptionId
|
||||
* - 已存在则更新,不存在则新增
|
||||
* - 规范化字段,设置幂等键 externalSubscriptionId
|
||||
* - 已存在则更新,不存在则新增
|
||||
*/
|
||||
async syncSingleSubscription(siteId: number, sub: any) {
|
||||
const { line_items, ...raw } = sub;
|
||||
|
|
@ -54,7 +54,7 @@ export class SubscriptionService {
|
|||
}
|
||||
|
||||
/**
|
||||
* 获取订阅分页列表(支持站点、状态、邮箱与关键字筛选)
|
||||
* 获取订阅分页列表(支持站点,状态,邮箱与关键字筛选)
|
||||
*/
|
||||
async getSubscriptionList({
|
||||
current = 1,
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ export class TemplateService {
|
|||
): Promise<Template> {
|
||||
// 首先根据 ID 查找模板
|
||||
const template = await this.templateModel.findOneBy({ id });
|
||||
// 如果模板不存在,则抛出错误
|
||||
// 如果模板不存在,则抛出错误
|
||||
if (!template) {
|
||||
throw new Error(`模板 ID ${id} 不存在`);
|
||||
}
|
||||
|
|
@ -78,22 +78,22 @@ export class TemplateService {
|
|||
/**
|
||||
* 根据 ID 删除一个模板
|
||||
* @param {number} id - 模板 ID
|
||||
* @returns {Promise<boolean>} 如果删除成功则返回 true,否则返回 false
|
||||
* @returns {Promise<boolean>} 如果删除成功则返回 true,否则返回 false
|
||||
*/
|
||||
async deleteTemplate(id: number): Promise<boolean> {
|
||||
// 首先根据 ID 查找模板
|
||||
const template = await this.templateModel.findOneBy({ id });
|
||||
// 如果模板不存在,则抛出错误
|
||||
// 如果模板不存在,则抛出错误
|
||||
if (!template) {
|
||||
throw new Error(`模板 ID ${id} 不存在`);
|
||||
}
|
||||
// 如果模板不可删除,则抛出错误
|
||||
// 如果模板不可删除,则抛出错误
|
||||
if (!template.deletable) {
|
||||
throw new Error(`模板 ${template.name} 不可删除`);
|
||||
}
|
||||
// 执行删除操作
|
||||
const result = await this.templateModel.delete(id);
|
||||
// 如果影响的行数大于 0,则表示删除成功
|
||||
// 如果影响的行数大于 0,则表示删除成功
|
||||
return result.affected > 0;
|
||||
}
|
||||
|
||||
|
|
@ -106,14 +106,14 @@ export class TemplateService {
|
|||
async render(name: string, data: Record<string, any>): Promise<string> {
|
||||
// 根据名称获取模板
|
||||
const template = await this.getTemplateByName(name);
|
||||
// 如果模板不存在,则抛出错误
|
||||
// 如果模板不存在,则抛出错误
|
||||
if (!template) {
|
||||
throw new Error(`模板 '${name}' 不存在`);
|
||||
}
|
||||
|
||||
// 获取模板的原始内容
|
||||
let rendered = template.value;
|
||||
// 遍历数据对象,替换模板中的占位符
|
||||
// 遍历数据对象,替换模板中的占位符
|
||||
for (const key in data) {
|
||||
// 创建一个正则表达式来匹配 {{key}}
|
||||
const regex = new RegExp(`{{${key}}}`, 'g');
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ export class UniExpressService {
|
|||
|
||||
async getOrderStatus(tracking_number: string) {
|
||||
try {
|
||||
const key = 'SMq45nJhQuNR3WHsJA6N'; // todo,写进常数
|
||||
const key = 'SMq45nJhQuNR3WHsJA6N'; // todo,写进常数
|
||||
const config: AxiosRequestConfig= {
|
||||
method: 'GET',
|
||||
url: `${this.url}/orders/trackinguniuni`,
|
||||
|
|
|
|||
|
|
@ -61,11 +61,11 @@ export class UserService {
|
|||
throw new Error('验证码错误');
|
||||
}
|
||||
|
||||
// 校验通过后,将设备加入白名单
|
||||
// 校验通过后,将设备加入白名单
|
||||
await this.deviceWhitelistService.addToWhitelist(deviceId);
|
||||
}
|
||||
|
||||
// 生成 JWT,包含角色和权限信息
|
||||
// 生成 JWT,包含角色和权限信息
|
||||
const token = await this.jwtService.sign({
|
||||
id: user.id,
|
||||
deviceId,
|
||||
|
|
@ -81,7 +81,7 @@ export class UserService {
|
|||
};
|
||||
}
|
||||
|
||||
// 中文注释:新增用户(支持可选备注)
|
||||
// 新增用户(支持可选备注)
|
||||
async addUser(username: string, password: string, remark?: string) {
|
||||
const existingUser = await this.userModel.findOne({
|
||||
where: { username },
|
||||
|
|
@ -93,13 +93,13 @@ export class UserService {
|
|||
const user = this.userModel.create({
|
||||
username,
|
||||
password: hashedPassword,
|
||||
// 中文注释:备注字段赋值(若提供)
|
||||
// 备注字段赋值(若提供)
|
||||
...(remark ? { remark } : {}),
|
||||
});
|
||||
return this.userModel.save(user);
|
||||
}
|
||||
|
||||
// 中文注释:用户列表支持分页与备注模糊查询(以及可选的布尔过滤)
|
||||
// 用户列表支持分页与备注模糊查询(以及可选的布尔过滤)
|
||||
async listUsers(
|
||||
current: number,
|
||||
pageSize: number,
|
||||
|
|
@ -111,13 +111,13 @@ export class UserService {
|
|||
isAdmin?: boolean;
|
||||
} = {}
|
||||
) {
|
||||
// 条件判断:构造 where 条件
|
||||
// 条件判断:构造 where 条件
|
||||
const where: Record<string, any> = {};
|
||||
if (filters.username) where.username = Like(`%${filters.username}%`); // 中文注释:用户名精确匹配(如需模糊可改为 Like)
|
||||
if (typeof filters.isActive === 'boolean') where.isActive = filters.isActive; // 中文注释:按启用状态过滤
|
||||
if (typeof filters.isSuper === 'boolean') where.isSuper = filters.isSuper; // 中文注释:按超管过滤
|
||||
if (typeof filters.isAdmin === 'boolean') where.isAdmin = filters.isAdmin; // 中文注释:按管理员过滤
|
||||
if (filters.remark) where.remark = Like(`%${filters.remark}%`); // 中文注释:备注模糊搜索
|
||||
if (filters.username) where.username = Like(`%${filters.username}%`); // 用户名精确匹配(如需模糊可改为 Like)
|
||||
if (typeof filters.isActive === 'boolean') where.isActive = filters.isActive; // 按启用状态过滤
|
||||
if (typeof filters.isSuper === 'boolean') where.isSuper = filters.isSuper; // 按超管过滤
|
||||
if (typeof filters.isAdmin === 'boolean') where.isAdmin = filters.isAdmin; // 按管理员过滤
|
||||
if (filters.remark) where.remark = Like(`%${filters.remark}%`); // 备注模糊搜索
|
||||
|
||||
const [items, total] = await this.userModel.findAndCount({
|
||||
where,
|
||||
|
|
@ -137,7 +137,7 @@ export class UserService {
|
|||
return this.userModel.save(user);
|
||||
}
|
||||
|
||||
// 中文注释:更新用户信息(支持用户名唯一校验与可选密码修改)
|
||||
// 更新用户信息(支持用户名唯一校验与可选密码修改)
|
||||
async updateUser(
|
||||
userId: number,
|
||||
payload: {
|
||||
|
|
@ -149,30 +149,30 @@ export class UserService {
|
|||
remark?: string;
|
||||
}
|
||||
) {
|
||||
// 条件判断:查询用户是否存在
|
||||
// 条件判断:查询用户是否存在
|
||||
const user = await this.userModel.findOne({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
// 条件判断:若提供了新用户名且与原用户名不同,校验唯一性
|
||||
// 条件判断:若提供了新用户名且与原用户名不同,校验唯一性
|
||||
if (payload.username && payload.username !== user.username) {
|
||||
const exist = await this.userModel.findOne({ where: { username: payload.username } });
|
||||
if (exist) throw new Error('用户名已存在');
|
||||
user.username = payload.username;
|
||||
}
|
||||
|
||||
// 条件判断:若提供密码则进行加密存储
|
||||
// 条件判断:若提供密码则进行加密存储
|
||||
if (payload.password) {
|
||||
user.password = await bcrypt.hash(payload.password, 10);
|
||||
}
|
||||
|
||||
// 条件判断:更新布尔与权限字段(若提供则覆盖)
|
||||
// 条件判断:更新布尔与权限字段(若提供则覆盖)
|
||||
if (typeof payload.isSuper === 'boolean') user.isSuper = payload.isSuper;
|
||||
if (typeof payload.isAdmin === 'boolean') user.isAdmin = payload.isAdmin;
|
||||
if (Array.isArray(payload.permissions)) user.permissions = payload.permissions;
|
||||
|
||||
// 条件判断:更新备注(若提供则覆盖)
|
||||
// 条件判断:更新备注(若提供则覆盖)
|
||||
if (typeof payload.remark === 'string') user.remark = payload.remark;
|
||||
|
||||
// 保存更新
|
||||
|
|
|
|||
|
|
@ -13,11 +13,11 @@ export class WPService {
|
|||
private readonly siteService: SiteService;
|
||||
|
||||
/**
|
||||
* 构建 URL,自动规范各段的斜杠,避免出现多 / 或少 / 导致请求失败
|
||||
* 使用示例:this.buildURL(wpApiUrl, '/wp-json', 'wc/v3/products', productId)
|
||||
* 构建 URL,自动规范各段的斜杠,避免出现多 / 或少 / 导致请求失败
|
||||
* 使用示例:this.buildURL(wpApiUrl, '/wp-json', 'wc/v3/products', productId)
|
||||
*/
|
||||
private buildURL(base: string, ...parts: Array<string | number>): string {
|
||||
// 去掉 base 末尾多余斜杠,但不影响协议中的 //
|
||||
// 去掉 base 末尾多余斜杠,但不影响协议中的 //
|
||||
const baseSanitized = String(base).replace(/\/+$/g, '');
|
||||
// 规范各段前后斜杠
|
||||
const segments = parts
|
||||
|
|
@ -26,14 +26,14 @@ export class WPService {
|
|||
.map((s) => s.replace(/^\/+|\/+$/g, ''))
|
||||
.filter(Boolean);
|
||||
const joined = [baseSanitized, ...segments].join('/');
|
||||
// 折叠除协议外的多余斜杠,例如 https://example.com//a///b -> https://example.com/a/b
|
||||
// 折叠除协议外的多余斜杠,例如 https://example.com//a///b -> https://example.com/a/b
|
||||
return joined.replace(/([^:])\/{2,}/g, '$1/');
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 WooCommerce SDK 实例
|
||||
* @param site 站点配置
|
||||
* @param namespace API 命名空间,默认 wc/v3;订阅推荐 wcs/v1
|
||||
* @param namespace API 命名空间,默认 wc/v3;订阅推荐 wcs/v1
|
||||
*/
|
||||
private createApi(site: any, namespace: WooCommerceRestApiVersion = 'wc/v3') {
|
||||
return new WooCommerceRestApi({
|
||||
|
|
@ -45,14 +45,14 @@ export class WPService {
|
|||
}
|
||||
|
||||
/**
|
||||
* 通过 SDK 获取单页数据,并返回数据与 totalPages
|
||||
* 通过 SDK 获取单页数据,并返回数据与 totalPages
|
||||
*/
|
||||
private async sdkGetPage<T>(api: any, resource: string, params: Record<string, any> = {}) {
|
||||
const page = params.page ?? 1;
|
||||
const per_page = params.per_page ?? 100;
|
||||
const res = await api.get(resource.replace(/^\/+/, ''), { ...params, page, per_page });
|
||||
if (res?.headers?.['content-type']?.includes('text/html')) {
|
||||
throw new Error('接口返回了 text/html,可能为 WordPress 登录页或错误页,请检查站点配置或权限');
|
||||
throw new Error('接口返回了 text/html,可能为 WordPress 登录页或错误页,请检查站点配置或权限');
|
||||
}
|
||||
const data = res.data as T[];
|
||||
const totalPages = Number(res.headers?.['x-wp-totalpages'] ?? 1);
|
||||
|
|
@ -61,7 +61,7 @@ export class WPService {
|
|||
}
|
||||
|
||||
/**
|
||||
* 通过 SDK 聚合分页数据,返回全部数据
|
||||
* 通过 SDK 聚合分页数据,返回全部数据
|
||||
*/
|
||||
private async sdkGetAll<T>(api: WooCommerceRestApi, resource: string, params: Record<string, any> = {}, maxPages: number = 50): Promise<T[]> {
|
||||
const result: T[] = [];
|
||||
|
|
@ -76,7 +76,7 @@ export class WPService {
|
|||
/**
|
||||
* 获取 WordPress 数据
|
||||
* @param wpApiUrl WordPress REST API 的基础地址
|
||||
* @param endpoint API 端点路径(例如 wc/v3/products)
|
||||
* @param endpoint API 端点路径(例如 wc/v3/products)
|
||||
* @param consumerKey WooCommerce 的消费者密钥
|
||||
* @param consumerSecret WooCommerce 的消费者密钥
|
||||
*/
|
||||
|
|
@ -91,7 +91,7 @@ export class WPService {
|
|||
try {
|
||||
const apiUrl = site.apiUrl;
|
||||
const { consumerKey, consumerSecret } = site;
|
||||
// 构建 URL,规避多/或少/问题
|
||||
// 构建 URL,规避多/或少/问题
|
||||
const url = this.buildURL(apiUrl, '/wp-json', endpoint);
|
||||
const auth = Buffer.from(`${consumerKey}:${consumerSecret}`).toString(
|
||||
'base64'
|
||||
|
|
@ -126,7 +126,7 @@ export class WPService {
|
|||
while (hasMore) {
|
||||
const config: AxiosRequestConfig = {
|
||||
method: 'GET',
|
||||
// 构建 URL,规避多/或少/问题
|
||||
// 构建 URL,规避多/或少/问题
|
||||
url: this.buildURL(apiUrl, '/wp-json', endpoint),
|
||||
headers: {
|
||||
Authorization: `Basic ${auth}`,
|
||||
|
|
@ -194,12 +194,12 @@ export class WPService {
|
|||
|
||||
/**
|
||||
* 获取 WooCommerce Subscriptions
|
||||
* 优先尝试 wc/v1/subscriptions(Subscriptions 插件提供),失败时回退 wc/v3/subscriptions。
|
||||
* 返回所有分页合并后的订阅数组。
|
||||
* 优先尝试 wc/v1/subscriptions(Subscriptions 插件提供),失败时回退 wc/v3/subscriptions.
|
||||
* 返回所有分页合并后的订阅数组.
|
||||
*/
|
||||
async getSubscriptions(siteId: number): Promise<Record<string, any>[]> {
|
||||
const site = await this.siteService.get(siteId);
|
||||
// 优先使用 Subscriptions 命名空间 wcs/v1,失败回退 wc/v3
|
||||
// 优先使用 Subscriptions 命名空间 wcs/v1,失败回退 wc/v3
|
||||
const api = this.createApi(site, 'wc/v3');
|
||||
return await this.sdkGetAll<Record<string, any>>(api, 'subscriptions');
|
||||
|
||||
|
|
@ -257,7 +257,7 @@ export class WPService {
|
|||
);
|
||||
const config: AxiosRequestConfig = {
|
||||
method: 'PUT',
|
||||
// 构建 URL,规避多/或少/问题
|
||||
// 构建 URL,规避多/或少/问题
|
||||
url: this.buildURL(apiUrl, '/wp-json', endpoint),
|
||||
headers: {
|
||||
Authorization: `Basic ${auth}`,
|
||||
|
|
@ -304,7 +304,7 @@ export class WPService {
|
|||
): Promise<Boolean> {
|
||||
const res = await this.updateData(`/wc/v3/products/${productId}`, site, {
|
||||
status,
|
||||
manage_stock: false, // 为true的时候,用quantity控制库存,为false时,直接用stock_status控制
|
||||
manage_stock: false, // 为true的时候,用quantity控制库存,为false时,直接用stock_status控制
|
||||
stock_status,
|
||||
});
|
||||
return res;
|
||||
|
|
@ -357,7 +357,7 @@ export class WPService {
|
|||
);
|
||||
const config: AxiosRequestConfig = {
|
||||
method: 'POST',
|
||||
// 构建 URL,规避多/或少/问题
|
||||
// 构建 URL,规避多/或少/问题
|
||||
url: this.buildURL(
|
||||
apiUrl,
|
||||
'/wp-json',
|
||||
|
|
@ -385,10 +385,10 @@ export class WPService {
|
|||
);
|
||||
|
||||
console.log('del', orderId, trackingId);
|
||||
// 删除接口: DELETE /wp-json/wc-shipment-tracking/v3/orders/<order_id>/shipment-trackings/<tracking_id>
|
||||
// 删除接口: DELETE /wp-json/wc-shipment-tracking/v3/orders/<order_id>/shipment-trackings/<tracking_id>
|
||||
const config: AxiosRequestConfig = {
|
||||
method: 'DELETE',
|
||||
// 构建 URL,规避多/或少/问题
|
||||
// 构建 URL,规避多/或少/问题
|
||||
url: this.buildURL(
|
||||
apiUrl,
|
||||
'/wp-json',
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import { SiteService } from './site.service';
|
|||
|
||||
@Provide()
|
||||
export class WpProductService {
|
||||
// 移除配置中的站点数组,统一从数据库获取站点信息
|
||||
// 移除配置中的站点数组,统一从数据库获取站点信息
|
||||
|
||||
@Inject()
|
||||
private readonly wpApiService: WPService;
|
||||
|
|
@ -31,7 +31,7 @@ export class WpProductService {
|
|||
|
||||
|
||||
async syncAllSites() {
|
||||
// 从数据库获取所有启用的站点,并逐站点同步产品与变体
|
||||
// 从数据库获取所有启用的站点,并逐站点同步产品与变体
|
||||
const { items: sites } = await this.siteService.list({ current: 1, pageSize: Infinity, isDisabled: false }, true);
|
||||
for (const site of sites) {
|
||||
const products = await this.wpApiService.getProducts(site);
|
||||
|
|
@ -46,7 +46,7 @@ export class WpProductService {
|
|||
}
|
||||
// 同步一个网站
|
||||
async syncSite(siteId: number) {
|
||||
// 通过数据库获取站点并转换为 WpSite,用于后续 WooCommerce 同步
|
||||
// 通过数据库获取站点并转换为 WpSite,用于后续 WooCommerce 同步
|
||||
const site = await this.siteService.get(siteId, true);
|
||||
const externalProductIds = this.wpProductModel.createQueryBuilder('wp_product')
|
||||
.select([
|
||||
|
|
@ -304,7 +304,7 @@ export class WpProductService {
|
|||
|
||||
async getProductList(param: QueryWpProductDTO) {
|
||||
const { current = 1, pageSize = 10, name, siteId, status } = param;
|
||||
// 第一步:先查询分页的产品
|
||||
// 第一步:先查询分页的产品
|
||||
const where: any = {};
|
||||
if (siteId) {
|
||||
where.siteId = siteId;
|
||||
|
|
@ -376,9 +376,9 @@ export class WpProductService {
|
|||
const items = rawResult.reduce((acc, row) => {
|
||||
// 在累加器中查找当前产品
|
||||
let product = acc.find(p => p.id === row.id);
|
||||
// 如果产品不存在,则创建新产品
|
||||
// 如果产品不存在,则创建新产品
|
||||
if (!product) {
|
||||
// 从原始产品列表中查找,以获取 'site' 关联数据
|
||||
// 从原始产品列表中查找,以获取 'site' 关联数据
|
||||
const originalProduct = products.find(p => p.id === row.id);
|
||||
product = {
|
||||
...Object.keys(row)
|
||||
|
|
@ -535,7 +535,7 @@ export class WpProductService {
|
|||
const params: Record<string, string> = {};
|
||||
const conditions: string[] = [];
|
||||
|
||||
// 英文名关键词全部匹配(AND)
|
||||
// 英文名关键词全部匹配(AND)
|
||||
if (nameFilter.length > 0) {
|
||||
const nameConds = nameFilter.map((word, index) => {
|
||||
const key = `name${index}`;
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export async function paginate<T>(
|
|||
where?: FindOptionsWhere<T> | FindOptionsWhere<T>[];
|
||||
relations?: string[];
|
||||
order?: Record<string, 'ASC' | 'DESC'>;
|
||||
transformerClass?: new () => T; // 可选:用于指定需要转换的类
|
||||
transformerClass?: new () => T; // 可选:用于指定需要转换的类
|
||||
}
|
||||
): Promise<PaginationResult<T>> {
|
||||
const {
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
"typeRoots": ["./typings", "./node_modules/@types"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"inlineSources": true // ✅ 把源码嵌入 map 文件,方便 VS Code 还原
|
||||
"inlineSources": true // ✅ 把源码嵌入 map 文件,方便 VS Code 还原
|
||||
|
||||
},
|
||||
"exclude": ["*.js", "*.ts", "dist", "node_modules", "test"]
|
||||
|
|
|
|||
Loading…
Reference in New Issue