feat: 重做出入库逻辑

This commit is contained in:
louis 2024-03-22 16:47:26 +08:00
parent 77247c140c
commit cab022af0a
12 changed files with 202 additions and 60 deletions

View File

@ -13,7 +13,7 @@ SWAGGER_PATH = api-docs
SWAGGER_VERSION = 1.0 SWAGGER_VERSION = 1.0
# db # db
DB_HOST = 127.0.0.1 DB_HOST = 192.168.60.39
DB_PORT = 13307 DB_PORT = 13307
DB_DATABASE = hxoa DB_DATABASE = hxoa
DB_USERNAME = root DB_USERNAME = root
@ -23,7 +23,7 @@ DB_LOGGING = "all"
# redis # redis
REDIS_PORT = 6379 REDIS_PORT = 6379
REDIS_HOST = 127.0.0.1 REDIS_HOST = 192.168.60.39
REDIS_PASSWORD = 123456 REDIS_PASSWORD = 123456
REDIS_DB = 0 REDIS_DB = 0

View File

@ -21,7 +21,9 @@ export enum MaterialsInOrOutEnum {
// 系统参数key // 系统参数key
export enum ParamConfigEnum { export enum ParamConfigEnum {
MaterialsInOutPrefix = 'materials_in_out_prefix' InventoryNumberPrefixIn = 'inventory_number_prefix_in',
InventoryNumberPrefixOut = 'inventory_number_prefix_out',
ProductNumberPrefix = 'product_number_prefix'
} }
// 合同审核状态 // 合同审核状态

View File

@ -57,7 +57,8 @@ export enum ErrorEnum {
// Contract // Contract
CONTRACT_NUMBER_EXIST = '1407:存在相同的合同编号', CONTRACT_NUMBER_EXIST = '1407:存在相同的合同编号',
// Inventory // Inventory
INVENTORY_INSUFFICIENT = '1408:库存不足', INVENTORY_INSUFFICIENT = '1408:库存不足',
MATERIALS_IN_OUT_NOT_FOUND = '1409:出入库信息不存在', MATERIALS_IN_OUT_NOT_FOUND = '1409:出入库信息不存在',
MATERIALS_IN_OUT_UNIT_PRICE_CANNOT_BE_MODIFIED = '1410:该价格的产品已经出库,单价不允许修改。若有疑问,请联系管理员'
} }

View File

