feat(area): 重构区域模块,使用i18n-iso-countries管理国家数据

refactor: 统一将productName字段重命名为name
chore: 添加i18n-iso-countries依赖
style: 优化字典名称格式化逻辑
This commit is contained in:
tikkhun 2025-12-01 23:53:12 +08:00
parent 10b42eca7a
commit f20f4727f6
15 changed files with 186 additions and 224 deletions

19
package-lock.json generated
View File

@ -27,6 +27,7 @@
"class-transformer": "^0.5.1",
"csv-parse": "^6.1.0",
"dayjs": "^1.11.13",
"i18n-iso-countries": "^7.14.0",
"mysql2": "^3.15.3",
"nodemailer": "^7.0.5",
"npm-check-updates": "^19.1.2",
@ -2044,6 +2045,12 @@
"wrappy": "1"
}
},
"node_modules/diacritics": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/diacritics/-/diacritics-1.3.0.tgz",
"integrity": "sha512-wlwEkqcsaxvPJML+rDh/2iS824jbREk6DUMUKkEaSlxdYHeS43cClJtsWglvw2RfeXGm6ohKDqsXteJ5sP5enA==",
"license": "MIT"
},
"node_modules/dir-glob": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/dir-glob/-/dir-glob-3.0.1.tgz",
@ -2691,6 +2698,18 @@
"node": ">= 0.8"
}
},
"node_modules/i18n-iso-countries": {
"version": "7.14.0",
"resolved": "https://registry.npmjs.org/i18n-iso-countries/-/i18n-iso-countries-7.14.0.tgz",
"integrity": "sha512-nXHJZYtNrfsi1UQbyRqm3Gou431elgLjKl//CYlnBGt5aTWdRPH1PiS2T/p/n8Q8LnqYqzQJik3Q7mkwvLokeg==",
"license": "MIT",
"dependencies": {
"diacritics": "1.3.0"
},
"engines": {
"node": ">= 12"
}
},
"node_modules/iconv-lite": {
"version": "0.7.0",
"resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.0.tgz",

View File

@ -22,6 +22,7 @@
"class-transformer": "^0.5.1",
"csv-parse": "^6.1.0",
"dayjs": "^1.11.13",
"i18n-iso-countries": "^7.14.0",
"mysql2": "^3.15.3",
"nodemailer": "^7.0.5",
"npm-check-updates": "^19.1.2",

View File

@ -1,15 +1,5 @@
import { Inject } from '@midwayjs/core';
import {
Body,
Controller,
Del,
Get,
Param,
Post,
Put,
Query,
} from '@midwayjs/decorator';
import { Body, Context, Controller, Del, Get, Inject, Param, Post, Put, Query } from '@midwayjs/core';
import {
ApiBearerAuth,
ApiBody,
@ -22,14 +12,39 @@ import { AreaService } from '../service/area.service';
import { CreateAreaDTO, QueryAreaDTO, UpdateAreaDTO } from '../dto/area.dto';
import { errorResponse, successResponse } from '../utils/response.util';
import { Area } from '../entity/area.entity';
import * as countries from 'i18n-iso-countries';
@ApiBearerAuth()
@ApiTags('Area')
@Controller('/api/area')
@Controller('/area')
export class AreaController {
@Inject()
ctx: Context;
@Inject()
areaService: AreaService;
@ApiOperation({ summary: '获取国家列表' })
@ApiOkResponse({ description: '国家列表' })
@Get('/countries')
async getCountries() {
try {
// 注册中文语言包
countries.registerLocale(require('i18n-iso-countries/langs/zh.json'));
// 获取所有国家的中文名称
const countryNames = countries.getNames('zh', { select: 'official' });
// 格式化为 { code, name } 的数组
const countryList = Object.keys(countryNames).map(code => ({
code,
name: countryNames[code],
}));
return successResponse(countryList, '查询成功');
} catch (error) {
console.log(error);
return errorResponse(error?.message || error);
}
}
@ApiOperation({ summary: '创建区域' })
@ApiBody({ type: CreateAreaDTO })
@ApiOkResponse({ type: Area, description: '成功创建的区域' })
@ -39,7 +54,8 @@ export class AreaController {
const newArea = await this.areaService.createArea(area);
return successResponse(newArea, '创建成功');
} catch (error) {
return errorResponse(error.message);
console.log(error);
return errorResponse(error?.message || error);
}
}
@ -52,7 +68,8 @@ export class AreaController {
const updatedArea = await this.areaService.updateArea(id, area);
return successResponse(updatedArea, '更新成功');
} catch (error) {
return errorResponse(error.message);
console.log(error);
return errorResponse(error?.message || error);
}
}
@ -64,7 +81,8 @@ export class AreaController {
await this.areaService.deleteArea(id);
return successResponse(null, '删除成功');
} catch (error) {
return errorResponse(error.message);
console.log(error);
return errorResponse(error?.message || error);
}
}
@ -77,19 +95,8 @@ export class AreaController {
const { list, total } = await this.areaService.getAreaList(query);
return successResponse({ list, total }, '查询成功');
} catch (error) {
return errorResponse(error.message);
}
}
@ApiOperation({ summary: '获取所有区域' })
@ApiOkResponse({ type: [Area], description: '所有区域列表' })
@Get('/all')
async getAllAreas() {
try {
const areas = await this.areaService.getAllAreas();
return successResponse(areas, '查询成功');
} catch (error) {
return errorResponse(error.message);
console.log(error);
return errorResponse(error?.message || error);
}
}
@ -104,7 +111,8 @@ export class AreaController {
}
return successResponse(area, '查询成功');
} catch (error) {
return errorResponse(error.message);
console.log(error);
return errorResponse(error?.message || error);
}
}
}

