From 01803605196d8d0b9363c3b6727c719bd8c1dbba Mon Sep 17 00:00:00 2001 From: tikkhun Date: Tue, 2 Dec 2025 15:02:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=88=86=E7=B1=BB?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=92=8C=E5=8C=BA=E5=9F=9F=E5=85=B3=E8=81=94?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现分类实体、控制器和服务,支持分类与产品的关联 在站点和仓库点DTO中添加区域字段,支持区域关联查询 更新数据库配置和种子数据,优化产品属性管理 --- scripts/create_db.d.ts | 1 + scripts/create_db.js | 21 ++ scripts/diagnose_db.d.ts | 1 + scripts/diagnose_db.js | 31 +++ src/config/config.default.ts | 10 +- src/controller/category.controller.ts | 98 ++++++++ src/controller/product.controller.ts | 17 +- src/db/datasource.ts | 9 +- src/db/seeds/area.seeder.ts | 17 +- src/db/seeds/category.seeder.ts | 39 +++ src/db/seeds/category_attribute.seeder.ts | 62 +++++ src/db/seeds/dict.seeder.ts | 146 ++++++------ src/dto/product.dto.ts | 276 ++++++---------------- src/dto/site.dto.ts | 10 + src/dto/stock.dto.ts | 5 + src/entity/category.entity.ts | 39 +++ src/entity/category_attribute.entity.ts | 26 ++ src/entity/product.entity.ts | 9 + src/service/category.service.ts | 105 ++++++++ src/service/product.service.ts | 98 ++++++-- src/service/site.service.ts | 145 +++++++++--- src/service/stock.service.ts | 47 +++- test_db_count.ts | 35 +++ 23 files changed, 888 insertions(+), 359 deletions(-) create mode 100644 scripts/create_db.d.ts create mode 100644 scripts/create_db.js create mode 100644 scripts/diagnose_db.d.ts create mode 100644 scripts/diagnose_db.js create mode 100644 src/controller/category.controller.ts create mode 100644 src/db/seeds/category.seeder.ts create mode 100644 src/db/seeds/category_attribute.seeder.ts create mode 100644 src/entity/category.entity.ts create mode 100644 src/entity/category_attribute.entity.ts create mode 100644 src/service/category.service.ts create mode 100644 test_db_count.ts diff --git a/scripts/create_db.d.ts b/scripts/create_db.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/scripts/create_db.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/scripts/create_db.js b/scripts/create_db.js new file mode 100644 index 0000000..6b99291 --- /dev/null +++ b/scripts/create_db.js @@ -0,0 +1,21 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const mysql = require("mysql2/promise"); +async function run() { + try { + const connection = await mysql.createConnection({ + socketPath: '/Users/zksu/Library/Application Support/Local/run/oLbUT7qMU/mysql/mysqld.sock', + user: 'root', + password: 'root', + }); + console.log('Connected to database server.'); + await connection.query('CREATE DATABASE IF NOT EXISTS inventory'); + console.log('Database "inventory" created or already exists.'); + await connection.end(); + } + catch (e) { + console.error('Error:', e); + } +} +run(); +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY3JlYXRlX2RiLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiY3JlYXRlX2RiLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiI7O0FBQ0Esd0NBQXdDO0FBRXhDLEtBQUssVUFBVSxHQUFHO0lBQ2hCLElBQUksQ0FBQztRQUNILE1BQU0sVUFBVSxHQUFHLE1BQU0sS0FBSyxDQUFDLGdCQUFnQixDQUFDO1lBQzlDLFVBQVUsRUFBRSwrRUFBK0U7WUFDM0YsSUFBSSxFQUFFLE1BQU07WUFDWixRQUFRLEVBQUUsTUFBTTtTQUNqQixDQUFDLENBQUM7UUFFSCxPQUFPLENBQUMsR0FBRyxDQUFDLCtCQUErQixDQUFDLENBQUM7UUFFN0MsTUFBTSxVQUFVLENBQUMsS0FBSyxDQUFDLHlDQUF5QyxDQUFDLENBQUM7UUFDbEUsT0FBTyxDQUFDLEdBQUcsQ0FBQyxpREFBaUQsQ0FBQyxDQUFDO1FBRS9ELE1BQU0sVUFBVSxDQUFDLEdBQUcsRUFBRSxDQUFDO0lBQ3pCLENBQUM7SUFBQyxPQUFPLENBQUMsRUFBRSxDQUFDO1FBQ1gsT0FBTyxDQUFDLEtBQUssQ0FBQyxRQUFRLEVBQUUsQ0FBQyxDQUFDLENBQUM7SUFDN0IsQ0FBQztBQUNILENBQUM7QUFFRCxHQUFHLEVBQUUsQ0FBQyIsInNvdXJjZXNDb250ZW50IjpbIlxuaW1wb3J0ICogYXMgbXlzcWwgZnJvbSAnbXlzcWwyL3Byb21pc2UnO1xuXG5hc3luYyBmdW5jdGlvbiBydW4oKSB7XG4gIHRyeSB7XG4gICAgY29uc3QgY29ubmVjdGlvbiA9IGF3YWl0IG15c3FsLmNyZWF0ZUNvbm5lY3Rpb24oe1xuICAgICAgc29ja2V0UGF0aDogJy9Vc2Vycy96a3N1L0xpYnJhcnkvQXBwbGljYXRpb24gU3VwcG9ydC9Mb2NhbC9ydW4vb0xiVVQ3cU1VL215c3FsL215c3FsZC5zb2NrJyxcbiAgICAgIHVzZXI6ICdyb290JyxcbiAgICAgIHBhc3N3b3JkOiAncm9vdCcsXG4gICAgfSk7XG5cbiAgICBjb25zb2xlLmxvZygnQ29ubmVjdGVkIHRvIGRhdGFiYXNlIHNlcnZlci4nKTtcblxuICAgIGF3YWl0IGNvbm5lY3Rpb24ucXVlcnkoJ0NSRUFURSBEQVRBQkFTRSBJRiBOT1QgRVhJU1RTIGludmVudG9yeScpO1xuICAgIGNvbnNvbGUubG9nKCdEYXRhYmFzZSBcImludmVudG9yeVwiIGNyZWF0ZWQgb3IgYWxyZWFkeSBleGlzdHMuJyk7XG4gICAgXG4gICAgYXdhaXQgY29ubmVjdGlvbi5lbmQoKTtcbiAgfSBjYXRjaCAoZSkge1xuICAgIGNvbnNvbGUuZXJyb3IoJ0Vycm9yOicsIGUpO1xuICB9XG59XG5cbnJ1bigpO1xuIl19 \ No newline at end of file diff --git a/scripts/diagnose_db.d.ts b/scripts/diagnose_db.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/scripts/diagnose_db.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/scripts/diagnose_db.js b/scripts/diagnose_db.js new file mode 100644 index 0000000..e6ec559 --- /dev/null +++ b/scripts/diagnose_db.js @@ -0,0 +1,31 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const mysql = require("mysql2/promise"); +async function run() { + try { + const connection = await mysql.createConnection({ + host: '127.0.0.1', + port: 10014, + user: 'root', + password: 'root', + database: 'inventory' + }); + console.log('Connected to database.'); + const tables = ['product', 'category_attribute', 'category']; + for (const table of tables) { + console.log(`\nConstraints for table: ${table}`); + const [rows] = await connection.execute(` + SELECT CONSTRAINT_NAME, CONSTRAINT_TYPE + FROM information_schema.TABLE_CONSTRAINTS + WHERE TABLE_SCHEMA = 'inventory' AND TABLE_NAME = '${table}' + `); + console.table(rows); + } + await connection.end(); + } + catch (e) { + console.error('Error:', e); + } +} +run(); +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiZGlhZ25vc2VfZGIuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyJkaWFnbm9zZV9kYi50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiOztBQUNBLHdDQUF3QztBQUV4QyxLQUFLLFVBQVUsR0FBRztJQUNoQixJQUFJLENBQUM7UUFDSCxNQUFNLFVBQVUsR0FBRyxNQUFNLEtBQUssQ0FBQyxnQkFBZ0IsQ0FBQztZQUM5QyxJQUFJLEVBQUUsV0FBVztZQUNqQixJQUFJLEVBQUUsS0FBSztZQUNYLElBQUksRUFBRSxNQUFNO1lBQ1osUUFBUSxFQUFFLE1BQU07WUFDaEIsUUFBUSxFQUFFLFdBQVc7U0FDdEIsQ0FBQyxDQUFDO1FBRUgsT0FBTyxDQUFDLEdBQUcsQ0FBQyx3QkFBd0IsQ0FBQyxDQUFDO1FBRXRDLE1BQU0sTUFBTSxHQUFHLENBQUMsU0FBUyxFQUFFLG9CQUFvQixFQUFFLFVBQVUsQ0FBQyxDQUFDO1FBRTdELEtBQUssTUFBTSxLQUFLLElBQUksTUFBTSxFQUFFLENBQUM7WUFDM0IsT0FBTyxDQUFDLEdBQUcsQ0FBQyw0QkFBNEIsS0FBSyxFQUFFLENBQUMsQ0FBQztZQUNqRCxNQUFNLENBQUMsSUFBSSxDQUFDLEdBQUcsTUFBTSxVQUFVLENBQUMsT0FBTyxDQUFDOzs7NkRBR2UsS0FBSztPQUMzRCxDQUFDLENBQUM7WUFDSCxPQUFPLENBQUMsS0FBSyxDQUFDLElBQUksQ0FBQyxDQUFDO1FBQ3RCLENBQUM7UUFFRCxNQUFNLFVBQVUsQ0FBQyxHQUFHLEVBQUUsQ0FBQztJQUN6QixDQUFDO0lBQUMsT0FBTyxDQUFDLEVBQUUsQ0FBQztRQUNYLE9BQU8sQ0FBQyxLQUFLLENBQUMsUUFBUSxFQUFFLENBQUMsQ0FBQyxDQUFDO0lBQzdCLENBQUM7QUFDSCxDQUFDO0FBRUQsR0FBRyxFQUFFLENBQUMiLCJzb3VyY2VzQ29udGVudCI6WyJcbmltcG9ydCAqIGFzIG15c3FsIGZyb20gJ215c3FsMi9wcm9taXNlJztcblxuYXN5bmMgZnVuY3Rpb24gcnVuKCkge1xuICB0cnkge1xuICAgIGNvbnN0IGNvbm5lY3Rpb24gPSBhd2FpdCBteXNxbC5jcmVhdGVDb25uZWN0aW9uKHtcbiAgICAgIGhvc3Q6ICcxMjcuMC4wLjEnLFxuICAgICAgcG9ydDogMTAwMTQsXG4gICAgICB1c2VyOiAncm9vdCcsXG4gICAgICBwYXNzd29yZDogJ3Jvb3QnLFxuICAgICAgZGF0YWJhc2U6ICdpbnZlbnRvcnknXG4gICAgfSk7XG5cbiAgICBjb25zb2xlLmxvZygnQ29ubmVjdGVkIHRvIGRhdGFiYXNlLicpO1xuXG4gICAgY29uc3QgdGFibGVzID0gWydwcm9kdWN0JywgJ2NhdGVnb3J5X2F0dHJpYnV0ZScsICdjYXRlZ29yeSddO1xuXG4gICAgZm9yIChjb25zdCB0YWJsZSBvZiB0YWJsZXMpIHtcbiAgICAgIGNvbnNvbGUubG9nKGBcXG5Db25zdHJhaW50cyBmb3IgdGFibGU6ICR7dGFibGV9YCk7XG4gICAgICBjb25zdCBbcm93c10gPSBhd2FpdCBjb25uZWN0aW9uLmV4ZWN1dGUoYFxuICAgICAgICBTRUxFQ1QgQ09OU1RSQUlOVF9OQU1FLCBDT05TVFJBSU5UX1RZUEUgXG4gICAgICAgIEZST00gaW5mb3JtYXRpb25fc2NoZW1hLlRBQkxFX0NPTlNUUkFJTlRTIFxuICAgICAgICBXSEVSRSBUQUJMRV9TQ0hFTUEgPSAnaW52ZW50b3J5JyBBTkQgVEFCTEVfTkFNRSA9ICcke3RhYmxlfSdcbiAgICAgIGApO1xuICAgICAgY29uc29sZS50YWJsZShyb3dzKTtcbiAgICB9XG4gICAgXG4gICAgYXdhaXQgY29ubmVjdGlvbi5lbmQoKTtcbiAgfSBjYXRjaCAoZSkge1xuICAgIGNvbnNvbGUuZXJyb3IoJ0Vycm9yOicsIGUpO1xuICB9XG59XG5cbnJ1bigpO1xuIl19 \ No newline at end of file diff --git a/src/config/config.default.ts b/src/config/config.default.ts index 3ff82a0..1eb06cf 100644 --- a/src/config/config.default.ts +++ b/src/config/config.default.ts @@ -36,7 +36,11 @@ import { DictItem } from '../entity/dict_item.entity'; import { Template } from '../entity/template.entity'; import { Area } from '../entity/area.entity'; import { ProductStockComponent } from '../entity/product_stock_component.entity'; +import { CategoryAttribute } from '../entity/category_attribute.entity'; +import { Category } from '../entity/category.entity'; import DictSeeder from '../db/seeds/dict.seeder'; +import CategorySeeder from '../db/seeds/category.seeder'; +import CategoryAttributeSeeder from '../db/seeds/category_attribute.seeder'; export default { // use for cookie sign key, should change to your own and keep security @@ -81,16 +85,18 @@ export default { DictItem, Template, Area, + CategoryAttribute, + Category, ], synchronize: true, logging: false, - seeders: [DictSeeder], + seeders: [DictSeeder, CategorySeeder, CategoryAttributeSeeder], }, dataSource: { default: { type: 'mysql', host: 'localhost', - port: 3306, + port: 10014, username: 'root', password: 'root', database: 'inventory', diff --git a/src/controller/category.controller.ts b/src/controller/category.controller.ts new file mode 100644 index 0000000..e8b625f --- /dev/null +++ b/src/controller/category.controller.ts @@ -0,0 +1,98 @@ +import { Controller, Get, Post, Put, Del, Body, Query, Inject, Param } from '@midwayjs/core'; +import { CategoryService } from '../service/category.service'; +import { successResponse, errorResponse } from '../utils/response.util'; +import { ApiOkResponse } from '@midwayjs/swagger'; + +@Controller('/category') +export class CategoryController { + @Inject() + categoryService: CategoryService; + + @ApiOkResponse() + @Get('/all') + async getAll() { + try { + const data = await this.categoryService.getAll(); + return successResponse(data); + } catch (error) { + return errorResponse(error?.message || error); + } + } + + @ApiOkResponse() + @Get('/') + async getList(@Query('current') current = 1, @Query('pageSize') pageSize = 10, @Query('name') name?: string) { + try { + const data = await this.categoryService.getList({ current, pageSize }, name); + return successResponse(data); + } catch (error) { + return errorResponse(error?.message || error); + } + } + + @ApiOkResponse() + @Post('/') + async create(@Body() body: any) { + try { + const data = await this.categoryService.create(body); + return successResponse(data); + } catch (error) { + return errorResponse(error?.message || error); + } + } + + @ApiOkResponse() + @Put('/:id') + async update(@Param('id') id: number, @Body() body: any) { + try { + const data = await this.categoryService.update(id, body); + return successResponse(data); + } catch (error) { + return errorResponse(error?.message || error); + } + } + + @ApiOkResponse() + @Del('/:id') + async delete(@Param('id') id: number) { + try { + await this.categoryService.delete(id); + return successResponse(null); + } catch (error) { + return errorResponse(error?.message || error); + } + } + + @ApiOkResponse() + @Get('/attribute/:categoryId') + async getCategoryAttributes(@Param('categoryId') categoryId: number) { + try { + const data = await this.categoryService.getCategoryAttributes(categoryId); + return successResponse(data); + } catch (error) { + return errorResponse(error?.message || error); + } + } + + @ApiOkResponse() + @Post('/attribute') + async createCategoryAttribute(@Body() body: { categoryId: number, attributeDictIds: number[] }) { + try { + const data = await this.categoryService.createCategoryAttribute(body.categoryId, body.attributeDictIds); + return successResponse(data); + } catch (error) { + return errorResponse(error?.message || error); + } + } + + @ApiOkResponse() + @Del('/attribute/:id') + async deleteCategoryAttribute(@Param('id') id: number) { + try { + await this.categoryService.deleteCategoryAttribute(id); + return successResponse(null); + } catch (error) { + return errorResponse(error?.message || error); + } + } +} diff --git a/src/controller/product.controller.ts b/src/controller/product.controller.ts index e593cea..ad333da 100644 --- a/src/controller/product.controller.ts +++ b/src/controller/product.controller.ts @@ -21,7 +21,6 @@ import { Context } from '@midwayjs/koa'; export class ProductController { @Inject() productService: ProductService; - ProductRes; @Inject() ctx: Context; @@ -171,7 +170,7 @@ export class ProductController { @Post('/:id/components') async setProductComponents(@Param('id') id: number, @Body() body: SetProductComponentsDTO) { try { - const data = await this.productService.setProductComponents(id, body?.items || []); + const data = await this.productService.setProductComponents(id, body?.components || []); return successResponse(data); } catch (error) { return errorResponse(error?.message || error); @@ -191,6 +190,20 @@ export class ProductController { } + // 获取所有 WordPress 商品 + @ApiOkResponse({ description: '获取所有 WordPress 商品' }) + @Get('/wp-products') + async getWpProducts() { + try { + const data = await this.productService.getWpProducts(); + return successResponse(data); + } catch (error) { + return errorResponse(error?.message || error); + } + } + + + // 通用属性接口:分页列表 @ApiOkResponse() @Get('/attribute') diff --git a/src/db/datasource.ts b/src/db/datasource.ts index 3f996fd..ef186cc 100644 --- a/src/db/datasource.ts +++ b/src/db/datasource.ts @@ -4,12 +4,13 @@ import { SeederOptions } from 'typeorm-extension'; const options: DataSourceOptions & SeederOptions = { type: 'mysql', - host: 'localhost', - port: 23306, + // host: 'localhost', + // port: 10014, + socketPath: '/Users/zksu/Library/Application Support/Local/run/oLbUT7qMU/mysql/mysqld.sock', username: 'root', - password: '12345678', + password: 'root', database: 'inventory', - synchronize: false, + synchronize: true, logging: true, entities: [__dirname + '/../entity/*.ts'], migrations: ['src/db/migrations/**/*.ts'], diff --git a/src/db/seeds/area.seeder.ts b/src/db/seeds/area.seeder.ts index eed3091..6adb8ec 100644 --- a/src/db/seeds/area.seeder.ts +++ b/src/db/seeds/area.seeder.ts @@ -11,15 +11,20 @@ export default class AreaSeeder implements Seeder { const areaRepository = dataSource.getRepository(Area); const areas = [ - { name: 'Australia' }, - { name: 'Canada' }, - { name: 'United States' }, - { name: 'Germany' }, - { name: 'Poland' }, + { name: 'Australia', code: 'AU' }, + { name: 'Canada', code: 'CA' }, + { name: 'United States', code: 'US' }, + { name: 'Germany', code: 'DE' }, + { name: 'Poland', code: 'PL' }, ]; for (const areaData of areas) { - const existingArea = await areaRepository.findOne({ where: { name: areaData.name } }); + const existingArea = await areaRepository.findOne({ + where: [ + { name: areaData.name }, + { code: areaData.code } + ] + }); if (!existingArea) { const newArea = areaRepository.create(areaData); await areaRepository.save(newArea); diff --git a/src/db/seeds/category.seeder.ts b/src/db/seeds/category.seeder.ts new file mode 100644 index 0000000..d7ab329 --- /dev/null +++ b/src/db/seeds/category.seeder.ts @@ -0,0 +1,39 @@ +import { Seeder } from 'typeorm-extension'; +import { DataSource } from 'typeorm'; +import { Category } from '../../entity/category.entity'; + +export default class CategorySeeder implements Seeder { + public async run( + dataSource: DataSource, + ): Promise { + const repository = dataSource.getRepository(Category); + + const categories = [ + { + name: 'nicotine-pouches', + title: 'Nicotine Pouches', + titleCN: '尼古丁袋', + sort: 1 + }, + { + name: 'vape', + title: 'vape', + titleCN: '电子烟', + sort: 2 + }, + { + name: 'pouches-can', + title: 'Pouches Can', + titleCN: ' nicotine 袋', + sort: 3 + }, + ]; + + for (const cat of categories) { + const existing = await repository.findOne({ where: { name: cat.name } }); + if (!existing) { + await repository.save(cat); + } + } + } +} diff --git a/src/db/seeds/category_attribute.seeder.ts b/src/db/seeds/category_attribute.seeder.ts new file mode 100644 index 0000000..6f11ea5 --- /dev/null +++ b/src/db/seeds/category_attribute.seeder.ts @@ -0,0 +1,62 @@ +import { Seeder } from 'typeorm-extension'; +import { DataSource } from 'typeorm'; +import { Dict } from '../../entity/dict.entity'; +import { Category } from '../../entity/category.entity'; +import { CategoryAttribute } from '../../entity/category_attribute.entity'; + +export default class CategoryAttributeSeeder implements Seeder { + public async run( + dataSource: DataSource, + ): Promise { + const dictRepository = dataSource.getRepository(Dict); + const categoryRepository = dataSource.getRepository(Category); + const categoryAttributeRepository = dataSource.getRepository(CategoryAttribute); + + // 1. 确保属性字典存在 + const attributeNames = ['brand', 'strength', 'flavor', 'size', 'humidity']; + const attributeDicts: Dict[] = []; + + for (const name of attributeNames) { + let dict = await dictRepository.findOne({ where: { name } }); + if (!dict) { + dict = new Dict(); + dict.name = name; + dict.title = name.charAt(0).toUpperCase() + name.slice(1); + dict.deletable = false; + dict = await dictRepository.save(dict); + console.log(`Created Dict: ${name}`); + } + attributeDicts.push(dict); + } + + // 2. 获取 'nicotine-pouches' 分类 (由 CategorySeeder 创建) + const nicotinePouchesCategory = await categoryRepository.findOne({ + where: { + name: 'nicotine-pouches' + } + }); + + if (!nicotinePouchesCategory) { + console.warn('Category "nicotine-pouches" not found. Skipping attribute linking. Please ensure CategorySeeder runs first.'); + return; + } + + // 3. 绑定属性到 'nicotine-pouches' 分类 + for (const attrDict of attributeDicts) { + const existing = await categoryAttributeRepository.findOne({ + where: { + category: { id: nicotinePouchesCategory.id }, + attributeDict: { id: attrDict.id } + } + }); + + if (!existing) { + const link = new CategoryAttribute(); + link.category = nicotinePouchesCategory; + link.attributeDict = attrDict; + await categoryAttributeRepository.save(link); + console.log(`Linked ${attrDict.name} to ${nicotinePouchesCategory.name}`); + } + } + } +} diff --git a/src/db/seeds/dict.seeder.ts b/src/db/seeds/dict.seeder.ts index 37ec8ee..35610b8 100644 --- a/src/db/seeds/dict.seeder.ts +++ b/src/db/seeds/dict.seeder.ts @@ -22,79 +22,77 @@ export default class DictSeeder implements Seeder { const dictItemRepository = dataSource.getRepository(DictItem); const flavorsData = [ - { title: 'Bellini', name: 'bellini' }, - { title: 'Max Polarmint', name: 'max-polarmint' }, - { title: 'Blueberry', name: 'blueberry' }, - { title: 'Citrus', name: 'citrus' }, - { title: 'Wintergreen', name: 'wintergreen' }, - { title: 'COOL MINT', name: 'cool-mint' }, - { title: 'JUICY PEACH', name: 'juicy-peach' }, - { title: 'ORANGE', name: 'orange' }, - { title: 'PEPPERMINT', name: 'peppermint' }, - { title: 'SPEARMINT', name: 'spearmint' }, - { title: 'STRAWBERRY', name: 'strawberry' }, - { title: 'WATERMELON', name: 'watermelon' }, - { title: 'COFFEE', name: 'coffee' }, - { title: 'LEMONADE', name: 'lemonade' }, - { title: 'apple mint', name: 'apple-mint' }, - { title: 'PEACH', name: 'peach' }, - { title: 'Mango', name: 'mango' }, - { title: 'ICE WINTERGREEN', name: 'ice-wintergreen' }, - { title: 'Pink Lemonade', name: 'pink-lemonade' }, - { title: 'Blackcherry', name: 'blackcherry' }, - { title: 'fresh mint', name: 'fresh-mint' }, - { title: 'Strawberry Lychee', name: 'strawberry-lychee' }, - { title: 'Passion Fruit', name: 'passion-fruit' }, - { title: 'Banana lce', name: 'banana-lce' }, - { title: 'Bubblegum', name: 'bubblegum' }, - { title: 'Mango lce', name: 'mango-lce' }, - { title: 'Grape lce', name: 'grape-lce' }, - { title: 'apple', name: 'apple' }, - { title: 'grape', name: 'grape' }, - { title: 'cherry', name: 'cherry' }, - { title: 'lemon', name: 'lemon' }, - { title: 'razz', name: 'razz' }, - { title: 'pineapple', name: 'pineapple' }, - { title: 'berry', name: 'berry' }, - { title: 'fruit', name: 'fruit' }, - { title: 'mint', name: 'mint' }, - { title: 'menthol', name: 'menthol' }, + { name: 'bellini', title: 'Bellini', titleCn: '贝利尼' }, + { name: 'max-polarmint', title: 'Max Polarmint', titleCn: '马克斯薄荷' }, + { name: 'blueberry', title: 'Blueberry', titleCn: '蓝莓' }, + { name: 'citrus', title: 'Citrus', titleCn: '柑橘' }, + { name: 'wintergreen', title: 'Wintergreen', titleCn: '冬绿薄荷' }, + { name: 'cool-mint', title: 'COOL MINT', titleCn: '清凉薄荷' }, + { name: 'juicy-peach', title: 'JUICY PEACH', titleCn: '多汁蜜桃' }, + { name: 'orange', title: 'ORANGE', titleCn: '橙子' }, + { name: 'peppermint', title: 'PEPPERMINT', titleCn: '胡椒薄荷' }, + { name: 'spearmint', title: 'SPEARMINT', titleCn: '绿薄荷' }, + { name: 'strawberry', title: 'STRAWBERRY', titleCn: '草莓' }, + { name: 'watermelon', title: 'WATERMELON', titleCn: '西瓜' }, + { name: 'coffee', title: 'COFFEE', titleCn: '咖啡' }, + { name: 'lemonade', title: 'LEMONADE', titleCn: '柠檬水' }, + { name: 'apple-mint', title: 'apple mint', titleCn: '苹果薄荷' }, + { name: 'peach', title: 'PEACH', titleCn: '桃子' }, + { name: 'mango', title: 'Mango', titleCn: '芒果' }, + { name: 'ice-wintergreen', title: 'ICE WINTERGREEN', titleCn: '冰冬绿薄荷' }, + { name: 'pink-lemonade', title: 'Pink Lemonade', titleCn: '粉红柠檬水' }, + { name: 'blackcherry', title: 'Blackcherry', titleCn: '黑樱桃' }, + { name: 'fresh-mint', title: 'fresh mint', titleCn: '清新薄荷' }, + { name: 'strawberry-lychee', title: 'Strawberry Lychee', titleCn: '草莓荔枝' }, + { name: 'passion-fruit', title: 'Passion Fruit', titleCn: '百香果' }, + { name: 'banana-lce', title: 'Banana lce', titleCn: '香蕉冰' }, + { name: 'bubblegum', title: 'Bubblegum', titleCn: '泡泡糖' }, + { name: 'mango-lce', title: 'Mango lce', titleCn: '芒果冰' }, + { name: 'grape-lce', title: 'Grape lce', titleCn: '葡萄冰' }, + { name: 'apple', title: 'apple', titleCn: '苹果' }, + { name: 'grape', title: 'grape', titleCn: '葡萄' }, + { name: 'cherry', title: 'cherry', titleCn: '樱桃' }, + { name: 'lemon', title: 'lemon', titleCn: '柠檬' }, + { name: 'razz', title: 'razz', titleCn: '覆盆子' }, + { name: 'pineapple', title: 'pineapple', titleCn: '菠萝' }, + { name: 'berry', title: 'berry', titleCn: '浆果' }, + { name: 'fruit', title: 'fruit', titleCn: '水果' }, + { name: 'mint', title: 'mint', titleCn: '薄荷' }, + { name: 'menthol', title: 'menthol', titleCn: '薄荷醇' }, ]; const brandsData = [ - { title: 'Yoone', name: 'yoone' }, - { title: 'White Fox', name: 'white-fox' }, - { title: 'ZYN', name: 'zyn' }, - { title: 'Zonnic', name: 'zonnic' }, - { title: 'Zolt', name: 'zolt' }, - { title: 'Velo', name: 'velo' }, - { title: 'Lucy', name: 'lucy' }, - { title: 'EGP', name: 'egp' }, - { title: 'Bridge', name: 'bridge' }, - { title: 'ZEX', name: 'zex' }, - { title: 'Sesh', name: 'sesh' }, - { title: 'Pablo', name: 'pablo' }, + { name: 'yoone', title: 'Yoone', titleCn: '' }, + { name: 'white-fox', title: 'White Fox', titleCn: '' }, + { name: 'zyn', title: 'ZYN', titleCn: '' }, + { name: 'zonnic', title: 'Zonnic', titleCn: '' }, + { name: 'zolt', title: 'Zolt', titleCn: '' }, + { name: 'velo', title: 'Velo', titleCn: '' }, + { name: 'lucy', title: 'Lucy', titleCn: '' }, + { name: 'egp', title: 'EGP', titleCn: '' }, + { name: 'bridge', title: 'Bridge', titleCn: '' }, + { name: 'zex', title: 'ZEX', titleCn: '' }, + { name: 'sesh', title: 'Sesh', titleCn: '' }, + { name: 'pablo', title: 'Pablo', titleCn: '' }, ]; const strengthsData = [ - { title: '3MG', name: '3mg' }, - { title: '9MG', name: '9mg' }, - { title: '2MG', name: '2mg' }, - { title: '4MG', name: '4mg' }, - { title: '12MG', name: '12mg' }, - { title: '18MG', name: '18mg' }, - { title: '6MG', name: '6mg' }, - { title: '16.5MG', name: '16-5mg' }, - { title: '6.5MG', name: '6-5mg' }, - { title: '30MG', name: '30mg' }, + { name: '2mg', title: '2MG', titleCn: '2毫克' }, + { name: '4mg', title: '4MG', titleCn: '4毫克' }, + { name: '3mg', title: '3MG', titleCn: '3毫克' }, + { name: '6mg', title: '6MG', titleCn: '6毫克' }, + { name: '6.5mg', title: '6.5MG', titleCn: '6.5毫克' }, + { name: '9mg', title: '9MG', titleCn: '9毫克' }, + { name: '12mg', title: '12MG', titleCn: '12毫克' }, + { name: '16.5mg', title: '16.5MG', titleCn: '16.5毫克' }, + { name: '18mg', title: '18MG', titleCn: '18毫克' }, + { name: '30mg', title: '30MG', titleCn: '30毫克' }, ]; - const nonFlavorTokensData = ['slim', 'pouches', 'pouch', 'mini', 'dry'].map(item => ({ title: item, name: item })); - // 初始化语言字典 const locales = [ - { name: 'zh-cn', title: '简体中文' }, - { name: 'en-us', title: 'English' }, + { name: 'zh-cn', title: '简体中文', titleCn: '简体中文' }, + { name: 'en-us', title: 'English', titleCn: '英文' }, ]; for (const locale of locales) { @@ -116,20 +114,19 @@ export default class DictSeeder implements Seeder { // 添加中文翻译 let item = await dictItemRepository.findOne({ where: { name: t.name, dict: { id: zhDict.id } } }); if (!item) { - await dictItemRepository.save({ name: t.name, title: t.zh, dict: zhDict }); + await dictItemRepository.save({ name: t.name, title: t.zh, titleCn: t.zh, dict: zhDict }); } // 添加英文翻译 item = await dictItemRepository.findOne({ where: { name: t.name, dict: { id: enDict.id } } }); if (!item) { - await dictItemRepository.save({ name: t.name, title: t.en, dict: enDict }); + await dictItemRepository.save({ name: t.name, title: t.en, titleCn: t.en, dict: enDict }); } } - const brandDict = await this.createOrFindDict(dictRepository, { title: '品牌', name: 'brand' }); - const flavorDict = await this.createOrFindDict(dictRepository, { title: '口味', name: 'flavor' }); - const strengthDict = await this.createOrFindDict(dictRepository, { title: '强度', name: 'strength' }); - const nonFlavorTokensDict = await this.createOrFindDict(dictRepository, { title: '非口味关键词', name: 'non-flavor-tokens' }); + const brandDict = await this.createOrFindDict(dictRepository, { name: 'brand', title: '品牌', titleCn: '品牌' }); + const flavorDict = await this.createOrFindDict(dictRepository, { name: 'flavor', title: '口味', titleCn: '口味' }); + const strengthDict = await this.createOrFindDict(dictRepository, { name: 'strength', title: '强度', titleCn: '强度' }); // 遍历品牌数据 await this.seedDictItems(dictItemRepository, brandDict, brandsData); @@ -139,9 +136,6 @@ export default class DictSeeder implements Seeder { // 遍历强度数据 await this.seedDictItems(dictItemRepository, strengthDict, strengthsData); - - // 遍历非口味关键词数据 - await this.seedDictItems(dictItemRepository, nonFlavorTokensDict, nonFlavorTokensData); } /** @@ -150,13 +144,13 @@ export default class DictSeeder implements Seeder { * @param dictInfo 字典信息 * @returns Dict 实例 */ - private async createOrFindDict(repo: any, dictInfo: { title: string; name: string }): Promise { + private async createOrFindDict(repo: any, dictInfo: { name: string; title: string; titleCn: string }): Promise { // 格式化 name const formattedName = this.formatName(dictInfo.name); let dict = await repo.findOne({ where: { name: formattedName } }); if (!dict) { // 如果字典不存在,则使用格式化后的 name 创建新字典 - dict = await repo.save({ title: dictInfo.title, name: formattedName }); + dict = await repo.save({ name: formattedName, title: dictInfo.title, titleCn: dictInfo.titleCn }); } return dict; } @@ -167,14 +161,14 @@ export default class DictSeeder implements Seeder { * @param dict 字典实例 * @param items 字典项数组 */ - private async seedDictItems(repo: any, dict: Dict, items: { title: string; name: string }[]): Promise { + private async seedDictItems(repo: any, dict: Dict, items: { name: string; title: string; titleCn: string }[]): Promise { for (const item of items) { // 格式化 name const formattedName = this.formatName(item.name); const existingItem = await repo.findOne({ where: { name: formattedName, dict: { id: dict.id } } }); if (!existingItem) { // 如果字典项不存在,则使用格式化后的 name 创建新字典项 - await repo.save({ ...item, name: formattedName, dict }); + await repo.save({ name: formattedName, title: item.title, titleCn: item.titleCn, dict }); } } } diff --git a/src/dto/product.dto.ts b/src/dto/product.dto.ts index f3f9d5b..ad314d2 100644 --- a/src/dto/product.dto.ts +++ b/src/dto/product.dto.ts @@ -1,6 +1,31 @@ import { ApiProperty } from '@midwayjs/swagger'; import { Rule, RuleType } from '@midwayjs/validate'; +/** + * 属性输入DTO + */ +export class AttributeInputDTO { + @ApiProperty({ description: '属性字典标识', example: 'brand' }) + @Rule(RuleType.string()) + dictName?: string; + + @ApiProperty({ description: '属性值', example: 'ZYN' }) + @Rule(RuleType.string()) + value?: string; + + @ApiProperty({ description: '属性ID', example: 1 }) + @Rule(RuleType.number()) + id?: number; + + @ApiProperty({ description: '属性名称', example: 'ZYN' }) + @Rule(RuleType.string()) + name?: string; + + @ApiProperty({ description: '属性显示名称', example: 'ZYN' }) + @Rule(RuleType.string()) + title?: string; +} + /** * DTO 用于创建产品 */ @@ -21,6 +46,10 @@ export class CreateProductDTO { @Rule(RuleType.string()) sku?: string; + @ApiProperty({ description: '分类ID (DictItem ID)', required: false }) + @Rule(RuleType.number()) + categoryId?: number; + // 通用属性输入(中文注释:通过 attributes 统一提交品牌/口味/强度/尺寸/干湿等) @ApiProperty({ description: '属性列表', type: 'array' }) @Rule(RuleType.array().required()) @@ -75,6 +104,10 @@ export class UpdateProductDTO { @Rule(RuleType.string()) sku?: string; + @ApiProperty({ description: '分类ID (DictItem ID)', required: false }) + @Rule(RuleType.number()) + categoryId?: number; + // 商品价格 @ApiProperty({ description: '价格', example: 99.99, required: false }) @Rule(RuleType.number()) @@ -96,223 +129,58 @@ export class UpdateProductDTO { type?: string; } +/** + * DTO 用于创建分类属性绑定 + */ +export class CreateCategoryAttributeDTO { + @ApiProperty({ description: '分类字典项ID', example: 1 }) + @Rule(RuleType.number().required()) + categoryItemId: number; + + @ApiProperty({ description: '属性字典ID列表', example: [2, 3] }) + @Rule(RuleType.array().items(RuleType.number()).required()) + attributeDictIds: number[]; +} + /** * DTO 用于分页查询产品 */ export class QueryProductDTO { - @ApiProperty({ example: '1', description: '页码' }) - @Rule(RuleType.number()) + @ApiProperty({ description: '当前页', example: 1 }) + @Rule(RuleType.number().default(1)) current: number; - @ApiProperty({ example: '10', description: '每页大小' }) - @Rule(RuleType.number()) + @ApiProperty({ description: '每页数量', example: 10 }) + @Rule(RuleType.number().default(10)) pageSize: number; - @ApiProperty({ example: 'ZYN', description: '关键字' }) - @Rule(RuleType.string()) - name: string; - - @ApiProperty({ example: '1', description: '品牌 ID' }) - @Rule(RuleType.string()) - brandId: number; -} - -// 属性输入项(中文注释:用于在创建/更新产品时传递字典项信息) -export class AttributeInputDTO { - @ApiProperty({ description: '字典名称', example: 'brand', required: false}) - @Rule(RuleType.string()) - dictName?: string; - - @ApiProperty({ description: '字典项 ID', required: false }) - @Rule(RuleType.number()) - id?: number; - - @ApiProperty({ description: '字典项显示名称', required: false }) - @Rule(RuleType.string()) - title?: string; - - @ApiProperty({ description: '字典项唯一标识', required: false }) + @ApiProperty({ description: '搜索关键字', required: false }) @Rule(RuleType.string()) name?: string; + + @ApiProperty({ description: '分类ID', required: false }) + @Rule(RuleType.number()) + categoryId?: number; + + @ApiProperty({ description: '品牌ID', required: false }) + @Rule(RuleType.number()) + brandId?: number; } /** - * DTO 用于创建品牌 + * DTO 用于设置产品组成 */ -export class CreateBrandDTO { - @ApiProperty({ example: 'ZYN', description: '品牌名称', required: true }) - @Rule(RuleType.string().required().empty({ message: '品牌名称不能为空' })) - title: string; - - @ApiProperty({ example: 'ZYN', description: '品牌唯一标识', required: true }) - @Rule(RuleType.string().required().empty({ message: 'key不能为空' })) - name: string; -} - -/** - * DTO 用于更新品牌 - */ -export class UpdateBrandDTO { - @ApiProperty({ example: 'ZYN', description: '品牌名称' }) - @Rule(RuleType.string()) - title: string; - - @ApiProperty({ example: 'ZYN', description: '品牌唯一标识' }) - @Rule(RuleType.string()) - name: string; -} - -/** - * DTO 用于查询品牌(支持分页) - */ -export class QueryBrandDTO { - @ApiProperty({ example: '1', description: '页码' }) - @Rule(RuleType.number()) - current: number; // 页码 - - @ApiProperty({ example: '10', description: '每页大小' }) - @Rule(RuleType.number()) - pageSize: number; // 每页大小 - - @ApiProperty({ example: 'ZYN', description: '关键字' }) - @Rule(RuleType.string()) - name: string; // 搜索关键字(支持模糊查询) -} - -export class CreateFlavorsDTO { - @ApiProperty({ example: 'WINTERGREEN', description: '口味名称', required: true }) - @Rule(RuleType.string().required().empty({ message: '口味名称不能为空' })) - title: string; - - @ApiProperty({ - example: 'WINTERGREEN', - description: '口味唯一标识', - required: true, - }) - @Rule(RuleType.string().required().empty({ message: 'key不能为空' })) - name: string; -} - -export class UpdateFlavorsDTO { - @ApiProperty({ example: 'WINTERGREEN', description: '口味名称' }) - @Rule(RuleType.string()) - title: string; - - @ApiProperty({ example: 'WINTERGREEN', description: '口味唯一标识' }) - @Rule(RuleType.string()) - name: string; -} - -export class QueryFlavorsDTO { - @ApiProperty({ example: '1', description: '页码' }) - @Rule(RuleType.number()) - current: number; // 页码 - - @ApiProperty({ example: '10', description: '每页大小' }) - @Rule(RuleType.number()) - pageSize: number; // 每页大小 - - @ApiProperty({ example: 'ZYN', description: '关键字' }) - @Rule(RuleType.string()) - name: string; // 搜索关键字(支持模糊查询) -} - -export class CreateStrengthDTO { - @ApiProperty({ example: '6MG', description: '规格名称', required: false }) - @Rule(RuleType.string()) - title?: string; - - @ApiProperty({ example: '6MG', description: '规格唯一标识', required: true }) - @Rule(RuleType.string().required().empty({ message: 'key不能为空' })) - name: string; -} - -export class UpdateStrengthDTO { - @ApiProperty({ example: '6MG', description: '规格名称' }) - @Rule(RuleType.string()) - title: string; - - @ApiProperty({ example: '6MG', description: '规格唯一标识' }) - @Rule(RuleType.string()) - name: string; -} - -export class QueryStrengthDTO { - @ApiProperty({ example: '1', description: '页码' }) - @Rule(RuleType.number()) - current: number; // 页码 - - @ApiProperty({ example: '10', description: '每页大小' }) - @Rule(RuleType.number()) - pageSize: number; // 每页大小 - - @ApiProperty({ example: 'YOONE', description: '关键字' }) - @Rule(RuleType.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 { - @ApiProperty({ description: '产品 ID' }) - productId: number; - - @ApiProperty({ description: 'sku 编码' }) - sku: string; -} - -export class BatchSetSkuDTO { - @ApiProperty({ description: 'sku 数据列表', type: [SkuItemDTO] }) - skus: SkuItemDTO[]; -} - -// 中文注释:产品库存组成项输入 -export class ProductComponentItemDTO { - @ApiProperty({ description: '组件 SKU' }) - @Rule(RuleType.string().required()) - sku: string; - - @ApiProperty({ description: '组成数量', example: 1 }) - @Rule(RuleType.number().min(1).default(1)) - quantity: number; -} - -// 中文注释:设置产品库存组成输入 export class SetProductComponentsDTO { - @ApiProperty({ description: '组成项列表', type: [ProductComponentItemDTO] }) - @Rule(RuleType.array().items(RuleType.object())) - items: ProductComponentItemDTO[]; + @ApiProperty({ description: '产品组成', type: 'array', required: true }) + @Rule( + RuleType.array() + .items( + RuleType.object({ + sku: RuleType.string().required(), + quantity: RuleType.number().required(), + }) + ) + .required() + ) + components: { sku: string; quantity: number }[]; } diff --git a/src/dto/site.dto.ts b/src/dto/site.dto.ts index 39f4f6f..58f3e63 100644 --- a/src/dto/site.dto.ts +++ b/src/dto/site.dto.ts @@ -44,6 +44,11 @@ export class CreateSiteDTO { type?: string; @Rule(RuleType.string().optional()) skuPrefix?: string; + + // 区域 + @ApiProperty({ description: '区域' }) + @Rule(RuleType.array().items(RuleType.string()).optional()) + areas?: string[]; } export class UpdateSiteDTO { @@ -61,6 +66,11 @@ export class UpdateSiteDTO { type?: string; @Rule(RuleType.string().optional()) skuPrefix?: string; + + // 区域 + @ApiProperty({ description: '区域' }) + @Rule(RuleType.array().items(RuleType.string()).optional()) + areas?: string[]; } export class QuerySiteDTO { diff --git a/src/dto/stock.dto.ts b/src/dto/stock.dto.ts index 9585d6d..639ea6d 100644 --- a/src/dto/stock.dto.ts +++ b/src/dto/stock.dto.ts @@ -167,6 +167,11 @@ export class CreateStockPointDTO { @ApiProperty() @Rule(RuleType.string()) contactPhone: string; + + // 区域 + @ApiProperty({ description: '区域' }) + @Rule(RuleType.array().items(RuleType.string()).optional()) + areas?: string[]; } export class UpdateStockPointDTO extends CreateStockPointDTO {} diff --git a/src/entity/category.entity.ts b/src/entity/category.entity.ts new file mode 100644 index 0000000..c0c86ae --- /dev/null +++ b/src/entity/category.entity.ts @@ -0,0 +1,39 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, OneToMany } from 'typeorm'; +import { ApiProperty } from '@midwayjs/swagger'; +import { Product } from './product.entity'; +import { CategoryAttribute } from './category_attribute.entity'; + +@Entity('category') +export class Category { + @ApiProperty({ description: 'ID' }) + @PrimaryGeneratedColumn() + id: number; + + @ApiProperty({ description: '分类显示名称' }) + @Column() + title: string; + + @ApiProperty({ description: '分类中文名称' }) + @Column({ nullable: true }) + titleCN: string; + + @ApiProperty({ description: '分类唯一标识' }) + @Column({ unique: true }) + name: string; + + @ApiProperty({ description: '排序' }) + @Column({ default: 0 }) + sort: number; + + @OneToMany(() => Product, product => product.category) + products: Product[]; + + @OneToMany(() => CategoryAttribute, categoryAttribute => categoryAttribute.category) + attributes: CategoryAttribute[]; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/entity/category_attribute.entity.ts b/src/entity/category_attribute.entity.ts new file mode 100644 index 0000000..830c087 --- /dev/null +++ b/src/entity/category_attribute.entity.ts @@ -0,0 +1,26 @@ +import { Entity, PrimaryGeneratedColumn, ManyToOne, JoinColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { Category } from './category.entity'; +import { Dict } from './dict.entity'; +import { ApiProperty } from '@midwayjs/swagger'; + +@Entity() +export class CategoryAttribute { + @PrimaryGeneratedColumn() + id: number; + + @ApiProperty({ description: '分类' }) + @ManyToOne(() => Category, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'category_id' }) + category: Category; + + @ApiProperty({ description: '关联的属性字典' }) + @ManyToOne(() => Dict, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'attribute_dict_id' }) + attributeDict: Dict; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/entity/product.entity.ts b/src/entity/product.entity.ts index eaef800..2d26865 100644 --- a/src/entity/product.entity.ts +++ b/src/entity/product.entity.ts @@ -7,10 +7,13 @@ import { ManyToMany, JoinTable, OneToMany, + ManyToOne, + JoinColumn, } from 'typeorm'; import { ApiProperty } from '@midwayjs/swagger'; import { DictItem } from './dict_item.entity'; import { ProductStockComponent } from './product_stock_component.entity'; +import { Category } from './category.entity'; @Entity() export class Product { @@ -64,6 +67,12 @@ export class Product { @Column({ default: 0 }) stock: number; + + // 分类关联 + @ManyToOne(() => Category, category => category.products) + @JoinColumn({ name: 'categoryId' }) + category: Category; + @ManyToMany(() => DictItem, dictItem => dictItem.products, { cascade: true, }) diff --git a/src/service/category.service.ts b/src/service/category.service.ts new file mode 100644 index 0000000..ec0b939 --- /dev/null +++ b/src/service/category.service.ts @@ -0,0 +1,105 @@ +import { Provide } from '@midwayjs/core'; +import { InjectEntityModel } from '@midwayjs/typeorm'; +import { Repository, Like, In } from 'typeorm'; +import { Category } from '../entity/category.entity'; +import { CategoryAttribute } from '../entity/category_attribute.entity'; +import { Dict } from '../entity/dict.entity'; + +@Provide() +export class CategoryService { + @InjectEntityModel(Category) + categoryModel: Repository; + + @InjectEntityModel(CategoryAttribute) + categoryAttributeModel: Repository; + + @InjectEntityModel(Dict) + dictModel: Repository; + + async getAll() { + return await this.categoryModel.find({ + order: { + sort: 'DESC', + createdAt: 'DESC' + } + }); + } + + async getList(options: { current?: number; pageSize?: number }, name?: string) { + const { current = 1, pageSize = 10 } = options; + const where = name ? [ + { title: Like(`%${name}%`) }, + { name: Like(`%${name}%`) } + ] : []; + + const [list, total] = await this.categoryModel.findAndCount({ + where: where.length ? where : undefined, + skip: (current - 1) * pageSize, + take: pageSize, + order: { + sort: 'DESC', + createdAt: 'DESC' + } + }); + return { list, total }; + } + + async create(data: Partial) { + return await this.categoryModel.save(data); + } + + async update(id: number, data: Partial) { + await this.categoryModel.update(id, data); + return await this.categoryModel.findOneBy({ id }); + } + + async delete(id: number) { + return await this.categoryModel.delete(id); + } + + async findByName(name: string) { + return await this.categoryModel.findOneBy({ name }); + } + + async getCategoryAttributes(categoryId: number) { + const categoryAttributes = await this.categoryAttributeModel.find({ + where: { category: { id: categoryId } }, + relations: ['attributeDict', 'category'], + }); + + return categoryAttributes.map(ca => ca.attributeDict); + } + + async createCategoryAttribute(categoryId: number, attributeDictIds: number[]) { + const category = await this.categoryModel.findOneBy({ id: categoryId }); + if (!category) throw new Error('分类不存在'); + + const dicts = await this.dictModel.findBy({ id: In(attributeDictIds) }); + if (dicts.length !== attributeDictIds.length) throw new Error('部分属性字典不存在'); + + // 检查是否已存在 + const exist = await this.categoryAttributeModel.find({ + where: { + category: { id: categoryId }, + attributeDict: { id: In(attributeDictIds) } + }, + relations: ['attributeDict'] + }); + + const existIds = exist.map(e => e.attributeDict.id); + const newIds = attributeDictIds.filter(id => !existIds.includes(id)); + + const newRecords = newIds.map(id => { + const record = new CategoryAttribute(); + record.category = category; + record.attributeDict = dicts.find(d => d.id === id); + return record; + }); + + return await this.categoryAttributeModel.save(newRecords); + } + + async deleteCategoryAttribute(id: number) { + return await this.categoryAttributeModel.delete(id); + } +} diff --git a/src/service/product.service.ts b/src/service/product.service.ts index 1074650..e72bd9e 100644 --- a/src/service/product.service.ts +++ b/src/service/product.service.ts @@ -4,16 +4,8 @@ import { Product } from '../entity/product.entity'; import { paginate } from '../utils/paginate.util'; import { PaginationParams } from '../interface'; import { - CreateBrandDTO, - CreateFlavorsDTO, CreateProductDTO, - CreateStrengthDTO, - CreateSizeDTO, - UpdateBrandDTO, - UpdateFlavorsDTO, UpdateProductDTO, - UpdateStrengthDTO, - UpdateSizeDTO, } from '../dto/product.dto'; import { BrandPaginatedResponse, @@ -33,6 +25,7 @@ import { StockService } from './stock.service'; import { Stock } from '../entity/stock.entity'; import { StockPoint } from '../entity/stock_point.entity'; import { ProductStockComponent } from '../entity/product_stock_component.entity'; +import { Category } from '../entity/category.entity'; @Provide() export class ProductService { @@ -69,6 +62,17 @@ export class ProductService { @InjectEntityModel(ProductStockComponent) productStockComponentModel: Repository; + @InjectEntityModel(Category) + categoryModel: Repository; + + + // 获取所有 WordPress 商品 + async getWpProducts() { + return this.wpProductModel.find(); + } + + + // async findProductsByName(name: string): Promise { // const where: any = {}; // const nameFilter = name ? name.split(' ').filter(Boolean) : []; @@ -89,7 +93,8 @@ export class ProductService { async findProductsByName(name: string): Promise { const nameFilter = name ? name.split(' ').filter(Boolean) : []; - const query = this.productModel.createQueryBuilder('product'); + const query = this.productModel.createQueryBuilder('product') + .leftJoinAndSelect('product.category', 'category'); // 保证 sku 不为空 query.where('product.sku IS NOT NULL'); @@ -128,6 +133,7 @@ export class ProductService { where: { sku, }, + relations: ['category', 'attributes', 'attributes.dict'] }); } @@ -139,7 +145,8 @@ export class ProductService { const qb = this.productModel .createQueryBuilder('product') .leftJoinAndSelect('product.attributes', 'attribute') - .leftJoinAndSelect('attribute.dict', 'dict'); + .leftJoinAndSelect('attribute.dict', 'dict') + .leftJoinAndSelect('product.category', 'category'); // 模糊搜索 name,支持多个关键词 const nameFilter = name ? name.split(' ').filter(Boolean) : []; @@ -231,16 +238,41 @@ export class ProductService { } async createProduct(createProductDTO: CreateProductDTO): Promise { - const { name, description, attributes, sku, price } = createProductDTO; + const { name, description, attributes, sku, price, categoryId } = createProductDTO; // 条件判断(中文注释:校验属性输入) if (!Array.isArray(attributes) || attributes.length === 0) { - throw new Error('属性列表不能为空'); + // 如果提供了 categoryId 但没有 attributes,初始化为空数组 + if (!attributes && categoryId) { + // 继续执行,下面会处理 categoryId + } else { + throw new Error('属性列表不能为空'); + } } + + const safeAttributes = attributes || []; // 解析属性输入(中文注释:按 id 或 dictName 创建/关联字典项) const resolvedAttributes: DictItem[] = []; - for (const attr of attributes) { + let categoryItem: Category | null = null; + + // 如果提供了 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 }); + } + continue; + } + let item: DictItem | null = null; if (attr.id) { // 中文注释:如果传入了 id,直接查找字典项并使用,不强制要求 dictName @@ -274,6 +306,9 @@ export class ProductService { product.name = name; product.description = description; product.attributes = resolvedAttributes; + if (categoryItem) { + product.category = categoryItem; + } // 条件判断(中文注释:设置商品类型,默认 simple) product.type = (createProductDTO.type as any) || 'single'; @@ -310,7 +345,7 @@ export class ProductService { updateProductDTO: UpdateProductDTO ): Promise { // 检查产品是否存在(包含属性关系) - const product = await this.productModel.findOne({ where: { id }, relations: ['attributes', 'attributes.dict'] }); + const product = await this.productModel.findOne({ where: { id }, relations: ['attributes', 'attributes.dict', 'category'] }); if (!product) { throw new Error(`产品 ID ${id} 不存在`); } @@ -322,6 +357,16 @@ export class ProductService { if (updateProductDTO.description !== undefined) { product.description = updateProductDTO.description; } + 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,可以清除分类(根据需求) + // product.category = null; + } + } if (updateProductDTO.price !== undefined) { product.price = Number(updateProductDTO.price); } @@ -350,6 +395,15 @@ 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 }); + if (categoryItem) product.category = categoryItem; + } + continue; + } + let item: DictItem | null = null; if (attr.id) { // 中文注释:当提供 id 时直接查询字典项,不强制要求 dictName @@ -629,7 +683,7 @@ export class ProductService { return this.dictItemModel.find({ where: { dict: { id: brandDict.id } } }); } - async createBrand(createBrandDTO: CreateBrandDTO): Promise { + async createBrand(createBrandDTO: any): Promise { const { title, name } = createBrandDTO; // 查找 'brand' 字典 @@ -652,7 +706,7 @@ export class ProductService { return await this.dictItemModel.save(brand); } - async updateBrand(id: number, updateBrand: UpdateBrandDTO) { + async updateBrand(id: number, updateBrand: any) { // 确认品牌是否存在 const brand = await this.dictItemModel.findOneBy({ id }); if (!brand) { @@ -712,7 +766,7 @@ export class ProductService { return this.dictItemModel.find({ where: { dict: { id: flavorsDict.id } } }); } - async createFlavors(createFlavorsDTO: CreateFlavorsDTO): Promise { + async createFlavors(createFlavorsDTO: any): Promise { const { title, name } = createFlavorsDTO; const flavorsDict = await this.dictModel.findOne({ where: { name: 'flavor' }, @@ -727,7 +781,7 @@ export class ProductService { return await this.dictItemModel.save(flavors); } - async updateFlavors(id: number, updateFlavors: UpdateFlavorsDTO) { + async updateFlavors(id: number, updateFlavors: any) { const flavors = await this.dictItemModel.findOneBy({ id }); if (!flavors) { throw new Error(`口味 ID ${id} 不存在`); @@ -779,7 +833,7 @@ export class ProductService { return this.dictItemModel.find({ where: { dict: { id: sizeDict.id } } }) as any; } - async createSize(createSizeDTO: CreateSizeDTO): Promise { + async createSize(createSizeDTO: any): Promise { const { title, name } = createSizeDTO; // 获取 size 字典(中文注释:用于挂载尺寸项) const sizeDict = await this.dictModel.findOne({ where: { name: 'size' } }); @@ -795,7 +849,7 @@ export class ProductService { return await this.dictItemModel.save(size); } - async updateSize(id: number, updateSize: UpdateSizeDTO) { + async updateSize(id: number, updateSize: any) { // 先查询(中文注释:确保尺寸项存在) const size = await this.dictItemModel.findOneBy({ id }); // 条件判断(中文注释:不存在则报错) @@ -866,7 +920,7 @@ export class ProductService { return this.dictItemModel.find({ where: { dict: { id: strengthDict.id } } }); } - async createStrength(createStrengthDTO: CreateStrengthDTO): Promise { + async createStrength(createStrengthDTO: any): Promise { const { title, name } = createStrengthDTO; const strengthDict = await this.dictModel.findOne({ where: { name: 'strength' }, @@ -950,7 +1004,7 @@ export class ProductService { await this.dictItemModel.delete({ id }); } - async updateStrength(id: number, updateStrength: UpdateStrengthDTO) { + async updateStrength(id: number, updateStrength: any) { const strength = await this.dictItemModel.findOneBy({ id }); if (!strength) { throw new Error(`规格 ID ${id} 不存在`); diff --git a/src/service/site.service.ts b/src/service/site.service.ts index edd2d83..6ae7fbd 100644 --- a/src/service/site.service.ts +++ b/src/service/site.service.ts @@ -3,7 +3,8 @@ import { InjectEntityModel } from '@midwayjs/typeorm'; import { Repository, Like, In } from 'typeorm'; import { Site } from '../entity/site.entity'; import { WpSite } from '../interface'; -import { UpdateSiteDTO } from '../dto/site.dto'; +import { CreateSiteDTO, UpdateSiteDTO } from '../dto/site.dto'; +import { Area } from '../entity/area.entity'; @Provide() @Scope(ScopeEnum.Singleton) @@ -11,11 +12,16 @@ export class SiteService { @InjectEntityModel(Site) siteModel: Repository; + @InjectEntityModel(Area) + areaModel: Repository; + async syncFromConfig(sites: WpSite[] = []) { // 将配置中的 WpSite 同步到数据库 Site 表(用于一次性导入或初始化) for (const siteConfig of sites) { // 按站点名称查询是否已存在记录 - const exist = await this.siteModel.findOne({ where: { name: siteConfig.name } }); + const exist = await this.siteModel.findOne({ + where: { name: siteConfig.name }, + }); // 将 WpSite 字段映射为 Site 实体字段 const payload: Partial = { name: siteConfig.name, @@ -25,66 +31,145 @@ export class SiteService { type: 'woocommerce', }; // 存在则更新,不存在则插入新记录 - if (exist) await this.siteModel.update({ id: exist.id }, payload); - else await this.siteModel.insert(payload as Site); + if (exist) { + await this.siteModel.update({ id: exist.id }, payload); + } else { + await this.siteModel.insert(payload as Site); + } } } - async create(data: Partial) { - // 创建新的站点记录 - await this.siteModel.insert(data as Site); + async create(data: CreateSiteDTO) { + // 从 DTO 中分离出区域代码和其他站点数据 + const { areas: areaCodes, ...restData } = data; + const newSite = new Site(); + Object.assign(newSite, restData); + + // 如果传入了区域代码,则查询并关联 Area 实体 + if (areaCodes && areaCodes.length > 0) { + const areas = await this.areaModel.findBy({ + code: In(areaCodes), + }); + newSite.areas = areas; + } else { + // 如果没有传入区域,则关联一个空数组,代表“全局” + newSite.areas = []; + } + + // 使用 save 方法保存实体及其关联关系 + await this.siteModel.save(newSite); return true; } async update(id: string | number, data: UpdateSiteDTO) { - // 更新指定站点记录,将布尔 isDisabled 转换为数值 0/1 + // 从 DTO 中分离出区域代码和其他站点数据 + const { areas: areaCodes, ...restData } = data; + + // 首先,根据 ID 查找要更新的站点实体 + const siteToUpdate = await this.siteModel.findOne({ + where: { id: Number(id) }, + }); + if (!siteToUpdate) { + // 如果找不到站点,则操作失败 + return false; + } + + // 更新站点的基本字段 const payload: Partial = { - ...data, + ...restData, isDisabled: - data.isDisabled === undefined // 未传入则不更新该字段 + data.isDisabled === undefined ? undefined - : data.isDisabled // true -> 1, false -> 0 + : data.isDisabled ? 1 : 0, } as any; - await this.siteModel.update({ id: Number(id) }, payload); + Object.assign(siteToUpdate, payload); + + // 如果 DTO 中传入了 areas 字段(即使是空数组),也要更新关联关系 + if (areaCodes !== undefined) { + if (areaCodes.length > 0) { + // 如果区域代码数组不为空,则查找并更新关联 + const areas = await this.areaModel.findBy({ + code: In(areaCodes), + }); + siteToUpdate.areas = areas; + } else { + // 如果传入空数组,则清空所有关联,代表“全局” + siteToUpdate.areas = []; + } + } + + // 使用 save 方法保存实体及其更新后的关联关系 + await this.siteModel.save(siteToUpdate); return true; } async get(id: string | number, includeSecret = false) { - // 根据主键获取站点;includeSecret 为 true 时返回密钥字段 - const site = await this.siteModel.findOne({ where: { id: Number(id) } }); - if (!site) return null; - if (includeSecret) return site; + // 根据主键获取站点,并使用 relations 加载关联的 areas + const site = await this.siteModel.findOne({ + where: { id: Number(id) }, + relations: ['areas'], + }); + if (!site) { + return null; + } + // 如果需要包含密钥,则直接返回 + if (includeSecret) { + return site; + } // 默认不返回密钥,进行字段脱敏 const { consumerKey, consumerSecret, ...rest } = site; return rest; } - - async list(param: { current?: number; pageSize?: number; keyword?: string; isDisabled?: boolean; ids?: string }, includeSecret = false) { + + async list( + param: { + current?: number; + pageSize?: number; + keyword?: string; + isDisabled?: boolean; + ids?: string; + }, + includeSecret = false + ) { // 分页查询站点列表,支持关键字、禁用状态与 ID 列表过滤 - 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 = {}; // 按名称模糊查询 - if (keyword) where.name = 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) { // 解析逗号分隔的 ID 字符串为数字数组,并过滤非法值 const numIds = String(ids) .split(',') .filter(Boolean) - .map((i) => Number(i)) - .filter((v) => !Number.isNaN(v)); - if (numIds.length > 0) where.id = In(numIds); + .map(i => Number(i)) + .filter(v => !Number.isNaN(v)); + if (numIds.length > 0) { + where.id = In(numIds); + } } - // 进行分页查询(skip/take)并返回总条数 - const [items, total] = await this.siteModel.findAndCount({ where, skip: (current - 1) * pageSize, take: pageSize }); - // 根据 includeSecret 决定是否脱敏返回密钥字段 - const data = includeSecret ? items : items.map((item: any) => { - const { consumerKey, consumerSecret, ...rest } = item; - return rest; + // 进行分页查询,并使用 relations 加载关联的 areas + const [items, total] = await this.siteModel.findAndCount({ + where, + skip: (current - 1) * pageSize, + take: pageSize, + relations: ['areas'], }); + // 根据 includeSecret 决定是否脱敏返回密钥字段 + const data = includeSecret + ? items + : items.map((item: any) => { + const { consumerKey, consumerSecret, ...rest } = item; + return rest; + }); return { items: data, total, current, pageSize }; } diff --git a/src/service/stock.service.ts b/src/service/stock.service.ts index 44d688f..2adc686 100644 --- a/src/service/stock.service.ts +++ b/src/service/stock.service.ts @@ -1,5 +1,5 @@ import { Provide } from '@midwayjs/core'; -import { Between, Like, Repository, LessThan, MoreThan } from 'typeorm'; +import { Between, Like, Repository, LessThan, MoreThan, In } from 'typeorm'; import { Stock } from '../entity/stock.entity'; import { StockRecord } from '../entity/stock_record.entity'; import { paginate } from '../utils/paginate.util'; @@ -27,6 +27,7 @@ import { User } from '../entity/user.entity'; import dayjs = require('dayjs'); import { Transfer } from '../entity/transfer.entity'; import { TransferItem } from '../entity/transfer_item.entity'; +import { Area } from '../entity/area.entity'; @Provide() export class StockService { @@ -51,35 +52,55 @@ export class StockService { @InjectEntityModel(TransferItem) transferItemModel: Repository; + @InjectEntityModel(Area) + areaModel: Repository; + async createStockPoint(data: CreateStockPointDTO) { - const { name, location, contactPerson, contactPhone } = data; + const { areas: areaCodes, ...restData } = data; const stockPoint = new StockPoint(); - stockPoint.name = name; - stockPoint.location = location; - stockPoint.contactPerson = contactPerson; - stockPoint.contactPhone = contactPhone; + Object.assign(stockPoint, restData); + + if (areaCodes && areaCodes.length > 0) { + const areas = await this.areaModel.findBy({ code: In(areaCodes) }); + stockPoint.areas = areas; + } else { + stockPoint.areas = []; + } + await this.stockPointModel.save(stockPoint); } async updateStockPoint(id: number, data: UpdateStockPointDTO) { - // 确认产品是否存在 - const point = await this.stockPointModel.findOneBy({ id }); - if (!point) { - throw new Error(`产品 ID ${id} 不存在`); + const { areas: areaCodes, ...restData } = data; + const pointToUpdate = await this.stockPointModel.findOneBy({ id }); + if (!pointToUpdate) { + throw new Error(`仓库点 ID ${id} 不存在`); } - // 更新产品 - await this.stockPointModel.update(id, data); + + Object.assign(pointToUpdate, restData); + + if (areaCodes !== undefined) { + if (areaCodes.length > 0) { + const areas = await this.areaModel.findBy({ code: In(areaCodes) }); + pointToUpdate.areas = areas; + } else { + pointToUpdate.areas = []; + } + } + + await this.stockPointModel.save(pointToUpdate); } async getStockPoints(query: QueryPointDTO) { const { current = 1, pageSize = 10 } = query; return await paginate(this.stockPointModel, { pagination: { current, pageSize }, + relations: ['areas'], }); } async getAllStockPoints(): Promise { - return await this.stockPointModel.find(); + return await this.stockPointModel.find({ relations: ['areas'] }); } async delStockPoints(id: number) { diff --git a/test_db_count.ts b/test_db_count.ts new file mode 100644 index 0000000..25ec183 --- /dev/null +++ b/test_db_count.ts @@ -0,0 +1,35 @@ + +import { App, IMidwayApplication, Inject } from '@midwayjs/core'; +import { Framework } from '@midwayjs/koa'; +import { Bootstrap } from '@midwayjs/bootstrap'; +import { Product } from './src/entity/product.entity'; +import { WpProduct } from './src/entity/wp_product.entity'; +import { Repository } from 'typeorm'; +import { InjectEntityModel } from '@midwayjs/typeorm'; + +@App() +class TestApp { + @InjectEntityModel(Product) + productModel: Repository; + + @InjectEntityModel(WpProduct) + wpProductModel: Repository; + + async run() { + if (!this.productModel || !this.wpProductModel) { + console.log('Models not injected'); + return; + } + const productCount = await this.productModel.count(); + const wpProductCount = await this.wpProductModel.count(); + console.log(`Product Count: ${productCount}`); + console.log(`WpProduct Count: ${wpProductCount}`); + } +} + +Bootstrap.run().then(async () => { + const app = await Bootstrap.getApplication() as IMidwayApplication; + const testApp = await app.getApplicationContext().getAsync(TestApp); + await testApp.run(); + process.exit(0); +});