@ -46,11 +46,6 @@ export class AuthController {
@ApiSecurityAuth() @ApiSecurityAuth()
@ApiOperation({ summary: '屏幕解锁使用密码和token' }) @ApiOperation({ summary: '屏幕解锁使用密码和token' })
@ApiResult({ type: LoginToken }) @ApiResult({ type: LoginToken })
async unlock(@Body() dto: LoginDto, @AuthUser() user: IAuthUser): Promise<Boolean> { async unlock(@Body() dto: LoginDto, @AuthUser() user: IAuthUser): Promise<Boolean> {
await this.authService.unlock(user.uid, dto.password); await this.authService.unlock(user.uid, dto.password);
return true; return true;

View File

@ -13,7 +13,8 @@ import {
IsString, IsString,
Matches, Matches,
MinLength, MinLength,
ValidateIf ValidateIf,
isNumber
} from 'class-validator'; } from 'class-validator';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { PagerDto } from '~/common/dto/pager.dto'; import { PagerDto } from '~/common/dto/pager.dto';
@ -37,6 +38,11 @@ export class MaterialsInOutDto {
@IsString() @IsString()
inventoryNumber: string; inventoryNumber: string;
@ApiProperty({ description: '库存id(产品和单价双主键决定一条库存)' })
@IsOptional()
@IsNumber()
inventoryId: number;
@ApiProperty({ description: '单位(字典)' }) @ApiProperty({ description: '单位(字典)' })
@IsNumber() @IsNumber()
@IsOptional() @IsOptional()

View File

@ -39,7 +39,7 @@ export class MaterialsInOutEntity extends CommonEntity {
productId: number; productId: number;
@Column({ @Column({
name: 'inOrOut', name: 'in_or_out',
type: 'tinyint', type: 'tinyint',
comment: '入库或出库' comment: '入库或出库'
}) })

View File

@ -49,12 +49,17 @@ export class MaterialsInOutService {
.leftJoin('materialsInOut.project', 'project') .leftJoin('materialsInOut.project', 'project')
.leftJoin('materialsInOut.product', 'product') .leftJoin('materialsInOut.product', 'product')
.leftJoin('product.unit', 'unit') .leftJoin('product.unit', 'unit')
.leftJoin('product.files', 'productFiles')
.leftJoin('product.company', 'company') .leftJoin('product.company', 'company')
.addSelect([ .addSelect([
'files.id', 'files.id',
'files.path', 'files.path',
'project.name', 'project.name',
'product.name', 'product.name',
'product.productSpecification',
'product.productNumber',
'productFiles.id',
'productFiles.path',
'unit.label', 'unit.label',
'company.name' 'company.name'
]) ])
@ -84,21 +89,8 @@ export class MaterialsInOutService {
* *
*/ */
async create(dto: MaterialsInOutDto): Promise<void> { async create(dto: MaterialsInOutDto): Promise<void> {
let { inOrOut, inventoryNumber, projectId } = dto; let { inOrOut, inventoryNumber, projectId, inventoryId } = dto;
if (Object.is(inOrOut, MaterialsInOrOutEnum.In)) { inventoryNumber = await this.generateInventoryNumber(inOrOut);
// 入库
inventoryNumber = await this.generateInventoryNumber();
} else {
// 出库
const inRecord = await this.materialsInOutRepository.findOne({
where: {
inventoryNumber
}
});
const { productId } = inRecord;
dto.productId = productId;
}
await this.entityManager.transaction(async manager => { await this.entityManager.transaction(async manager => {
// 1.生成出入库记录 // 1.生成出入库记录
const { productId, quantity, unitPrice } = await manager.save(MaterialsInOutEntity, { const { productId, quantity, unitPrice } = await manager.save(MaterialsInOutEntity, {
@ -110,7 +102,7 @@ export class MaterialsInOutService {
Object.is(inOrOut, MaterialsInOrOutEnum.In) Object.is(inOrOut, MaterialsInOrOutEnum.In)
? this.materialsInventoryService.inInventory ? this.materialsInventoryService.inInventory
: this.materialsInventoryService.outInventory : 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' } 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; let changedQuantity = 0;
if (isDefined(data.quantity) && entity.quantity !== data.quantity) { if (isDefined(data.quantity) && entity.quantity !== data.quantity) {
if (entity.inOrOut === MaterialsInOrOutEnum.In) { if (entity.inOrOut === MaterialsInOrOutEnum.In) {
@ -165,6 +189,12 @@ export class MaterialsInOutService {
manager manager
); );
} }
// 完成所有业务逻辑后,更新出入库记录
await manager.update(MaterialsInOutEntity, id, {
...data
});
if (fileIds?.length) { if (fileIds?.length) {
const count = await this.storageRepository const count = await this.storageRepository
.createQueryBuilder('storage') .createQueryBuilder('storage')
@ -208,7 +238,7 @@ export class MaterialsInOutService {
{ {
productId: entity.productId, productId: entity.productId,
quantity: entity.quantity, quantity: entity.quantity,
unitPrice: undefined, unitPrice: entity.unitPrice,
projectId: entity.projectId projectId: entity.projectId
}, },
manager manager
@ -261,12 +291,14 @@ export class MaterialsInOutService {
* *
* @returns * @returns
*/ */
async generateInventoryNumber() { async generateInventoryNumber(inOrOut: MaterialsInOrOutEnum = MaterialsInOrOutEnum.In) {
const prefix = const prefix =
( (
await this.paramConfigRepository.findOne({ await this.paramConfigRepository.findOne({
where: { where: {
key: ParamConfigEnum.MaterialsInOutPrefix key: inOrOut
? ParamConfigEnum.InventoryNumberPrefixOut
: ParamConfigEnum.InventoryNumberPrefixIn
} }
}) })
)?.value || ''; )?.value || '';
@ -276,6 +308,7 @@ export class MaterialsInOutService {
`MAX(CAST(REPLACE(materialsInOut.inventoryNumber, '${prefix}', '') AS UNSIGNED))`, `MAX(CAST(REPLACE(materialsInOut.inventoryNumber, '${prefix}', '') AS UNSIGNED))`,
'maxInventoryNumber' 'maxInventoryNumber'
) )
.where('materialsInOut.inOrOut = :inOrOut', { inOrOut })
.getRawOne(); .getRawOne();
const lastNumber = lastMaterial.maxInventoryNumber const lastNumber = lastMaterial.maxInventoryNumber
? parseInt(lastMaterial.maxInventoryNumber.replace(prefix, '')) ? parseInt(lastMaterial.maxInventoryNumber.replace(prefix, ''))

View File

@ -62,7 +62,15 @@ export class MaterialsInventoryService {
.leftJoin('mio.product', 'product') .leftJoin('mio.product', 'product')
.leftJoin('product.unit', 'unit') .leftJoin('product.unit', 'unit')
.leftJoin('product.company', 'company') .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({ .where({
time: MoreThan(time[0]) time: MoreThan(time[0])
}) })
@ -99,7 +107,7 @@ export class MaterialsInventoryService {
sheet.getCell('A2').value = `日期:${dayjs(time[0]).format('YYYY年M月')}`; sheet.getCell('A2').value = `日期:${dayjs(time[0]).format('YYYY年M月')}`;
// 设置表头 // 设置表头
const headers = [ const headers = [
'库存编号', '出入库单号',
'公司名称', '公司名称',
'产品名称', '产品名称',
'单位', '单位',
@ -185,7 +193,10 @@ export class MaterialsInventoryService {
} }
}); });
const groupedData = groupBy<MaterialsInOutEntity>(currentMonthProjectData, 'inventoryNumber'); const groupedData = groupBy<MaterialsInOutEntity>(
currentMonthProjectData,
record => `${record.productId}-${record.unitPrice}`
);
let number = 0; let number = 0;
const groupedInventories = groupBy( const groupedInventories = groupBy(
currentProjectInventories, currentProjectInventories,
@ -309,7 +320,14 @@ export class MaterialsInventoryService {
.leftJoin('materialsInventory.product', 'product') .leftJoin('materialsInventory.product', 'product')
.leftJoin('product.unit', 'unit') .leftJoin('product.unit', 'unit')
.leftJoin('product.company', 'company') .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'); .where('materialsInventory.isDelete = 0');
return paginate<MaterialsInventoryEntity>(queryBuilder, { return paginate<MaterialsInventoryEntity>(queryBuilder, {
page, page,
@ -337,6 +355,7 @@ export class MaterialsInventoryService {
/** /**
* *
* id和价格双主键存储
* @param data ID,ID和入库数量和单价 * @param data ID,ID和入库数量和单价
* @param manager * @param manager
*/ */
@ -346,13 +365,14 @@ export class MaterialsInventoryService {
productId: number; productId: number;
quantity: number; quantity: number;
unitPrice?: number; unitPrice?: number;
changedUnitPrice?: number;
}, },
manager: EntityManager manager: EntityManager
): Promise<void> { ): Promise<void> {
const { projectId, productId, quantity: inQuantity, unitPrice } = data; const { projectId, productId, quantity: inQuantity, unitPrice, changedUnitPrice } = data;
const exsitedInventory = await manager.findOne(MaterialsInventoryEntity, { const exsitedInventory = await manager.findOne(MaterialsInventoryEntity, {
where: { projectId, productId }, // 查出某个项目的某个产品的库存情况 where: { projectId, productId, unitPrice }, // 根据项目,产品,价格查出之前的实时库存情况
lock: { mode: 'pessimistic_write' } // 开启悲观行锁,防止脏读和修改 lock: { mode: 'pessimistic_write' } // 开启悲观行锁,防止脏读和修改
}); });
@ -373,29 +393,28 @@ export class MaterialsInventoryService {
throw new Error('库存数量不合法'); throw new Error('库存数量不合法');
} }
await manager.update(MaterialsInventoryEntity, id, { await manager.update(MaterialsInventoryEntity, id, {
quantity: newQuantity quantity: newQuantity,
unitPrice: changedUnitPrice || undefined
}); });
} }
/** /**
* *
* @param data id和入库数量和单价 * @param data ID()
* @param manager * @param manager
*/ */
async outInventory( async outInventory(
data: { data: {
projectId: number;
productId: number;
quantity: number; quantity: number;
unitPrice?: number; inventoryId?: number;
}, },
manager: EntityManager manager: EntityManager
): Promise<void> { ): Promise<void> {
const { projectId, productId, quantity: outQuantity } = data; const { quantity: outQuantity, inventoryId } = data;
// 开启悲观行锁,防止脏读和修改 // 开启悲观行锁,防止脏读和修改
const inventory = await manager.findOne(MaterialsInventoryEntity, { const inventory = await manager.findOne(MaterialsInventoryEntity, {
where: { projectId, productId }, where: { id: inventoryId },
lock: { mode: 'pessimistic_write' } lock: { mode: 'pessimistic_write' }
}); });
// 检查库存剩余 // 检查库存剩余
@ -406,26 +425,41 @@ export class MaterialsInventoryService {
let { quantity, id } = inventory; let { quantity, id } = inventory;
const newQuantity = calcNumber(quantity || 0, outQuantity || 0, 'subtract'); const newQuantity = calcNumber(quantity || 0, outQuantity || 0, 'subtract');
if (isNaN(newQuantity)) { if (isNaN(newQuantity)) {
throw new Error('库存数量不合法'); throw new Error('库存数量不足。请检查库存或重新操作。');
} }
await manager.update(MaterialsInventoryEntity, id, { await manager.update(MaterialsInventoryEntity, id, {
quantity: newQuantity quantity: newQuantity
}); });
} }
/** /**
* *
*/ */
async delete(id: number): Promise<void> { async delete(id: number): Promise<void> {
// 合同比较重要,做逻辑删除 // 比较重要,做逻辑删除
await this.materialsInventoryRepository.update(id, { isDelete: 1 }); await this.materialsInventoryRepository.update(id, { isDelete: 1 });
} }
/** /**
* *
*/ */
async info(id: number) { async info(id: number) {
const info = await this.materialsInventoryRepository const info = await this.materialsInventoryRepository
.createQueryBuilder('materialsInventory') .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({ .where({
id id
}) })

View File

@ -8,6 +8,12 @@ export class ProductDto {
@IsString() @IsString()
name: string; name: string;
@ApiProperty({ description: '产品规格' })
@IsOptional()
@IsString()
productSpecification: string;
@ApiProperty({ description: '产品备注' }) @ApiProperty({ description: '产品备注' })
@IsOptional() @IsOptional()
@IsString() @IsString()
@ -47,4 +53,10 @@ export class ProductQueryDto extends IntersectionType(
@IsOptional() @IsOptional()
@IsString() @IsString()
name?: string; name?: string;
@ApiProperty({ description: '关键字(名字/编号/规格)' })
@IsOptional()
@IsString()
keyword?: string;
} }

View File

@ -17,6 +17,16 @@ import pinyin from 'pinyin';
import { DictItemEntity } from '../system/dict-item/dict-item.entity'; import { DictItemEntity } from '../system/dict-item/dict-item.entity';
@Entity({ name: 'product' }) @Entity({ name: 'product' })
export class ProductEntity extends CommonEntity { export class ProductEntity extends CommonEntity {
@Column({
name: 'product_number',
type: 'varchar',
length: 255,
comment: '产品编号'
})
@ApiProperty({ description: '产品编号' })
productNumber: string;
@Column({ @Column({
name: 'name', name: 'name',
type: 'varchar', type: 'varchar',
@ -25,7 +35,16 @@ export class ProductEntity extends CommonEntity {
}) })
@ApiProperty({ description: '产品名称' }) @ApiProperty({ description: '产品名称' })
name: string; name: string;
@Column({
name: 'product_specification',
type: 'varchar',
nullable: true,
length: 255,
comment: '产品规格'
})
@ApiProperty({ description: '产品规格', nullable: true })
productSpecification?: string;
@Column({ @Column({
name: 'remark', name: 'remark',
type: 'varchar', type: 'varchar',

View File

@ -4,9 +4,10 @@ import { ProductService } from './product.service';
import { ProductEntity } from './product.entity'; import { ProductEntity } from './product.entity';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { StorageModule } from '../tools/storage/storage.module'; import { StorageModule } from '../tools/storage/storage.module';
import { ParamConfigModule } from '../system/param-config/param-config.module';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([ProductEntity]), StorageModule], imports: [TypeOrmModule.forFeature([ProductEntity]), StorageModule, ParamConfigModule],
controllers: [ProductController], controllers: [ProductController],
providers: [ProductService] providers: [ProductService]
}) })

View File

@ -9,6 +9,8 @@ import { Storage } from '../tools/storage/storage.entity';
import { BusinessException } from '~/common/exceptions/biz.exception'; import { BusinessException } from '~/common/exceptions/biz.exception';
import { ErrorEnum } from '~/constants/error-code.constant'; import { ErrorEnum } from '~/constants/error-code.constant';
import { fieldSearch } from '~/shared/database/field-search'; import { fieldSearch } from '~/shared/database/field-search';
import { ParamConfigEnum } from '~/constants/enum';
import { ParamConfigEntity } from '../system/param-config/param-config.entity';
@Injectable() @Injectable()
export class ProductService { export class ProductService {
@ -17,7 +19,9 @@ export class ProductService {
@InjectRepository(ProductEntity) @InjectRepository(ProductEntity)
private productRepository: Repository<ProductEntity>, private productRepository: Repository<ProductEntity>,
@InjectRepository(Storage) @InjectRepository(Storage)
private storageRepository: Repository<Storage> private storageRepository: Repository<Storage>,
@InjectRepository(ParamConfigEntity)
private paramConfigRepository: Repository<ParamConfigEntity>
) {} ) {}
/** /**
@ -28,13 +32,13 @@ export class ProductService {
pageSize, pageSize,
...fields ...fields
}: ProductQueryDto): Promise<Pagination<ProductEntity>> { }: ProductQueryDto): Promise<Pagination<ProductEntity>> {
const { company: companyName, ...ext } = fields; const { company: companyName, keyword, ...ext } = fields;
const sqb = this.productRepository const sqb = this.productRepository
.createQueryBuilder('product') .createQueryBuilder('product')
.leftJoin('product.files', 'files') .leftJoin('product.files', 'files')
.leftJoin('product.company', 'company') .leftJoin('product.company', 'company')
.leftJoin('product.unit', 'unit') .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)) .where(fieldSearch(ext))
.andWhere('product.isDelete = 0') .andWhere('product.isDelete = 0')
.addOrderBy('product.namePinyin', 'ASC'); .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<ProductEntity>(sqb, { return paginate<ProductEntity>(sqb, {
page, page,
pageSize pageSize
@ -62,7 +76,9 @@ export class ProductService {
if (isExsit) { if (isExsit) {
throw new BusinessException(ErrorEnum.PRODUCT_EXIST); 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); throw new BusinessException(ErrorEnum.STORAGE_NOT_FOUND);
} }
// 附件要批量插入 // 附件要批量插入
await manager await manager.createQueryBuilder().relation(ProductEntity, 'files').of(id).add(fileIds);
.createQueryBuilder()
.relation(ProductEntity, 'files')
.of(id)
.add(fileIds);
} }
}); });
} }
@ -146,4 +158,31 @@ export class ProductService {
.addAndRemove(linkedFiles, product.files); .addAndRemove(linkedFiles, product.files);
}); });
} }
/**
*
* @returns
*/
async generateProductNumber(): Promise<string> {
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}`;
}
} }