From 366fd93dde44a5c94afe07d74a561167e35f932a Mon Sep 17 00:00:00 2001 From: tikkhun Date: Fri, 28 Nov 2025 16:58:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=8C=BA=E5=9F=9F?= =?UTF-8?q?=E5=9D=90=E6=A0=87=E5=8A=9F=E8=83=BD=E5=B9=B6=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E4=BA=A7=E5=93=81=E5=B1=9E=E6=80=A7=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在区域实体中添加经纬度字段,支持坐标功能 - 重构产品属性管理,使用ID关联替代对象嵌套 - 新增产品尺寸管理功能及相关API - 添加库存查询接口,支持按SKU批量查询 - 统一站点名称字段从siteName改为name - 添加数据库迁移指南和API文档 - 优化实体加载方式,使用通配符匹配 --- area-api-doc.md | 254 ++++++++++++++++++ migration-guide.md | 49 ++++ package.json | 4 +- src/config/config.default.ts | 2 +- src/controller/product.controller.ts | 94 +++++++ src/controller/site.controller.ts | 2 +- src/db/datasource.ts | 76 +----- src/db/migrations/1764294088896-Area.ts | 6 +- .../migrations/1764299629279-ProductStock.ts | 16 ++ src/dto/area.dto.ts | 16 ++ src/dto/dict.dto.ts | 9 + src/dto/product.dto.ts | 78 +++--- src/dto/reponse.dto.ts | 13 +- src/dto/site.dto.ts | 6 +- src/entity/area.entity.ts | 8 + src/entity/dict_item.entity.ts | 4 +- src/entity/product.entity.ts | 4 + src/interface.ts | 2 +- src/service/area.service.ts | 12 + src/service/logistics.service.ts | 4 +- src/service/order.service.ts | 8 +- src/service/product.service.ts | 204 ++++++++++++-- src/service/site.service.ts | 6 +- src/service/stock.service.ts | 16 ++ 24 files changed, 742 insertions(+), 151 deletions(-) create mode 100644 area-api-doc.md create mode 100644 migration-guide.md create mode 100644 src/db/migrations/1764299629279-ProductStock.ts diff --git a/area-api-doc.md b/area-api-doc.md new file mode 100644 index 0000000..8eb579d --- /dev/null +++ b/area-api-doc.md @@ -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(`${area.name}`); + } + }); + ``` + +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可能返回的错误信息: + +- `区域名称已存在`: 当尝试创建或更新区域名称与现有名称重复时 +- `区域不存在`: 当尝试更新或删除不存在的区域时 +- `权限错误`: 当请求缺少有效的授权令牌时 \ No newline at end of file diff --git a/migration-guide.md b/migration-guide.md new file mode 100644 index 0000000..ba5339e --- /dev/null +++ b/migration-guide.md @@ -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开始为区域添加坐标信息 \ No newline at end of file diff --git a/package.json b/package.json index 169ab4b..c40ff7a 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,9 @@ "build": "mwtsc --cleanOutDir", "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", - "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": { "type": "git", diff --git a/src/config/config.default.ts b/src/config/config.default.ts index a5f2c8c..d1bb37a 100644 --- a/src/config/config.default.ts +++ b/src/config/config.default.ts @@ -111,7 +111,7 @@ export default { // wpApiUrl: 'http://localhost:10004', // consumerKey: 'ck_dc9e151e9048c8ed3e27f35ac79d2bf7d6840652', // consumerSecret: 'cs_d05d625d7b0ac05c6d765671d8417f41d9477e38', - // siteName: 'Local', + // name: 'Local', // email: 'tom@yoonevape.com', // emailPswd: '', // }, diff --git a/src/controller/product.controller.ts b/src/controller/product.controller.ts index 1313485..8c3cdf5 100644 --- a/src/controller/product.controller.ts +++ b/src/controller/product.controller.ts @@ -17,20 +17,25 @@ import { CreateFlavorsDTO, CreateProductDTO, CreateStrengthDTO, + CreateSizeDTO, QueryBrandDTO, QueryFlavorsDTO, QueryProductDTO, QueryStrengthDTO, + QuerySizeDTO, UpdateBrandDTO, UpdateFlavorsDTO, UpdateProductDTO, UpdateStrengthDTO, + UpdateSizeDTO, } from '../dto/product.dto'; import { ApiOkResponse } from '@midwayjs/swagger'; import { BooleanRes, ProductBrandListRes, ProductBrandRes, + ProductSizeListRes, + ProductSizeRes, ProductListRes, ProductRes, ProductsRes, @@ -406,4 +411,93 @@ export class ProductController { 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); + } + } } diff --git a/src/controller/site.controller.ts b/src/controller/site.controller.ts index 5c848d5..02cf312 100644 --- a/src/controller/site.controller.ts +++ b/src/controller/site.controller.ts @@ -15,7 +15,7 @@ export class SiteController { async all() { try { 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) { return errorResponse(error?.message || '获取失败'); } diff --git a/src/db/datasource.ts b/src/db/datasource.ts index a05ac66..3f996fd 100644 --- a/src/db/datasource.ts +++ b/src/db/datasource.ts @@ -1,41 +1,6 @@ import { DataSource, DataSourceOptions } from 'typeorm'; 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 = { type: 'mysql', @@ -46,44 +11,7 @@ const options: DataSourceOptions & SeederOptions = { database: 'inventory', synchronize: false, logging: true, - entities: [ - 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, - ], + entities: [__dirname + '/../entity/*.ts'], migrations: ['src/db/migrations/**/*.ts'], seeds: ['src/db/seeds/**/*.ts'], }; diff --git a/src/db/migrations/1764294088896-Area.ts b/src/db/migrations/1764294088896-Area.ts index 188c35a..e2948bb 100644 --- a/src/db/migrations/1764294088896-Area.ts +++ b/src/db/migrations/1764294088896-Area.ts @@ -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 \`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(`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 `source` int NOT NULL DEFAULT '0'`); // 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 \`product\` DROP COLUMN \`source\``); 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_926a14ac4c91f38792831acd2a\` ON \`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 INDEX \`IDX_644ffaf8fbde4db798cb47712f\` ON \`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\`)`); } } diff --git a/src/db/migrations/1764299629279-ProductStock.ts b/src/db/migrations/1764299629279-ProductStock.ts new file mode 100644 index 0000000..9767e45 --- /dev/null +++ b/src/db/migrations/1764299629279-ProductStock.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class ProductStock1764299629279 implements MigrationInterface { + name = 'ProductStock1764299629279' + + public async up(queryRunner: QueryRunner): Promise { + 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 { + await queryRunner.query(`ALTER TABLE \`order_item_original\` DROP FOREIGN KEY \`FK_ca48e4bce0bb8cecd24cc8081e5\``); + await queryRunner.query(`DROP TABLE \`order_item_original\``); + } + +} diff --git a/src/dto/area.dto.ts b/src/dto/area.dto.ts index acf0d05..9bb8b82 100644 --- a/src/dto/area.dto.ts +++ b/src/dto/area.dto.ts @@ -7,6 +7,14 @@ export class CreateAreaDTO { @ApiProperty({ type: 'string', description: '区域名称', example: '欧洲' }) @Rule(RuleType.string().required()) 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: '欧洲' }) @Rule(RuleType.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; } // 查询区域的数据传输对象 diff --git a/src/dto/dict.dto.ts b/src/dto/dict.dto.ts index b84ac8e..85c8b02 100644 --- a/src/dto/dict.dto.ts +++ b/src/dto/dict.dto.ts @@ -26,6 +26,9 @@ export class CreateDictItemDTO { @Rule(RuleType.string().required()) title: string; // 字典项标题 + @Rule(RuleType.string().allow(null)) + titleCN?: string; // 字典项中文标题 (可选) + @Rule(RuleType.number().required()) dictId: number; // 所属字典的ID } @@ -37,4 +40,10 @@ export class UpdateDictItemDTO { @Rule(RuleType.string()) title?: string; // 字典项标题 (可选) + + @Rule(RuleType.string().allow(null)) + value?: string; // 字典项值 (可选) + + @Rule(RuleType.string().allow(null)) + titleCN?: string; // 字典项中文标题 (可选) } diff --git a/src/dto/product.dto.ts b/src/dto/product.dto.ts index 2fdfb22..2419073 100644 --- a/src/dto/product.dto.ts +++ b/src/dto/product.dto.ts @@ -1,16 +1,6 @@ import { ApiProperty } from '@midwayjs/swagger'; 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 用于创建产品 */ @@ -31,32 +21,17 @@ export class CreateProductDTO { @Rule(RuleType.string()) sku?: string; - @ApiProperty({ description: '品牌', type: DictItemDTO }) - @Rule( - RuleType.object().keys({ - title: RuleType.string().required(), - name: RuleType.string(), - }) - ) - brand: DictItemDTO; + @ApiProperty({ description: '品牌 ID', type: 'number' }) + @Rule(RuleType.number().required()) + brandId: number; - @ApiProperty({ description: '规格', type: DictItemDTO }) - @Rule( - RuleType.object().keys({ - title: RuleType.string().required(), - name: RuleType.string(), - }) - ) - strength: DictItemDTO; + @ApiProperty({ description: '规格 ID', type: 'number' }) + @Rule(RuleType.number().required()) + strengthId: number; - @ApiProperty({ description: '口味', type: DictItemDTO }) - @Rule( - RuleType.object().keys({ - title: RuleType.string().required(), - name: RuleType.string(), - }) - ) - flavor: DictItemDTO; + @ApiProperty({ description: '口味 ID', type: 'number' }) + @Rule(RuleType.number().required()) + flavorsId: number; @ApiProperty() @Rule(RuleType.string()) @@ -213,6 +188,41 @@ export class QueryStrengthDTO { 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 { @ApiProperty({ description: '产品 ID' }) productId: number; diff --git a/src/dto/reponse.dto.ts b/src/dto/reponse.dto.ts index 6ee01ee..1c43649 100644 --- a/src/dto/reponse.dto.ts +++ b/src/dto/reponse.dto.ts @@ -63,9 +63,20 @@ export class ProductStrengthListRes extends SuccessWrapper( ) {} //产品规格返所有数据 export class ProductStrengthAllRes extends SuccessArrayWrapper(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( WpProductDTO diff --git a/src/dto/site.dto.ts b/src/dto/site.dto.ts index 17fcdd1..39f4f6f 100644 --- a/src/dto/site.dto.ts +++ b/src/dto/site.dto.ts @@ -20,7 +20,7 @@ export class SiteConfig { @ApiProperty({ description: '站点名' }) @Rule(RuleType.string()) - siteName: string; + name: string; @ApiProperty({ description: '平台类型', enum: ['woocommerce', 'shopyy'] }) @Rule(RuleType.string().valid('woocommerce', 'shopyy')) @@ -39,7 +39,7 @@ export class CreateSiteDTO { @Rule(RuleType.string().optional()) consumerSecret?: string; @Rule(RuleType.string()) - siteName: string; + name: string; @Rule(RuleType.string().valid('woocommerce', 'shopyy').optional()) type?: string; @Rule(RuleType.string().optional()) @@ -54,7 +54,7 @@ export class UpdateSiteDTO { @Rule(RuleType.string().optional()) consumerSecret?: string; @Rule(RuleType.string().optional()) - siteName?: string; + name?: string; @Rule(RuleType.boolean().optional()) isDisabled?: boolean; @Rule(RuleType.string().valid('woocommerce', 'shopyy').optional()) diff --git a/src/entity/area.entity.ts b/src/entity/area.entity.ts index 51caf17..7afaf34 100644 --- a/src/entity/area.entity.ts +++ b/src/entity/area.entity.ts @@ -17,6 +17,14 @@ export class Area { @Column({ unique: true }) 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({ example: '2022-12-12 11:11:11', description: '创建时间', diff --git a/src/entity/dict_item.entity.ts b/src/entity/dict_item.entity.ts index 61fe9b2..c53b21a 100644 --- a/src/entity/dict_item.entity.ts +++ b/src/entity/dict_item.entity.ts @@ -25,7 +25,9 @@ export class DictItem { // 字典项名称 @Column({ comment: '字典项显示名称' }) title: string; - + // 目前没有单独做国际化, 所以这里先添加 titleCN 用来标注 + @Column({ comment: '字典项中文名称', nullable: true }) + titleCN: string; // 唯一标识 @Column({ unique: true, comment: '字典唯一标识名称' }) name: string; diff --git a/src/entity/product.entity.ts b/src/entity/product.entity.ts index 181642f..a718cf5 100644 --- a/src/entity/product.entity.ts +++ b/src/entity/product.entity.ts @@ -52,6 +52,10 @@ export class Product { @Column({ type: 'decimal', precision: 10, scale: 2, default: 0 }) promotionPrice: number; + @ApiProperty({ description: '库存', example: 100 }) + @Column({ default: 0 }) + stock: number; + @ManyToMany(() => DictItem, { cascade: true, }) diff --git a/src/interface.ts b/src/interface.ts index f7b94f9..e000403 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -10,7 +10,7 @@ export interface WpSite { wpApiUrl: string; consumerKey: string; consumerSecret: string; - siteName: string; + name: string; email: string; emailPswd: string; } diff --git a/src/service/area.service.ts b/src/service/area.service.ts index e9c9120..fbe2eac 100644 --- a/src/service/area.service.ts +++ b/src/service/area.service.ts @@ -22,6 +22,12 @@ export class AreaService { } const area = new Area(); 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); } @@ -43,6 +49,12 @@ export class AreaService { } 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); } diff --git a/src/service/logistics.service.ts b/src/service/logistics.service.ts index a79503d..36d9232 100644 --- a/src/service/logistics.service.ts +++ b/src/service/logistics.service.ts @@ -561,11 +561,11 @@ export class LogisticsService { // 从数据库批量获取站点信息,构建映射以避免 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) => [String(s.id), s.siteName])); + const siteMap = new Map(sites.map((s: any) => [String(s.id), s.name])); return orders.map(order => ({ ...order, - siteName: siteMap.get(order.siteId) || '', + name: siteMap.get(order.siteId) || '', })); } diff --git a/src/service/order.service.ts b/src/service/order.service.ts index bcd787b..9fd08f1 100644 --- a/src/service/order.service.ts +++ b/src/service/order.service.ts @@ -1350,7 +1350,7 @@ export class OrderService { return { ...order, - siteName: site?.name, + name: site?.name, // Site 实体无邮箱字段,这里返回空字符串保持兼容 email: '', items, @@ -1418,11 +1418,11 @@ 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.siteName])); + const siteMap = new Map(sites.map((s: any) => [String(s.id), s.name])); return orders.map(order => ({ externalOrderId: order.externalOrderId, 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 } }); if (!order) throw new Error(`订单 ${id}不存在`); 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) { await this.wpService.updateOrder(site, order.externalOrderId, { status: OrderStatus.CANCEL, diff --git a/src/service/product.service.ts b/src/service/product.service.ts index 658a167..2ecc4e0 100644 --- a/src/service/product.service.ts +++ b/src/service/product.service.ts @@ -8,16 +8,19 @@ import { CreateFlavorsDTO, CreateProductDTO, CreateStrengthDTO, + CreateSizeDTO, UpdateBrandDTO, UpdateFlavorsDTO, UpdateProductDTO, UpdateStrengthDTO, + UpdateSizeDTO, } from '../dto/product.dto'; import { BrandPaginatedResponse, FlavorsPaginatedResponse, ProductPaginatedResponse, StrengthPaginatedResponse, + SizePaginatedResponse, } from '../dto/reponse.dto'; import { InjectEntityModel } from '@midwayjs/typeorm'; 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 { Context } from '@midwayjs/koa'; import { TemplateService } from './template.service'; +import { StockService } from './stock.service'; @Provide() export class ProductService { @@ -35,6 +39,9 @@ export class ProductService { @Inject() templateService: TemplateService; + @Inject() + stockService: StockService; + @InjectEntityModel(Product) productModel: Repository; @@ -157,6 +164,16 @@ export class ProductService { 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 getAttributeTitle = (dictName: string) => @@ -169,8 +186,13 @@ export class ProductService { description: product.description, humidity: getAttributeTitle('humidity'), sku: product.sku, + stock: stockMap[product.sku] || 0, // 使用映射的库存或默认为 0 + price: product.price, + promotionPrice: product.promotionPrice, + source: product.source, // 中文注释:补充返回产品来源字段 createdAt: product.createdAt, updatedAt: product.updatedAt, + attributes: product.attributes, brandName: getAttributeTitle('brand'), flavorsName: getAttributeTitle('flavor'), strengthName: getAttributeTitle('strength'), @@ -213,25 +235,19 @@ export class ProductService { } async createProduct(createProductDTO: CreateProductDTO): Promise { - const { name, description, brand, flavor, strength, humidity, sku } = + const { name, description, brandId, flavorsId, strengthId, humidity, sku, price } = createProductDTO; // 获取或创建品牌、口味、规格 - const brandItem = await this.getOrCreateDictItem( - 'brand', - brand.title, - brand.name - ); - const flavorItem = await this.getOrCreateDictItem( - 'flavor', - flavor.title, - flavor.name - ); - const strengthItem = await this.getOrCreateDictItem( - 'strength', - strength.title, - strength.name - ); + const brandItem = await this.dictItemModel.findOne({ where: { id: brandId } }); + if (!brandItem) throw new Error('品牌不存在'); + + const flavorItem = await this.dictItemModel.findOne({ where: { id: flavorsId } }); + if (!flavorItem) throw new Error('口味不存在'); + + const strengthItem = await this.dictItemModel.findOne({ where: { id: strengthId } }); + if (!strengthItem) throw new Error('规格不存在'); + 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); } @@ -276,15 +301,75 @@ export class ProductService { id: number, updateProductDTO: UpdateProductDTO ): Promise { - // 确认产品是否存在 - const product = await this.productModel.findOneBy({ id }); + // 检查产品是否存在(包含属性关系) + const product = await this.productModel.findOne({ where: { id }, relations: ['attributes', 'attributes.dict'] }); if (!product) { throw new Error(`产品 ID ${id} 不存在`); } - // 更新产品 - await this.productModel.update(id, updateProductDTO); - // 返回更新后的产品 - return await this.productModel.findOneBy({ id }); + + // 处理基础字段更新(若传入则更新) + if (updateProductDTO.name !== undefined) { + 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 { @@ -523,6 +608,81 @@ export class ProductService { return result.affected > 0; } + // size 尺寸相关方法 + async getSizeList( + pagination: PaginationParams, + title?: string + ): Promise { + // 查找 '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 { + // 查找 '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 { + 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 { + // 先查询(中文注释:确保尺寸项存在) + 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 { const strengthDict = await this.dictModel.findOne({ diff --git a/src/service/site.service.ts b/src/service/site.service.ts index 0075ccc..edd2d83 100644 --- a/src/service/site.service.ts +++ b/src/service/site.service.ts @@ -15,10 +15,10 @@ export class SiteService { // 将配置中的 WpSite 同步到数据库 Site 表(用于一次性导入或初始化) 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 实体字段 const payload: Partial = { - name: siteConfig.siteName, + name: siteConfig.name, apiUrl: (siteConfig as any).wpApiUrl, consumerKey: (siteConfig as any).consumerKey, consumerSecret: (siteConfig as any).consumerSecret, @@ -66,7 +66,7 @@ export class SiteService { const { current = 1, pageSize = 10, keyword, isDisabled, ids } = (param || {}) as 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 (ids) { diff --git a/src/service/stock.service.ts b/src/service/stock.service.ts index c730269..0525c8c 100644 --- a/src/service/stock.service.ts +++ b/src/service/stock.service.ts @@ -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) { const {