feat: 添加区域坐标功能并重构产品属性管理

- 在区域实体中添加经纬度字段,支持坐标功能
- 重构产品属性管理,使用ID关联替代对象嵌套
- 新增产品尺寸管理功能及相关API
- 添加库存查询接口,支持按SKU批量查询
- 统一站点名称字段从siteName改为name
- 添加数据库迁移指南和API文档
- 优化实体加载方式,使用通配符匹配
This commit is contained in:
tikkhun 2025-11-28 16:58:14 +08:00
parent fb8509b5f5
commit 366fd93dde
24 changed files with 742 additions and 151 deletions

254
area-api-doc.md Normal file
View File

@ -0,0 +1,254 @@
# Area 区域管理 API 文档
## 概述
本文档详细描述了区域管理相关的API接口包括增删改查操作以及新增的坐标功能。
## API 接口列表
### 1. 创建区域
**请求信息**
- URL: `/api/area`
- Method: `POST`
- Headers: `Authorization: Bearer {token}`
**请求体 (JSON)**
```json
{
"name": "欧洲",
"latitude": 48.8566,
"longitude": 2.3522
}
```
**参数说明**
- `name`: 区域名称 (必填)
- `latitude`: 纬度 (-90 到 90 之间,可选)
- `longitude`: 经度 (-180 到 180 之间,可选)
**响应示例**
```json
{
"code": 0,
"message": "创建成功",
"data": {
"id": 1,
"name": "欧洲",
"latitude": 48.8566,
"longitude": 2.3522,
"createdAt": "2024-01-01T12:00:00Z",
"updatedAt": "2024-01-01T12:00:00Z"
}
}
```
### 2. 更新区域
**请求信息**
- URL: `/api/area/{id}`
- Method: `PUT`
- Headers: `Authorization: Bearer {token}`
**请求体 (JSON)**
```json
{
"name": "欧洲区域",
"latitude": 48.8566,
"longitude": 2.3522
}
```
**参数说明**
- `name`: 区域名称 (可选)
- `latitude`: 纬度 (-90 到 90 之间,可选)
- `longitude`: 经度 (-180 到 180 之间,可选)
**响应示例**
```json
{
"code": 0,
"message": "更新成功",
"data": {
"id": 1,
"name": "欧洲区域",
"latitude": 48.8566,
"longitude": 2.3522,
"createdAt": "2024-01-01T12:00:00Z",
"updatedAt": "2024-01-01T12:30:00Z"
}
}
```
### 3. 删除区域
**请求信息**
- URL: `/api/area/{id}`
- Method: `DELETE`
- Headers: `Authorization: Bearer {token}`
**响应示例**
```json
{
"code": 0,
"message": "删除成功",
"data": null
}
```
### 4. 获取区域列表(分页)
**请求信息**
- URL: `/api/area`
- Method: `GET`
- Headers: `Authorization: Bearer {token}`
- Query Parameters:
- `currentPage`: 当前页码 (默认 1)
- `pageSize`: 每页数量 (默认 10)
- `name`: 区域名称(可选,用于搜索)
**响应示例**
```json
{
"code": 0,
"message": "查询成功",
"data": {
"list": [
{
"id": 1,
"name": "欧洲",
"latitude": 48.8566,
"longitude": 2.3522,
"createdAt": "2024-01-01T12:00:00Z",
"updatedAt": "2024-01-01T12:00:00Z"
}
],
"total": 1
}
}
```
### 5. 获取所有区域
**请求信息**
- URL: `/api/area/all`
- Method: `GET`
- Headers: `Authorization: Bearer {token}`
**响应示例**
```json
{
"code": 0,
"message": "查询成功",
"data": [
{
"id": 1,
"name": "欧洲",
"latitude": 48.8566,
"longitude": 2.3522,
"createdAt": "2024-01-01T12:00:00Z",
"updatedAt": "2024-01-01T12:00:00Z"
},
{
"id": 2,
"name": "亚洲",
"latitude": 35.6762,
"longitude": 139.6503,
"createdAt": "2024-01-01T12:10:00Z",
"updatedAt": "2024-01-01T12:10:00Z"
}
]
}
```
### 6. 根据ID获取区域详情
**请求信息**
- URL: `/api/area/{id}`
- Method: `GET`
- Headers: `Authorization: Bearer {token}`
**响应示例**
```json
{
"code": 0,
"message": "查询成功",
"data": {
"id": 1,
"name": "欧洲",
"latitude": 48.8566,
"longitude": 2.3522,
"createdAt": "2024-01-01T12:00:00Z",
"updatedAt": "2024-01-01T12:00:00Z"
}
}
```
## 世界地图实现建议
对于前端实现世界地图并显示区域坐标,推荐以下方案:
### 1. 使用开源地图库
- **Leaflet.js**: 轻量级开源地图库,易于集成
- **Mapbox**: 提供丰富的地图样式和交互功能
- **Google Maps API**: 功能强大但需要API密钥
### 2. 实现步骤
1. **获取区域数据**:
使用 `/api/area/all` 接口获取所有包含坐标信息的区域
2. **初始化地图**:
```javascript
// Leaflet示例
const map = L.map('map').setView([0, 0], 2);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).addTo(map);
```
3. **添加区域标记**:
```javascript
// 假设areas是从API获取的数据
areas.forEach(area => {
if (area.latitude && area.longitude) {
const marker = L.marker([area.latitude, area.longitude]).addTo(map);
marker.bindPopup(`<b>${area.name}</b>`);
}
});
```
4. **添加交互功能**:
- 点击标记显示区域详情
- 搜索和筛选功能
- 编辑坐标功能调用更新API
### 3. 坐标输入建议
在区域管理界面,可以添加以下功能来辅助坐标输入:
1. 提供搜索框,根据地点名称自动获取坐标
2. 集成小型地图,允许用户点击选择位置
3. 添加验证,确保输入的坐标在有效范围内
## 数据模型说明
### Area 实体
| 字段名 | 类型 | 描述 | 是否必填 |
|--------|------|------|----------|
| id | number | 区域ID | 否(自动生成) |
| name | string | 区域名称 | 是 |
| latitude | number | 纬度(范围:-90 到 90 | 否 |
| longitude | number | 经度(范围:-180 到 180 | 否 |
| createdAt | Date | 创建时间 | 否(自动生成) |
| updatedAt | Date | 更新时间 | 否(自动生成) |
## 错误处理
API可能返回的错误信息
- `区域名称已存在`: 当尝试创建或更新区域名称与现有名称重复时
- `区域不存在`: 当尝试更新或删除不存在的区域时
- `权限错误`: 当请求缺少有效的授权令牌时

49
migration-guide.md Normal file
View File

@ -0,0 +1,49 @@
# 数据库迁移指南
为了支持区域坐标功能,需要执行数据库迁移操作,将新增的 `latitude``longitude` 字段添加到数据库表中。
## 执行迁移步骤
### 1. 生成迁移文件
运行以下命令生成迁移文件:
```bash
npm run migration:generate -- ./src/db/migrations/AddCoordinatesToArea
```
### 2. 检查迁移文件
生成的迁移文件会自动包含添加 `latitude``longitude` 字段的SQL语句。您可以检查文件内容确保迁移逻辑正确。
### 3. 执行迁移
运行以下命令执行迁移,将更改应用到数据库:
```bash
npm run migration:run
```
## 手动迁移SQL可选
如果需要手动执行SQL可以使用以下语句
```sql
ALTER TABLE `area`
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
```
## 注意事项
- 确保在执行迁移前备份数据库
- 迁移不会影响现有数据,新增字段默认为 NULL
- 迁移后可以通过API开始为区域添加坐标信息

View File

@ -46,7 +46,9 @@
"build": "mwtsc --cleanOutDir", "build": "mwtsc --cleanOutDir",
"seed": "ts-node src/db/seed/index.ts", "seed": "ts-node src/db/seed/index.ts",
"seed:run": "ts-node ./node_modules/typeorm-extension/bin/cli.cjs seed:run -d src/db/datasource.ts", "seed:run": "ts-node ./node_modules/typeorm-extension/bin/cli.cjs seed:run -d src/db/datasource.ts",
"typeorm": "ts-node ./node_modules/typeorm/cli.js" "typeorm": "ts-node ./node_modules/typeorm/cli.js",
"migration:generate": "npm run typeorm -- -d src/db/datasource.ts migration:generate",
"migration:run": "npm run typeorm -- migration:run -d src/db/datasource.ts"
}, },
"repository": { "repository": {
"type": "git", "type": "git",

View File

@ -111,7 +111,7 @@ export default {
// wpApiUrl: 'http://localhost:10004', // wpApiUrl: 'http://localhost:10004',
// consumerKey: 'ck_dc9e151e9048c8ed3e27f35ac79d2bf7d6840652', // consumerKey: 'ck_dc9e151e9048c8ed3e27f35ac79d2bf7d6840652',
// consumerSecret: 'cs_d05d625d7b0ac05c6d765671d8417f41d9477e38', // consumerSecret: 'cs_d05d625d7b0ac05c6d765671d8417f41d9477e38',
// siteName: 'Local', // name: 'Local',
// email: 'tom@yoonevape.com', // email: 'tom@yoonevape.com',
// emailPswd: '', // emailPswd: '',
// }, // },

View File

@ -17,20 +17,25 @@ import {
CreateFlavorsDTO, CreateFlavorsDTO,
CreateProductDTO, CreateProductDTO,
CreateStrengthDTO, CreateStrengthDTO,
CreateSizeDTO,
QueryBrandDTO, QueryBrandDTO,
QueryFlavorsDTO, QueryFlavorsDTO,
QueryProductDTO, QueryProductDTO,
QueryStrengthDTO, QueryStrengthDTO,
QuerySizeDTO,
UpdateBrandDTO, UpdateBrandDTO,
UpdateFlavorsDTO, UpdateFlavorsDTO,
UpdateProductDTO, UpdateProductDTO,
UpdateStrengthDTO, UpdateStrengthDTO,
UpdateSizeDTO,
} from '../dto/product.dto'; } from '../dto/product.dto';
import { ApiOkResponse } from '@midwayjs/swagger'; import { ApiOkResponse } from '@midwayjs/swagger';
import { import {
BooleanRes, BooleanRes,
ProductBrandListRes, ProductBrandListRes,
ProductBrandRes, ProductBrandRes,
ProductSizeListRes,
ProductSizeRes,
ProductListRes, ProductListRes,
ProductRes, ProductRes,
ProductsRes, ProductsRes,
@ -406,4 +411,93 @@ export class ProductController {
return errorResponse(error?.message || error); return errorResponse(error?.message || error);
} }
} }
// size 路由与增删改查
@ApiOkResponse()
@Get('/sizeAll')
async getSizeAll() {
try {
// 中文注释:获取所有尺寸项
const data = await this.productService.getSizeAll();
return successResponse(data);
} catch (error) {
return errorResponse(error?.message || error);
}
}
@ApiOkResponse({ type: ProductSizeListRes })
@Get('/size')
async getSize(@Query() query: QuerySizeDTO) {
// 中文注释:解析分页与关键字
const { current = 1, pageSize = 10, name } = query;
try {
// 中文注释:分页查询尺寸列表
const data = await this.productService.getSizeList(
{ current, pageSize },
name
);
return successResponse(data);
} catch (error) {
return errorResponse(error?.message || error);
}
}
@ApiOkResponse({ type: ProductSizeRes })
@Post('/size')
async createSize(@Body() sizeData: CreateSizeDTO) {
try {
// 条件判断(中文注释:唯一性校验,禁止重复)
const hasSize = await this.productService.hasAttribute(
'size',
sizeData.name
);
if (hasSize) {
return errorResponse('尺寸已存在');
}
// 调用服务创建(中文注释:新增尺寸项)
const data = await this.productService.createSize(sizeData);
return successResponse(data);
} catch (error) {
return errorResponse(error?.message || error);
}
}
@ApiOkResponse({ type: ProductSizeRes })
@Put('/size/:id')
async updateSize(
@Param('id') id: number,
@Body() sizeData: UpdateSizeDTO
) {
try {
// 条件判断(中文注释:唯一性校验,排除自身)
const hasSize = await this.productService.hasAttribute(
'size',
sizeData.name,
id
);
if (hasSize) {
return errorResponse('尺寸已存在');
}
// 调用服务更新(中文注释:提交变更)
const data = await this.productService.updateSize(id, sizeData);
return successResponse(data);
} catch (error) {
return errorResponse(error?.message || error);
}
}
@ApiOkResponse({ type: BooleanRes })
@Del('/size/:id')
async deleteSize(@Param('id') id: number) {
try {
// 条件判断(中文注释:若有商品关联则不可删除)
const hasProducts = await this.productService.hasProductsInAttribute(id);
if (hasProducts) throw new Error('该尺寸下有商品,无法删除');
// 调用服务删除(中文注释:返回是否成功)
const data = await this.productService.deleteSize(id);
return successResponse(data);
} catch (error) {
return errorResponse(error?.message || error);
}
}
} }