View File

@ -134,9 +134,9 @@ export class ProductController {
@ApiOkResponse({ type: ProductRes })
@Put('updateNameCn/:id/:nameCn')
async updateProductNameCn(@Param('id') id: number, @Param('nameCn') nameCn: string) {
async updatenameCn(@Param('id') id: number, @Param('nameCn') nameCn: string) {
try {
const data = this.productService.updateProductNameCn(id, nameCn);
const data = this.productService.updatenameCn(id, nameCn);
return successResponse(data);
} catch (error) {
return errorResponse(error?.message || error);

View File

@ -8,18 +8,21 @@ export default class AreaSeeder implements Seeder {
dataSource: DataSource,
factoryManager: SeederFactoryManager
): Promise<any> {
const repository = dataSource.getRepository(Area);
const areaRepository = dataSource.getRepository(Area);
const areas = [
{ name: '加拿大' },
{ name: '澳大利亚' },
{ name: '欧洲' },
{ name: 'Australia' },
{ name: 'Canada' },
{ name: 'United States' },
{ name: 'Germany' },
{ name: 'Poland' },
];
for (const area of areas) {
const existing = await repository.findOne({ where: { name: area.name } });
if (!existing) {
await repository.insert(area);
for (const areaData of areas) {
const existingArea = await areaRepository.findOne({ where: { name: areaData.name } });
if (!existingArea) {
const newArea = areaRepository.create(areaData);
await areaRepository.save(newArea);
}
}
}

View File

@ -10,8 +10,9 @@ export default class DictSeeder implements Seeder {
* @returns
*/
private formatName(name: string): string {
// 将空格、下划线、点统一转换成中划线,并转为小写
return String(name).replace(/[_\s.]+/g, '-').toLowerCase();
// return String(name).replace(/[\_\s.]+/g, '-').toLowerCase();
// 只替换空格和下划线
return String(name).replace(/[\_\s]+/g, '-').toLowerCase();
}
public async run(

View File

@ -2,47 +2,28 @@
import { ApiProperty } from '@midwayjs/swagger';
import { Rule, RuleType } from '@midwayjs/validate';
// 创建区域的数据传输对象
export class CreateAreaDTO {
@ApiProperty({ type: 'string', description: '区域名称', example: '欧洲' })
@ApiProperty({ description: '编码' })
@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;
code: string;
}
// 更新区域的数据传输对象
export class UpdateAreaDTO {
@ApiProperty({ type: 'string', description: '区域名称', example: '欧洲' })
@ApiProperty({ description: '编码', required: false })
@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;
code?: string;
}
// 查询区域的数据传输对象
export class QueryAreaDTO {
@ApiProperty({ type: 'number', description: '当前页码', example: 1 })
@Rule(RuleType.number().min(1).default(1))
currentPage: number;
@ApiProperty({ description: '当前页', required: false, default: 1 })
@Rule(RuleType.number().integer().min(1).default(1))
currentPage?: number;
@ApiProperty({ type: 'number', description: '每页数量', example: 10 })
@Rule(RuleType.number().min(1).max(100).default(10))
pageSize: number;
@ApiProperty({ description: '每页数量', required: false, default: 10 })
@Rule(RuleType.number().integer().min(1).default(10))
pageSize?: number;
@ApiProperty({ type: 'string', description: '区域名称', example: '欧洲' })
@ApiProperty({ description: '关键词(名称或编码)', required: false })
@Rule(RuleType.string())
name?: string;
keyword?: string;
}

View File

@ -20,7 +20,7 @@ export class QueryStockDTO {
@ApiProperty()
@Rule(RuleType.string())
productName: string;
name: string;
@ApiProperty()
@Rule(RuleType.string())
@ -62,7 +62,7 @@ export class QueryStockRecordDTO {
@ApiProperty()
@Rule(RuleType.string())
productName: string;
name: string;
@ApiProperty()
@Rule(RuleType.string())
@ -96,7 +96,7 @@ export class QueryPurchaseOrderDTO {
export class StockDTO extends Stock {
@ApiProperty()
@Rule(RuleType.string())
productName: string;
name: string;
@ApiProperty({
type: 'object',

View File

@ -1,43 +1,17 @@
import { ApiProperty } from '@midwayjs/swagger';
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
@Entity('area')
export class Area {
@ApiProperty({ type: 'number' })
@PrimaryGeneratedColumn()
id: number;
@ApiProperty({ type: 'string', description: '区域名称' })
@Column({ unique: true })
@ApiProperty({ description: '名称' })
@Column()
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: '创建时间',
required: true,
})
@CreateDateColumn()
createdAt: Date;
@ApiProperty({
example: '2022-12-12 11:11:11',
description: '更新时间',
required: true,
})
@UpdateDateColumn()
updatedAt: Date;
@ApiProperty({ description: '编码' })
@Column({ unique: true })
code: string;
}

View File

@ -14,7 +14,7 @@ export class PurchaseOrderItem {
@ApiProperty({ type: String })
@Column()
productName: string;
name: string;
@ApiProperty({ type: Number })
@Column()

View File

@ -13,7 +13,7 @@ export class TransferItem {
@ApiProperty({ type: String })
@Column()
productName: string;
name: string;
@ApiProperty({ type: Number })
@Column()

View File

@ -1,102 +1,80 @@
import { Provide } from '@midwayjs/core';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository } from 'typeorm';
import { Like, Repository } from 'typeorm';
import { Area } from '../entity/area.entity';
import { CreateAreaDTO, QueryAreaDTO, UpdateAreaDTO } from '../dto/area.dto';
import * as countries from 'i18n-iso-countries';
@Provide()
export class AreaService {
@InjectEntityModel(Area)
areaModel: Repository<Area>;
areaRepository: Repository<Area>;
/**
*
* @param params
*/
async createArea(params: CreateAreaDTO) {
// 检查区域名称是否已存在
const existing = await this.areaModel.findOne({ where: { name: params.name } });
if (existing) {
throw new Error('区域名称已存在');
}
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);
constructor() {
// 在服务初始化时注册中文语言包
countries.registerLocale(require('i18n-iso-countries/langs/zh.json'));
}
/**
*
* @param id ID
* @param params
*/
async updateArea(id: number, params: UpdateAreaDTO) {
const area = await this.areaModel.findOneBy({ id });
if (!area) {
throw new Error('区域不存在');
}
if (params.name) {
// 检查新的区域名称是否已存在
const existing = await this.areaModel.findOne({ where: { name: params.name } });
if (existing && existing.id !== id) {
throw new Error('区域名称已存在');
}
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);
}
/**
*
* @param id ID
*/
async deleteArea(id: number) {
const result = await this.areaModel.delete(id);
return result.affected > 0;
}
/**
*
* @param query
*/
async getAreaList(query: QueryAreaDTO) {
const { currentPage, pageSize, name } = query;
const [list, total] = await this.areaModel.findAndCount({
where: name ? { name } : {},
const { currentPage = 1, pageSize = 10, keyword = '' } = query;
const [list, total] = await this.areaRepository.findAndCount({
where: [{ name: Like(`%${keyword}%`) }, { code: Like(`%${keyword}%`) }],
skip: (currentPage - 1) * pageSize,
take: pageSize,
order: {
id: 'DESC',
},
});
return { list, total };
}
/**
*
*/
async getAllAreas() {
return await this.areaModel.find();
async getAreaById(id: number) {
return this.areaRepository.findOne({ where: { id } });
}
/**
* ID获取区域详情
* @param id ID
*/
async getAreaById(id: number) {
return await this.areaModel.findOneBy({ id });
async createArea(createAreaDTO: CreateAreaDTO) {
// 根据 code 获取国家中文名称
const name = countries.getName(createAreaDTO.code, 'zh', {
select: 'official',
});
// 如果找不到对应的国家,则抛出错误
if (!name) {
throw new Error(`无效的国家代码: ${createAreaDTO.code}`);
}
const area = new Area();
area.name = name;
area.code = createAreaDTO.code;
return this.areaRepository.save(area);
}
async updateArea(id: number, updateAreaDTO: UpdateAreaDTO) {
const area = await this.getAreaById(id);
if (!area) {
return null;
}
// 如果 code 发生变化,则更新 name
if (updateAreaDTO.code && updateAreaDTO.code !== area.code) {
const name = countries.getName(updateAreaDTO.code, 'zh', {
select: 'official',
});
if (!name) {
throw new Error(`无效的国家代码: ${updateAreaDTO.code}`);
}
area.name = name;
area.code = updateAreaDTO.code;
}
return this.areaRepository.save(area);
}
async deleteArea(id: number) {
const area = await this.getAreaById(id);
if (!area) {
return false;
}
await this.areaRepository.remove(area);
return true;
}
}

View File

@ -9,7 +9,7 @@ import * as xlsx from 'xlsx';
@Provide()
export class DictService {
@InjectEntityModel(Dict)
dictModel: Repository<Dict>;
@ -18,7 +18,8 @@ export class DictService {
// 格式化名称为 kebab-case
private formatName(name: string): string {
return String(name).replace(/[_\s.]+/g, '-').toLowerCase();
// 只替换空格和下划线
return String(name).replace(/[_\s]+/g, '-').toLowerCase();
}
// 生成并返回字典的XLSX模板

View File

@ -509,7 +509,7 @@ export class ProductService {
// 重复定义的 getProductList 已合并到前面的实现(中文注释:移除重复)
async updateProductNameCn(id: number, nameCn: string): Promise<Product> {
async updatenameCn(id: number, nameCn: string): Promise<Product> {
// 确认产品是否存在
const product = await this.productModel.findOneBy({ id });
if (!product) {

View File

@ -167,7 +167,7 @@ export class StockService {
qb
.select([
'poi.purchaseOrderId AS purchaseOrderId',
"JSON_ARRAYAGG(JSON_OBJECT('id', poi.id, 'productName', poi.productName,'sku', poi.sku, 'quantity', poi.quantity, 'price', poi.price)) AS items",
"JSON_ARRAYAGG(JSON_OBJECT('id', poi.id, 'name', poi.name,'sku', poi.sku, 'quantity', poi.quantity, 'price', poi.price)) AS items",
])
.from(PurchaseOrderItem, 'poi')
.groupBy('poi.purchaseOrderId'),
@ -240,9 +240,9 @@ export class StockService {
// 获取库存列表
async getStocks(query: QueryStockDTO) {
const { current = 1, pageSize = 10, productName, sku } = query;
const nameKeywords = productName
? productName.split(' ').filter(Boolean)
const { current = 1, pageSize = 10, name, sku } = query;
const nameKeywords = name
? name.split(' ').filter(Boolean)
: [];
let queryBuilder = this.stockModel
@ -250,8 +250,8 @@ export class StockService {
.select([
// 'stock.id as id',
'stock.sku as sku',
'product.name as productName',
'product.nameCn as productNameCn',
'product.name as name',
'product.nameCn as nameCn',
'JSON_ARRAYAGG(JSON_OBJECT("id", stock.stockPointId, "quantity", stock.quantity)) as stockPoint',
'MIN(stock.updatedAt) as updatedAt',
'MAX(stock.createdAt) as createdAt',
@ -264,33 +264,29 @@ export class StockService {
.createQueryBuilder('stock')
.select('COUNT(DISTINCT stock.sku)', 'count')
.leftJoin(Product, 'product', 'product.sku = stock.sku');
if (sku) {
queryBuilder.andWhere('stock.sku = :sku', { sku });
totalQueryBuilder.andWhere('stock.sku = :sku', { sku });
}
if (nameKeywords.length) {
nameKeywords.forEach((name, index) => {
queryBuilder.andWhere(
`EXISTS (
SELECT 1 FROM product p
WHERE p.sku = stock.sku
AND p.name LIKE :name${index}
)`,
{ [`name${index}`]: `%${name}%` }
);
totalQueryBuilder.andWhere(
`EXISTS (
SELECT 1 FROM product p
WHERE p.sku = stock.sku
AND p.name LIKE :name${index}
)`,
{ [`name${index}`]: `%${name}%` }
);
if (sku || nameKeywords.length) {
const conditions = [];
if (sku) {
conditions.push(`stock.sku LIKE :sku`);
}
if (nameKeywords.length) {
nameKeywords.forEach((name, index) => {
conditions.push(`product.name LIKE :name${index}`);
});
}
const whereClause = conditions.join(' OR ');
queryBuilder.andWhere(`(${whereClause})`, {
sku: `%${sku}%`,
...nameKeywords.reduce((acc, name, index) => ({ ...acc, [`name${index}`]: `%${name}%` }), {}),
});
totalQueryBuilder.andWhere(`(${whereClause})`, {
sku: `%${sku}%`,
...nameKeywords.reduce((acc, name, index) => ({ ...acc, [`name${index}`]: `%${name}%` }), {}),
});
}
if (query.order) {
const sortFieldMap: Record<string, string> = {
productName: 'product.name',
name: 'product.name',
sku: 'stock.sku',
updatedAt: 'updatedAt',
createdAt: 'createdAt',
@ -422,7 +418,7 @@ export class StockService {
pageSize = 10,
stockPointId,
sku,
productName,
name,
operationType,
startDate,
endDate,
@ -447,9 +443,9 @@ export class StockService {
'sp.name as stockPointName',
])
.where(where);
if (productName)
if (name)
queryBuilder.andWhere('product.name LIKE :name', {
name: `%${productName}%`,
name: `%${name}%`,
});
const items = await queryBuilder
.orderBy('stock_record.createdAt', 'DESC')