diff --git a/.env.development b/.env.development index 89c8c66..673dab6 100644 --- a/.env.development +++ b/.env.development @@ -13,7 +13,7 @@ SWAGGER_PATH = api-docs SWAGGER_VERSION = 1.0 # db -DB_HOST = 127.0.0.1 +DB_HOST = 192.168.60.39 DB_PORT = 13307 DB_DATABASE = hxoa DB_USERNAME = root @@ -23,7 +23,7 @@ DB_LOGGING = "all" # redis REDIS_PORT = 6379 -REDIS_HOST = 127.0.0.1 +REDIS_HOST = 192.168.60.39 REDIS_PASSWORD = 123456 REDIS_DB = 0 diff --git a/src/constants/enum/index.ts b/src/constants/enum/index.ts index 7cc6079..257a9dd 100644 --- a/src/constants/enum/index.ts +++ b/src/constants/enum/index.ts @@ -21,7 +21,9 @@ export enum MaterialsInOrOutEnum { // 系统参数key export enum ParamConfigEnum { - MaterialsInOutPrefix = 'materials_in_out_prefix' + InventoryNumberPrefixIn = 'inventory_number_prefix_in', + InventoryNumberPrefixOut = 'inventory_number_prefix_out', + ProductNumberPrefix = 'product_number_prefix' } // 合同审核状态 diff --git a/src/constants/error-code.constant.ts b/src/constants/error-code.constant.ts index beb178b..0b6be78 100644 --- a/src/constants/error-code.constant.ts +++ b/src/constants/error-code.constant.ts @@ -57,7 +57,8 @@ export enum ErrorEnum { // Contract CONTRACT_NUMBER_EXIST = '1407:存在相同的合同编号', - // Inventory + // Inventory INVENTORY_INSUFFICIENT = '1408:库存不足', MATERIALS_IN_OUT_NOT_FOUND = '1409:出入库信息不存在', + MATERIALS_IN_OUT_UNIT_PRICE_CANNOT_BE_MODIFIED = '1410:该价格的产品已经出库,单价不允许修改。若有疑问,请联系管理员' } diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 25e72a9..4e2a593 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -46,11 +46,6 @@ export class AuthController { @ApiSecurityAuth() @ApiOperation({ summary: '屏幕解锁,使用密码和token' }) @ApiResult({ type: LoginToken }) - - - - - async unlock(@Body() dto: LoginDto, @AuthUser() user: IAuthUser): Promise { await this.authService.unlock(user.uid, dto.password); return true; diff --git a/src/modules/materials_inventory/in_out/materials_in_out.dto.ts b/src/modules/materials_inventory/in_out/materials_in_out.dto.ts index 8244b59..767a29c 100644 --- a/src/modules/materials_inventory/in_out/materials_in_out.dto.ts +++ b/src/modules/materials_inventory/in_out/materials_in_out.dto.ts @@ -13,7 +13,8 @@ import { IsString, Matches, MinLength, - ValidateIf + ValidateIf, + isNumber } from 'class-validator'; import dayjs from 'dayjs'; import { PagerDto } from '~/common/dto/pager.dto'; @@ -37,6 +38,11 @@ export class MaterialsInOutDto { @IsString() inventoryNumber: string; + @ApiProperty({ description: '库存id(产品和单价双主键决定一条库存)' }) + @IsOptional() + @IsNumber() + inventoryId: number; + @ApiProperty({ description: '单位(字典)' }) @IsNumber() @IsOptional() diff --git a/src/modules/materials_inventory/in_out/materials_in_out.entity.ts b/src/modules/materials_inventory/in_out/materials_in_out.entity.ts index 5351eb2..eb6ab98 100644 --- a/src/modules/materials_inventory/in_out/materials_in_out.entity.ts +++ b/src/modules/materials_inventory/in_out/materials_in_out.entity.ts @@ -39,7 +39,7 @@ export class MaterialsInOutEntity extends CommonEntity { productId: number; @Column({ - name: 'inOrOut', + name: 'in_or_out', type: 'tinyint', comment: '入库或出库' }) diff --git a/src/modules/materials_inventory/in_out/materials_in_out.service.ts b/src/modules/materials_inventory/in_out/materials_in_out.service.ts index aea3e81..617a80d 100644 --- a/src/modules/materials_inventory/in_out/materials_in_out.service.ts +++ b/src/modules/materials_inventory/in_out/materials_in_out.service.ts @@ -49,12 +49,17 @@ export class MaterialsInOutService { .leftJoin('materialsInOut.project', 'project') .leftJoin('materialsInOut.product', 'product') .leftJoin('product.unit', 'unit') + .leftJoin('product.files', 'productFiles') .leftJoin('product.company', 'company') .addSelect([ 'files.id', 'files.path', 'project.name', 'product.name', + 'product.productSpecification', + 'product.productNumber', + 'productFiles.id', + 'productFiles.path', 'unit.label', 'company.name' ]) @@ -84,21 +89,8 @@ export class MaterialsInOutService { * 新增 */ async create(dto: MaterialsInOutDto): Promise { - let { inOrOut, inventoryNumber, projectId } = dto; - if (Object.is(inOrOut, MaterialsInOrOutEnum.In)) { - // 入库 - inventoryNumber = await this.generateInventoryNumber(); - } else { - // 出库 - const inRecord = await this.materialsInOutRepository.findOne({ - where: { - inventoryNumber - } - }); - const { productId } = inRecord; - dto.productId = productId; - } - + let { inOrOut, inventoryNumber, projectId, inventoryId } = dto; + inventoryNumber = await this.generateInventoryNumber(inOrOut); await this.entityManager.transaction(async manager => { // 1.生成出入库记录 const { productId, quantity, unitPrice } = await manager.save(MaterialsInOutEntity, { @@ -110,7 +102,7 @@ export class MaterialsInOutService { Object.is(inOrOut, MaterialsInOrOutEnum.In) ? this.materialsInventoryService.inInventory : this.materialsInventoryService.outInventory - )({ productId, quantity, unitPrice, projectId }, manager); + )({ productId, quantity, unitPrice, projectId, inventoryId }, manager); }); } @@ -125,9 +117,41 @@ export class MaterialsInOutService { }, lock: { mode: 'pessimistic_write' } }); - await manager.update(MaterialsInOutEntity, id, { - ...data - }); + + // 修改入库记录的价格,会直接更改库存实际价格. + // 1.如果有了出库记录,不允许修改入库价格。 + if ( + Object.is(data.inOrOut, MaterialsInOrOutEnum.In) && + isDefined(data.unitPrice) && + Math.abs(Number(data.unitPrice) - Number(entity.unitPrice)) !== 0 + ) { + const outEntity = await manager.findOne(MaterialsInOutEntity, { + where: { + unitPrice: entity.unitPrice, + inOrOut: MaterialsInOrOutEnum.Out, + projectId: entity.projectId, + productId: entity.productId + } + }); + if (isDefined(outEntity)) { + throw new BusinessException(ErrorEnum.MATERIALS_IN_OUT_UNIT_PRICE_CANNOT_BE_MODIFIED); + } + await ( + Object.is(data.inOrOut, MaterialsInOrOutEnum.In) + ? this.materialsInventoryService.inInventory + : this.materialsInventoryService.outInventory + )( + { + productId: entity.productId, + quantity: 0, + unitPrice: entity.unitPrice, + projectId: entity.projectId, + changedUnitPrice: data.unitPrice + }, + manager + ); + } + let changedQuantity = 0; if (isDefined(data.quantity) && entity.quantity !== data.quantity) { if (entity.inOrOut === MaterialsInOrOutEnum.In) { @@ -165,6 +189,12 @@ export class MaterialsInOutService { manager ); } + + // 完成所有业务逻辑后,更新出入库记录 + await manager.update(MaterialsInOutEntity, id, { + ...data + }); + if (fileIds?.length) { const count = await this.storageRepository .createQueryBuilder('storage') @@ -208,7 +238,7 @@ export class MaterialsInOutService { { productId: entity.productId, quantity: entity.quantity, - unitPrice: undefined, + unitPrice: entity.unitPrice, projectId: entity.projectId }, manager @@ -261,12 +291,14 @@ export class MaterialsInOutService { * 生成库存单号 * @returns 库存单号 */ - async generateInventoryNumber() { + async generateInventoryNumber(inOrOut: MaterialsInOrOutEnum = MaterialsInOrOutEnum.In) { const prefix = ( await this.paramConfigRepository.findOne({ where: { - key: ParamConfigEnum.MaterialsInOutPrefix + key: inOrOut + ? ParamConfigEnum.InventoryNumberPrefixOut + : ParamConfigEnum.InventoryNumberPrefixIn } }) )?.value || ''; @@ -276,6 +308,7 @@ export class MaterialsInOutService { `MAX(CAST(REPLACE(materialsInOut.inventoryNumber, '${prefix}', '') AS UNSIGNED))`, 'maxInventoryNumber' ) + .where('materialsInOut.inOrOut = :inOrOut', { inOrOut }) .getRawOne(); const lastNumber = lastMaterial.maxInventoryNumber ? parseInt(lastMaterial.maxInventoryNumber.replace(prefix, '')) diff --git a/src/modules/materials_inventory/materials_inventory.service.ts b/src/modules/materials_inventory/materials_inventory.service.ts index 09ea420..1a144c3 100644 --- a/src/modules/materials_inventory/materials_inventory.service.ts +++ b/src/modules/materials_inventory/materials_inventory.service.ts @@ -62,7 +62,15 @@ export class MaterialsInventoryService { .leftJoin('mio.product', 'product') .leftJoin('product.unit', 'unit') .leftJoin('product.company', 'company') - .addSelect(['project.id', 'project.name', 'product.name', 'unit.label', 'company.name']) + .addSelect([ + 'project.id', + 'project.name', + 'unit.label', + 'company.name', + 'product.name', + 'product.productSpecification', + 'product.productNumber' + ]) .where({ time: MoreThan(time[0]) }) @@ -99,7 +107,7 @@ export class MaterialsInventoryService { sheet.getCell('A2').value = `日期:${dayjs(time[0]).format('YYYY年M月')}`; // 设置表头 const headers = [ - '库存编号', + '出入库单号', '公司名称', '产品名称', '单位', @@ -185,7 +193,10 @@ export class MaterialsInventoryService { } }); - const groupedData = groupBy(currentMonthProjectData, 'inventoryNumber'); + const groupedData = groupBy( + currentMonthProjectData, + record => `${record.productId}-${record.unitPrice}` + ); let number = 0; const groupedInventories = groupBy( currentProjectInventories, @@ -309,7 +320,14 @@ export class MaterialsInventoryService { .leftJoin('materialsInventory.product', 'product') .leftJoin('product.unit', 'unit') .leftJoin('product.company', 'company') - .addSelect(['project.name', 'product.name', 'unit.label', 'company.name']) + .addSelect([ + 'project.name', + 'unit.label', + 'company.name', + 'product.name', + 'product.productSpecification', + 'product.productNumber' + ]) .where('materialsInventory.isDelete = 0'); return paginate(queryBuilder, { page, @@ -337,6 +355,7 @@ export class MaterialsInventoryService { /** * 产品入库后计算最新库存 + * 请注意。产品库存需要根据产品id和价格双主键存储。因为产品价格会变化,需要分开统计。 * @param data 传入项目ID,产品ID和入库数量和单价 * @param manager 传入事务对象防止开启多重事务 */ @@ -346,13 +365,14 @@ export class MaterialsInventoryService { productId: number; quantity: number; unitPrice?: number; + changedUnitPrice?: number; }, manager: EntityManager ): Promise { - const { projectId, productId, quantity: inQuantity, unitPrice } = data; + const { projectId, productId, quantity: inQuantity, unitPrice, changedUnitPrice } = data; const exsitedInventory = await manager.findOne(MaterialsInventoryEntity, { - where: { projectId, productId }, // 查出某个项目的某个产品的库存情况 + where: { projectId, productId, unitPrice }, // 根据项目,产品,价格查出之前的实时库存情况 lock: { mode: 'pessimistic_write' } // 开启悲观行锁,防止脏读和修改 }); @@ -373,29 +393,28 @@ export class MaterialsInventoryService { throw new Error('库存数量不合法'); } await manager.update(MaterialsInventoryEntity, id, { - quantity: newQuantity + quantity: newQuantity, + unitPrice: changedUnitPrice || undefined }); } /** * 产品出库 - * @param data 传入产品id和入库数量和单价 + * @param data 传入库存ID(一定存在。) * @param manager 传入事务对象防止开启多重事务 */ async outInventory( data: { - projectId: number; - productId: number; quantity: number; - unitPrice?: number; + inventoryId?: number; }, manager: EntityManager ): Promise { - const { projectId, productId, quantity: outQuantity } = data; + const { quantity: outQuantity, inventoryId } = data; // 开启悲观行锁,防止脏读和修改 const inventory = await manager.findOne(MaterialsInventoryEntity, { - where: { projectId, productId }, + where: { id: inventoryId }, lock: { mode: 'pessimistic_write' } }); // 检查库存剩余 @@ -406,26 +425,41 @@ export class MaterialsInventoryService { let { quantity, id } = inventory; const newQuantity = calcNumber(quantity || 0, outQuantity || 0, 'subtract'); if (isNaN(newQuantity)) { - throw new Error('库存数量不合法'); + throw new Error('库存数量不足。请检查库存或重新操作。'); } await manager.update(MaterialsInventoryEntity, id, { quantity: newQuantity }); } + /** * 删除 */ async delete(id: number): Promise { - // 合同比较重要,做逻辑删除 + // 比较重要,做逻辑删除 await this.materialsInventoryRepository.update(id, { isDelete: 1 }); } /** - * 获取单个合同信息 + * 获取某个价格的某个商品库存信息 */ async info(id: number) { const info = await this.materialsInventoryRepository .createQueryBuilder('materialsInventory') + .leftJoin('materialsInventory.project', 'project') + .leftJoin('materialsInventory.product', 'product') + .leftJoin('product.unit', 'unit') + .leftJoin('product.company', 'company') + .addSelect([ + 'project.name', + 'project.id', + 'product.id', + 'product.name', + 'unit.label', + 'company.name', + 'product.productSpecification', + 'product.productNumber' + ]) .where({ id }) diff --git a/src/modules/product/product.dto.ts b/src/modules/product/product.dto.ts index e8b4b08..85ca3fd 100644 --- a/src/modules/product/product.dto.ts +++ b/src/modules/product/product.dto.ts @@ -8,6 +8,12 @@ export class ProductDto { @IsString() name: string; + + @ApiProperty({ description: '产品规格' }) + @IsOptional() + @IsString() + productSpecification: string; + @ApiProperty({ description: '产品备注' }) @IsOptional() @IsString() @@ -47,4 +53,10 @@ export class ProductQueryDto extends IntersectionType( @IsOptional() @IsString() name?: string; + + @ApiProperty({ description: '关键字(名字/编号/规格)' }) + @IsOptional() + @IsString() + keyword?: string; + } diff --git a/src/modules/product/product.entity.ts b/src/modules/product/product.entity.ts index cc90a05..6fbbce5 100644 --- a/src/modules/product/product.entity.ts +++ b/src/modules/product/product.entity.ts @@ -17,6 +17,16 @@ import pinyin from 'pinyin'; import { DictItemEntity } from '../system/dict-item/dict-item.entity'; @Entity({ name: 'product' }) export class ProductEntity extends CommonEntity { + + @Column({ + name: 'product_number', + type: 'varchar', + length: 255, + comment: '产品编号' + }) + @ApiProperty({ description: '产品编号' }) + productNumber: string; + @Column({ name: 'name', type: 'varchar', @@ -25,7 +35,16 @@ export class ProductEntity extends CommonEntity { }) @ApiProperty({ description: '产品名称' }) name: string; - + + @Column({ + name: 'product_specification', + type: 'varchar', + nullable: true, + length: 255, + comment: '产品规格' + }) + @ApiProperty({ description: '产品规格', nullable: true }) + productSpecification?: string; @Column({ name: 'remark', type: 'varchar', diff --git a/src/modules/product/product.module.ts b/src/modules/product/product.module.ts index fdc44e9..14e2367 100644 --- a/src/modules/product/product.module.ts +++ b/src/modules/product/product.module.ts @@ -4,9 +4,10 @@ import { ProductService } from './product.service'; import { ProductEntity } from './product.entity'; import { TypeOrmModule } from '@nestjs/typeorm'; import { StorageModule } from '../tools/storage/storage.module'; +import { ParamConfigModule } from '../system/param-config/param-config.module'; @Module({ - imports: [TypeOrmModule.forFeature([ProductEntity]), StorageModule], + imports: [TypeOrmModule.forFeature([ProductEntity]), StorageModule, ParamConfigModule], controllers: [ProductController], providers: [ProductService] }) diff --git a/src/modules/product/product.service.ts b/src/modules/product/product.service.ts index 88cce60..eacf1e4 100644 --- a/src/modules/product/product.service.ts +++ b/src/modules/product/product.service.ts @@ -9,6 +9,8 @@ import { Storage } from '../tools/storage/storage.entity'; import { BusinessException } from '~/common/exceptions/biz.exception'; import { ErrorEnum } from '~/constants/error-code.constant'; import { fieldSearch } from '~/shared/database/field-search'; +import { ParamConfigEnum } from '~/constants/enum'; +import { ParamConfigEntity } from '../system/param-config/param-config.entity'; @Injectable() export class ProductService { @@ -17,7 +19,9 @@ export class ProductService { @InjectRepository(ProductEntity) private productRepository: Repository, @InjectRepository(Storage) - private storageRepository: Repository + private storageRepository: Repository, + @InjectRepository(ParamConfigEntity) + private paramConfigRepository: Repository ) {} /** @@ -28,13 +32,13 @@ export class ProductService { pageSize, ...fields }: ProductQueryDto): Promise> { - const { company: companyName, ...ext } = fields; + const { company: companyName, keyword, ...ext } = fields; const sqb = this.productRepository .createQueryBuilder('product') .leftJoin('product.files', 'files') .leftJoin('product.company', 'company') .leftJoin('product.unit', 'unit') - .addSelect(['files.id', 'files.path', 'company.name', 'company.id','unit.id','unit.label']) + .addSelect(['files.id', 'files.path', 'company.name', 'company.id', 'unit.id', 'unit.label']) .where(fieldSearch(ext)) .andWhere('product.isDelete = 0') .addOrderBy('product.namePinyin', 'ASC'); @@ -45,6 +49,16 @@ export class ProductService { } }); } + if (keyword) { + //关键字模糊查询product的name,productNumber,productSpecification + sqb.andWhere( + '(product.name like :keyword or product.productNumber like :keyword or product.productSpecification like :keyword)', + { + keyword: `%${keyword}%` + } + ); + + } return paginate(sqb, { page, pageSize @@ -62,7 +76,9 @@ export class ProductService { if (isExsit) { throw new BusinessException(ErrorEnum.PRODUCT_EXIST); } - await this.productRepository.insert(this.productRepository.create(dto)); + await this.productRepository.insert( + this.productRepository.create({ ...dto, productNumber: await this.generateProductNumber() }) + ); } /** @@ -91,11 +107,7 @@ export class ProductService { throw new BusinessException(ErrorEnum.STORAGE_NOT_FOUND); } // 附件要批量插入 - await manager - .createQueryBuilder() - .relation(ProductEntity, 'files') - .of(id) - .add(fileIds); + await manager.createQueryBuilder().relation(ProductEntity, 'files').of(id).add(fileIds); } }); } @@ -146,4 +158,31 @@ export class ProductService { .addAndRemove(linkedFiles, product.files); }); } + + /** + * 生成产品编号 + * @returns 产品编号 + */ + async generateProductNumber(): Promise { + const prefix = + ( + await this.paramConfigRepository.findOne({ + where: { + key: ParamConfigEnum.ProductNumberPrefix + } + }) + )?.value || ''; + const lastProduct = await this.productRepository + .createQueryBuilder('product') + .select( + `MAX(CAST(REPLACE(COALESCE(product.product_number, ''), '${prefix}', '') AS UNSIGNED))`, + 'productNumber' + ) + .getRawOne(); + const lastNumber = lastProduct.productNumber + ? parseInt(lastProduct.productNumber.replace(prefix, '')) + : 0; + const newNumber = lastNumber + 1 < 1000 ? 1000 : lastNumber + 1; + return `${prefix}${newNumber}`; + } }