View File

@ -15,7 +15,7 @@ export class SiteController {
async all() { async all() {
try { try {
const { items } = await this.siteService.list({ current: 1, pageSize: 1000, isDisabled: false }); const { items } = await this.siteService.list({ current: 1, pageSize: 1000, isDisabled: false });
return successResponse(items.map((v: any) => ({ id: v.id, siteName: v.siteName }))); return successResponse(items.map((v: any) => ({ id: v.id, name: v.name })));
} catch (error) { } catch (error) {
return errorResponse(error?.message || '获取失败'); return errorResponse(error?.message || '获取失败');
} }

View File

@ -1,41 +1,6 @@
import { DataSource, DataSourceOptions } from 'typeorm'; import { DataSource, DataSourceOptions } from 'typeorm';
import { SeederOptions } from 'typeorm-extension'; import { SeederOptions } from 'typeorm-extension';
import { Product } from '../entity/product.entity';
import { WpProduct } from '../entity/wp_product.entity';
import { Variation } from '../entity/variation.entity';
import { User } from '../entity/user.entity';
import { PurchaseOrder } from '../entity/purchase_order.entity';
import { PurchaseOrderItem } from '../entity/purchase_order_item.entity';
import { Stock } from '../entity/stock.entity';
import { StockPoint } from '../entity/stock_point.entity';
import { StockRecord } from '../entity/stock_record.entity';
import { Order } from '../entity/order.entity';
import { OrderItem } from '../entity/order_item.entity';
import { OrderCoupon } from '../entity/order_coupon.entity';
import { OrderFee } from '../entity/order_fee.entity';
import { OrderRefund } from '../entity/order_refund.entity';
import { OrderRefundItem } from '../entity/order_refund_item.entity';
import { OrderSale } from '../entity/order_sale.entity';
import { OrderSaleOriginal } from '../entity/order_item_original.entity';
import { OrderShipping } from '../entity/order_shipping.entity';
import { Service } from '../entity/service.entity';
import { ShippingAddress } from '../entity/shipping_address.entity';
import { OrderNote } from '../entity/order_note.entity';
import { OrderShipment } from '../entity/order_shipment.entity';
import { Shipment } from '../entity/shipment.entity';
import { ShipmentItem } from '../entity/shipment_item.entity';
import { Transfer } from '../entity/transfer.entity';
import { TransferItem } from '../entity/transfer_item.entity';
import { CustomerTag } from '../entity/customer_tag.entity';
import { Customer } from '../entity/customer.entity';
import { DeviceWhitelist } from '../entity/device_whitelist';
import { AuthCode } from '../entity/auth_code';
import { Subscription } from '../entity/subscription.entity';
import { Site } from '../entity/site.entity';
import { Dict } from '../entity/dict.entity';
import { DictItem } from '../entity/dict_item.entity';
import { Template } from '../entity/template.entity';
import { Area } from '../entity/area.entity';
const options: DataSourceOptions & SeederOptions = { const options: DataSourceOptions & SeederOptions = {
type: 'mysql', type: 'mysql',
@ -46,44 +11,7 @@ const options: DataSourceOptions & SeederOptions = {
database: 'inventory', database: 'inventory',
synchronize: false, synchronize: false,
logging: true, logging: true,
entities: [ entities: [__dirname + '/../entity/*.ts'],
Product,
WpProduct,
Variation,
User,
PurchaseOrder,
PurchaseOrderItem,
Stock,
StockPoint,
StockRecord,
Order,
OrderItem,
OrderCoupon,
OrderFee,
OrderRefund,
OrderRefundItem,
OrderSale,
OrderSaleOriginal,
OrderShipment,
ShipmentItem,
Shipment,
OrderShipping,
Service,
ShippingAddress,
OrderNote,
Transfer,
TransferItem,
CustomerTag,
Customer,
DeviceWhitelist,
AuthCode,
Subscription,
Site,
Dict,
DictItem,
Template,
Area,
],
migrations: ['src/db/migrations/**/*.ts'], migrations: ['src/db/migrations/**/*.ts'],
seeds: ['src/db/seeds/**/*.ts'], seeds: ['src/db/seeds/**/*.ts'],
}; };

View File

@ -8,7 +8,7 @@ export class Area1764294088896 implements MigrationInterface {
// await queryRunner.query(`CREATE TABLE \`area\` (\`id\` int NOT NULL AUTO_INCREMENT, \`name\` varchar(255) NOT NULL, \`createdAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updatedAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE INDEX \`IDX_644ffaf8fbde4db798cb47712f\` (\`name\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); // await queryRunner.query(`CREATE TABLE \`area\` (\`id\` int NOT NULL AUTO_INCREMENT, \`name\` varchar(255) NOT NULL, \`createdAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updatedAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE INDEX \`IDX_644ffaf8fbde4db798cb47712f\` (\`name\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`);
// await queryRunner.query(`CREATE TABLE \`stock_point_areas_area\` (\`stockPointId\` int NOT NULL, \`areaId\` int NOT NULL, INDEX \`IDX_07d2db2150151e2ef341d2f1de\` (\`stockPointId\`), INDEX \`IDX_92707ea81fc19dc707dba24819\` (\`areaId\`), PRIMARY KEY (\`stockPointId\`, \`areaId\`)) ENGINE=InnoDB`); // await queryRunner.query(`CREATE TABLE \`stock_point_areas_area\` (\`stockPointId\` int NOT NULL, \`areaId\` int NOT NULL, INDEX \`IDX_07d2db2150151e2ef341d2f1de\` (\`stockPointId\`), INDEX \`IDX_92707ea81fc19dc707dba24819\` (\`areaId\`), PRIMARY KEY (\`stockPointId\`, \`areaId\`)) ENGINE=InnoDB`);
// await queryRunner.query(`CREATE TABLE \`site_areas_area\` (\`siteId\` int NOT NULL, \`areaId\` int NOT NULL, INDEX \`IDX_926a14ac4c91f38792831acd2a\` (\`siteId\`), INDEX \`IDX_7c26c582048e3ecd3cd5938cb9\` (\`areaId\`), PRIMARY KEY (\`siteId\`, \`areaId\`)) ENGINE=InnoDB`); // await queryRunner.query(`CREATE TABLE \`site_areas_area\` (\`siteId\` int NOT NULL, \`areaId\` int NOT NULL, INDEX \`IDX_926a14ac4c91f38792831acd2a\` (\`siteId\`), INDEX \`IDX_7c26c582048e3ecd3cd5938cb9\` (\`areaId\`), PRIMARY KEY (\`siteId\`, \`areaId\`)) ENGINE=InnoDB`);
// await queryRunner.query(`ALTER TABLE \`site\` DROP COLUMN \`siteName\``); // await queryRunner.query(`ALTER TABLE \`site\` DROP COLUMN \`name\``);
// await queryRunner.query(`ALTER TABLE `product` ADD `promotionPrice` decimal(10,2) NOT NULL DEFAULT '0.00'`); // await queryRunner.query(`ALTER TABLE `product` ADD `promotionPrice` decimal(10,2) NOT NULL DEFAULT '0.00'`);
// await queryRunner.query(`ALTER TABLE `product` ADD `source` int NOT NULL DEFAULT '0'`); // await queryRunner.query(`ALTER TABLE `product` ADD `source` int NOT NULL DEFAULT '0'`);
// await queryRunner.query(`ALTER TABLE \`site\` ADD \`token\` varchar(255) NULL`); // await queryRunner.query(`ALTER TABLE \`site\` ADD \`token\` varchar(255) NULL`);
@ -30,7 +30,7 @@ export class Area1764294088896 implements MigrationInterface {
await queryRunner.query(`ALTER TABLE \`site\` DROP COLUMN \`token\``); await queryRunner.query(`ALTER TABLE \`site\` DROP COLUMN \`token\``);
await queryRunner.query(`ALTER TABLE \`product\` DROP COLUMN \`source\``); await queryRunner.query(`ALTER TABLE \`product\` DROP COLUMN \`source\``);
await queryRunner.query(`ALTER TABLE \`product\` DROP COLUMN \`promotionPrice\``); await queryRunner.query(`ALTER TABLE \`product\` DROP COLUMN \`promotionPrice\``);
await queryRunner.query(`ALTER TABLE \`site\` ADD \`siteName\` varchar(255) NOT NULL`); await queryRunner.query(`ALTER TABLE \`site\` ADD \`name\` varchar(255) NOT NULL`);
await queryRunner.query(`DROP INDEX \`IDX_7c26c582048e3ecd3cd5938cb9\` ON \`site_areas_area\``); await queryRunner.query(`DROP INDEX \`IDX_7c26c582048e3ecd3cd5938cb9\` ON \`site_areas_area\``);
await queryRunner.query(`DROP INDEX \`IDX_926a14ac4c91f38792831acd2a\` ON \`site_areas_area\``); await queryRunner.query(`DROP INDEX \`IDX_926a14ac4c91f38792831acd2a\` ON \`site_areas_area\``);
await queryRunner.query(`DROP TABLE \`site_areas_area\``); await queryRunner.query(`DROP TABLE \`site_areas_area\``);
@ -39,7 +39,7 @@ export class Area1764294088896 implements MigrationInterface {
await queryRunner.query(`DROP TABLE \`stock_point_areas_area\``); await queryRunner.query(`DROP TABLE \`stock_point_areas_area\``);
await queryRunner.query(`DROP INDEX \`IDX_644ffaf8fbde4db798cb47712f\` ON \`area\``); await queryRunner.query(`DROP INDEX \`IDX_644ffaf8fbde4db798cb47712f\` ON \`area\``);
await queryRunner.query(`DROP TABLE \`area\``); await queryRunner.query(`DROP TABLE \`area\``);
await queryRunner.query(`CREATE UNIQUE INDEX \`IDX_4ca3fbc46d2dbf393ff4ebddbb\` ON \`site\` (\`siteName\`)`); await queryRunner.query(`CREATE UNIQUE INDEX \`IDX_4ca3fbc46d2dbf393ff4ebddbb\` ON \`site\` (\`name\`)`);
} }
} }

View File

@ -0,0 +1,16 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class ProductStock1764299629279 implements MigrationInterface {
name = 'ProductStock1764299629279'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE \`order_item_original\` (\`id\` int NOT NULL AUTO_INCREMENT, \`order_id\` int NOT NULL, \`name\` varchar(255) NOT NULL, \`siteId\` varchar(255) NOT NULL, \`externalOrderId\` varchar(255) NOT NULL, \`externalOrderItemId\` varchar(255) NULL, \`externalProductId\` varchar(255) NOT NULL, \`externalVariationId\` varchar(255) NOT NULL, \`quantity\` int NOT NULL, \`subtotal\` decimal(10,2) NULL, \`subtotal_tax\` decimal(10,2) NULL, \`total\` decimal(10,2) NULL, \`total_tax\` decimal(10,2) NULL, \`sku\` varchar(255) NULL, \`price\` decimal(10,2) NOT NULL, \`createdAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updatedAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`);
await queryRunner.query(`ALTER TABLE \`order_item_original\` ADD CONSTRAINT \`FK_ca48e4bce0bb8cecd24cc8081e5\` FOREIGN KEY (\`order_id\`) REFERENCES \`order\`(\`id\`) ON DELETE NO ACTION ON UPDATE NO ACTION`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP FOREIGN KEY \`FK_ca48e4bce0bb8cecd24cc8081e5\``);
await queryRunner.query(`DROP TABLE \`order_item_original\``);
}
}

View File

@ -7,6 +7,14 @@ export class CreateAreaDTO {
@ApiProperty({ type: 'string', description: '区域名称', example: '欧洲' }) @ApiProperty({ type: 'string', description: '区域名称', example: '欧洲' })
@Rule(RuleType.string().required()) @Rule(RuleType.string().required())
name: string; name: string;
@ApiProperty({ type: 'number', description: '纬度', example: 48.8566, required: false })
@Rule(RuleType.number().min(-90).max(90).allow(null))
latitude?: number;
@ApiProperty({ type: 'number', description: '经度', example: 2.3522, required: false })
@Rule(RuleType.number().min(-180).max(180).allow(null))
longitude?: number;
} }
// 更新区域的数据传输对象 // 更新区域的数据传输对象
@ -14,6 +22,14 @@ export class UpdateAreaDTO {
@ApiProperty({ type: 'string', description: '区域名称', example: '欧洲' }) @ApiProperty({ type: 'string', description: '区域名称', example: '欧洲' })
@Rule(RuleType.string()) @Rule(RuleType.string())
name?: string; name?: string;
@ApiProperty({ type: 'number', description: '纬度', example: 48.8566, required: false })
@Rule(RuleType.number().min(-90).max(90).allow(null))
latitude?: number;
@ApiProperty({ type: 'number', description: '经度', example: 2.3522, required: false })
@Rule(RuleType.number().min(-180).max(180).allow(null))
longitude?: number;
} }
// 查询区域的数据传输对象 // 查询区域的数据传输对象

