> = {
+ [K in Uppercase]: `${T}:${Lowercase}`;
+};
+type AddPrefixToObjectValue> = {
+ [K in keyof P]: K extends string ? `${T}:${P[K]}` : never;
+};
+
+/** 资源操作需要特定的权限 */
+export function Perm(permission: string | string[]) {
+ return applyDecorators(SetMetadata(PERMISSION_KEY, permission));
+}
+
+/** (此举非必需)保存通过 definePermission 定义的所有权限,可用于前端开发人员开发阶段的 ts 类型提示,避免前端权限定义与后端定义不匹配 */
+let permissions: string[] = [];
+/**
+ * 定义权限,同时收集所有被定义的权限
+ *
+ * - 通过对象形式定义, eg:
+ * ```ts
+ * definePermission('app:health', {
+ * NETWORK: 'network'
+ * };
+ * ```
+ *
+ * - 通过字符串数组形式定义, eg:
+ * ```ts
+ * definePermission('app:health', ['network']);
+ * ```
+ */
+export function definePermission>(
+ modulePrefix: T,
+ actionMap: U
+): AddPrefixToObjectValue;
+export function definePermission>(
+ modulePrefix: T,
+ actions: U
+): TupleToObject;
+export function definePermission(modulePrefix: string, actions) {
+ if (isPlainObject(actions)) {
+ Object.entries(actions).forEach(([key, action]) => {
+ actions[key] = `${modulePrefix}:${action}`;
+ });
+ permissions = [...new Set([...permissions, ...Object.values(actions)])];
+ return actions;
+ } else if (Array.isArray(actions)) {
+ const permissionFormats = actions.map(action => `${modulePrefix}:${action}`);
+ permissions = [...new Set([...permissions, ...permissionFormats])];
+
+ return actions.reduce((prev, action) => {
+ prev[action.toUpperCase()] = `${modulePrefix}:${action}`;
+ return prev;
+ }, {});
+ }
+}
+
+/** 获取所有通过 definePermission 定义的权限 */
+export const getDefinePermissions = () => permissions;
diff --git a/src/modules/auth/decorators/public.decorator.ts b/src/modules/auth/decorators/public.decorator.ts
new file mode 100644
index 0000000..b9592f2
--- /dev/null
+++ b/src/modules/auth/decorators/public.decorator.ts
@@ -0,0 +1,8 @@
+import { SetMetadata } from '@nestjs/common';
+
+import { PUBLIC_KEY } from '../auth.constant';
+
+/**
+ * 当接口不需要检测用户登录时添加该装饰器
+ */
+export const Public = () => SetMetadata(PUBLIC_KEY, true);
diff --git a/src/modules/auth/decorators/resource.decorator.ts b/src/modules/auth/decorators/resource.decorator.ts
new file mode 100644
index 0000000..6734598
--- /dev/null
+++ b/src/modules/auth/decorators/resource.decorator.ts
@@ -0,0 +1,22 @@
+import { SetMetadata, applyDecorators } from '@nestjs/common';
+
+import { ObjectLiteral, ObjectType, Repository } from 'typeorm';
+
+import { RESOURCE_KEY } from '../auth.constant';
+
+export type Condition = (
+ Repository: Repository,
+ items: number[],
+ user: IAuthUser
+) => Promise;
+
+export interface ResourceObject {
+ entity: ObjectType;
+ condition: Condition;
+}
+export function Resource(
+ entity: ObjectType,
+ condition?: Condition
+) {
+ return applyDecorators(SetMetadata(RESOURCE_KEY, { entity, condition }));
+}
diff --git a/src/modules/auth/dto/account.dto.ts b/src/modules/auth/dto/account.dto.ts
new file mode 100644
index 0000000..cf87459
--- /dev/null
+++ b/src/modules/auth/dto/account.dto.ts
@@ -0,0 +1,72 @@
+import { ApiProperty, OmitType, PartialType, PickType } from '@nestjs/swagger';
+import { IsEmail, IsOptional, IsString, Matches, MaxLength, MinLength } from 'class-validator';
+
+import { MenuEntity } from '~/modules/system/menu/menu.entity';
+
+export class AccountUpdateDto {
+ @ApiProperty({ description: '用户呢称' })
+ @IsString()
+ @IsOptional()
+ nickname: string;
+
+ @ApiProperty({ description: '用户邮箱' })
+ @IsEmail()
+ email: string;
+
+ @ApiProperty({ description: '用户QQ' })
+ @IsOptional()
+ @IsString()
+ @Matches(/^[0-9]+$/)
+ @MinLength(5)
+ @MaxLength(11)
+ qq: string;
+
+ @ApiProperty({ description: '用户手机号' })
+ @IsOptional()
+ @IsString()
+ phone: string;
+
+ @ApiProperty({ description: '用户头像' })
+ @IsOptional()
+ @IsString()
+ avatar: string;
+
+ @ApiProperty({ description: '用户备注' })
+ @IsOptional()
+ @IsString()
+ remark: string;
+}
+
+export class ResetPasswordDto {
+ @ApiProperty({ description: '临时token', example: 'uuid' })
+ @IsString()
+ accessToken: string;
+
+ @ApiProperty({ description: '密码', example: 'a123456' })
+ @IsString()
+ @Matches(/^\S*(?=\S{6,})(?=\S*\d)(?=\S*[A-Za-z])\S*$/)
+ @MinLength(6)
+ password: string;
+}
+
+export class MenuMeta extends PartialType(
+ OmitType(MenuEntity, [
+ 'parentId',
+ 'createdAt',
+ 'updatedAt',
+ 'id',
+ 'roles',
+ 'path',
+ 'name'
+ ] as const)
+) {
+ title: string;
+}
+export class AccountMenus extends PickType(MenuEntity, [
+ 'id',
+ 'path',
+ 'name',
+ 'component'
+] as const) {
+ meta: MenuMeta;
+}
diff --git a/src/modules/auth/dto/auth.dto.ts b/src/modules/auth/dto/auth.dto.ts
new file mode 100644
index 0000000..f6ec215
--- /dev/null
+++ b/src/modules/auth/dto/auth.dto.ts
@@ -0,0 +1,42 @@
+import { ApiProperty } from '@nestjs/swagger';
+
+import { IsOptional, IsString, Matches, MaxLength, MinLength } from 'class-validator';
+
+export class LoginDto {
+ @ApiProperty({ description: '手机号/邮箱' })
+ @IsOptional()
+ username: string;
+
+ @ApiProperty({ description: '密码', example: 'a123456' })
+ @IsString()
+ @Matches(/^\S*(?=\S{6,})(?=\S*\d)(?=\S*[A-Za-z])\S*$/, { message: '密码错误' })
+ @MinLength(6)
+ password: string;
+
+ @ApiProperty({ description: '验证码标识,手机端不需要' })
+ @IsOptional()
+ captchaId: string;
+
+ @ApiProperty({ description: '用户输入的验证码' })
+ @IsOptional()
+ @MinLength(4)
+ @MaxLength(4)
+ verifyCode: string;
+}
+
+export class RegisterDto {
+ @ApiProperty({ description: '账号' })
+ @IsString()
+ username: string;
+
+ @ApiProperty({ description: '密码' })
+ @IsString()
+ @Matches(/^\S*(?=\S{6,})(?=\S*\d)(?=\S*[A-Za-z])\S*$/)
+ @MinLength(6)
+ @MaxLength(16)
+ password: string;
+
+ @ApiProperty({ description: '语言', examples: ['EN', 'ZH'] })
+ @IsString()
+ lang: string;
+}
diff --git a/src/modules/auth/dto/captcha.dto.ts b/src/modules/auth/dto/captcha.dto.ts
new file mode 100644
index 0000000..fdff06c
--- /dev/null
+++ b/src/modules/auth/dto/captcha.dto.ts
@@ -0,0 +1,47 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { Type } from 'class-transformer';
+import { IsEmail, IsInt, IsMobilePhone, IsOptional, IsString } from 'class-validator';
+
+export class ImageCaptchaDto {
+ @ApiProperty({
+ required: false,
+ default: 100,
+ description: '验证码宽度'
+ })
+ @Type(() => Number)
+ @IsInt()
+ @IsOptional()
+ readonly width: number = 100;
+
+ @ApiProperty({
+ required: false,
+ default: 50,
+ description: '验证码宽度'
+ })
+ @Type(() => Number)
+ @IsInt()
+ @IsOptional()
+ readonly height: number = 50;
+}
+
+export class SendEmailCodeDto {
+ @ApiProperty({ description: '邮箱' })
+ @IsEmail({}, { message: '邮箱格式不正确' })
+ email: string;
+}
+
+export class SendSmsCodeDto {
+ @ApiProperty({ description: '手机号' })
+ @IsMobilePhone('zh-CN', {}, { message: '手机号格式不正确' })
+ phone: string;
+}
+
+export class CheckCodeDto {
+ @ApiProperty({ description: '手机号/邮箱' })
+ @IsString()
+ account: string;
+
+ @ApiProperty({ description: '验证码' })
+ @IsString()
+ code: string;
+}
diff --git a/src/modules/auth/entities/access-token.entity.ts b/src/modules/auth/entities/access-token.entity.ts
new file mode 100644
index 0000000..fbbdc0c
--- /dev/null
+++ b/src/modules/auth/entities/access-token.entity.ts
@@ -0,0 +1,40 @@
+import {
+ BaseEntity,
+ Column,
+ CreateDateColumn,
+ Entity,
+ JoinColumn,
+ ManyToOne,
+ OneToOne,
+ PrimaryGeneratedColumn
+} from 'typeorm';
+
+import { UserEntity } from '~/modules/user/user.entity';
+
+import { RefreshTokenEntity } from './refresh-token.entity';
+
+@Entity('user_access_tokens')
+export class AccessTokenEntity extends BaseEntity {
+ @PrimaryGeneratedColumn('uuid')
+ id!: string;
+
+ @Column({ length: 500 })
+ value!: string;
+
+ @Column({ comment: '令牌过期时间' })
+ expired_at!: Date;
+
+ @CreateDateColumn({ comment: '令牌创建时间' })
+ created_at!: Date;
+
+ @OneToOne(() => RefreshTokenEntity, refreshToken => refreshToken.accessToken, {
+ cascade: true
+ })
+ refreshToken!: RefreshTokenEntity;
+
+ @ManyToOne(() => UserEntity, user => user.accessTokens, {
+ onDelete: 'CASCADE'
+ })
+ @JoinColumn({ name: 'user_id' })
+ user!: UserEntity;
+}
diff --git a/src/modules/auth/entities/refresh-token.entity.ts b/src/modules/auth/entities/refresh-token.entity.ts
new file mode 100644
index 0000000..4df36ce
--- /dev/null
+++ b/src/modules/auth/entities/refresh-token.entity.ts
@@ -0,0 +1,32 @@
+import {
+ BaseEntity,
+ Column,
+ CreateDateColumn,
+ Entity,
+ JoinColumn,
+ OneToOne,
+ PrimaryGeneratedColumn
+} from 'typeorm';
+
+import { AccessTokenEntity } from './access-token.entity';
+
+@Entity('user_refresh_tokens')
+export class RefreshTokenEntity extends BaseEntity {
+ @PrimaryGeneratedColumn('uuid')
+ id!: string;
+
+ @Column({ length: 500 })
+ value!: string;
+
+ @Column({ comment: '令牌过期时间' })
+ expired_at!: Date;
+
+ @CreateDateColumn({ comment: '令牌创建时间' })
+ created_at!: Date;
+
+ @OneToOne(() => AccessTokenEntity, accessToken => accessToken.refreshToken, {
+ onDelete: 'CASCADE'
+ })
+ @JoinColumn()
+ accessToken!: AccessTokenEntity;
+}
diff --git a/src/modules/auth/guards/jwt-auth.guard.ts b/src/modules/auth/guards/jwt-auth.guard.ts
new file mode 100644
index 0000000..4349a13
--- /dev/null
+++ b/src/modules/auth/guards/jwt-auth.guard.ts
@@ -0,0 +1,94 @@
+import { ExecutionContext, HttpException, HttpStatus, Injectable, UnauthorizedException } from '@nestjs/common';
+import { Reflector } from '@nestjs/core';
+import { AuthGuard } from '@nestjs/passport';
+import { FastifyRequest } from 'fastify';
+import { isEmpty, isNil } from 'lodash';
+
+import { BusinessException } from '~/common/exceptions/biz.exception';
+import { ErrorEnum } from '~/constants/error-code.constant';
+import { AuthService } from '~/modules/auth/auth.service';
+
+import { checkIsDemoMode } from '~/utils';
+
+import { AuthStrategy, PUBLIC_KEY } from '../auth.constant';
+import { TokenService } from '../services/token.service';
+
+// https://docs.nestjs.com/recipes/passport#implement-protected-route-and-jwt-strategy-guards
+@Injectable()
+export class JwtAuthGuard extends AuthGuard(AuthStrategy.JWT) {
+ constructor(
+ private reflector: Reflector,
+ private authService: AuthService,
+ private tokenService: TokenService
+ ) {
+ super();
+ }
+
+ async canActivate(context: ExecutionContext): Promise {
+ const isPublic = this.reflector.getAllAndOverride(PUBLIC_KEY, [
+ context.getHandler(),
+ context.getClass()
+ ]);
+ const request = context.switchToHttp().getRequest();
+ // const response = context.switchToHttp().getResponse()
+
+ // TODO 此处代码的作用是判断如果在演示环境下,则拒绝用户的增删改操作,去掉此代码不影响正常的业务逻辑
+ if (request.method !== 'GET' && !request.url.includes('/auth/login')) checkIsDemoMode();
+
+ const isSse = request.headers.accept === 'text/event-stream';
+
+ if (isSse && !request.headers.authorization?.startsWith('Bearer')) {
+ const { token } = request.query as Record;
+ if (token) request.headers.authorization = `Bearer ${token}`;
+ }
+
+ const Authorization = request.headers.authorization;
+
+ let result: any = false;
+ try {
+ result = await super.canActivate(context);
+ } catch (e) {
+ // 需要后置判断 这样携带了 token 的用户就能够解析到 request.user
+ if (isPublic) return true;
+
+ if (isEmpty(Authorization)) throw new UnauthorizedException('未登录');
+
+ // 判断 token 是否存在, 如果不存在则认证失败
+ const accessToken = isNil(Authorization)
+ ? undefined
+ : await this.tokenService.checkAccessToken(Authorization!);
+
+ if (!accessToken) throw new UnauthorizedException('令牌无效');
+ }
+
+ // SSE 请求
+ if (isSse) {
+ const { uid } = request.params as Record;
+
+ if (Number(uid) !== request.user.uid)
+ throw new UnauthorizedException('路径参数 uid 与当前 token 登录的用户 uid 不一致');
+ }
+
+ const pv = await this.authService.getPasswordVersionByUid(request.user.uid);
+ if (pv !== `${request.user.pv}`) {
+ // 密码版本不一致,登录期间已更改过密码
+ throw new HttpException(ErrorEnum.INVALID_LOGIN,HttpStatus.UNAUTHORIZED);
+ }
+
+ // 不允许多端登录
+ // const cacheToken = await this.authService.getTokenByUid(request.user.uid);
+ // if (Authorization !== cacheToken) {
+ // // 与redis保存不一致 即二次登录
+ // throw new ApiException(ErrorEnum.CODE_1106);
+ // }
+
+ return result;
+ }
+
+ handleRequest(err, user, info) {
+ // You can throw an exception based on either "info" or "err" arguments
+ if (err || !user) throw err || new UnauthorizedException();
+
+ return user;
+ }
+}
diff --git a/src/modules/auth/guards/local.guard.ts b/src/modules/auth/guards/local.guard.ts
new file mode 100644
index 0000000..2bcaca9
--- /dev/null
+++ b/src/modules/auth/guards/local.guard.ts
@@ -0,0 +1,11 @@
+import { ExecutionContext, Injectable } from '@nestjs/common';
+import { AuthGuard } from '@nestjs/passport';
+
+import { AuthStrategy } from '../auth.constant';
+
+@Injectable()
+export class LocalGuard extends AuthGuard(AuthStrategy.LOCAL) {
+ async canActivate(context: ExecutionContext) {
+ return true;
+ }
+}
diff --git a/src/modules/auth/guards/rbac.guard.ts b/src/modules/auth/guards/rbac.guard.ts
new file mode 100644
index 0000000..3cf7239
--- /dev/null
+++ b/src/modules/auth/guards/rbac.guard.ts
@@ -0,0 +1,64 @@
+import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
+import { Reflector } from '@nestjs/core';
+import { FastifyRequest } from 'fastify';
+
+import { BusinessException } from '~/common/exceptions/biz.exception';
+import { ErrorEnum } from '~/constants/error-code.constant';
+import { AuthService } from '~/modules/auth/auth.service';
+
+import { ALLOW_ANON_KEY, PERMISSION_KEY, PUBLIC_KEY, Roles } from '../auth.constant';
+
+@Injectable()
+export class RbacGuard implements CanActivate {
+ constructor(
+ private reflector: Reflector,
+ private authService: AuthService
+ ) {}
+
+ async canActivate(context: ExecutionContext): Promise {
+ const isPublic = this.reflector.getAllAndOverride(PUBLIC_KEY, [
+ context.getHandler(),
+ context.getClass()
+ ]);
+
+ if (isPublic) return true;
+
+ const request = context.switchToHttp().getRequest();
+
+ const { user } = request;
+ if (!user) throw new UnauthorizedException('登录无效');
+
+ // allowAnon 是需要登录后可访问(无需权限), Public 则是无需登录也可访问.
+ const allowAnon = this.reflector.get(ALLOW_ANON_KEY, context.getHandler());
+ if (allowAnon) return true;
+
+ const payloadPermission = this.reflector.getAllAndOverride(PERMISSION_KEY, [
+ context.getHandler(),
+ context.getClass()
+ ]);
+
+ // 控制器没有设置接口权限,则默认通过
+ if (!payloadPermission) return true;
+
+ // 管理员放开所有权限
+ if (user.roles.includes(Roles.ADMIN)) return true;
+
+ const allPermissions =
+ (await this.authService.getPermissionsCache(user.uid)) ??
+ (await this.authService.getPermissions(user.uid));
+ // console.log(allPermissions)
+ let canNext = false;
+
+ // handle permission strings
+ if (Array.isArray(payloadPermission)) {
+ // 只要有一个权限满足即可
+ canNext = payloadPermission.every(i => allPermissions.includes(i));
+ }
+
+ if (typeof payloadPermission === 'string') canNext = allPermissions.includes(payloadPermission);
+
+ if (!canNext) throw new BusinessException(ErrorEnum.NO_PERMISSION);
+
+ return true;
+ }
+}
diff --git a/src/modules/auth/guards/resource.guard.ts b/src/modules/auth/guards/resource.guard.ts
new file mode 100644
index 0000000..301181f
--- /dev/null
+++ b/src/modules/auth/guards/resource.guard.ts
@@ -0,0 +1,81 @@
+import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
+import { Reflector } from '@nestjs/core';
+import { FastifyRequest } from 'fastify';
+
+import { isArray, isEmpty, isNil } from 'lodash';
+
+import { DataSource, In, Repository } from 'typeorm';
+
+import { BusinessException } from '~/common/exceptions/biz.exception';
+
+import { ErrorEnum } from '~/constants/error-code.constant';
+
+import { PUBLIC_KEY, RESOURCE_KEY, Roles } from '../auth.constant';
+import { ResourceObject } from '../decorators/resource.decorator';
+
+@Injectable()
+export class ResourceGuard implements CanActivate {
+ constructor(
+ private reflector: Reflector,
+ private dataSource: DataSource
+ ) {}
+
+ async canActivate(context: ExecutionContext): Promise {
+ const isPublic = this.reflector.getAllAndOverride(PUBLIC_KEY, [
+ context.getHandler(),
+ context.getClass()
+ ]);
+
+ const request = context.switchToHttp().getRequest();
+ const isSse = request.headers.accept === 'text/event-stream';
+ // 忽略 sse 请求
+ if (isPublic || isSse) return true;
+
+ const { user } = request;
+
+ if (!user) return false;
+
+ // 如果是检查资源所属,且不是超级管理员,还需要进一步判断是否是自己的数据
+ const { entity, condition } = this.reflector.get(
+ RESOURCE_KEY,
+ context.getHandler()
+ ) ?? { entity: null, condition: null };
+
+ if (entity && !user.roles.includes(Roles.ADMIN)) {
+ const repo: Repository = this.dataSource.getRepository(entity);
+
+ /**
+ * 获取请求中的 items (ids) 验证数据拥有者
+ * @param request
+ */
+ const getRequestItems = (request?: FastifyRequest): number[] => {
+ const { params = {}, body = {}, query = {} } = (request ?? {}) as any;
+ const id = params.id ?? body.id ?? query.id;
+
+ if (id) return [id];
+
+ const { items } = body;
+ return !isNil(items) && isArray(items) ? items : [];
+ };
+
+ const items = getRequestItems(request);
+ if (isEmpty(items)) throw new BusinessException(ErrorEnum.REQUESTED_RESOURCE_NOT_FOUND);
+
+ if (condition) return condition(repo, items, user);
+
+ const recordQuery = {
+ where: {
+ id: In(items),
+ user: { id: user.uid }
+ },
+ relations: ['user']
+ };
+
+ const records = await repo.find(recordQuery);
+
+ if (isEmpty(records)) throw new BusinessException(ErrorEnum.REQUESTED_RESOURCE_NOT_FOUND);
+ }
+
+ return true;
+ }
+}
diff --git a/src/modules/auth/models/auth.model.ts b/src/modules/auth/models/auth.model.ts
new file mode 100644
index 0000000..01a5e08
--- /dev/null
+++ b/src/modules/auth/models/auth.model.ts
@@ -0,0 +1,14 @@
+import { ApiProperty } from '@nestjs/swagger';
+
+export class ImageCaptcha {
+ @ApiProperty({ description: 'base64格式的svg图片' })
+ img: string;
+
+ @ApiProperty({ description: '验证码对应的唯一ID' })
+ id: string;
+}
+
+export class LoginToken {
+ @ApiProperty({ description: 'JWT身份Token' })
+ token: string;
+}
diff --git a/src/modules/auth/services/captcha.service.ts b/src/modules/auth/services/captcha.service.ts
new file mode 100644
index 0000000..f7ddfc0
--- /dev/null
+++ b/src/modules/auth/services/captcha.service.ts
@@ -0,0 +1,35 @@
+import { InjectRedis } from '@liaoliaots/nestjs-redis';
+import { Injectable } from '@nestjs/common';
+
+import Redis from 'ioredis';
+import { isEmpty } from 'lodash';
+
+import { BusinessException } from '~/common/exceptions/biz.exception';
+import { ErrorEnum } from '~/constants/error-code.constant';
+import { genCaptchaImgKey } from '~/helper/genRedisKey';
+import { CaptchaLogService } from '~/modules/system/log/services/captcha-log.service';
+
+@Injectable()
+export class CaptchaService {
+ constructor(
+ @InjectRedis() private redis: Redis,
+
+ private captchaLogService: CaptchaLogService
+ ) {}
+
+ /**
+ * 校验图片验证码
+ */
+ async checkImgCaptcha(id: string, code: string): Promise {
+ const result = await this.redis.get(genCaptchaImgKey(id));
+ if (isEmpty(result) || code.toLowerCase() !== result.toLowerCase())
+ throw new BusinessException(ErrorEnum.INVALID_VERIFICATION_CODE);
+
+ // 校验成功后移除验证码
+ await this.redis.del(genCaptchaImgKey(id));
+ }
+
+ async log(account: string, code: string, provider: 'sms' | 'email', uid?: number): Promise {
+ await this.captchaLogService.create(account, code, provider, uid);
+ }
+}
diff --git a/src/modules/auth/services/token.service.ts b/src/modules/auth/services/token.service.ts
new file mode 100644
index 0000000..2e552cd
--- /dev/null
+++ b/src/modules/auth/services/token.service.ts
@@ -0,0 +1,150 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { JwtService } from '@nestjs/jwt';
+import dayjs from 'dayjs';
+
+import { ISecurityConfig, SecurityConfig } from '~/config';
+import { RoleService } from '~/modules/system/role/role.service';
+import { UserEntity } from '~/modules/user/user.entity';
+import { generateUUID } from '~/utils';
+
+import { AccessTokenEntity } from '../entities/access-token.entity';
+import { RefreshTokenEntity } from '../entities/refresh-token.entity';
+
+/**
+ * 令牌服务
+ */
+@Injectable()
+export class TokenService {
+ constructor(
+ private jwtService: JwtService,
+ private roleService: RoleService,
+ @Inject(SecurityConfig.KEY) private securityConfig: ISecurityConfig
+ ) {}
+
+ /**
+ * 根据accessToken刷新AccessToken与RefreshToken
+ * @param accessTokenSign
+ * @param response
+ */
+ async refreshToken(accessToken: AccessTokenEntity) {
+ const { user, refreshToken } = accessToken;
+
+ if (refreshToken) {
+ const now = dayjs();
+ // 判断refreshToken是否过期
+ if (now.isAfter(refreshToken.expired_at)) return null;
+
+ const roleIds = await this.roleService.getRoleIdsByUser(user.id);
+ const roleValues = await this.roleService.getRoleValues(roleIds);
+
+ // 如果没过期则生成新的access_token和refresh_token
+ const token = await this.generateAccessToken(user.id, roleValues);
+
+ await accessToken.remove();
+ return token;
+ }
+ return null;
+ }
+
+ generateJwtSign(payload: any) {
+ const jwtSign = this.jwtService.sign(payload);
+
+ return jwtSign;
+ }
+
+ async generateAccessToken(uid: number, roles: string[] = []) {
+ const payload: IAuthUser = {
+ uid,
+ pv: 1,
+ roles
+ };
+
+ const jwtSign = this.jwtService.sign(payload);
+
+ // 生成accessToken
+ const accessToken = new AccessTokenEntity();
+ accessToken.value = jwtSign;
+ accessToken.user = { id: uid } as UserEntity;
+ accessToken.expired_at = dayjs().add(this.securityConfig.jwtExprire, 'second').toDate();
+
+ await accessToken.save();
+
+ // 生成refreshToken
+ const refreshToken = await this.generateRefreshToken(accessToken, dayjs());
+
+ return {
+ accessToken: jwtSign,
+ refreshToken
+ };
+ }
+
+ /**
+ * 生成新的RefreshToken并存入数据库
+ * @param accessToken
+ * @param now
+ */
+ async generateRefreshToken(accessToken: AccessTokenEntity, now: dayjs.Dayjs): Promise {
+ const refreshTokenPayload = {
+ uuid: generateUUID()
+ };
+
+ const refreshTokenSign = this.jwtService.sign(refreshTokenPayload, {
+ secret: this.securityConfig.refreshSecret
+ });
+
+ const refreshToken = new RefreshTokenEntity();
+ refreshToken.value = refreshTokenSign;
+ refreshToken.expired_at = now.add(this.securityConfig.refreshExpire, 'second').toDate();
+ refreshToken.accessToken = accessToken;
+
+ await refreshToken.save();
+
+ return refreshTokenSign;
+ }
+
+ /**
+ * 检查accessToken是否存在
+ * @param value
+ */
+ async checkAccessToken(value: string) {
+ return AccessTokenEntity.findOne({
+ where: { value },
+ relations: ['user', 'refreshToken'],
+ cache: true
+ });
+ }
+
+ /**
+ * 移除AccessToken且自动移除关联的RefreshToken
+ * @param value
+ */
+ async removeAccessToken(value: string) {
+ const accessToken = await AccessTokenEntity.findOne({
+ where: { value }
+ });
+ if (accessToken) await accessToken.remove();
+ }
+
+ /**
+ * 移除RefreshToken
+ * @param value
+ */
+ async removeRefreshToken(value: string) {
+ const refreshToken = await RefreshTokenEntity.findOne({
+ where: { value },
+ relations: ['accessToken']
+ });
+ if (refreshToken) {
+ if (refreshToken.accessToken) await refreshToken.accessToken.remove();
+ await refreshToken.remove();
+ }
+ }
+
+ /**
+ * 验证Token是否正确,如果正确则返回所属用户对象
+ * @param token
+ */
+ async verifyAccessToken(token: string): Promise {
+ return this.jwtService.verify(token);
+ }
+}
diff --git a/src/modules/auth/strategies/jwt.strategy.ts b/src/modules/auth/strategies/jwt.strategy.ts
new file mode 100644
index 0000000..a5175e7
--- /dev/null
+++ b/src/modules/auth/strategies/jwt.strategy.ts
@@ -0,0 +1,22 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { PassportStrategy } from '@nestjs/passport';
+import { ExtractJwt, Strategy } from 'passport-jwt';
+
+import { ISecurityConfig, SecurityConfig } from '~/config';
+
+import { AuthStrategy } from '../auth.constant';
+
+@Injectable()
+export class JwtStrategy extends PassportStrategy(Strategy, AuthStrategy.JWT) {
+ constructor(@Inject(SecurityConfig.KEY) private securityConfig: ISecurityConfig) {
+ super({
+ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
+ ignoreExpiration: false,
+ secretOrKey: securityConfig.jwtSecret
+ });
+ }
+
+ async validate(payload: IAuthUser) {
+ return payload;
+ }
+}
diff --git a/src/modules/auth/strategies/local.strategy.ts b/src/modules/auth/strategies/local.strategy.ts
new file mode 100644
index 0000000..da2d9d2
--- /dev/null
+++ b/src/modules/auth/strategies/local.strategy.ts
@@ -0,0 +1,21 @@
+import { Injectable } from '@nestjs/common';
+import { PassportStrategy } from '@nestjs/passport';
+import { Strategy } from 'passport-local';
+
+import { AuthStrategy } from '../auth.constant';
+import { AuthService } from '../auth.service';
+
+@Injectable()
+export class LocalStrategy extends PassportStrategy(Strategy, AuthStrategy.LOCAL) {
+ constructor(private authService: AuthService) {
+ super({
+ usernameField: 'credential',
+ passwordField: 'password'
+ });
+ }
+
+ async validate(username: string, password: string): Promise {
+ const user = await this.authService.validateUser(username, password);
+ return user;
+ }
+}
diff --git a/src/modules/common/base.service.ts b/src/modules/common/base.service.ts
new file mode 100644
index 0000000..7cb472d
--- /dev/null
+++ b/src/modules/common/base.service.ts
@@ -0,0 +1,11 @@
+import { Injectable } from '@nestjs/common';
+
+@Injectable()
+export class BaseService {
+ generateInventoryInOutNumber(): string {
+ // Generate a random inventory number
+ return Math.floor(Math.random() * 1000000).toString();
+ }
+
+ // Add more common methods here
+}
diff --git a/src/modules/company/company.controller.ts b/src/modules/company/company.controller.ts
new file mode 100644
index 0000000..1ad2602
--- /dev/null
+++ b/src/modules/company/company.controller.ts
@@ -0,0 +1,80 @@
+import {
+ Body,
+ Controller,
+ Get,
+ Query,
+ Put,
+ Delete,
+ Post,
+ BadRequestException
+} from '@nestjs/common';
+import { ApiOperation, ApiTags } from '@nestjs/swagger';
+import { Perm, definePermission } from '../auth/decorators/permission.decorator';
+import { ApiSecurityAuth } from '~/common/decorators/swagger.decorator';
+import { CompanyService } from './company.service';
+import { ApiResult } from '~/common/decorators/api-result.decorator';
+import { CompanyEntity } from './company.entity';
+import { CompanyDto, CompanyQueryDto, CompanyUpdateDto } from './company.dto';
+import { IdParam } from '~/common/decorators/id-param.decorator';
+import { Domain, SkDomain } from '~/common/decorators/domain.decorator';
+export const permissions = definePermission('app:company', {
+ LIST: 'list',
+ CREATE: 'create',
+ READ: 'read',
+ UPDATE: 'update',
+ DELETE: 'delete'
+} as const);
+
+@ApiTags('Company - 公司')
+@ApiSecurityAuth()
+@Controller('company')
+export class CompanyController {
+ constructor(private companyService: CompanyService) {}
+
+ @Get()
+ @ApiOperation({ summary: '获取公司列表' })
+ @ApiResult({ type: [CompanyEntity], isPage: true })
+ @Perm(permissions.LIST)
+ async list(@Domain() domain: SkDomain, @Query() dto: CompanyQueryDto) {
+ return this.companyService.findAll({ ...dto, domain });
+ }
+
+ @Get(':id')
+ @ApiOperation({ summary: '获取公司信息' })
+ @ApiResult({ type: CompanyDto })
+ @Perm(permissions.READ)
+ async info(@IdParam() id: number) {
+ return this.companyService.info(id);
+ }
+
+ @Post()
+ @ApiOperation({ summary: '新增公司' })
+ @Perm(permissions.CREATE)
+ async create(@Domain() domain: SkDomain, @Body() dto: CompanyDto): Promise {
+ await this.companyService.create({ ...dto, domain });
+ }
+
+ @Put(':id')
+ @ApiOperation({ summary: '更新公司' })
+ @Perm(permissions.UPDATE)
+ async update(@IdParam() id: number, @Body() dto: CompanyUpdateDto): Promise {
+ await this.companyService.update(id, dto);
+ }
+
+ @Delete(':id')
+ @ApiOperation({ summary: '删除公司' })
+ @Perm(permissions.DELETE)
+ async delete(@IdParam() id: number): Promise {
+ await this.companyService.delete(id);
+ }
+
+ @Put('unlink-attachments/:id')
+ @ApiOperation({ summary: '附件解除关联' })
+ @Perm(permissions.UPDATE)
+ async unlinkAttachments(
+ @IdParam() id: number,
+ @Body() { fileIds }: CompanyUpdateDto
+ ): Promise {
+ await this.companyService.unlinkAttachments(id, fileIds);
+ }
+}
diff --git a/src/modules/company/company.dto.ts b/src/modules/company/company.dto.ts
new file mode 100644
index 0000000..6a9f350
--- /dev/null
+++ b/src/modules/company/company.dto.ts
@@ -0,0 +1,53 @@
+import { ApiProperty, IntersectionType, PartialType } from '@nestjs/swagger';
+import {
+ IsArray,
+ IsDate,
+ IsDateString,
+ IsIn,
+ IsInt,
+ IsNumber,
+ IsOptional,
+ IsString,
+ Matches,
+ MinLength
+} from 'class-validator';
+import { PagerDto } from '~/common/dto/pager.dto';
+import { Storage } from '../tools/storage/storage.entity';
+import { IsUnique } from '~/shared/database/constraints/unique.constraint';
+import { CompanyEntity } from './company.entity';
+import { DomainType, SkDomain } from '~/common/decorators/domain.decorator';
+
+export class CompanyDto extends DomainType {
+ @ApiProperty({ description: '公司名称' })
+ @IsUnique(CompanyEntity, { message: '已存在同名公司' })
+ @IsString()
+ name: string;
+
+ @ApiProperty({ description: '附件' })
+ files: Storage[];
+}
+
+export class CompanyUpdateDto extends PartialType(CompanyDto) {
+ @ApiProperty({ description: '附件' })
+ @IsOptional()
+ @IsArray()
+ fileIds: number[];
+}
+
+export class ComapnyCreateDto extends PartialType(CompanyDto) {
+ @ApiProperty({ description: '附件' })
+ @IsOptional()
+ @IsArray()
+ fileIds: number[];
+}
+
+export class CompanyQueryDto extends IntersectionType(
+ PagerDto,
+ PartialType(CompanyDto),
+ DomainType
+) {
+ @ApiProperty({ description: '公司名称' })
+ @IsOptional()
+ @IsString()
+ name: string;
+}
diff --git a/src/modules/company/company.entity.ts b/src/modules/company/company.entity.ts
new file mode 100644
index 0000000..2f75103
--- /dev/null
+++ b/src/modules/company/company.entity.ts
@@ -0,0 +1,39 @@
+import { ApiHideProperty, ApiProperty } from '@nestjs/swagger';
+import { Column, Entity, JoinTable, ManyToMany, OneToMany, Relation } from 'typeorm';
+import { CommonEntity } from '~/common/entity/common.entity';
+import { Storage } from '../tools/storage/storage.entity';
+import { ProductEntity } from '../product/product.entity';
+import { SkDomain } from '~/common/decorators/domain.decorator';
+
+@Entity({ name: 'company' })
+export class CompanyEntity extends CommonEntity {
+ @Column({
+ name: 'name',
+ type: 'varchar',
+ unique: true,
+ length: 255,
+ comment: '公司名称'
+ })
+ @ApiProperty({ description: '公司名称' })
+ name: string;
+
+ @Column({ name: 'is_delete', type: 'tinyint', default: 0, comment: '是否删除' })
+ @ApiProperty({ description: '删除状态:0未删除,1已删除' })
+ isDelete: number;
+
+ @Column({ type: 'int', default: 1, comment: '所属域' })
+ @ApiProperty({ description: '所属域' })
+ domain: SkDomain;
+
+ @ApiHideProperty()
+ @OneToMany(() => ProductEntity, product => product.company)
+ products: Relation;
+
+ @ManyToMany(() => Storage, storage => storage.companys)
+ @JoinTable({
+ name: 'company_storage',
+ joinColumn: { name: 'company_id', referencedColumnName: 'id' },
+ inverseJoinColumn: { name: 'file_id', referencedColumnName: 'id' }
+ })
+ files: Relation;
+}
diff --git a/src/modules/company/company.module.ts b/src/modules/company/company.module.ts
new file mode 100644
index 0000000..5fba5b1
--- /dev/null
+++ b/src/modules/company/company.module.ts
@@ -0,0 +1,14 @@
+import { Module } from '@nestjs/common';
+import { CompanyController } from './company.controller';
+import { CompanyService } from './company.service';
+import { CompanyEntity } from './company.entity';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import { StorageModule } from '../tools/storage/storage.module';
+import { DatabaseModule } from '~/shared/database/database.module';
+
+@Module({
+ imports: [TypeOrmModule.forFeature([CompanyEntity]), StorageModule, DatabaseModule],
+ controllers: [CompanyController],
+ providers: [CompanyService]
+})
+export class CompanyModule {}
diff --git a/src/modules/company/company.service.ts b/src/modules/company/company.service.ts
new file mode 100644
index 0000000..8cb1fdd
--- /dev/null
+++ b/src/modules/company/company.service.ts
@@ -0,0 +1,124 @@
+import { Injectable } from '@nestjs/common';
+import { InjectEntityManager, InjectRepository } from '@nestjs/typeorm';
+import { CompanyEntity } from './company.entity';
+import { EntityManager, Like, Repository } from 'typeorm';
+import { CompanyDto, CompanyQueryDto, CompanyUpdateDto } from './company.dto';
+import { Pagination } from '~/helper/paginate/pagination';
+import { paginate } from '~/helper/paginate';
+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 { SkDomain } from '~/common/decorators/domain.decorator';
+
+@Injectable()
+export class CompanyService {
+ constructor(
+ @InjectEntityManager() private entityManager: EntityManager,
+ @InjectRepository(CompanyEntity)
+ private companyRepository: Repository,
+ @InjectRepository(Storage)
+ private storageRepository: Repository
+ ) {}
+
+ /**
+ * 查询所有公司
+ */
+ async findAll({
+ page,
+ pageSize,
+ ...fields
+ }: CompanyQueryDto): Promise> {
+ const queryBuilder = this.companyRepository
+ .createQueryBuilder('company')
+ .leftJoin('company.files', 'files')
+ .addSelect(['files.id', 'files.path'])
+ .where(fieldSearch(fields))
+ .andWhere('company.isDelete = 0');
+
+ return paginate(queryBuilder, {
+ page,
+ pageSize
+ });
+ }
+
+ /**
+ * 新增
+ */
+ async create(dto: CompanyDto): Promise {
+ await this.companyRepository.insert(dto);
+ }
+
+ /**
+ * 更新
+ */
+ async update(id: number, { fileIds, ...data }: Partial): Promise {
+ await this.entityManager.transaction(async manager => {
+ await manager.update(CompanyEntity, id, {
+ ...data
+ });
+ const company = await this.companyRepository
+ .createQueryBuilder('company')
+ .leftJoinAndSelect('company.files', 'files')
+ .where('company.id = :id', { id })
+ .getOne();
+ if (fileIds?.length) {
+ const count = await this.storageRepository
+ .createQueryBuilder('storage')
+ .where('storage.id in(:fileIds)', { fileIds })
+ .getCount();
+ if (count !== fileIds?.length) {
+ throw new BusinessException(ErrorEnum.STORAGE_NOT_FOUND);
+ }
+ // 附件要批量插入
+ await manager.createQueryBuilder().relation(CompanyEntity, 'files').of(id).add(fileIds);
+ }
+ });
+ }
+
+ /**
+ * 删除
+ */
+ async delete(id: number): Promise {
+ // 合同比较重要,做逻辑删除
+ await this.companyRepository.update(id, { isDelete: 1 });
+ }
+
+ /**
+ * 获取单个合同信息
+ */
+ async info(id: number) {
+ const info = await this.companyRepository
+ .createQueryBuilder('company')
+ .where({
+ id
+ })
+ .andWhere('company.isDelete = 0')
+ .getOne();
+ return info;
+ }
+
+ /**
+ * 解除附件关联
+ * @param id 合同ID
+ * @param fileIds 附件ID
+ */
+ async unlinkAttachments(id: number, fileIds: number[]) {
+ await this.entityManager.transaction(async manager => {
+ const company = await this.companyRepository
+ .createQueryBuilder('company')
+ .leftJoinAndSelect('company.files', 'files')
+ .where('company.id = :id', { id })
+ .getOne();
+ const linkedFiles = company.files
+ .map(item => item.id)
+ .filter(item => !fileIds.includes(item));
+ // 附件要批量更新
+ await manager
+ .createQueryBuilder()
+ .relation(CompanyEntity, 'files')
+ .of(id)
+ .addAndRemove(linkedFiles, company.files);
+ });
+ }
+}
diff --git a/src/modules/contract/contract.controller.ts b/src/modules/contract/contract.controller.ts
new file mode 100644
index 0000000..d21578b
--- /dev/null
+++ b/src/modules/contract/contract.controller.ts
@@ -0,0 +1,80 @@
+import {
+ Body,
+ Controller,
+ Get,
+ Query,
+ Put,
+ Delete,
+ Post,
+ BadRequestException
+} from '@nestjs/common';
+import { ApiOperation, ApiTags } from '@nestjs/swagger';
+import { Perm, definePermission } from '../auth/decorators/permission.decorator';
+import { ApiSecurityAuth } from '~/common/decorators/swagger.decorator';
+import { ContractService } from './contract.service';
+import { ApiResult } from '~/common/decorators/api-result.decorator';
+import { ContractEntity } from './contract.entity';
+import { ContractDto, ContractQueryDto, ContractUpdateDto } from './contract.dto';
+import { IdParam } from '~/common/decorators/id-param.decorator';
+import { Domain, SkDomain } from '~/common/decorators/domain.decorator';
+export const permissions = definePermission('app:contract', {
+ LIST: 'list',
+ CREATE: 'create',
+ READ: 'read',
+ UPDATE: 'update',
+ DELETE: 'delete'
+} as const);
+
+@ApiTags('Contract - 合同')
+@ApiSecurityAuth()
+@Controller('contract')
+export class ContractController {
+ constructor(private contractService: ContractService) {}
+
+ @Get()
+ @ApiOperation({ summary: '获取合同列表' })
+ @ApiResult({ type: [ContractEntity], isPage: true })
+ @Perm(permissions.LIST)
+ async list(@Domain() domain: SkDomain, @Query() dto: ContractQueryDto) {
+ return this.contractService.findAll({ ...dto, domain });
+ }
+
+ @Get(':id')
+ @ApiOperation({ summary: '获取合同信息' })
+ @ApiResult({ type: ContractDto })
+ @Perm(permissions.READ)
+ async info(@IdParam() id: number) {
+ return this.contractService.info(id);
+ }
+
+ @Post()
+ @ApiOperation({ summary: '新增合同' })
+ @Perm(permissions.CREATE)
+ async create(@Domain() domain: SkDomain, @Body() dto: ContractDto): Promise {
+ await this.contractService.create({ ...dto, domain });
+ }
+
+ @Put(':id')
+ @ApiOperation({ summary: '更新合同' })
+ @Perm(permissions.UPDATE)
+ async update(@IdParam() id: number, @Body() dto: ContractUpdateDto): Promise {
+ await this.contractService.update(id, dto);
+ }
+
+ @Delete(':id')
+ @ApiOperation({ summary: '删除合同' })
+ @Perm(permissions.DELETE)
+ async delete(@IdParam() id: number): Promise {
+ await this.contractService.delete(id);
+ }
+
+ @Put('unlink-attachments/:id')
+ @ApiOperation({ summary: '附件解除关联' })
+ @Perm(permissions.UPDATE)
+ async unlinkAttachments(
+ @IdParam() id: number,
+ @Body() { fileIds }: ContractUpdateDto
+ ): Promise {
+ await this.contractService.unlinkAttachments(id, fileIds);
+ }
+}
diff --git a/src/modules/contract/contract.dto.ts b/src/modules/contract/contract.dto.ts
new file mode 100644
index 0000000..46e6e85
--- /dev/null
+++ b/src/modules/contract/contract.dto.ts
@@ -0,0 +1,72 @@
+import { ApiProperty, IntersectionType, PartialType } from '@nestjs/swagger';
+import {
+ IsArray,
+ IsDate,
+ IsDateString,
+ IsEnum,
+ IsIn,
+ IsInt,
+ IsNumber,
+ IsOptional,
+ IsString,
+ Matches,
+ MinLength
+} from 'class-validator';
+import { PagerDto } from '~/common/dto/pager.dto';
+import { Storage } from '../tools/storage/storage.entity';
+import { ContractStatusEnum } from '~/constants/enum';
+import { DomainType, SkDomain } from '~/common/decorators/domain.decorator';
+
+export class ContractDto extends DomainType {
+ @ApiProperty({ description: '合同编号' })
+ @Matches(/^[a-z0-9A-Z]+$/, { message: '合同编号只能包含字母和数字' })
+ @IsString()
+ contractNumber: string;
+
+ @ApiProperty({ description: '合同标题' })
+ @IsString()
+ title: string;
+
+ @ApiProperty({ description: '合同类型' })
+ @IsNumber()
+ type: number;
+
+ @ApiProperty({ description: '甲方' })
+ @IsString()
+ partyA: string;
+
+ @ApiProperty({ description: '乙方' })
+ @IsString()
+ partyB: string;
+
+ @ApiProperty({ description: '签订日期' })
+ @IsOptional()
+ @IsDateString()
+ signingDate?: string;
+
+ @ApiProperty({ description: '交付期限' })
+ @IsOptional()
+ @IsDateString()
+ deliveryDeadline?: string;
+
+ @ApiProperty({ description: '审核状态(字典)' })
+ @IsOptional()
+ @IsEnum(ContractStatusEnum)
+ status: number;
+
+ @ApiProperty({ description: '附件' })
+ files: Storage[];
+}
+
+export class ContractUpdateDto extends PartialType(ContractDto) {
+ @ApiProperty({ description: '附件' })
+ @IsOptional()
+ @IsArray()
+ fileIds: number[];
+}
+export class ContractQueryDto extends IntersectionType(
+ PagerDto,
+ PartialType(ContractDto),
+ DomainType
+) {}
+
diff --git a/src/modules/contract/contract.entity.ts b/src/modules/contract/contract.entity.ts
new file mode 100644
index 0000000..40856b7
--- /dev/null
+++ b/src/modules/contract/contract.entity.ts
@@ -0,0 +1,62 @@
+import { ApiHideProperty, ApiProperty } from '@nestjs/swagger';
+import { Column, Entity, JoinTable, ManyToMany, Relation } from 'typeorm';
+import { CommonEntity } from '~/common/entity/common.entity';
+import { Storage } from '../tools/storage/storage.entity';
+import { SkDomain } from '~/common/decorators/domain.decorator';
+
+@Entity({ name: 'contract' })
+export class ContractEntity extends CommonEntity {
+ @Column({
+ name: 'contract_number',
+ type: 'varchar',
+ length: 255,
+ unique: true,
+ comment: '合同编号'
+ })
+ @ApiProperty({ description: '合同编号' })
+ contractNumber: string;
+
+ @Column({ name: 'title', type: 'varchar', length: 255, comment: '合同标题' })
+ @ApiProperty({ description: '合同标题' })
+ title: string;
+
+ @Column({ type: 'int', comment: '合同类型(字典)' })
+ @ApiProperty({ description: '合同类型(字典)' })
+ type: number;
+
+ @Column({ name: 'party_a', length: 255, type: 'varchar', comment: '甲方' })
+ @ApiProperty({ description: '甲方' })
+ partyA: string;
+
+ @Column({ name: 'party_b', length: 255, type: 'varchar', comment: '乙方' })
+ @ApiProperty({ description: '乙方' })
+ partyB: string;
+
+ @Column({ name: 'signing_date', type: 'date', nullable: true })
+ @ApiProperty({ description: '签订日期' })
+ signingDate: Date;
+
+ @Column({ name: 'delivery_deadline', type: 'date', nullable: true })
+ @ApiProperty({ description: '交付期限' })
+ deliveryDeadline: Date;
+
+ @Column({ name: 'status', type: 'tinyint', default: 0, comment: '审核状态(字典)' })
+ @ApiProperty({ description: '审核状态:0待审核,1同意,2.不同意(字典)' })
+ status: number;
+
+ @Column({ name: 'is_delete', type: 'tinyint', default: 0, comment: '是否删除' })
+ @ApiProperty({ description: '删除状态:0未删除,1已删除' })
+ isDelete: number;
+
+ @Column({ type: 'int', default: 1, comment: '所属域' })
+ @ApiProperty({ description: '所属域' })
+ domain: SkDomain;
+
+ @ManyToMany(() => Storage, storage => storage.contracts)
+ @JoinTable({
+ name: 'contract_storage',
+ joinColumn: { name: 'contract_id', referencedColumnName: 'id' },
+ inverseJoinColumn: { name: 'file_id', referencedColumnName: 'id' }
+ })
+ files: Relation;
+}
diff --git a/src/modules/contract/contract.module.ts b/src/modules/contract/contract.module.ts
new file mode 100644
index 0000000..d5eabe8
--- /dev/null
+++ b/src/modules/contract/contract.module.ts
@@ -0,0 +1,13 @@
+import { Module } from '@nestjs/common';
+import { ContractController } from './contract.controller';
+import { ContractService } from './contract.service';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import { ContractEntity } from './contract.entity';
+import { StorageModule } from '../tools/storage/storage.module';
+
+@Module({
+ imports: [TypeOrmModule.forFeature([ContractEntity]), StorageModule],
+ controllers: [ContractController],
+ providers: [ContractService]
+})
+export class ContractModule {}
diff --git a/src/modules/contract/contract.service.ts b/src/modules/contract/contract.service.ts
new file mode 100644
index 0000000..267ef0b
--- /dev/null
+++ b/src/modules/contract/contract.service.ts
@@ -0,0 +1,145 @@
+import { Injectable } from '@nestjs/common';
+import { InjectEntityManager, InjectRepository } from '@nestjs/typeorm';
+import { ContractEntity } from './contract.entity';
+import { EntityManager, Like, Not, Repository } from 'typeorm';
+import { ContractDto, ContractQueryDto, ContractUpdateDto } from './contract.dto';
+import { Pagination } from '~/helper/paginate/pagination';
+import { isNumber } from 'lodash';
+import { paginate } from '~/helper/paginate';
+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 { SkDomain } from '~/common/decorators/domain.decorator';
+
+@Injectable()
+export class ContractService {
+ constructor(
+ @InjectEntityManager() private entityManager: EntityManager,
+ @InjectRepository(ContractEntity)
+ private contractRepository: Repository,
+ @InjectRepository(Storage)
+ private storageRepository: Repository
+ ) {}
+
+ /**
+ * 查找所有合同
+ */
+ async findAll({
+ page,
+ pageSize,
+ ...fields
+ }: ContractQueryDto): Promise> {
+ const queryBuilder = this.contractRepository
+ .createQueryBuilder('contract')
+ .leftJoin('contract.files', 'files')
+ .addSelect(['files.id', 'files.path'])
+ .where(fieldSearch(fields))
+ .andWhere('contract.isDelete = 0');
+
+ return paginate(queryBuilder, {
+ page,
+ pageSize
+ });
+ }
+
+ /**
+ * 新增
+ */
+ async create({ contractNumber, ...ext }: ContractDto): Promise {
+ if (await this.checkIsContractNumberExsit(contractNumber)) {
+ throw new BusinessException(ErrorEnum.CONTRACT_NUMBER_EXIST);
+ }
+ await this.contractRepository.insert(
+ this.contractRepository.create({ contractNumber, ...ext })
+ );
+ }
+
+ /**
+ * 更新
+ */
+ async update(
+ id: number,
+ { fileIds, contractNumber, ...ext }: Partial
+ ): Promise {
+ await this.entityManager.transaction(async manager => {
+ if (contractNumber && (await this.checkIsContractNumberExsit(contractNumber, id))) {
+ throw new BusinessException(ErrorEnum.CONTRACT_NUMBER_EXIST);
+ }
+ await manager.update(ContractEntity, id, {
+ ...ext,
+ contractNumber
+ });
+
+ if (fileIds?.length) {
+ const count = await this.storageRepository
+ .createQueryBuilder('storage')
+ .where('storage.id in(:fileIds)', { fileIds })
+ .getCount();
+ if (count !== fileIds?.length) {
+ throw new BusinessException(ErrorEnum.STORAGE_NOT_FOUND);
+ }
+ // 附件要批量插入
+ await manager.createQueryBuilder().relation(ContractEntity, 'files').of(id).add(fileIds);
+ }
+ });
+ }
+
+ /**
+ * 是否存在相同编号的合同
+ * @param contractNumber 合同编号
+ */
+ async checkIsContractNumberExsit(contractNumber: string, id?: number): Promise {
+ return !!(await this.contractRepository.findOne({
+ where: {
+ contractNumber: contractNumber,
+ id: Not(id)
+ }
+ }));
+ }
+ /**
+ * 删除
+ */
+ async delete(id: number): Promise {
+ // 合同比较重要,做逻辑删除
+ await this.contractRepository.update(id, { isDelete: 1 });
+ }
+
+ /**
+ * 获取单个合同信息
+ */
+ async info(id: number) {
+ const info = await this.contractRepository
+ .createQueryBuilder('contract')
+ .where({
+ id
+ })
+ .andWhere('contract.isDelete = 0')
+ .getOne();
+ return info;
+ }
+
+ /**
+ * 解除附件关联
+ * @param id 合同ID
+ * @param fileIds 附件ID
+ */
+ async unlinkAttachments(id: number, fileIds: number[]) {
+ await this.entityManager.transaction(async manager => {
+ const contract = await this.contractRepository
+ .createQueryBuilder('contract')
+ .leftJoinAndSelect('contract.files', 'files')
+ .where('contract.id = :id', { id })
+ .getOne();
+ const linkedFiles = contract.files
+ .map(item => item.id)
+ .filter(item => !fileIds.includes(item));
+ // 附件要批量更新
+ await manager
+ .createQueryBuilder()
+ .relation(ContractEntity, 'files')
+ .of(id)
+ .addAndRemove(linkedFiles, contract.files);
+ });
+ }
+}
diff --git a/src/modules/domian/domain.controller.ts b/src/modules/domian/domain.controller.ts
new file mode 100644
index 0000000..0bd02a3
--- /dev/null
+++ b/src/modules/domian/domain.controller.ts
@@ -0,0 +1,67 @@
+import {
+ Body,
+ Controller,
+ Get,
+ Query,
+ Put,
+ Delete,
+ Post,
+ BadRequestException
+} from '@nestjs/common';
+import { ApiOperation, ApiTags } from '@nestjs/swagger';
+import { Perm, definePermission } from '../auth/decorators/permission.decorator';
+import { ApiSecurityAuth } from '~/common/decorators/swagger.decorator';
+import { DomainService } from './domain.service';
+import { ApiResult } from '~/common/decorators/api-result.decorator';
+import { DomainEntity } from './domain.entity';
+import { DomainDto, DomainQueryDto, DomainUpdateDto } from './domain.dto';
+import { IdParam } from '~/common/decorators/id-param.decorator';
+export const permissions = definePermission('app:domain', {
+ CREATE: 'create',
+ READ: 'read',
+ UPDATE: 'update',
+ DELETE: 'delete'
+} as const);
+
+@ApiTags('Domain - 域')
+@ApiSecurityAuth()
+@Controller('domain')
+export class DomainController {
+ constructor(private domainService: DomainService) {}
+
+ @Get()
+ @ApiOperation({ summary: '获取域列表' })
+ @ApiResult({ type: [DomainEntity], isPage: true })
+ async list(@Query() dto: DomainQueryDto) {
+ return this.domainService.findAll(dto);
+ }
+
+ @Get(':id')
+ @ApiOperation({ summary: '获取域信息' })
+ @ApiResult({ type: DomainDto })
+ @Perm(permissions.READ)
+ async info(@IdParam() id: number) {
+ return this.domainService.info(id);
+ }
+
+ @Post()
+ @ApiOperation({ summary: '新增域' })
+ @Perm(permissions.CREATE)
+ async create(@Body() dto: DomainDto): Promise {
+ await this.domainService.create(dto);
+ }
+
+ @Put(':id')
+ @ApiOperation({ summary: '更新域' })
+ @Perm(permissions.UPDATE)
+ async update(@IdParam() id: number, @Body() dto: DomainUpdateDto): Promise {
+ await this.domainService.update(id, dto);
+ }
+
+ @Delete(':id')
+ @ApiOperation({ summary: '删除域' })
+ @Perm(permissions.DELETE)
+ async delete(@IdParam() id: number): Promise {
+ await this.domainService.delete(id);
+ }
+}
diff --git a/src/modules/domian/domain.dto.ts b/src/modules/domian/domain.dto.ts
new file mode 100644
index 0000000..61d94fb
--- /dev/null
+++ b/src/modules/domian/domain.dto.ts
@@ -0,0 +1,12 @@
+import { ApiProperty, IntersectionType, PartialType } from '@nestjs/swagger';
+import { IsString } from 'class-validator';
+import { PagerDto } from '~/common/dto/pager.dto';
+
+export class DomainDto {
+ @ApiProperty({ description: '域标题' })
+ @IsString()
+ title: string;
+}
+
+export class DomainUpdateDto extends PartialType(DomainDto) {}
+export class DomainQueryDto extends IntersectionType(PagerDto, PartialType(DomainDto)) {}
diff --git a/src/modules/domian/domain.entity.ts b/src/modules/domian/domain.entity.ts
new file mode 100644
index 0000000..8ed3c48
--- /dev/null
+++ b/src/modules/domian/domain.entity.ts
@@ -0,0 +1,15 @@
+import { ApiHideProperty, ApiProperty } from '@nestjs/swagger';
+import { Column, Entity, JoinTable, ManyToMany, Relation } from 'typeorm';
+import { CommonEntity } from '~/common/entity/common.entity';
+import { Storage } from '../tools/storage/storage.entity';
+
+@Entity({ name: 'domain' })
+export class DomainEntity extends CommonEntity {
+ @Column({ name: 'title', type: 'varchar', length: 255, comment: '域标题' })
+ @ApiProperty({ description: '域标题' })
+ title: string;
+
+ @Column({ name: 'is_delete', type: 'tinyint', default: 0, comment: '是否删除' })
+ @ApiProperty({ description: '删除状态:0未删除,1已删除' })
+ isDelete: number;
+}
diff --git a/src/modules/domian/domain.module.ts b/src/modules/domian/domain.module.ts
new file mode 100644
index 0000000..9aaa16f
--- /dev/null
+++ b/src/modules/domian/domain.module.ts
@@ -0,0 +1,12 @@
+import { Module } from '@nestjs/common';
+import { DomainController } from './domain.controller';
+import { DomainService } from './domain.service';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import { DomainEntity } from './domain.entity';
+
+@Module({
+ imports: [TypeOrmModule.forFeature([DomainEntity])],
+ controllers: [DomainController],
+ providers: [DomainService]
+})
+export class DomainModule {}
diff --git a/src/modules/domian/domain.service.ts b/src/modules/domian/domain.service.ts
new file mode 100644
index 0000000..7fdfecd
--- /dev/null
+++ b/src/modules/domian/domain.service.ts
@@ -0,0 +1,95 @@
+import { Injectable } from '@nestjs/common';
+import { InjectEntityManager, InjectRepository } from '@nestjs/typeorm';
+import { DomainEntity } from './domain.entity';
+import { EntityManager, Like, Not, Repository } from 'typeorm';
+import { DomainDto, DomainQueryDto, DomainUpdateDto } from './domain.dto';
+import { Pagination } from '~/helper/paginate/pagination';
+import { isNumber } from 'lodash';
+import { paginate } from '~/helper/paginate';
+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';
+
+@Injectable()
+export class DomainService {
+ constructor(
+ @InjectEntityManager() private entityManager: EntityManager,
+ @InjectRepository(DomainEntity)
+ private domainRepository: Repository
+ ) {}
+
+ /**
+ * 查找所有域
+ */
+ async findAll({ page, pageSize, ...fields }: DomainQueryDto): Promise> {
+ const queryBuilder = this.domainRepository
+ .createQueryBuilder('domain')
+ .where(fieldSearch(fields))
+ .andWhere('domain.isDelete = 0');
+
+ return paginate(queryBuilder, {
+ page,
+ pageSize
+ });
+ }
+
+ /**
+ * 新增
+ */
+ async create({ title, ...ext }: DomainDto): Promise {
+ if (await this.checkIsDomainExsit(title)) {
+ throw new BusinessException(ErrorEnum.DOMAIN_TITLE_DUPLICATE);
+ }
+ await this.domainRepository.insert(this.domainRepository.create({ title, ...ext }));
+ }
+
+ /**
+ * 更新
+ */
+ async update(id: number, { title, ...ext }: Partial): Promise {
+ await this.entityManager.transaction(async manager => {
+ if (title && (await this.checkIsDomainExsit(title, id))) {
+ throw new BusinessException(ErrorEnum.CONTRACT_NUMBER_EXIST);
+ }
+ await manager.update(DomainEntity, id, {
+ ...ext,
+ title
+ });
+ });
+ }
+
+ /**
+ * 是否存在相同的域
+ * @param title 域编号
+ */
+ async checkIsDomainExsit(title: string, id?: number): Promise {
+ return !!(await this.domainRepository.findOne({
+ where: {
+ title: title,
+ id: Not(id)
+ }
+ }));
+ }
+ /**
+ * 删除
+ */
+ async delete(id: number): Promise {
+ // 域比较重要,做逻辑删除
+ await this.domainRepository.update(id, { isDelete: 1 });
+ }
+
+ /**
+ * 获取单个域信息
+ */
+ async info(id: number) {
+ const info = await this.domainRepository
+ .createQueryBuilder('domain')
+ .where({
+ id
+ })
+ .andWhere('domain.isDelete = 0')
+ .getOne();
+ return info;
+ }
+}
diff --git a/src/modules/health/health.controller.ts b/src/modules/health/health.controller.ts
new file mode 100644
index 0000000..6927b4c
--- /dev/null
+++ b/src/modules/health/health.controller.ts
@@ -0,0 +1,71 @@
+import { Controller, Get } from '@nestjs/common';
+import { ApiTags } from '@nestjs/swagger';
+import {
+ DiskHealthIndicator,
+ HealthCheck,
+ HttpHealthIndicator,
+ MemoryHealthIndicator,
+ TypeOrmHealthIndicator
+} from '@nestjs/terminus';
+
+import { Perm, definePermission } from '../auth/decorators/permission.decorator';
+
+export const PermissionHealth = definePermission('app:health', {
+ NETWORK: 'network',
+ DB: 'database',
+ MH: 'memory-heap',
+ MR: 'memory-rss',
+ DISK: 'disk'
+} as const);
+
+@ApiTags('Health - 健康检查')
+@Controller('health')
+export class HealthController {
+ constructor(
+ private http: HttpHealthIndicator,
+ private db: TypeOrmHealthIndicator,
+ private memory: MemoryHealthIndicator,
+ private disk: DiskHealthIndicator
+ ) {}
+
+ @Get('network')
+ @HealthCheck()
+ @Perm(PermissionHealth.NETWORK)
+ async checkNetwork() {
+ return this.http.pingCheck('louis', 'https://gitee.com/lu-zixun');
+ }
+
+ @Get('database')
+ @HealthCheck()
+ @Perm(PermissionHealth.DB)
+ async checkDatabase() {
+ return this.db.pingCheck('database');
+ }
+
+ @Get('memory-heap')
+ @HealthCheck()
+ @Perm(PermissionHealth.MH)
+ async checkMemoryHeap() {
+ // the process should not use more than 200MB memory
+ return this.memory.checkHeap('memory-heap', 200 * 1024 * 1024);
+ }
+
+ @Get('memory-rss')
+ @HealthCheck()
+ @Perm(PermissionHealth.MR)
+ async checkMemoryRSS() {
+ // the process should not have more than 200MB RSS memory allocated
+ return this.memory.checkRSS('memory-rss', 200 * 1024 * 1024);
+ }
+
+ @Get('disk')
+ @HealthCheck()
+ @Perm(PermissionHealth.DISK)
+ async checkDisk() {
+ return this.disk.checkStorage('disk', {
+ // The used disk storage should not exceed 75% of the full disk size
+ thresholdPercent: 0.75,
+ path: '/'
+ });
+ }
+}
diff --git a/src/modules/health/health.module.ts b/src/modules/health/health.module.ts
new file mode 100644
index 0000000..674c072
--- /dev/null
+++ b/src/modules/health/health.module.ts
@@ -0,0 +1,11 @@
+import { HttpModule } from '@nestjs/axios';
+import { Module } from '@nestjs/common';
+import { TerminusModule } from '@nestjs/terminus';
+
+import { HealthController } from './health.controller';
+
+@Module({
+ imports: [TerminusModule, HttpModule],
+ controllers: [HealthController]
+})
+export class HealthModule {}
diff --git a/src/modules/materials_inventory/in_out/materials_in_out.controller.ts b/src/modules/materials_inventory/in_out/materials_in_out.controller.ts
new file mode 100644
index 0000000..c41a04d
--- /dev/null
+++ b/src/modules/materials_inventory/in_out/materials_in_out.controller.ts
@@ -0,0 +1,90 @@
+import { Body, Controller, Delete, Get, Post, Put, Query, Res } from '@nestjs/common';
+import { ApiOperation, ApiTags } from '@nestjs/swagger';
+import { ApiResult } from '~/common/decorators/api-result.decorator';
+import { IdParam } from '~/common/decorators/id-param.decorator';
+import { MaterialsInOutService } from './materials_in_out.service';
+import { MaterialsInOutEntity } from './materials_in_out.entity';
+import { ApiSecurityAuth } from '~/common/decorators/swagger.decorator';
+import { definePermission, Perm } from '~/modules/auth/decorators/permission.decorator';
+import {
+ MaterialsInOutQueryDto,
+ MaterialsInOutDto,
+ MaterialsInOutUpdateDto,
+ MaterialsInOutExportDto
+} from './materials_in_out.dto';
+import { Domain, DomainType, SkDomain } from '~/common/decorators/domain.decorator';
+import { FastifyReply } from 'fastify';
+export const permissions = definePermission('materials_inventory:history_in_out', {
+ LIST: 'list',
+ CREATE: 'create',
+ READ: 'read',
+ UPDATE: 'update',
+ DELETE: 'delete',
+ EXPORT: 'export'
+} as const);
+
+@ApiTags('Materials In Out History - 原材料出入库记录')
+@ApiSecurityAuth()
+@Controller('materials-in-out')
+export class MaterialsInOutController {
+ constructor(private materialsInOutService: MaterialsInOutService) { }
+
+ @Get('export')
+ @ApiOperation({ summary: '导出原材料盘点表' })
+ @Perm(permissions.EXPORT)
+ async exportMaterialsInventoryCheck(
+ @Domain() domain: SkDomain,
+ @Query() dto: MaterialsInOutExportDto,
+ @Res() res: FastifyReply
+ ): Promise {
+ await this.materialsInOutService.exportMaterialsInventoryCheck({ ...dto, domain }, res);
+ }
+
+
+ @Get()
+ @ApiOperation({ summary: '获取原材料出入库记录列表' })
+ @ApiResult({ type: [MaterialsInOutEntity], isPage: true })
+ @Perm(permissions.LIST)
+ async list(@Domain() domain: SkDomain, @Query() dto: MaterialsInOutQueryDto) {
+ return this.materialsInOutService.findAll({ ...dto, domain });
+ }
+
+ @Get(':id')
+ @ApiOperation({ summary: '获取原材料出入库记录信息' })
+ @ApiResult({ type: MaterialsInOutDto })
+ @Perm(permissions.READ)
+ async info(@IdParam() id: number) {
+ return this.materialsInOutService.info(id);
+ }
+
+ @Post()
+ @ApiOperation({ summary: '新增原材料出入库记录' })
+ @Perm(permissions.CREATE)
+ async create(@Domain() domain: SkDomain, @Body() dto: MaterialsInOutDto): Promise {
+ return this.materialsInOutService.create({ ...dto, domain });
+ }
+
+ @Put(':id')
+ @ApiOperation({ summary: '更新原材料出入库记录' })
+ @Perm(permissions.UPDATE)
+ async update(@IdParam() id: number, @Body() dto: MaterialsInOutUpdateDto): Promise {
+ await this.materialsInOutService.update(id, dto);
+ }
+
+ @Delete(':id')
+ @ApiOperation({ summary: '删除原材料出入库记录' })
+ @Perm(permissions.DELETE)
+ async delete(@IdParam() id: number): Promise {
+ await this.materialsInOutService.delete(id);
+ }
+
+ @Put('unlink-attachments/:id')
+ @ApiOperation({ summary: '附件解除关联' })
+ @Perm(permissions.UPDATE)
+ async unlinkAttachments(
+ @IdParam() id: number,
+ @Body() { fileIds }: MaterialsInOutUpdateDto
+ ): Promise {
+ await this.materialsInOutService.unlinkAttachments(id, fileIds);
+ }
+}
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
new file mode 100644
index 0000000..f4faab5
--- /dev/null
+++ b/src/modules/materials_inventory/in_out/materials_in_out.dto.ts
@@ -0,0 +1,199 @@
+import { ApiProperty, IntersectionType, PartialType } from '@nestjs/swagger';
+import { Transform } from 'class-transformer';
+import {
+ IsArray,
+ IsBoolean,
+ IsDate,
+ IsDateString,
+ IsEnum,
+ IsIn,
+ IsInt,
+ IsNumber,
+ IsOptional,
+ IsString,
+ Matches,
+ MinLength,
+ ValidateIf,
+ isNumber
+} from 'class-validator';
+import dayjs from 'dayjs';
+import { DomainType } from '~/common/decorators/domain.decorator';
+import { PagerDto } from '~/common/dto/pager.dto';
+import { MaterialsInOrOutEnum } from '~/constants/enum';
+import { Storage } from '~/modules/tools/storage/storage.entity';
+import { formatToDate } from '~/utils';
+
+export class MaterialsInOutDto extends DomainType {
+ @IsOptional()
+ @IsNumber()
+ @ApiProperty({ description: '项目Id' })
+ projectId?: number;
+
+ @ApiProperty({ description: '产品Id' })
+ @ValidateIf(o => !o.inventoryInOutNumber)
+ @IsNumber()
+ productId: number;
+
+ @ApiProperty({ description: '原材料库存编号' })
+ @IsOptional()
+ @IsString()
+ inventoryInOutNumber: string;
+
+ @ApiProperty({ description: '库存id(产品和单价双主键决定一条库存)' })
+ @IsOptional()
+ @IsNumber()
+ inventoryId: number;
+
+ @ApiProperty({ description: '单位(字典)' })
+ @IsNumber()
+ @IsOptional()
+ unitId: number;
+
+ @ApiProperty({ description: '入库或出库 0:入库 1:出库' })
+ @IsEnum(MaterialsInOrOutEnum)
+ inOrOut: MaterialsInOrOutEnum;
+
+ @ApiProperty({ description: '时间' })
+ @Transform(params => {
+ return params.value ? new Date(params.value) : null;
+ })
+ @IsOptional()
+ time: Date;
+
+ @ApiProperty({ description: '数量' })
+ @IsNumber()
+ quantity: number;
+
+ @ApiProperty({ description: '单价' })
+ @IsOptional()
+ @IsNumber()
+ unitPrice: number;
+
+ @ApiProperty({ description: '金额' })
+ @IsOptional()
+ @IsNumber()
+ amount: number;
+
+ @ApiProperty({ description: '经办人' })
+ @IsOptional()
+ @IsString()
+ agent: string;
+
+ @ApiProperty({ description: '领料单号' })
+ @IsOptional()
+ @IsString()
+ issuanceNumber: string;
+
+ @ApiProperty({ description: '库存位置' })
+ @IsOptional()
+ @IsString()
+ position: string;
+
+ @IsOptional()
+ @IsString()
+ @ApiProperty({ description: '备注' })
+ remark: string;
+}
+
+export class MaterialsInOutUpdateDto extends PartialType(MaterialsInOutDto) {
+ @ApiProperty({ description: '附件' })
+ @IsOptional()
+ @IsArray()
+ fileIds: number[];
+}
+export class MaterialsInOutQueryDto extends IntersectionType(
+ PagerDto,
+ DomainType
+) {
+ @ApiProperty({ description: '出入库时间YYYY-MM-DD' })
+ @IsOptional()
+ // @IsString()
+ @Transform(params => {
+ // 开始和结束时间用的是一天的开始和一天的结束的时分秒
+ return params.value
+ ? [
+ params.value[0] ? `${formatToDate(params.value[0], 'YYYY-MM-DD')} 00:00:00` : null,
+ params.value[1] ? `${formatToDate(params.value[1], 'YYYY-MM-DD')} 23:59:59` : null
+ ]
+ : [];
+ })
+ time?: string[];
+
+ @ApiProperty({ description: '入库或出库 0:入库 1:出库' })
+ @IsOptional()
+ @IsEnum(MaterialsInOrOutEnum)
+ inOrOut?: MaterialsInOrOutEnum;
+
+ @ApiProperty({ description: '产品名称' })
+ @IsOptional()
+ @IsString()
+ product?: string;
+
+ @ApiProperty({ description: '经办人' })
+ @IsOptional()
+ @IsString()
+ agent?: string;
+
+ @ApiProperty({ description: '领料单号' })
+ @IsOptional()
+ @IsString()
+ issuanceNumber?: string;
+
+ @ApiProperty({ description: '原材料库存编号' })
+ @IsOptional()
+ @IsString()
+ inventoryInOutNumber?: string;
+
+ @IsOptional()
+ @IsString()
+ @ApiProperty({ description: '备注' })
+ remark?: string;
+
+ @IsOptional()
+ @IsNumber()
+ @ApiProperty({ description: '项目Id' })
+ projectId?: number;
+
+ @IsOptional()
+ @IsBoolean()
+ @ApiProperty({ description: '是否是用于创建出库记录' })
+ isCreateOut?: boolean;
+}
+export class MaterialsInOutExportDto extends IntersectionType(
+
+ DomainType
+) {
+
+ @ApiProperty({ description: '导出时间YYYY-MM-DD' })
+ @IsOptional()
+ @IsArray()
+ @Transform(params => {
+ // 开始和结束时间用的是一月的开始和一月的结束的时分秒
+ const date = params.value;
+ return [
+ date ? `${date[0]} 00:00:00` : null,
+ date ? `${date[1]} 23:59:59` : null
+ ];
+ })
+ time?: string[];
+
+ @ApiProperty({ description: '导出文件名' })
+ @IsOptional()
+ @IsString()
+ filename?: string
+
+ @ApiProperty({ description: '入库或出库 0:入库 1:出库' })
+ @IsOptional()
+ @IsEnum(MaterialsInOrOutEnum)
+ inOrOut?: MaterialsInOrOutEnum;
+
+ @ApiProperty({ description: '产品名称' })
+ @IsOptional()
+ @IsString()
+ product?: string;
+
+ @ApiProperty({ description: '经办人' })
+ @IsOptional()
+ @IsString()
+ agent?: string;
+}
\ No newline at end of file
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
new file mode 100644
index 0000000..b8e36b0
--- /dev/null
+++ b/src/modules/materials_inventory/in_out/materials_in_out.entity.ts
@@ -0,0 +1,148 @@
+import { ApiHideProperty, ApiProperty } from '@nestjs/swagger';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Expose } from 'class-transformer';
+import pinyin from 'pinyin';
+import {
+ BeforeInsert,
+ Column,
+ Entity,
+ JoinColumn,
+ JoinTable,
+ ManyToMany,
+ ManyToOne,
+ Relation,
+ Repository
+} from 'typeorm';
+import { CommonEntity } from '~/common/entity/common.entity';
+import { MaterialsInOrOutEnum, ParamConfigEnum } from '~/constants/enum';
+import { ProductEntity } from '~/modules/product/product.entity';
+import { ProjectEntity } from '~/modules/project/project.entity';
+import { ParamConfigEntity } from '~/modules/system/param-config/param-config.entity';
+import { Storage } from '~/modules/tools/storage/storage.entity';
+import { MaterialsInventoryEntity } from '../materials_inventory.entity';
+import { SkDomain } from '~/common/decorators/domain.decorator';
+@Entity({ name: 'materials_in_out' })
+export class MaterialsInOutEntity extends CommonEntity {
+ @Column({
+ name: 'inventory_inout_number',
+ type: 'varchar',
+ length: 50,
+ comment: '原材料出入库编号'
+ })
+ @ApiProperty({ description: '原材料出入库编号' })
+ inventoryInOutNumber: string;
+
+ @Column({
+ name: 'product_id',
+ type: 'int',
+ comment: '产品'
+ })
+ @ApiProperty({ description: '产品' })
+ productId: number;
+
+ @Column({
+ name: 'inventory_id',
+ type: 'int',
+ comment: '库存'
+ })
+ @ApiProperty({ description: '库存' })
+ inventoryId: number;
+
+ @Column({
+ name: 'in_or_out',
+ type: 'tinyint',
+ comment: '入库或出库'
+ })
+ @ApiProperty({ description: '入库或出库 0:入库 1:出库' })
+ inOrOut: MaterialsInOrOutEnum;
+
+ @Column({
+ name: 'time',
+ type: 'datetime',
+ nullable: true,
+ comment: '时间'
+ })
+ @ApiProperty({ description: '时间' })
+ time: Date;
+
+ @Column({
+ name: 'quantity',
+ type: 'int',
+ default: 0,
+ comment: '数量'
+ })
+ @ApiProperty({ description: '数量' })
+ quantity: number;
+
+ @Column({
+ name: 'unit_price',
+ type: 'decimal',
+ precision: 15,
+ default: 0,
+ scale: 10,
+ comment: '单价'
+ })
+ @ApiProperty({ description: '单价' })
+ unitPrice: number;
+
+ @Column({
+ name: 'amount',
+ type: 'decimal',
+ precision: 15,
+ default: 0,
+ scale: 10,
+ comment: '金额'
+ })
+ @ApiProperty({ description: '金额' })
+ amount: number;
+
+ @Column({ name: 'agent', type: 'varchar', length: 50, comment: '经办人', nullable: true })
+ @ApiProperty({ description: '经办人' })
+ agent: string;
+
+ @Column({
+ name: 'issuance_number',
+ type: 'varchar',
+ length: 100,
+ nullable: true,
+ comment: '领料单号'
+ })
+ @ApiProperty({ description: '领料单号' })
+ issuanceNumber: string;
+
+ @Column({ name: 'remark', type: 'varchar', length: 255, comment: '备注', nullable: true })
+ @ApiProperty({ description: '备注' })
+ remark: string;
+
+ @Column({ name: 'project_id', type: 'int', comment: '项目', nullable: true })
+ @ApiProperty({ description: '项目Id' })
+ projectId: number;
+
+ @Column({ name: 'is_delete', type: 'tinyint', default: 0, comment: '是否删除' })
+ @ApiProperty({ description: '删除状态:0未删除,1已删除' })
+ isDelete: number;
+
+ @ManyToOne(() => ProjectEntity)
+ @JoinColumn({ name: 'project_id' })
+ project: ProjectEntity;
+
+ @ManyToOne(() => ProductEntity)
+ @JoinColumn({ name: 'product_id' })
+ product: ProductEntity;
+
+ @Column({ type: 'int', default: 1, comment: '所属域' })
+ @ApiProperty({ description: '所属域' })
+ domain: SkDomain;
+
+ @ManyToMany(() => Storage, storage => storage.materialsInOuts)
+ @JoinTable({
+ name: 'materials_in_out_storage',
+ joinColumn: { name: 'materials_in_out_id', referencedColumnName: 'id' },
+ inverseJoinColumn: { name: 'file_id', referencedColumnName: 'id' }
+ })
+ files: Relation;
+
+ @ManyToOne(() => MaterialsInventoryEntity)
+ @JoinColumn({ name: 'inventory_id' })
+ inventory: MaterialsInventoryEntity;
+}
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
new file mode 100644
index 0000000..5cc4ef6
--- /dev/null
+++ b/src/modules/materials_inventory/in_out/materials_in_out.service.ts
@@ -0,0 +1,465 @@
+import { Injectable } from '@nestjs/common';
+import { InjectEntityManager, InjectRepository } from '@nestjs/typeorm';
+
+import { Between, EntityManager, In, Repository } from 'typeorm';
+import { Pagination } from '~/helper/paginate/pagination';
+import { BusinessException } from '~/common/exceptions/biz.exception';
+import { ErrorEnum } from '~/constants/error-code.constant';
+import { paginate } from '~/helper/paginate';
+import { Storage } from '~/modules/tools/storage/storage.entity';
+import {
+ MaterialsInOutQueryDto,
+ MaterialsInOutDto,
+ MaterialsInOutUpdateDto,
+ MaterialsInOutExportDto
+} from './materials_in_out.dto';
+import { MaterialsInOutEntity } from './materials_in_out.entity';
+import { fieldSearch } from '~/shared/database/field-search';
+import { ParamConfigEntity } from '~/modules/system/param-config/param-config.entity';
+import { MaterialsInOrOutEnum, ParamConfigEnum } from '~/constants/enum';
+import { MaterialsInventoryEntity } from '../materials_inventory.entity';
+import { MaterialsInventoryService } from '../materials_inventory.service';
+import { isDefined } from 'class-validator';
+import { FastifyReply } from 'fastify';
+import * as ExcelJS from 'exceljs';
+import dayjs from 'dayjs';
+@Injectable()
+export class MaterialsInOutService {
+ constructor(
+ @InjectEntityManager() private entityManager: EntityManager,
+ @InjectRepository(MaterialsInOutEntity)
+ private materialsInOutRepository: Repository,
+ @InjectRepository(Storage)
+ private storageRepository: Repository,
+ @InjectRepository(ParamConfigEntity)
+ private paramConfigRepository: Repository,
+ private materialsInventoryService: MaterialsInventoryService
+ ) { }
+
+
+ /**
+ * 导出出入库记录表
+ */
+ async exportMaterialsInventoryCheck(
+ { time, domain, filename, ...ext }: MaterialsInOutExportDto,
+ res: FastifyReply
+ ): Promise {
+ const ROW_HEIGHT = 20;
+ const HEADER_FONT_SIZE = 18;
+
+ // 生成数据
+ const sqb = this.buildSearchQuery()
+ .where(fieldSearch(ext))
+ .andWhere({
+ time: Between(time[0], time[1])
+ })
+ .andWhere('materialsInOut.isDelete = 0');
+ const data = await sqb.addOrderBy('materialsInOut.time', 'DESC').getMany();
+ const workbook = new ExcelJS.Workbook();
+ const sheet = workbook.addWorksheet('出入库记录');
+ sheet.mergeCells('A1:T1');
+ // 设置标题
+ sheet.getCell('A1').value = '山东矿机华信智能科技有限公司出入库记录表';
+ // 设置日期
+ sheet.mergeCells('A2:C2');
+ sheet.getCell('A2').value = `日期:${dayjs(time[0]).format('YYYY年M月D日')}-${dayjs(time[1]).format('YYYY年M月D日')}`;
+ // 设置表头
+ const headers = [
+ '出入库单号',
+ '出入库',
+ '项目',
+ '公司名称',
+ '产品名称',
+ '规格型号',
+ '时间',
+ '单位',
+ '数量',
+ '单价',
+ '金额',
+ '经办人',
+ '领料单号',
+ '备注'
+ ];
+ sheet.addRow(headers);
+ for (let index = 0; index < data.length; index++) {
+ const record = data[index];
+ sheet.addRow([
+ `${record.inventoryInOutNumber}`,
+ record.project?.name || '',
+ record.inOrOut === MaterialsInOrOutEnum.In ? '入库' : "出库",
+ record.product?.company?.name || '',
+ record.product?.name || '',
+ record.product?.productSpecification || '',
+ `${dayjs(record.time).format('YYYY-MM-DD HH:mm')}`,
+ record.product.unit.label || '',
+ record.quantity,
+ parseFloat(`${record.unitPrice || 0}`),
+ parseFloat(`${record.amount || 0}`),
+ `${record?.agent || ''}`,
+ record?.issuanceNumber || '',
+ record?.remark || ''
+ ]);
+ }
+ // 固定信息样式设定
+ sheet.eachRow((row, index) => {
+ if (index >= 3) {
+ row.alignment = { vertical: 'middle', horizontal: 'center' };
+ row.height = ROW_HEIGHT;
+ row.eachCell(cell => {
+ cell.border = {
+ top: { style: 'thin' },
+ left: { style: 'thin' },
+ bottom: { style: 'thin' },
+ right: { style: 'thin' }
+ };
+ });
+ }
+ });
+
+ sheet.columns.forEach((column, index: number) => {
+ let maxColumnLength = 0;
+ const autoWidth = ['B', 'C', 'S', 'U'];
+ if (String.fromCharCode(65 + index) === 'B') maxColumnLength = 20;
+ if (autoWidth.includes(String.fromCharCode(65 + index))) {
+ column.eachCell({ includeEmpty: true }, (cell, rowIndex) => {
+ if (rowIndex >= 5) {
+ const columnLength = `${cell.value || ''}`.length;
+ if (columnLength > maxColumnLength) {
+ maxColumnLength = columnLength;
+ }
+ }
+ });
+ column.width = maxColumnLength < 12 ? 12 : maxColumnLength; // Minimum width of 10
+ } else {
+ column.width = 12;
+ }
+ });
+ //读取buffer进行传输
+ const buffer = await workbook.xlsx.writeBuffer();
+ res
+ .header('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
+ .header(
+ 'Content-Disposition',
+ `attachment; filename="${filename}.xls"`
+ )
+ .send(buffer);
+ }
+
+
+
+ /**
+ * 查询所有出入库记录
+ */
+ async findAll({
+ page,
+ pageSize,
+ product: productName,
+ projectId,
+ isCreateOut,
+ ...ext
+ }: MaterialsInOutQueryDto): Promise> {
+ const sqb = this.buildSearchQuery()
+ .where(fieldSearch(ext))
+ .andWhere('materialsInOut.isDelete = 0')
+ .addOrderBy('materialsInOut.createdAt', 'DESC');
+
+ if (productName) {
+ sqb.andWhere('product.name like :productName', { productName: `%${productName}%` });
+ }
+
+ if (projectId) {
+ sqb.andWhere('project.id = :projectId', { projectId });
+ }
+
+ if (isCreateOut) {
+ sqb.andWhere('materialsInOut.inOrOut = 0');
+ }
+ const pageData = await paginate(sqb, {
+ page,
+ pageSize
+ });
+ return pageData;
+ }
+
+ buildSearchQuery() {
+ return this.materialsInOutRepository
+ .createQueryBuilder('materialsInOut')
+ .leftJoin('materialsInOut.files', 'files')
+ .leftJoin('materialsInOut.project', 'project')
+ .leftJoin('materialsInOut.product', 'product')
+ .leftJoin('materialsInOut.inventory', 'inventory')
+ .leftJoin('product.unit', 'unit')
+ .leftJoin('product.files', 'productFiles')
+ .leftJoin('product.company', 'company')
+ .addSelect([
+ 'inventory.id',
+ 'inventory.position',
+ 'inventory.inventoryNumber',
+ 'files.id',
+ 'files.path',
+ 'project.name',
+ 'product.name',
+ 'product.productSpecification',
+ 'product.productNumber',
+ 'productFiles.id',
+ 'productFiles.path',
+ 'unit.label',
+ 'company.name'
+ ]);
+ }
+ /**
+ * 新增
+ */
+ async create(dto: MaterialsInOutDto): Promise {
+ let {
+ inOrOut,
+ inventoryInOutNumber,
+ projectId,
+ inventoryId,
+ position,
+ unitPrice,
+ quantity,
+ productId,
+ domain
+ } = dto;
+ inventoryInOutNumber = await this.generateInventoryInOutNumber(inOrOut);
+ let newRecordId;
+ await this.entityManager.transaction(async manager => {
+ delete dto.position;
+ // 1.更新增减库存
+ const inventoryEntity = await (
+ Object.is(inOrOut, MaterialsInOrOutEnum.In)
+ ? this.materialsInventoryService.inInventory.bind(this.materialsInventoryService)
+ : this.materialsInventoryService.outInventory.bind(this.materialsInventoryService)
+ )({ productId, quantity, unitPrice, projectId, inventoryId, position }, manager, domain);
+ // 2.生成出入库记录
+ const { id } = await manager.save(MaterialsInOutEntity, {
+ ...this.materialsInOutRepository.create({ ...dto, inventoryId: inventoryEntity?.id }),
+ inventoryInOutNumber
+ });
+ newRecordId = id;
+ });
+ return newRecordId;
+ }
+
+ /**
+ * 更新
+ */
+ async update(id: number, { fileIds, ...data }: Partial): Promise {
+ await this.entityManager.transaction(async manager => {
+ /* 暂时不允许更改金额和数量,以及不能影响库存变化, */
+ const entity = await manager.findOne(MaterialsInOutEntity, {
+ where: {
+ id
+ },
+ lock: { mode: 'pessimistic_write' }
+ });
+
+ // 修改入库记录的价格
+ // 1.会直接更改库存实际价格.(仅仅只能之前价格为0时可以修改)
+ // 2.会同步库存所有的出库记录,修改其单价和金额.
+ if (
+ Object.is(data.inOrOut, MaterialsInOrOutEnum.In) &&
+ isDefined(data.unitPrice) &&
+ Math.abs(Number(data.unitPrice) - Number(entity.unitPrice)) !== 0
+ ) {
+ if (entity.unitPrice != 0) {
+ throw new BusinessException(
+ ErrorEnum.MATERIALS_IN_OUT_UNIT_PRICE_MUST_ZERO_WHEN_MODIFIED
+ );
+ }
+ const outEntities = await manager.find(MaterialsInOutEntity, {
+ where: {
+ inventoryId: entity.inventoryId,
+ inOrOut: MaterialsInOrOutEnum.Out
+ }
+ });
+ if (outEntities?.length > 0) {
+ await manager.update(
+ MaterialsInOutEntity,
+ {
+ id: In(outEntities.map(item => item.id))
+ },
+ {
+ unitPrice: data.unitPrice,
+ amount: () => `quantity * ${data.unitPrice}`
+ }
+ );
+ }
+ await manager.update(MaterialsInventoryEntity, entity.inventoryId, {
+ unitPrice: data.unitPrice
+ });
+ }
+ // 修改入库时的项目,必须同步到库存项目中
+ if (
+ Object.is(data.inOrOut, MaterialsInOrOutEnum.In) &&
+ isDefined(data.projectId) &&
+ data.projectId != entity.projectId
+ ) {
+ await manager.update(MaterialsInventoryEntity, entity.inventoryId, {
+ projectId: data.projectId
+ });
+ }
+
+ // 暂时不允许修改数量
+ // let changedQuantity = 0;
+ // if (isDefined(data.quantity) && entity.quantity !== data.quantity) {
+ // if (entity.inOrOut === MaterialsInOrOutEnum.In) {
+ // // 入库减少等于出库
+ // if (data.quantity - entity.quantity < 0) {
+ // data.inOrOut = MaterialsInOrOutEnum.Out;
+ // } else {
+ // // 入库增多等于入库
+ // data.inOrOut = MaterialsInOrOutEnum.In;
+ // }
+ // } else {
+ // // 出库减少等于入库
+ // if (data.quantity - entity.quantity < 0) {
+ // data.inOrOut = MaterialsInOrOutEnum.In;
+ // } else {
+ // // 出库增多等于出库
+ // data.inOrOut = MaterialsInOrOutEnum.Out;
+ // }
+ // }
+ // changedQuantity = Math.abs(data.quantity - entity.quantity);
+ // }
+ // // 2.更新增减库存
+ // if (changedQuantity !== 0) {
+ // await (
+ // Object.is(data.inOrOut, MaterialsInOrOutEnum.In)
+ // ? this.materialsInventoryService.inInventory
+ // : this.materialsInventoryService.outInventory
+ // )(
+ // {
+ // productId: entity.productId,
+ // quantity: Math.abs(changedQuantity),
+ // unitPrice: undefined,
+ // projectId: entity.projectId
+ // },
+ // manager
+ // );
+ // }
+ // 完成所有业务逻辑后,更新出入库记录
+ await manager.update(MaterialsInOutEntity, id, {
+ ...data
+ });
+
+ if (fileIds?.length) {
+ const count = await this.storageRepository
+ .createQueryBuilder('storage')
+ .where('storage.id in(:fileIds)', { fileIds })
+ .getCount();
+ if (count !== fileIds?.length) {
+ throw new BusinessException(ErrorEnum.STORAGE_NOT_FOUND);
+ }
+ // 附件要批量插入
+ await manager
+ .createQueryBuilder()
+ .relation(MaterialsInOutEntity, 'files')
+ .of(id)
+ .add(fileIds);
+ }
+ });
+ }
+
+ /**
+ * 删除
+ */
+ async delete(id: number): Promise {
+ await this.entityManager.transaction(async manager => {
+ const entity = await manager.findOne(MaterialsInOutEntity, {
+ where: {
+ id,
+ isDelete: 0
+ },
+ lock: { mode: 'pessimistic_write' }
+ });
+ if (!entity) {
+ throw new BusinessException(ErrorEnum.MATERIALS_IN_OUT_NOT_FOUND);
+ }
+
+ // 更新库存
+ await (
+ Object.is(entity.inOrOut, MaterialsInOrOutEnum.In)
+ ? this.materialsInventoryService.outInventory.bind(this.materialsInventoryService)
+ : this.materialsInventoryService.inInventory.bind(this.materialsInventoryService)
+ )(
+ {
+ quantity: entity.quantity,
+ inventoryId: entity.inventoryId
+ },
+ manager
+ );
+ });
+
+ // 出入库比较重要,做逻辑删除
+ await this.materialsInOutRepository.update(id, { isDelete: 1 });
+ }
+
+ /**
+ * 获取单个出入库信息
+ */
+ async info(id: number) {
+ const info = await this.buildSearchQuery()
+ .where({
+ id
+ })
+ .andWhere('materialsInOut.isDelete = 0')
+ .getOne();
+ return info;
+ }
+
+ /**
+ * 解除附件关联
+ * @param id 出入库ID
+ * @param fileIds 附件ID
+ */
+ async unlinkAttachments(id: number, fileIds: number[]) {
+ await this.entityManager.transaction(async manager => {
+ const materialsInOut = await this.materialsInOutRepository
+ .createQueryBuilder('materialsInOut')
+ .leftJoinAndSelect('materialsInOut.files', 'files')
+ .where('materialsInOut.id = :id', { id })
+ .getOne();
+ const linkedFiles = materialsInOut.files
+ .map(item => item.id)
+ .filter(item => !fileIds.includes(item));
+ // 附件要批量更新
+ await manager
+ .createQueryBuilder()
+ .relation(MaterialsInOutEntity, 'files')
+ .of(id)
+ .addAndRemove(linkedFiles, materialsInOut.files);
+ });
+ }
+
+ /**
+ * 生成库存出入库单号
+ * @returns 库存出入库单号
+ */
+ async generateInventoryInOutNumber(inOrOut: MaterialsInOrOutEnum = MaterialsInOrOutEnum.In) {
+ const prefix =
+ (
+ await this.paramConfigRepository.findOne({
+ where: {
+ key: inOrOut
+ ? ParamConfigEnum.InventoryInOutNumberPrefixOut
+ : ParamConfigEnum.InventoryInOutNumberPrefixIn
+ }
+ })
+ )?.value || '';
+ const lastMaterial = await this.materialsInOutRepository
+ .createQueryBuilder('materialsInOut')
+ .select(
+ `MAX(CAST(REPLACE(materialsInOut.inventoryInOutNumber, '${prefix}', '') AS UNSIGNED))`,
+ 'maxInventoryInOutNumber'
+ )
+ .where('materialsInOut.inOrOut = :inOrOut', { inOrOut })
+ .getRawOne();
+ const lastNumber = lastMaterial.maxInventoryInOutNumber
+ ? parseInt(lastMaterial.maxInventoryInOutNumber.replace(prefix, ''))
+ : 0;
+ const newNumber = lastNumber + 1 < 1000 ? 1000 : lastNumber + 1;
+ return `${prefix}${newNumber}`;
+ }
+}
diff --git a/src/modules/materials_inventory/materials_inventory.controller.ts b/src/modules/materials_inventory/materials_inventory.controller.ts
new file mode 100644
index 0000000..8e53f13
--- /dev/null
+++ b/src/modules/materials_inventory/materials_inventory.controller.ts
@@ -0,0 +1,80 @@
+import { Body, Controller, Delete, Get, Param, Post, Put, Query, Res } from '@nestjs/common';
+import { ApiOperation, ApiTags } from '@nestjs/swagger';
+import { ApiResult } from '~/common/decorators/api-result.decorator';
+import { IdParam } from '~/common/decorators/id-param.decorator';
+import { Perm, definePermission } from '../auth/decorators/permission.decorator';
+import {
+ MaterialsInventoryQueryDto,
+ MaterialsInventoryDto,
+ MaterialsInventoryUpdateDto,
+ MaterialsInventoryExportDto
+} from '../materials_inventory/materials_inventory.dto';
+import { MaterialsInventoryService } from './materials_inventory.service';
+import { MaterialsInventoryEntity } from './materials_inventory.entity';
+import { ApiSecurityAuth } from '~/common/decorators/swagger.decorator';
+import { FastifyReply } from 'fastify';
+import { Domain, DomainType, SkDomain } from '~/common/decorators/domain.decorator';
+
+export const permissions = definePermission('app:materials_inventory', {
+ LIST: 'list',
+ CREATE: 'create',
+ READ: 'read',
+ UPDATE: 'update',
+ DELETE: 'delete',
+ EXPORT: 'export'
+} as const);
+
+@ApiTags('MaterialsI Inventory - 原材料库存')
+@ApiSecurityAuth()
+@Controller('materials-inventory')
+export class MaterialsInventoryController {
+ constructor(private miService: MaterialsInventoryService) {}
+
+ @Get('export')
+ @ApiOperation({ summary: '导出原材料盘点表' })
+ @Perm(permissions.EXPORT)
+ async exportMaterialsInventoryCheck(
+ @Domain() domain: SkDomain,
+ @Query() dto: MaterialsInventoryExportDto,
+ @Res() res: FastifyReply
+ ): Promise {
+ await this.miService.exportMaterialsInventoryCheck({ ...dto, domain }, res);
+ }
+
+ @Get()
+ @ApiOperation({ summary: '获取原材料库存列表' })
+ @ApiResult({ type: [MaterialsInventoryEntity], isPage: true })
+ @Perm(permissions.LIST)
+ async list(@Domain() domain: SkDomain, @Query() dto: MaterialsInventoryQueryDto) {
+ return this.miService.findAll({ ...dto, domain });
+ }
+
+ @Get(':id')
+ @ApiOperation({ summary: '获取原材料库存信息' })
+ @ApiResult({ type: MaterialsInventoryDto })
+ @Perm(permissions.READ)
+ async info(@IdParam() id: number) {
+ return this.miService.info(id);
+ }
+
+ @Post()
+ @ApiOperation({ summary: '新增原材料库存' })
+ @Perm(permissions.CREATE)
+ async create(@Body() dto: MaterialsInventoryDto): Promise {
+ await this.miService.create(dto);
+ }
+
+ @Put(':id')
+ @ApiOperation({ summary: '更新原材料库存' })
+ @Perm(permissions.UPDATE)
+ async update(@IdParam() id: number, @Body() dto: MaterialsInventoryUpdateDto): Promise {
+ await this.miService.update(id, dto);
+ }
+
+ @Delete(':id')
+ @ApiOperation({ summary: '删除原材料库存' })
+ @Perm(permissions.DELETE)
+ async delete(@IdParam() id: number): Promise {
+ await this.miService.delete(id);
+ }
+}
diff --git a/src/modules/materials_inventory/materials_inventory.dto.ts b/src/modules/materials_inventory/materials_inventory.dto.ts
new file mode 100644
index 0000000..b05e466
--- /dev/null
+++ b/src/modules/materials_inventory/materials_inventory.dto.ts
@@ -0,0 +1,74 @@
+import { ApiProperty, IntersectionType, PartialType } from '@nestjs/swagger';
+import {
+ IsArray,
+ IsDate,
+ IsDateString,
+ IsEnum,
+ IsIn,
+ IsInt,
+ IsNumber,
+ IsOptional,
+ IsString,
+ Matches,
+ MinLength
+} from 'class-validator';
+import { PagerDto } from '~/common/dto/pager.dto';
+import { Storage } from '../tools/storage/storage.entity';
+import { Transform } from 'class-transformer';
+import dayjs from 'dayjs';
+import { formatToDate } from '~/utils';
+import { HasInventoryStatusEnum } from '~/constants/enum';
+import { DomainType } from '~/common/decorators/domain.decorator';
+
+export class MaterialsInventoryDto extends DomainType {}
+
+export class MaterialsInventoryUpdateDto extends PartialType(MaterialsInventoryDto) {}
+export class MaterialsInventoryQueryDto extends IntersectionType(
+ PagerDto,
+ PartialType(MaterialsInventoryDto),
+ DomainType
+) {
+ @ApiProperty({ description: '产品名' })
+ @IsOptional()
+ @IsString()
+ product: string;
+
+ @ApiProperty({ description: '关键字' })
+ @IsOptional()
+ @IsString()
+ keyword: string;
+
+ @ApiProperty({ description: '产品名' })
+ @IsOptional()
+ @IsEnum(HasInventoryStatusEnum)
+ isHasInventory: HasInventoryStatusEnum;
+
+ @ApiProperty({ description: '项目Id' })
+ @IsOptional()
+ @IsNumber()
+ projectId: number;
+}
+export class MaterialsInventoryExportDto extends DomainType {
+ @ApiProperty({ description: '项目' })
+ @IsOptional()
+ @IsNumber()
+ projectId: number;
+
+ @ApiProperty({ description: '导出时间YYYY-MM-DD' })
+ @IsOptional()
+ @IsArray()
+ @Transform(params => {
+ // 开始和结束时间用的是一月的开始和一月的结束的时分秒
+ const date = params.value;
+ return [
+ date ? `${date[0]} 00:00:00` : null,
+ date ? `${date[1]} 23:59:59` : null
+ ];
+ })
+ time?: string[];
+
+ @ApiProperty({ description: '文件名' })
+ @IsOptional()
+ @IsString()
+ filename: string;
+}
diff --git a/src/modules/materials_inventory/materials_inventory.entity.ts b/src/modules/materials_inventory/materials_inventory.entity.ts
new file mode 100644
index 0000000..0625be9
--- /dev/null
+++ b/src/modules/materials_inventory/materials_inventory.entity.ts
@@ -0,0 +1,98 @@
+import { ApiHideProperty, ApiProperty } from '@nestjs/swagger';
+import {
+ Column,
+ Entity,
+ JoinColumn,
+ JoinTable,
+ ManyToMany,
+ ManyToOne,
+ OneToMany,
+ Relation
+} from 'typeorm';
+import { CommonEntity } from '~/common/entity/common.entity';
+import { ProductEntity } from '../product/product.entity';
+import { ProjectEntity } from '../project/project.entity';
+import { MaterialsInOutEntity } from './in_out/materials_in_out.entity';
+import { SkDomain } from '~/common/decorators/domain.decorator';
+
+@Entity({ name: 'materials_inventory' })
+export class MaterialsInventoryEntity extends CommonEntity {
+ @Column({
+ name: 'project_id',
+ type: 'int',
+ comment: '项目'
+ })
+ @ApiProperty({ description: '项目' })
+ projectId: number;
+
+ @Column({
+ name: 'product_id',
+ type: 'int',
+ comment: '产品'
+ })
+ @ApiProperty({ description: '产品' })
+ productId: number;
+
+ @Column({
+ name: 'position',
+ type: 'varchar',
+ length: 255,
+ nullable: true,
+ comment: '库存位置'
+ })
+ @ApiProperty({ description: '库存位置' })
+ position: string;
+
+ @Column({
+ name: 'quantity',
+ type: 'int',
+ default: 0,
+ comment: '库存产品数量'
+ })
+ @ApiProperty({ description: '库存产品数量' })
+ quantity: number;
+
+ @Column({
+ name: 'unit_price',
+ type: 'decimal',
+ precision: 15,
+ default: 0,
+ scale: 10,
+ comment: '库存产品单价'
+ })
+ @ApiProperty({ description: '库存产品单价' })
+ unitPrice: number;
+
+ @Column({ name: 'remark', type: 'varchar', length: 255, comment: '备注', nullable: true })
+ @ApiProperty({ description: '备注' })
+ remark: string;
+
+ @Column({ name: 'is_delete', type: 'tinyint', default: 0, comment: '是否删除' })
+ @ApiProperty({ description: '删除状态:0未删除,1已删除' })
+ isDelete: number;
+
+ @Column({ type: 'int', default: 1, comment: '所属域' })
+ @ApiProperty({ description: '所属域' })
+ domain: SkDomain;
+
+ @ManyToOne(() => ProjectEntity)
+ @JoinColumn({ name: 'project_id' })
+ project: ProjectEntity;
+
+ @ManyToOne(() => ProductEntity)
+ @JoinColumn({ name: 'product_id' })
+ product: ProductEntity;
+
+ @Column({
+ name: 'inventory_number',
+ type: 'varchar',
+ length: 50,
+ comment: '库存编号'
+ })
+ @ApiProperty({ description: '库存编号' })
+ inventoryNumber: string;
+
+ @ApiHideProperty()
+ @OneToMany(() => MaterialsInOutEntity, inout => inout.inventory)
+ materialsInOuts: Relation;
+}
diff --git a/src/modules/materials_inventory/materials_inventory.module.ts b/src/modules/materials_inventory/materials_inventory.module.ts
new file mode 100644
index 0000000..4e9aee3
--- /dev/null
+++ b/src/modules/materials_inventory/materials_inventory.module.ts
@@ -0,0 +1,24 @@
+import { Module } from '@nestjs/common';
+import { MaterialsInventoryController } from './materials_inventory.controller';
+import { MaterialsInventoryService } from './materials_inventory.service';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import { MaterialsInventoryEntity } from './materials_inventory.entity';
+import { StorageModule } from '../tools/storage/storage.module';
+import { MaterialsInOutController } from './in_out/materials_in_out.controller';
+import { MaterialsInOutService } from './in_out/materials_in_out.service';
+import { MaterialsInOutEntity } from './in_out/materials_in_out.entity';
+import { ParamConfigModule } from '../system/param-config/param-config.module';
+import { ProjectModule } from '../project/project.module';
+import { ProjectEntity } from '../project/project.entity';
+
+@Module({
+ imports: [
+ TypeOrmModule.forFeature([MaterialsInventoryEntity, MaterialsInOutEntity,ProjectEntity]),
+ ParamConfigModule,
+ StorageModule,
+ ProjectModule
+ ],
+ controllers: [MaterialsInventoryController, MaterialsInOutController],
+ providers: [MaterialsInventoryService, MaterialsInOutService]
+})
+export class MaterialsInventoryModule {}
diff --git a/src/modules/materials_inventory/materials_inventory.service.ts b/src/modules/materials_inventory/materials_inventory.service.ts
new file mode 100644
index 0000000..a8b0516
--- /dev/null
+++ b/src/modules/materials_inventory/materials_inventory.service.ts
@@ -0,0 +1,571 @@
+import { Injectable } from '@nestjs/common';
+import { InjectEntityManager, InjectRepository } from '@nestjs/typeorm';
+import { MaterialsInventoryEntity } from './materials_inventory.entity';
+import { EntityManager, In, MoreThan, Repository } from 'typeorm';
+import {
+ MaterialsInventoryDto,
+ MaterialsInventoryExportDto,
+ MaterialsInventoryQueryDto,
+ MaterialsInventoryUpdateDto
+} from './materials_inventory.dto';
+import { Pagination } from '~/helper/paginate/pagination';
+import { FastifyReply } from 'fastify';
+import { paginate } from '~/helper/paginate';
+import * as ExcelJS from 'exceljs';
+import dayjs from 'dayjs';
+import { MaterialsInOutEntity } from './in_out/materials_in_out.entity';
+import { fieldSearch } from '~/shared/database/field-search';
+import { groupBy, sum, uniqBy } from 'lodash';
+import { HasInventoryStatusEnum, MaterialsInOrOutEnum, ParamConfigEnum } from '~/constants/enum';
+import { ProjectEntity } from '../project/project.entity';
+import { calcNumber } from '~/utils';
+import { BusinessException } from '~/common/exceptions/biz.exception';
+import { ErrorEnum } from '~/constants/error-code.constant';
+import { ParamConfigEntity } from '../system/param-config/param-config.entity';
+import { isDefined } from 'class-validator';
+import { DomainType } from '~/common/decorators/domain.decorator';
+@Injectable()
+export class MaterialsInventoryService {
+ constructor(
+ @InjectEntityManager() private entityManager: EntityManager,
+ @InjectRepository(MaterialsInventoryEntity)
+ private materialsInventoryRepository: Repository,
+ @InjectRepository(MaterialsInOutEntity)
+ private materialsInOutRepository: Repository,
+ @InjectRepository(ProjectEntity)
+ private projectRepository: Repository,
+ @InjectRepository(ParamConfigEntity)
+ private paramConfigRepository: Repository
+ ) { }
+
+ /**
+ * 导出原材料盘点表
+ */
+ async exportMaterialsInventoryCheck(
+ { time, projectId, domain }: MaterialsInventoryExportDto,
+ res: FastifyReply
+ ): Promise {
+ const ROW_HEIGHT = 20;
+ const HEADER_FONT_SIZE = 18;
+ const workbook = new ExcelJS.Workbook();
+ let projects: ProjectEntity[] = [];
+ if (projectId) {
+ projects = [await this.projectRepository.findOneBy({ id: projectId })];
+ }
+ // 查询出项目产品所属的当前库存
+ const inventoriesInProjects = await this.materialsInventoryRepository.find({
+ where: {
+ ...(projects?.length ? { projectId: In(projects.map(item => item.id)) } : null)
+ },
+ relations: ['product', 'product.company', 'product.unit']
+ });
+
+ // 生成数据
+ const sqb = this.materialsInOutRepository
+ .createQueryBuilder('mio')
+ .leftJoin('mio.project', 'project')
+ .leftJoin('mio.product', 'product')
+ .leftJoin('product.unit', 'unit')
+ .leftJoin('product.company', 'company')
+ .addSelect([
+ 'project.id',
+ 'project.name',
+ 'unit.label',
+ 'company.name',
+ 'product.name',
+ 'product.productSpecification',
+ 'product.productNumber'
+ ])
+ .where({
+ time: MoreThan(time[0])
+ })
+ .andWhere('mio.isDelete = 0');
+
+ if (projectId) {
+ sqb.andWhere('project.id = :projectId', { projectId });
+ }
+
+ const data = await sqb.addOrderBy('mio.time', 'DESC').getMany();
+ if (!projectId) {
+ projects = uniqBy(
+ data.filter(item => item.inOrOut === MaterialsInOrOutEnum.Out).map(item => item.project),
+ 'id'
+ );
+ }
+
+ for (const project of projects) {
+ const currentProjectInventories = inventoriesInProjects.filter(({ projectId }) =>
+ Object.is(projectId, project.id)
+ );
+ const currentProjectData = data.filter(
+ item => item.projectId === project.id || item.inOrOut === MaterialsInOrOutEnum.Out
+ );
+ const currentMonthProjectData = currentProjectData.filter(item => {
+ return (
+ dayjs(item.time).isAfter(dayjs(time[0])) && dayjs(item.time).isBefore(dayjs(time[1]))
+ );
+ });
+ const sheet = workbook.addWorksheet(project.name);
+ sheet.mergeCells('A1:T1');
+ // 设置标题
+ sheet.getCell('A1').value = '山东矿机华信智能科技有限公司原材料盘点表';
+ // 设置日期
+ sheet.mergeCells('A2:B2');
+ sheet.getCell('A2').value = `日期:${dayjs(time[0]).format('YYYY年M月')}`;
+ // 设置表头
+ const headers = [
+ '序号',
+ '公司名称',
+ '产品名称',
+ '单位',
+ '库存数量',
+ '单价',
+ '金额',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '结存数量',
+ '单价',
+ '金额',
+ '备注'
+ ];
+ sheet.addRow(headers);
+ sheet.addRow([
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '入库时间',
+ '数量',
+ '单价',
+ '金额',
+ '出库时间',
+ '数量',
+ '单价',
+ '金额',
+ '',
+ '',
+ '',
+ ''
+ ]);
+ for (let i = 1; i <= 7; i++) {
+ sheet.mergeCells(`${String.fromCharCode(64 + i)}3:${String.fromCharCode(64 + i)}4`);
+ }
+ // 入库
+ sheet.mergeCells('H3:K3');
+ sheet.getCell('H3').value = '入库';
+
+ // 出库
+ sheet.mergeCells('L3:O3');
+ sheet.getCell('L3').value = '出库';
+
+ for (let i = 8; i <= 15; i++) {
+ sheet.getCell(`${String.fromCharCode(64 + i)}4`).style.fill = {
+ type: 'pattern',
+ pattern: 'solid',
+ fgColor: { argb: 'FFFFC000' }
+ };
+ }
+
+ for (let i = 16; i <= 19; i++) {
+ sheet.mergeCells(`${String.fromCharCode(64 + i)}3:${String.fromCharCode(64 + i)}4`);
+ }
+
+ // 固定信息样式设定
+ sheet.eachRow((row, index) => {
+ row.alignment = { vertical: 'middle', horizontal: 'center' };
+ row.font = { bold: true };
+ row.height = ROW_HEIGHT;
+ if (index >= 3) {
+ row.eachCell(cell => {
+ cell.border = {
+ top: { style: 'thin' },
+ left: { style: 'thin' },
+ bottom: { style: 'thin' },
+ right: { style: 'thin' }
+ };
+ });
+ }
+ });
+
+ // 根据库存Id分组
+ const groupedData = groupBy(
+ currentMonthProjectData,
+ record => record.inventoryId
+ );
+ let number = 0;
+ const groupedInventories = groupBy(currentProjectInventories, item => item.id);
+ let orderNo = 0;
+
+ for (const key in groupedInventories) {
+ orderNo++;
+ // 目前暂定逻辑出库只有一次或者没有出库。不会对一个入库的记录多次出库,故而用find。---废弃
+ // 2024.04.16 改成
+ const inventory = groupedInventories[key][0];
+ const outRecords = groupedData[key].filter(
+ item => item.inOrOut === MaterialsInOrOutEnum.Out
+ );
+ const inRecords = groupedData[key].filter(item => item.inOrOut === MaterialsInOrOutEnum.In);
+ const outRecordQuantity = outRecords
+ .map(item => item.quantity)
+ .reduce((acc, cur) => {
+ return calcNumber(acc, cur, 'add');
+ }, 0);
+
+ const inRecordQuantity = inRecords
+ .map(item => item.quantity)
+ .reduce((acc, cur) => {
+ return calcNumber(acc, cur, 'add');
+ }, 0);
+ // 这里的单价默认入库价格和出库价格一致,所以直接用总数量*入库单价
+ const outRecordAmount = calcNumber(outRecordQuantity, inventory.unitPrice || 0, 'multiply');
+ const inRecordAmount = calcNumber(inRecordQuantity, inventory.unitPrice || 0, 'multiply');
+ const currInventories = groupedInventories[key]?.shift();
+ const allDataFromMonth = data.filter(res => res.inventoryId == Number(key));
+ let currentQuantity = 0;
+ let balanceQuantity = 0;
+ // 月初库存数量
+ if (currInventories) {
+ const sumIn = sum(
+ allDataFromMonth
+ .filter(res => Object.is(res.inOrOut, MaterialsInOrOutEnum.In))
+ .map(item => item.quantity)
+ );
+ const sumOut = sum(
+ allDataFromMonth
+ .filter(res => Object.is(res.inOrOut, MaterialsInOrOutEnum.Out))
+ .map(item => item.quantity)
+ );
+ const sumDistance = calcNumber(sumIn, sumOut, 'subtract');
+ currentQuantity = calcNumber(currInventories.quantity, sumDistance, 'subtract');
+ }
+ // 结存库存数量
+ balanceQuantity = calcNumber(
+ currentQuantity,
+ calcNumber(inRecordQuantity, outRecordQuantity, 'subtract'),
+ 'add'
+ );
+ number++;
+ sheet.addRow([
+ `${orderNo}`,
+ inventory.product?.company?.name || '',
+ inventory.product?.name || '',
+ inventory.product.unit.label || '',
+ currentQuantity,
+ parseFloat(`${inventory.unitPrice || 0}`),
+ calcNumber(currentQuantity, inventory.unitPrice || 0, 'multiply'),
+ // inRecord.time,
+ '',
+ inRecordQuantity,
+ parseFloat(`${inventory.unitPrice || 0}`),
+ parseFloat(`${inRecordAmount}`),
+ // outRecord?.time || '',
+ '',
+ outRecordQuantity,
+ parseFloat(`${inventory?.unitPrice || 0}`),
+ parseFloat(`${outRecordAmount}`),
+ balanceQuantity,
+ parseFloat(`${inventory?.unitPrice || 0}`),
+ calcNumber(balanceQuantity, inventory?.unitPrice || 0, 'multiply'),
+ // `${inRecord?.agent || ''}/${outRecord?.agent || ''}`,
+ ''
+ ]);
+ }
+ sheet.getCell('A1').font = { size: HEADER_FONT_SIZE };
+
+ // 固定信息样式设定
+ sheet.eachRow((row, index) => {
+ if (index >= 5) {
+ row.alignment = { vertical: 'middle', horizontal: 'center' };
+ row.height = ROW_HEIGHT;
+ row.eachCell(cell => {
+ cell.border = {
+ top: { style: 'thin' },
+ left: { style: 'thin' },
+ bottom: { style: 'thin' },
+ right: { style: 'thin' }
+ };
+ });
+ }
+ });
+
+ sheet.columns.forEach((column, index: number) => {
+ let maxColumnLength = 0;
+ const autoWidth = ['B', 'C', 'S', 'U'];
+ if (String.fromCharCode(65 + index) === 'B') maxColumnLength = 20;
+ if (autoWidth.includes(String.fromCharCode(65 + index))) {
+ column.eachCell({ includeEmpty: true }, (cell, rowIndex) => {
+ if (rowIndex >= 5) {
+ const columnLength = `${cell.value || ''}`.length;
+ if (columnLength > maxColumnLength) {
+ maxColumnLength = columnLength;
+ }
+ }
+ });
+ column.width = maxColumnLength < 12 ? 12 : maxColumnLength; // Minimum width of 10
+ } else {
+ column.width = 12;
+ }
+ });
+ }
+ //读取buffer进行传输
+ const buffer = await workbook.xlsx.writeBuffer();
+ res
+ .header('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
+ .header(
+ 'Content-Disposition',
+ `attachment; filename="${encodeURIComponent('导出_excel' + new Date().getTime() + '.xls')}"`
+ )
+ .send(buffer);
+ }
+
+ /**
+ * 查询所有盘点信息
+ */
+ async findAll({
+ page,
+ pageSize,
+ product,
+ keyword,
+ projectId,
+ isHasInventory,
+ domain
+ }: MaterialsInventoryQueryDto): Promise> {
+ const queryBuilder = 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',
+ 'unit.id',
+ 'unit.label',
+ 'company.id',
+ 'company.name',
+ 'product.id',
+ 'product.name',
+ 'product.productSpecification',
+ 'product.productNumber'
+ ])
+ .where(fieldSearch({ domain }))
+ .andWhere('materialsInventory.isDelete = 0');
+ if (product) {
+ queryBuilder.andWhere('product.name like :product', { product: `%${product}%` });
+ }
+
+ if (projectId) {
+ queryBuilder.andWhere('project.id = :projectId', { projectId });
+ }
+
+ if (keyword) {
+ queryBuilder.andWhere(
+ '(materialsInventory.inventoryNumber like :keyword or product.name like :keyword or product.productNumber like :keyword or product.productSpecification like :keyword)',
+ {
+ keyword: `%${keyword}%`
+ }
+ );
+ }
+ if (isHasInventory == HasInventoryStatusEnum.Yes) {
+ queryBuilder.andWhere('materialsInventory.quantity > 0');
+ }
+ if (isHasInventory == HasInventoryStatusEnum.No) {
+ queryBuilder.andWhere('materialsInventory.quantity = 0');
+ }
+ return paginate(queryBuilder, {
+ page,
+ pageSize
+ });
+ }
+
+ /**
+ * 新增库存
+ */
+ async create(dto: MaterialsInventoryDto): Promise {
+ await this.materialsInventoryRepository.insert(dto);
+ }
+
+ /**
+ * 更新库存
+ */
+ async update(id: number, data: Partial): Promise {
+ await this.entityManager.transaction(async manager => {
+ await manager.update(MaterialsInventoryEntity, id, {
+ ...data
+ });
+ });
+ }
+
+ /**
+ * 产品入库后计算最新库存
+ * 请注意。产品库存需要根据产品id和价格双主键存储。因为产品价格会变化,需要分开统计。
+ * @param data 传入项目ID,产品ID和入库数量和单价
+ * @param manager 传入事务对象防止开启多重事务
+ */
+ async inInventory(
+ data: {
+ position?: string;
+ projectId: number;
+ productId: number;
+ quantity: number;
+ inventoryId?: number;
+ unitPrice?: number;
+ changedUnitPrice?: number;
+ },
+ manager: EntityManager,
+ domain?: DomainType
+ ): Promise {
+ const {
+ projectId,
+ productId,
+ quantity: inQuantity,
+ unitPrice,
+ changedUnitPrice,
+ position,
+ inventoryId
+ } = data;
+ let searchPayload: any = {};
+ if (isDefined(inventoryId)) {
+ searchPayload = { id: inventoryId, domain };
+ } else {
+ searchPayload = { projectId, productId, unitPrice, domain };
+ }
+ const exsitedInventory = await manager.findOne(MaterialsInventoryEntity, {
+ where: searchPayload, // 根据项目,产品,价格查出之前的实时库存情况
+ lock: { mode: 'pessimistic_write' } // 开启悲观行锁,防止脏读和修改
+ });
+
+ // 若不存在库存,直接新增库存
+ if (!exsitedInventory) {
+ const inventoryNumber = await this.generateInventoryNumber();
+ const { raw } = await manager.insert(MaterialsInventoryEntity, {
+ projectId,
+ productId,
+ unitPrice,
+ inventoryNumber,
+ position,
+ quantity: inQuantity
+ });
+ return manager.findOne(MaterialsInventoryEntity, { where: { id: raw.insertId } });
+ }
+ // 若该项目存在库存,则该项目该产品的库存增加
+ let { quantity, id } = exsitedInventory;
+ const newQuantity = calcNumber(quantity || 0, inQuantity || 0, 'add');
+ if (isNaN(newQuantity)) {
+ throw new Error('库存数量不合法');
+ }
+ await manager.update(MaterialsInventoryEntity, id, {
+ quantity: newQuantity,
+ unitPrice: changedUnitPrice || undefined
+ });
+
+ return manager.findOne(MaterialsInventoryEntity, { where: { id } });
+ }
+
+ /**
+ * 产品出库
+ * @param data 传入库存ID(一定存在。)
+ * @param manager 传入事务对象防止开启多重事务
+ */
+ async outInventory(
+ data: {
+ quantity: number;
+ inventoryId?: number;
+ },
+ manager: EntityManager,
+ ): Promise {
+ const { quantity: outQuantity, inventoryId } = data;
+ // 开启悲观行锁,防止脏读和修改
+ const inventory = await manager.findOne(MaterialsInventoryEntity, {
+ where: { id: inventoryId },
+ lock: { mode: 'pessimistic_write' }
+ });
+ // 检查库存剩余
+ if (inventory.quantity < outQuantity) {
+ throw new BusinessException(ErrorEnum.INVENTORY_INSUFFICIENT);
+ }
+ // 若该项目的该产品库存充足,则该项目该产品的库存减少
+ let { quantity, id } = inventory;
+ const newQuantity = calcNumber(quantity || 0, outQuantity || 0, 'subtract');
+ if (isNaN(newQuantity)) {
+ throw new BusinessException(ErrorEnum.INVENTORY_INSUFFICIENT);
+ }
+ await manager.update(MaterialsInventoryEntity, id, {
+ quantity: newQuantity
+ });
+
+ return manager.findOne(MaterialsInventoryEntity, { where: { id } });
+ }
+
+ /**
+ * 删除
+ */
+ 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
+ })
+ .andWhere('materialsInventory.isDelete = 0')
+ .getOne();
+ return info;
+ }
+
+ /**
+ * 生成库存编号
+ * @returns 库存编号
+ */
+ async generateInventoryNumber() {
+ const prefix =
+ (
+ await this.paramConfigRepository.findOne({
+ where: {
+ key: ParamConfigEnum.InventoryNumberPrefix
+ }
+ })
+ )?.value || '';
+ const lastInventory = await this.materialsInventoryRepository
+ .createQueryBuilder('materials_inventory')
+ .select(
+ `MAX(CAST(REPLACE(materials_inventory.inventoryNumber, '${prefix}', '') AS UNSIGNED))`,
+ 'maxInventoryNumber'
+ )
+ .getRawOne();
+ const lastNumber = lastInventory.maxInventoryNumber
+ ? parseInt(lastInventory.maxInventoryNumber.replace(prefix, ''))
+ : 0;
+ const newNumber = lastNumber + 1 < 1000 ? 1000 : lastNumber + 1;
+ return `${prefix}${newNumber}`;
+ }
+}
diff --git a/src/modules/netdisk/manager/manage-qiniu.service.ts b/src/modules/netdisk/manager/manage-qiniu.service.ts
new file mode 100644
index 0000000..3554b7b
--- /dev/null
+++ b/src/modules/netdisk/manager/manage-qiniu.service.ts
@@ -0,0 +1,836 @@
+import { basename, extname } from 'node:path';
+
+import { Injectable } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { isEmpty } from 'lodash';
+import * as qiniu from 'qiniu';
+import { auth, conf, rs } from 'qiniu';
+
+import { ConfigKeyPaths } from '~/config';
+import {
+ NETDISK_COPY_SUFFIX,
+ NETDISK_DELIMITER,
+ NETDISK_HANDLE_MAX_ITEM,
+ NETDISK_LIMIT
+} from '~/constants/oss.constant';
+
+import { AccountInfo } from '~/modules/user/user.model';
+import { UserService } from '~/modules/user/user.service';
+
+import { generateRandomValue } from '~/utils';
+
+import { SFileInfo, SFileInfoDetail, SFileList } from './manage.class';
+import { FileOpItem } from './manage.dto';
+
+@Injectable()
+export class QiNiuNetDiskManageService {
+ private config: conf.ConfigOptions;
+ private mac: auth.digest.Mac;
+ private bucketManager: rs.BucketManager;
+
+ private get qiniuConfig() {
+ return this.configService.get('oss', { infer: true });
+ }
+
+ constructor(
+ private configService: ConfigService,
+ private userService: UserService
+ ) {
+ this.mac = new qiniu.auth.digest.Mac(this.qiniuConfig.accessKey, this.qiniuConfig.secretKey);
+ this.config = new qiniu.conf.Config({
+ zone: this.qiniuConfig.zone
+ });
+ // bucket manager
+ this.bucketManager = new qiniu.rs.BucketManager(this.mac, this.config);
+ }
+
+ /**
+ * 获取文件列表
+ * @param prefix 当前文件夹路径,搜索模式下会被忽略
+ * @param marker 下一页标识
+ * @returns iFileListResult
+ */
+ async getFileList(prefix = '', marker = '', skey = ''): Promise {
+ // 是否需要搜索
+ const searching = !isEmpty(skey);
+ return new Promise((resolve, reject) => {
+ this.bucketManager.listPrefix(
+ this.qiniuConfig.bucket,
+ {
+ prefix: searching ? '' : prefix,
+ limit: NETDISK_LIMIT,
+ delimiter: searching ? '' : NETDISK_DELIMITER,
+ marker
+ },
+ (err, respBody, respInfo) => {
+ if (err) {
+ reject(err);
+ return;
+ }
+ if (respInfo.statusCode === 200) {
+ // 如果这个nextMarker不为空,那么还有未列举完毕的文件列表,下次调用listPrefix的时候,
+ // 指定options里面的marker为这个值
+ const fileList: SFileInfo[] = [];
+ // 处理目录,但只有非搜索模式下可用
+ if (!searching && !isEmpty(respBody.commonPrefixes)) {
+ // dir
+ for (const dirPath of respBody.commonPrefixes) {
+ const name = (dirPath as string).substr(0, dirPath.length - 1).replace(prefix, '');
+ if (isEmpty(skey) || name.includes(skey)) {
+ fileList.push({
+ name: (dirPath as string).substr(0, dirPath.length - 1).replace(prefix, ''),
+ type: 'dir',
+ id: generateRandomValue(10)
+ });
+ }
+ }
+ }
+ // handle items
+ if (!isEmpty(respBody.items)) {
+ // file
+ for (const item of respBody.items) {
+ // 搜索模式下处理
+ if (searching) {
+ const pathList: string[] = item.key.split(NETDISK_DELIMITER);
+ // dir is empty stirng, file is key string
+ const name = pathList.pop();
+ if (
+ item.key.endsWith(NETDISK_DELIMITER) &&
+ pathList[pathList.length - 1].includes(skey)
+ ) {
+ // 结果是目录
+ const ditName = pathList.pop();
+ fileList.push({
+ id: generateRandomValue(10),
+ name: ditName,
+ type: 'dir',
+ belongTo: pathList.join(NETDISK_DELIMITER)
+ });
+ } else if (name.includes(skey)) {
+ // 文件
+ fileList.push({
+ id: generateRandomValue(10),
+ name,
+ type: 'file',
+ fsize: item.fsize,
+ mimeType: item.mimeType,
+ putTime: new Date(Number.parseInt(item.putTime) / 10000),
+ belongTo: pathList.join(NETDISK_DELIMITER)
+ });
+ }
+ } else {
+ // 正常获取列表
+ const fileKey = item.key.replace(prefix, '') as string;
+ if (!isEmpty(fileKey)) {
+ fileList.push({
+ id: generateRandomValue(10),
+ name: fileKey,
+ type: 'file',
+ fsize: item.fsize,
+ mimeType: item.mimeType,
+ putTime: new Date(Number.parseInt(item.putTime) / 10000)
+ });
+ }
+ }
+ }
+ }
+ resolve({
+ list: fileList,
+ marker: respBody.marker || null
+ });
+ } else {
+ reject(
+ new Error(`Qiniu Error Code: ${respInfo.statusCode}, Info: ${respInfo.statusMessage}`)
+ );
+ }
+ }
+ );
+ });
+ }
+
+ /**
+ * 获取文件信息
+ */
+ async getFileInfo(name: string, path: string): Promise {
+ return new Promise((resolve, reject) => {
+ this.bucketManager.stat(
+ this.qiniuConfig.bucket,
+ `${path}${name}`,
+ (err, respBody, respInfo) => {
+ if (err) {
+ reject(err);
+ return;
+ }
+ if (respInfo.statusCode === 200) {
+ const detailInfo: SFileInfoDetail = {
+ fsize: respBody.fsize,
+ hash: respBody.hash,
+ md5: respBody.md5,
+ mimeType: respBody.mimeType.split('/x-qn-meta')[0],
+ putTime: new Date(Number.parseInt(respBody.putTime) / 10000),
+ type: respBody.type,
+ uploader: '',
+ mark: respBody?.['x-qn-meta']?.['!mark'] ?? ''
+ };
+ if (!respBody.endUser) {
+ resolve(detailInfo);
+ } else {
+ this.userService
+ .getAccountInfo(Number.parseInt(respBody.endUser))
+ .then((user: AccountInfo) => {
+ if (isEmpty(user)) {
+ resolve(detailInfo);
+ } else {
+ detailInfo.uploader = user.username;
+ resolve(detailInfo);
+ }
+ });
+ }
+ } else {
+ reject(
+ new Error(`Qiniu Error Code: ${respInfo.statusCode}, Info: ${respInfo.statusMessage}`)
+ );
+ }
+ }
+ );
+ });
+ }
+
+ /**
+ * 修改文件MimeType
+ */
+ async changeFileHeaders(
+ name: string,
+ path: string,
+ headers: { [k: string]: string }
+ ): Promise {
+ return new Promise((resolve, reject) => {
+ this.bucketManager.changeHeaders(
+ this.qiniuConfig.bucket,
+ `${path}${name}`,
+ headers,
+ (err, _, respInfo) => {
+ if (err) {
+ reject(err);
+ return;
+ }
+ if (respInfo.statusCode === 200) {
+ resolve();
+ } else {
+ reject(
+ new Error(`Qiniu Error Code: ${respInfo.statusCode}, Info: ${respInfo.statusMessage}`)
+ );
+ }
+ }
+ );
+ });
+ }
+
+ /**
+ * 创建文件夹
+ * @returns true创建成功
+ */
+ async createDir(dirName: string): Promise {
+ const safeDirName = dirName.endsWith('/') ? dirName : `${dirName}/`;
+ return new Promise((resolve, reject) => {
+ // 上传一个空文件以用于显示文件夹效果
+ const formUploader = new qiniu.form_up.FormUploader(this.config);
+ const putExtra = new qiniu.form_up.PutExtra();
+ formUploader.put(
+ this.createUploadToken(''),
+ safeDirName,
+ ' ',
+ putExtra,
+ (respErr, respBody, respInfo) => {
+ if (respErr) {
+ reject(respErr);
+ return;
+ }
+ if (respInfo.statusCode === 200) {
+ resolve();
+ } else {
+ reject(
+ new Error(`Qiniu Error Code: ${respInfo.statusCode}, Info: ${respInfo.statusMessage}`)
+ );
+ }
+ }
+ );
+ });
+ }
+
+ /**
+ * 检查文件是否存在,同可检查目录
+ */
+ async checkFileExist(filePath: string): Promise {
+ return new Promise((resolve, reject) => {
+ // fix path end must a /
+
+ // 检测文件夹是否存在
+ this.bucketManager.stat(this.qiniuConfig.bucket, filePath, (respErr, respBody, respInfo) => {
+ if (respErr) {
+ reject(respErr);
+ return;
+ }
+ if (respInfo.statusCode === 200) {
+ // 文件夹存在
+ resolve(true);
+ } else if (respInfo.statusCode === 612) {
+ // 文件夹不存在
+ resolve(false);
+ } else {
+ reject(
+ new Error(`Qiniu Error Code: ${respInfo.statusCode}, Info: ${respInfo.statusMessage}`)
+ );
+ }
+ });
+ });
+ }
+
+ /**
+ * 创建Upload Token, 默认过期时间一小时
+ * @returns upload token
+ */
+ createUploadToken(endUser: string): string {
+ const policy = new qiniu.rs.PutPolicy({
+ scope: this.qiniuConfig.bucket,
+ insertOnly: 1,
+ fsizeLimit: 1024 ** 2 * 10,
+ endUser
+ });
+ const uploadToken = policy.uploadToken(this.mac);
+ return uploadToken;
+ }
+
+ /**
+ * 重命名文件
+ * @param dir 文件路径
+ * @param name 文件名称
+ */
+ async renameFile(dir: string, name: string, toName: string): Promise {
+ const fileName = `${dir}${name}`;
+ const toFileName = `${dir}${toName}`;
+ const op = {
+ force: true
+ };
+ return new Promise((resolve, reject) => {
+ this.bucketManager.move(
+ this.qiniuConfig.bucket,
+ fileName,
+ this.qiniuConfig.bucket,
+ toFileName,
+ op,
+ (err, respBody, respInfo) => {
+ if (err) {
+ reject(err);
+ } else {
+ if (respInfo.statusCode === 200) {
+ resolve();
+ } else {
+ reject(
+ new Error(
+ `Qiniu Error Code: ${respInfo.statusCode}, Info: ${respInfo.statusMessage}`
+ )
+ );
+ }
+ }
+ }
+ );
+ });
+ }
+
+ /**
+ * 移动文件
+ */
+ async moveFile(dir: string, toDir: string, name: string): Promise {
+ const fileName = `${dir}${name}`;
+ const toFileName = `${toDir}${name}`;
+ const op = {
+ force: true
+ };
+ return new Promise((resolve, reject) => {
+ this.bucketManager.move(
+ this.qiniuConfig.bucket,
+ fileName,
+ this.qiniuConfig.bucket,
+ toFileName,
+ op,
+ (err, respBody, respInfo) => {
+ if (err) {
+ reject(err);
+ } else {
+ if (respInfo.statusCode === 200) {
+ resolve();
+ } else {
+ reject(
+ new Error(
+ `Qiniu Error Code: ${respInfo.statusCode}, Info: ${respInfo.statusMessage}`
+ )
+ );
+ }
+ }
+ }
+ );
+ });
+ }
+
+ /**
+ * 复制文件
+ */
+ async copyFile(dir: string, toDir: string, name: string): Promise {
+ const fileName = `${dir}${name}`;
+ // 拼接文件名
+ const ext = extname(name);
+ const bn = basename(name, ext);
+ const toFileName = `${toDir}${bn}${NETDISK_COPY_SUFFIX}${ext}`;
+ const op = {
+ force: true
+ };
+ return new Promise((resolve, reject) => {
+ this.bucketManager.copy(
+ this.qiniuConfig.bucket,
+ fileName,
+ this.qiniuConfig.bucket,
+ toFileName,
+ op,
+ (err, respBody, respInfo) => {
+ if (err) {
+ reject(err);
+ } else {
+ if (respInfo.statusCode === 200) {
+ resolve();
+ } else {
+ reject(
+ new Error(
+ `Qiniu Error Code: ${respInfo.statusCode}, Info: ${respInfo.statusMessage}`
+ )
+ );
+ }
+ }
+ }
+ );
+ });
+ }
+
+ /**
+ * 重命名文件夹
+ */
+ async renameDir(path: string, name: string, toName: string): Promise {
+ const dirName = `${path}${name}`;
+ const toDirName = `${path}${toName}`;
+ let hasFile = true;
+ let marker = '';
+ const op = {
+ force: true
+ };
+ const bucketName = this.qiniuConfig.bucket;
+ while (hasFile) {
+ await new Promise((resolve, reject) => {
+ // 列举当前目录下的所有文件
+ this.bucketManager.listPrefix(
+ this.qiniuConfig.bucket,
+ {
+ prefix: dirName,
+ limit: NETDISK_HANDLE_MAX_ITEM,
+ marker
+ },
+ (err, respBody, respInfo) => {
+ if (err) {
+ reject(err);
+ return;
+ }
+ if (respInfo.statusCode === 200) {
+ const moveOperations = respBody.items.map(item => {
+ const { key } = item;
+ const destKey = key.replace(dirName, toDirName);
+ return qiniu.rs.moveOp(bucketName, key, bucketName, destKey, op);
+ });
+ this.bucketManager.batch(moveOperations, (err2, respBody2, respInfo2) => {
+ if (err2) {
+ reject(err2);
+ return;
+ }
+ if (respInfo2.statusCode === 200) {
+ if (isEmpty(respBody.marker)) hasFile = false;
+ else marker = respBody.marker;
+
+ resolve();
+ } else {
+ reject(
+ new Error(
+ `Qiniu Error Code: ${respInfo2.statusCode}, Info: ${respInfo2.statusMessage}`
+ )
+ );
+ }
+ });
+ } else {
+ reject(
+ new Error(
+ `Qiniu Error Code: ${respInfo.statusCode}, Info: ${respInfo.statusMessage}`
+ )
+ );
+ }
+ }
+ );
+ });
+ }
+ }
+
+ /**
+ * 获取七牛下载的文件url链接
+ * @param key 文件路径
+ * @returns 连接
+ */
+ getDownloadLink(key: string): string {
+ if (this.qiniuConfig.access === 'public') {
+ return this.bucketManager.publicDownloadUrl(this.qiniuConfig.domain, key);
+ } else if (this.qiniuConfig.access === 'private') {
+ return this.bucketManager.privateDownloadUrl(
+ this.qiniuConfig.domain,
+ key,
+ Date.now() / 1000 + 36000
+ );
+ }
+ throw new Error('qiniu config access type not support');
+ }
+
+ /**
+ * 删除文件
+ * @param dir 删除的文件夹目录
+ * @param name 文件名
+ */
+ async deleteFile(dir: string, name: string): Promise {
+ return new Promise((resolve, reject) => {
+ this.bucketManager.delete(
+ this.qiniuConfig.bucket,
+ `${dir}${name}`,
+ (err, respBody, respInfo) => {
+ if (err) {
+ reject(err);
+ return;
+ }
+ if (respInfo.statusCode === 200) {
+ resolve();
+ } else {
+ reject(
+ new Error(`Qiniu Error Code: ${respInfo.statusCode}, Info: ${respInfo.statusMessage}`)
+ );
+ }
+ }
+ );
+ });
+ }
+
+ /**
+ * 删除文件夹
+ * @param dir 文件夹所在的上级目录
+ * @param name 文件目录名称
+ */
+ async deleteMultiFileOrDir(fileList: FileOpItem[], dir: string): Promise {
+ const files = fileList.filter(item => item.type === 'file');
+ if (files.length > 0) {
+ // 批处理文件
+ const copyOperations = files.map(item => {
+ const fileName = `${dir}${item.name}`;
+ return qiniu.rs.deleteOp(this.qiniuConfig.bucket, fileName);
+ });
+ await new Promise((resolve, reject) => {
+ this.bucketManager.batch(copyOperations, (err, respBody, respInfo) => {
+ if (err) {
+ reject(err);
+ return;
+ }
+ if (respInfo.statusCode === 200) {
+ resolve();
+ } else if (respInfo.statusCode === 298) {
+ reject(new Error('操作异常,但部分文件夹删除成功'));
+ } else {
+ reject(
+ new Error(`Qiniu Error Code: ${respInfo.statusCode}, Info: ${respInfo.statusMessage}`)
+ );
+ }
+ });
+ });
+ }
+ // 处理文件夹
+ const dirs = fileList.filter(item => item.type === 'dir');
+ if (dirs.length > 0) {
+ // 处理文件夹的复制
+ for (let i = 0; i < dirs.length; i++) {
+ const dirName = `${dir}${dirs[i].name}/`;
+ let hasFile = true;
+ let marker = '';
+ while (hasFile) {
+ await new Promise((resolve, reject) => {
+ // 列举当前目录下的所有文件
+ this.bucketManager.listPrefix(
+ this.qiniuConfig.bucket,
+ {
+ prefix: dirName,
+ limit: NETDISK_HANDLE_MAX_ITEM,
+ marker
+ },
+ (err, respBody, respInfo) => {
+ if (err) {
+ reject(err);
+ return;
+ }
+ if (respInfo.statusCode === 200) {
+ const moveOperations = respBody.items.map(item => {
+ const { key } = item;
+ return qiniu.rs.deleteOp(this.qiniuConfig.bucket, key);
+ });
+ this.bucketManager.batch(moveOperations, (err2, respBody2, respInfo2) => {
+ if (err2) {
+ reject(err2);
+ return;
+ }
+ if (respInfo2.statusCode === 200) {
+ if (isEmpty(respBody.marker)) hasFile = false;
+ else marker = respBody.marker;
+
+ resolve();
+ } else {
+ reject(
+ new Error(
+ `Qiniu Error Code: ${respInfo2.statusCode}, Info: ${respInfo2.statusMessage}`
+ )
+ );
+ }
+ });
+ } else {
+ reject(
+ new Error(
+ `Qiniu Error Code: ${respInfo.statusCode}, Info: ${respInfo.statusMessage}`
+ )
+ );
+ }
+ }
+ );
+ });
+ }
+ }
+ }
+ }
+
+ /**
+ * 复制文件,含文件夹
+ */
+ async copyMultiFileOrDir(fileList: FileOpItem[], dir: string, toDir: string): Promise {
+ const files = fileList.filter(item => item.type === 'file');
+ const op = {
+ force: true
+ };
+ if (files.length > 0) {
+ // 批处理文件
+ const copyOperations = files.map(item => {
+ const fileName = `${dir}${item.name}`;
+ // 拼接文件名
+ const ext = extname(item.name);
+ const bn = basename(item.name, ext);
+ const toFileName = `${toDir}${bn}${NETDISK_COPY_SUFFIX}${ext}`;
+ return qiniu.rs.copyOp(
+ this.qiniuConfig.bucket,
+ fileName,
+ this.qiniuConfig.bucket,
+ toFileName,
+ op
+ );
+ });
+ await new Promise((resolve, reject) => {
+ this.bucketManager.batch(copyOperations, (err, respBody, respInfo) => {
+ if (err) {
+ reject(err);
+ return;
+ }
+ if (respInfo.statusCode === 200) {
+ resolve();
+ } else if (respInfo.statusCode === 298) {
+ reject(new Error('操作异常,但部分文件夹删除成功'));
+ } else {
+ reject(
+ new Error(`Qiniu Error Code: ${respInfo.statusCode}, Info: ${respInfo.statusMessage}`)
+ );
+ }
+ });
+ });
+ }
+ // 处理文件夹
+ const dirs = fileList.filter(item => item.type === 'dir');
+ if (dirs.length > 0) {
+ // 处理文件夹的复制
+ for (let i = 0; i < dirs.length; i++) {
+ const dirName = `${dir}${dirs[i].name}/`;
+ const copyDirName = `${toDir}${dirs[i].name}${NETDISK_COPY_SUFFIX}/`;
+ let hasFile = true;
+ let marker = '';
+ while (hasFile) {
+ await new Promise((resolve, reject) => {
+ // 列举当前目录下的所有文件
+ this.bucketManager.listPrefix(
+ this.qiniuConfig.bucket,
+ {
+ prefix: dirName,
+ limit: NETDISK_HANDLE_MAX_ITEM,
+ marker
+ },
+ (err, respBody, respInfo) => {
+ if (err) {
+ reject(err);
+ return;
+ }
+ if (respInfo.statusCode === 200) {
+ const moveOperations = respBody.items.map(item => {
+ const { key } = item;
+ const destKey = key.replace(dirName, copyDirName);
+ return qiniu.rs.copyOp(
+ this.qiniuConfig.bucket,
+ key,
+ this.qiniuConfig.bucket,
+ destKey,
+ op
+ );
+ });
+ this.bucketManager.batch(moveOperations, (err2, respBody2, respInfo2) => {
+ if (err2) {
+ reject(err2);
+ return;
+ }
+ if (respInfo2.statusCode === 200) {
+ if (isEmpty(respBody.marker)) hasFile = false;
+ else marker = respBody.marker;
+
+ resolve();
+ } else {
+ reject(
+ new Error(
+ `Qiniu Error Code: ${respInfo2.statusCode}, Info: ${respInfo2.statusMessage}`
+ )
+ );
+ }
+ });
+ } else {
+ reject(
+ new Error(
+ `Qiniu Error Code: ${respInfo.statusCode}, Info: ${respInfo.statusMessage}`
+ )
+ );
+ }
+ }
+ );
+ });
+ }
+ }
+ }
+ }
+
+ /**
+ * 移动文件,含文件夹
+ */
+ async moveMultiFileOrDir(fileList: FileOpItem[], dir: string, toDir: string): Promise