View File

@ -26,6 +26,9 @@ export class CreateDictItemDTO {
@Rule(RuleType.string().required()) @Rule(RuleType.string().required())
title: string; // 字典项标题 title: string; // 字典项标题
@Rule(RuleType.string().allow(null))
titleCN?: string; // 字典项中文标题 (可选)
@Rule(RuleType.number().required()) @Rule(RuleType.number().required())
dictId: number; // 所属字典的ID dictId: number; // 所属字典的ID
} }
@ -37,4 +40,10 @@ export class UpdateDictItemDTO {
@Rule(RuleType.string()) @Rule(RuleType.string())
title?: string; // 字典项标题 (可选) title?: string; // 字典项标题 (可选)
@Rule(RuleType.string().allow(null))
value?: string; // 字典项值 (可选)
@Rule(RuleType.string().allow(null))
titleCN?: string; // 字典项中文标题 (可选)
} }

View File

@ -1,16 +1,6 @@
import { ApiProperty } from '@midwayjs/swagger'; import { ApiProperty } from '@midwayjs/swagger';
import { Rule, RuleType } from '@midwayjs/validate'; import { Rule, RuleType } from '@midwayjs/validate';
class DictItemDTO {
@ApiProperty({ description: '显示名称', required: false })
@Rule(RuleType.string())
title?: string;
@ApiProperty({ description: '唯一标识', required: true })
@Rule(RuleType.string().required())
name: string;
}
/** /**
* DTO * DTO
*/ */
@ -31,32 +21,17 @@ export class CreateProductDTO {
@Rule(RuleType.string()) @Rule(RuleType.string())
sku?: string; sku?: string;
@ApiProperty({ description: '品牌', type: DictItemDTO }) @ApiProperty({ description: '品牌 ID', type: 'number' })
@Rule( @Rule(RuleType.number().required())
RuleType.object().keys({ brandId: number;
title: RuleType.string().required(),
name: RuleType.string(),
})
)
brand: DictItemDTO;
@ApiProperty({ description: '规格', type: DictItemDTO }) @ApiProperty({ description: '规格 ID', type: 'number' })
@Rule( @Rule(RuleType.number().required())
RuleType.object().keys({ strengthId: number;
title: RuleType.string().required(),
name: RuleType.string(),
})
)
strength: DictItemDTO;
@ApiProperty({ description: '口味', type: DictItemDTO }) @ApiProperty({ description: '口味 ID', type: 'number' })
@Rule( @Rule(RuleType.number().required())
RuleType.object().keys({ flavorsId: number;
title: RuleType.string().required(),
name: RuleType.string(),
})
)
flavor: DictItemDTO;
@ApiProperty() @ApiProperty()
@Rule(RuleType.string()) @Rule(RuleType.string())
@ -213,6 +188,41 @@ export class QueryStrengthDTO {
name: string; // 搜索关键字(支持模糊查询) name: string; // 搜索关键字(支持模糊查询)
} }
// size 新增 DTO
export class CreateSizeDTO {
@ApiProperty({ example: '6', description: '尺寸名称', required: false })
@Rule(RuleType.string())
title?: string;
@ApiProperty({ example: '6', description: '尺寸唯一标识', required: true })
@Rule(RuleType.string().required().empty({ message: 'key不能为空' }))
name: string;
}
export class UpdateSizeDTO {
@ApiProperty({ example: '6', description: '尺寸名称' })
@Rule(RuleType.string())
title: string;
@ApiProperty({ example: '6', description: '尺寸唯一标识' })
@Rule(RuleType.string())
name: string;
}
export class QuerySizeDTO {
@ApiProperty({ example: '1', description: '页码' })
@Rule(RuleType.number())
current: number; // 页码
@ApiProperty({ example: '10', description: '每页大小' })
@Rule(RuleType.number())
pageSize: number; // 每页大小
@ApiProperty({ example: '6', description: '关键字' })
@Rule(RuleType.string())
name: string; // 搜索关键字(支持模糊查询)
}
export class SkuItemDTO { export class SkuItemDTO {
@ApiProperty({ description: '产品 ID' }) @ApiProperty({ description: '产品 ID' })
productId: number; productId: number;

View File

@ -63,9 +63,20 @@ export class ProductStrengthListRes extends SuccessWrapper(
) {} ) {}
//产品规格返所有数据 //产品规格返所有数据
export class ProductStrengthAllRes extends SuccessArrayWrapper(Dict) {} export class ProductStrengthAllRes extends SuccessArrayWrapper(Dict) {}
//产品规格返回数据 //产品规格返回数据
export class ProductStrengthRes extends SuccessWrapper(Dict) {} export class ProductStrengthRes extends SuccessWrapper(Dict) {}
// 产品尺寸返分页数据
export class SizePaginatedResponse extends PaginatedWrapper(Dict) {}
// 产品尺寸返分页返回数据
export class ProductSizeListRes extends SuccessWrapper(
SizePaginatedResponse
) {}
// 产品尺寸返所有数据
export class ProductSizeAllRes extends SuccessArrayWrapper(Dict) {}
// 产品尺寸返回数据
export class ProductSizeRes extends SuccessWrapper(Dict) {}
//产品分页数据 //产品分页数据
export class WpProductPaginatedResponse extends PaginatedWrapper( export class WpProductPaginatedResponse extends PaginatedWrapper(
WpProductDTO WpProductDTO

View File

@ -20,7 +20,7 @@ export class SiteConfig {
@ApiProperty({ description: '站点名' }) @ApiProperty({ description: '站点名' })
@Rule(RuleType.string()) @Rule(RuleType.string())
siteName: string; name: string;
@ApiProperty({ description: '平台类型', enum: ['woocommerce', 'shopyy'] }) @ApiProperty({ description: '平台类型', enum: ['woocommerce', 'shopyy'] })
@Rule(RuleType.string().valid('woocommerce', 'shopyy')) @Rule(RuleType.string().valid('woocommerce', 'shopyy'))
@ -39,7 +39,7 @@ export class CreateSiteDTO {
@Rule(RuleType.string().optional()) @Rule(RuleType.string().optional())
consumerSecret?: string; consumerSecret?: string;
@Rule(RuleType.string()) @Rule(RuleType.string())
siteName: string; name: string;
@Rule(RuleType.string().valid('woocommerce', 'shopyy').optional()) @Rule(RuleType.string().valid('woocommerce', 'shopyy').optional())
type?: string; type?: string;
@Rule(RuleType.string().optional()) @Rule(RuleType.string().optional())
@ -54,7 +54,7 @@ export class UpdateSiteDTO {
@Rule(RuleType.string().optional()) @Rule(RuleType.string().optional())
consumerSecret?: string; consumerSecret?: string;
@Rule(RuleType.string().optional()) @Rule(RuleType.string().optional())
siteName?: string; name?: string;
@Rule(RuleType.boolean().optional()) @Rule(RuleType.boolean().optional())
isDisabled?: boolean; isDisabled?: boolean;
@Rule(RuleType.string().valid('woocommerce', 'shopyy').optional()) @Rule(RuleType.string().valid('woocommerce', 'shopyy').optional())

View File

@ -17,6 +17,14 @@ export class Area {
@Column({ unique: true }) @Column({ unique: true })
name: string; name: string;
@ApiProperty({ type: 'number', description: '纬度', required: false })
@Column({ type: 'decimal', precision: 10, scale: 6, nullable: true })
latitude?: number;
@ApiProperty({ type: 'number', description: '经度', required: false })
@Column({ type: 'decimal', precision: 10, scale: 6, nullable: true })
longitude?: number;
@ApiProperty({ @ApiProperty({
example: '2022-12-12 11:11:11', example: '2022-12-12 11:11:11',
description: '创建时间', description: '创建时间',

View File

@ -25,7 +25,9 @@ export class DictItem {
// 字典项名称 // 字典项名称
@Column({ comment: '字典项显示名称' }) @Column({ comment: '字典项显示名称' })
title: string; title: string;
// 目前没有单独做国际化, 所以这里先添加 titleCN 用来标注
@Column({ comment: '字典项中文名称', nullable: true })
titleCN: string;
// 唯一标识 // 唯一标识
@Column({ unique: true, comment: '字典唯一标识名称' }) @Column({ unique: true, comment: '字典唯一标识名称' })
name: string; name: string;

View File

@ -52,6 +52,10 @@ export class Product {
@Column({ type: 'decimal', precision: 10, scale: 2, default: 0 }) @Column({ type: 'decimal', precision: 10, scale: 2, default: 0 })
promotionPrice: number; promotionPrice: number;
@ApiProperty({ description: '库存', example: 100 })
@Column({ default: 0 })
stock: number;
@ManyToMany(() => DictItem, { @ManyToMany(() => DictItem, {
cascade: true, cascade: true,
}) })

View File

@ -10,7 +10,7 @@ export interface WpSite {
wpApiUrl: string; wpApiUrl: string;
consumerKey: string; consumerKey: string;
consumerSecret: string; consumerSecret: string;
siteName: string; name: string;
email: string; email: string;
emailPswd: string; emailPswd: string;
} }

View File

@ -22,6 +22,12 @@ export class AreaService {
} }
const area = new Area(); const area = new Area();
area.name = params.name; area.name = params.name;
if (params.latitude !== undefined) {
area.latitude = params.latitude;
}
if (params.longitude !== undefined) {
area.longitude = params.longitude;
}
return await this.areaModel.save(area); return await this.areaModel.save(area);
} }
@ -43,6 +49,12 @@ export class AreaService {
} }
area.name = params.name; area.name = params.name;
} }
if (params.latitude !== undefined) {
area.latitude = params.latitude;
}
if (params.longitude !== undefined) {
area.longitude = params.longitude;
}
return await this.areaModel.save(area); return await this.areaModel.save(area);
} }

View File

@ -561,11 +561,11 @@ export class LogisticsService {
// 从数据库批量获取站点信息,构建映射以避免 N+1 查询 // 从数据库批量获取站点信息,构建映射以避免 N+1 查询
const siteIds = Array.from(new Set(orders.map(o => o.siteId).filter(Boolean))); 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 { 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.siteName])); const siteMap = new Map(sites.map((s: any) => [String(s.id), s.name]));
return orders.map(order => ({ return orders.map(order => ({
...order, ...order,
siteName: siteMap.get(order.siteId) || '', name: siteMap.get(order.siteId) || '',
})); }));
} }

View File

@ -1350,7 +1350,7 @@ export class OrderService {
return { return {
...order, ...order,
siteName: site?.name, name: site?.name,
// Site 实体无邮箱字段,这里返回空字符串保持兼容 // Site 实体无邮箱字段,这里返回空字符串保持兼容
email: '', email: '',
items, items,
@ -1418,11 +1418,11 @@ export class OrderService {
// 批量获取订单涉及的站点名称,避免使用配置文件 // 批量获取订单涉及的站点名称,避免使用配置文件
const siteIds = Array.from(new Set(orders.map(o => o.siteId).filter(Boolean))); 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 { 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.siteName])); const siteMap = new Map(sites.map((s: any) => [String(s.id), s.name]));
return orders.map(order => ({ return orders.map(order => ({
externalOrderId: order.externalOrderId, externalOrderId: order.externalOrderId,
id: order.id, id: order.id,
siteName: siteMap.get(order.siteId) || '', name: siteMap.get(order.siteId) || '',
})); }));
} }
@ -1430,7 +1430,7 @@ export class OrderService {
const order = await this.orderModel.findOne({ where: { id } }); const order = await this.orderModel.findOne({ where: { id } });
if (!order) throw new Error(`订单 ${id}不存在`); if (!order) throw new Error(`订单 ${id}不存在`);
const s: any = await this.siteService.get(Number(order.siteId), true); const s: any = await this.siteService.get(Number(order.siteId), true);
const site = { id: String(s.id), wpApiUrl: s.apiUrl, consumerKey: s.consumerKey, consumerSecret: s.consumerSecret, siteName: s.siteName, email: '', emailPswd: '' } as WpSite; const site = { id: String(s.id), wpApiUrl: s.apiUrl, consumerKey: s.consumerKey, consumerSecret: s.consumerSecret, name: s.name, email: '', emailPswd: '' } as WpSite;
if (order.status !== OrderStatus.CANCEL) { if (order.status !== OrderStatus.CANCEL) {
await this.wpService.updateOrder(site, order.externalOrderId, { await this.wpService.updateOrder(site, order.externalOrderId, {
status: OrderStatus.CANCEL, status: OrderStatus.CANCEL,

View File

@ -8,16 +8,19 @@ import {
CreateFlavorsDTO, CreateFlavorsDTO,
CreateProductDTO, CreateProductDTO,
CreateStrengthDTO, CreateStrengthDTO,
CreateSizeDTO,
UpdateBrandDTO, UpdateBrandDTO,
UpdateFlavorsDTO, UpdateFlavorsDTO,
UpdateProductDTO, UpdateProductDTO,
UpdateStrengthDTO, UpdateStrengthDTO,
UpdateSizeDTO,
} from '../dto/product.dto'; } from '../dto/product.dto';
import { import {
BrandPaginatedResponse, BrandPaginatedResponse,
FlavorsPaginatedResponse, FlavorsPaginatedResponse,
ProductPaginatedResponse, ProductPaginatedResponse,
StrengthPaginatedResponse, StrengthPaginatedResponse,
SizePaginatedResponse,
} from '../dto/reponse.dto'; } from '../dto/reponse.dto';
import { InjectEntityModel } from '@midwayjs/typeorm'; import { InjectEntityModel } from '@midwayjs/typeorm';
import { WpProduct } from '../entity/wp_product.entity'; import { WpProduct } from '../entity/wp_product.entity';
@ -26,6 +29,7 @@ import { Dict } from '../entity/dict.entity';
import { DictItem } from '../entity/dict_item.entity'; import { DictItem } from '../entity/dict_item.entity';
import { Context } from '@midwayjs/koa'; import { Context } from '@midwayjs/koa';
import { TemplateService } from './template.service'; import { TemplateService } from './template.service';
import { StockService } from './stock.service';
@Provide() @Provide()
export class ProductService { export class ProductService {
@ -35,6 +39,9 @@ export class ProductService {
@Inject() @Inject()
templateService: TemplateService; templateService: TemplateService;
@Inject()
stockService: StockService;
@InjectEntityModel(Product) @InjectEntityModel(Product)
productModel: Repository<Product>; productModel: Repository<Product>;
@ -157,6 +164,16 @@ export class ProductService {
const [items, total] = await qb.getManyAndCount(); const [items, total] = await qb.getManyAndCount();
// 获取所有 SKU 的库存信息
const skus = items.map(item => item.sku).filter(Boolean);
const stocks = await this.stockService.getStocksBySkus(skus);
// 将库存信息映射到 SKU
const stockMap = stocks.reduce((map, stock) => {
map[stock.productSku] = stock.totalQuantity;
return map;
}, {});
// 格式化返回的数据 // 格式化返回的数据
const formattedItems = items.map(product => { const formattedItems = items.map(product => {
const getAttributeTitle = (dictName: string) => const getAttributeTitle = (dictName: string) =>
@ -169,8 +186,13 @@ export class ProductService {
description: product.description, description: product.description,
humidity: getAttributeTitle('humidity'), humidity: getAttributeTitle('humidity'),
sku: product.sku, sku: product.sku,
stock: stockMap[product.sku] || 0, // 使用映射的库存或默认为 0
price: product.price,
promotionPrice: product.promotionPrice,
source: product.source, // 中文注释:补充返回产品来源字段
createdAt: product.createdAt, createdAt: product.createdAt,
updatedAt: product.updatedAt, updatedAt: product.updatedAt,
attributes: product.attributes,
brandName: getAttributeTitle('brand'), brandName: getAttributeTitle('brand'),
flavorsName: getAttributeTitle('flavor'), flavorsName: getAttributeTitle('flavor'),
strengthName: getAttributeTitle('strength'), strengthName: getAttributeTitle('strength'),
@ -213,25 +235,19 @@ export class ProductService {
} }
async createProduct(createProductDTO: CreateProductDTO): Promise<Product> { async createProduct(createProductDTO: CreateProductDTO): Promise<Product> {
const { name, description, brand, flavor, strength, humidity, sku } = const { name, description, brandId, flavorsId, strengthId, humidity, sku, price } =
createProductDTO; createProductDTO;
// 获取或创建品牌、口味、规格 // 获取或创建品牌、口味、规格
const brandItem = await this.getOrCreateDictItem( const brandItem = await this.dictItemModel.findOne({ where: { id: brandId } });
'brand', if (!brandItem) throw new Error('品牌不存在');
brand.title,
brand.name const flavorItem = await this.dictItemModel.findOne({ where: { id: flavorsId } });
); if (!flavorItem) throw new Error('口味不存在');
const flavorItem = await this.getOrCreateDictItem(
'flavor', const strengthItem = await this.dictItemModel.findOne({ where: { id: strengthId } });
flavor.title, if (!strengthItem) throw new Error('规格不存在');
flavor.name
);
const strengthItem = await this.getOrCreateDictItem(
'strength',
strength.title,
strength.name
);
const humidityItem = await this.getOrCreateDictItem('humidity', humidity); const humidityItem = await this.getOrCreateDictItem('humidity', humidity);
// 检查具有完全相同属性组合的产品是否已存在 // 检查具有完全相同属性组合的产品是否已存在
@ -268,6 +284,15 @@ 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); return await this.productModel.save(product);
} }
@ -276,15 +301,75 @@ export class ProductService {
id: number, id: number,
updateProductDTO: UpdateProductDTO updateProductDTO: UpdateProductDTO
): Promise<Product> { ): Promise<Product> {
// 确认产品是否存在 // 检查产品是否存在(包含属性关系)
const product = await this.productModel.findOneBy({ id }); const product = await this.productModel.findOne({ where: { id }, relations: ['attributes', 'attributes.dict'] });
if (!product) { if (!product) {
throw new Error(`产品 ID ${id} 不存在`); throw new Error(`产品 ID ${id} 不存在`);
} }
// 更新产品
await this.productModel.update(id, updateProductDTO); // 处理基础字段更新(若传入则更新)
// 返回更新后的产品 if (updateProductDTO.name !== undefined) {
return await this.productModel.findOneBy({ id }); product.name = updateProductDTO.name;
}
if (updateProductDTO.description !== undefined) {
product.description = updateProductDTO.description;
}
if (updateProductDTO.price !== undefined) {
product.price = Number(updateProductDTO.price);
}
if ((updateProductDTO as any).promotionPrice !== undefined) {
product.promotionPrice = Number((updateProductDTO as any).promotionPrice);
}
if (updateProductDTO.sku !== undefined) {
// 校验 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 已存在,请更换后重试');
}
product.sku = newSku;
}
}
// 处理属性更新(品牌/口味/强度/干湿)
const nextAttributes: DictItem[] = [...(product.attributes || [])];
// 根据 dict.name 查找或替换已有属性
const replaceAttr = (dictName: string, item: DictItem) => {
const idx = nextAttributes.findIndex(a => a.dict?.name === dictName);
if (idx >= 0) nextAttributes[idx] = item; else nextAttributes.push(item);
};
// 品牌
if (updateProductDTO.brandId !== undefined) {
const brandItem = await this.dictItemModel.findOne({ where: { id: updateProductDTO.brandId }, relations: ['dict'] });
if (!brandItem) throw new Error('品牌不存在');
replaceAttr('brand', brandItem);
}
// 口味
if (updateProductDTO.flavorsId !== undefined) {
const flavorItem = await this.dictItemModel.findOne({ where: { id: updateProductDTO.flavorsId }, relations: ['dict'] });
if (!flavorItem) throw new Error('口味不存在');
replaceAttr('flavor', flavorItem);
}
// 强度
if (updateProductDTO.strengthId !== undefined) {
const strengthItem = await this.dictItemModel.findOne({ where: { id: updateProductDTO.strengthId }, relations: ['dict'] });
if (!strengthItem) throw new Error('规格不存在');
replaceAttr('strength', strengthItem);
}
// 干湿(按 title 获取或创建)
if (updateProductDTO.humidity !== undefined) {
const humidityItem = await this.getOrCreateDictItem('humidity', updateProductDTO.humidity);
replaceAttr('humidity', humidityItem);
}
product.attributes = nextAttributes;
// 保存更新后的产品
const saved = await this.productModel.save(product);
return saved;
} }
async updateProductNameCn(id: number, nameCn: string): Promise<Product> { async updateProductNameCn(id: number, nameCn: string): Promise<Product> {
@ -523,6 +608,81 @@ export class ProductService {
return result.affected > 0; return result.affected > 0;
} }
// size 尺寸相关方法
async getSizeList(
pagination: PaginationParams,
title?: string
): Promise<SizePaginatedResponse> {
// 查找 'size' 字典(中文注释:用于尺寸)
const sizeDict = await this.dictModel.findOne({ where: { name: 'size' } });
// 条件判断(中文注释:如果字典不存在则返回空分页)
if (!sizeDict) {
return {
items: [],
total: 0,
...pagination,
} as any;
}
// 构建 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' 字典(中文注释:获取所有尺寸项)
const sizeDict = await this.dictModel.findOne({ where: { name: 'size' } });
// 条件判断(中文注释:如果字典不存在返回空数组)
if (!sizeDict) {
return [] as any;
}
return this.dictItemModel.find({ where: { dict: { id: sizeDict.id } } }) as any;
}
async createSize(createSizeDTO: CreateSizeDTO): Promise<DictItem> {
const { title, name } = createSizeDTO;
// 获取 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;
size.dict = sizeDict;
return await this.dictItemModel.save(size);
}
async updateSize(id: number, updateSize: UpdateSizeDTO) {
// 先查询(中文注释:确保尺寸项存在)
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
const result = await this.dictItemModel.delete(id);
return result.affected > 0;
}
async hasStrength(title: string, id?: string): Promise<boolean> { async hasStrength(title: string, id?: string): Promise<boolean> {
const strengthDict = await this.dictModel.findOne({ const strengthDict = await this.dictModel.findOne({

View File

@ -15,10 +15,10 @@ export class SiteService {
// 将配置中的 WpSite 同步到数据库 Site 表(用于一次性导入或初始化) // 将配置中的 WpSite 同步到数据库 Site 表(用于一次性导入或初始化)
for (const siteConfig of sites) { for (const siteConfig of sites) {
// 按站点名称查询是否已存在记录 // 按站点名称查询是否已存在记录
const exist = await this.siteModel.findOne({ where: { name: siteConfig.siteName } }); const exist = await this.siteModel.findOne({ where: { name: siteConfig.name } });
// 将 WpSite 字段映射为 Site 实体字段 // 将 WpSite 字段映射为 Site 实体字段
const payload: Partial<Site> = { const payload: Partial<Site> = {
name: siteConfig.siteName, name: siteConfig.name,
apiUrl: (siteConfig as any).wpApiUrl, apiUrl: (siteConfig as any).wpApiUrl,
consumerKey: (siteConfig as any).consumerKey, consumerKey: (siteConfig as any).consumerKey,
consumerSecret: (siteConfig as any).consumerSecret, consumerSecret: (siteConfig as any).consumerSecret,
@ -66,7 +66,7 @@ export class SiteService {
const { current = 1, pageSize = 10, keyword, isDisabled, ids } = (param || {}) as any; const { current = 1, pageSize = 10, keyword, isDisabled, ids } = (param || {}) as any;
const where: any = {}; const where: any = {};
// 按名称模糊查询 // 按名称模糊查询
if (keyword) where.siteName = Like(`%${keyword}%`); if (keyword) where.name = Like(`%${keyword}%`);
// 按禁用状态过滤(布尔转数值) // 按禁用状态过滤(布尔转数值)
if (typeof isDisabled === 'boolean') where.isDisabled = isDisabled ? 1 : 0; if (typeof isDisabled === 'boolean') where.isDisabled = isDisabled ? 1 : 0;
if (ids) { if (ids) {

View File

@ -297,6 +297,22 @@ export class StockService {
}; };
} }
async getStocksBySkus(skus: string[]) {
if (!skus || skus.length === 0) {
return [];
}
const stocks = await this.stockModel
.createQueryBuilder('stock')
.select('stock.productSku', 'productSku')
.addSelect('SUM(stock.quantity)', 'totalQuantity')
.where('stock.productSku IN (:...skus)', { skus })
.groupBy('stock.productSku')
.getRawMany();
return stocks;
}
// 更新库存 // 更新库存
async updateStock(data: UpdateStockDTO) { async updateStock(data: UpdateStockDTO) {
const { const {