用 NestJS 打造采购订单主从 CRUD:从零到上线的完整实战

60 阅读3分钟

用 NestJS 打造采购订单主从 CRUD:从零到上线的完整实战

关键词:NestJS、TypeORM、PostgreSQL、Supabase、Vercel、Swagger、单元测试
目标读者:有前端或全栈背景、想快速上手 NestJS 后端开发的同学


一、项目背景与需求分析

采购订单(主表)与采购订单明细(从表)是企业 ERP 系统的通用场景。我们要实现:

  • 采购订单主表,记录供应商、下单日期、金额等信息;
  • 订单明细(从表),包含物料编码、数量、单价、税额等;
  • 完整的 CRUD:创建、查询(列表/详情)、更新、删除;
  • 数据库使用 Supabase 提供的 PostgreSQL;
  • 前端调用方式:REST API;
  • 同时提供 Swagger 文档、在线测试页面、自动化测试以及 Vercel 部署方案。

数据模型(简化版)

关键字段说明
purchase_ordersorder_codesupplier_nameorder_datetotal_amountstatus主表,记录一张订单的核心数据
purchase_order_itemspurchase_order_idline_nosku_codequantityunit_price从表,记录订单明细
关联方式外键 purchase_order_items.purchase_order_id → purchase_orders.id一对多关系

二、项目准备:搭建 NestJS + TypeORM 环境

  1. 安装依赖

    npm install @nestjs/typeorm typeorm pg @nestjs/config class-validator class-transformer @nestjs/swagger swagger-ui-express
    
  2. 数据库配置

    在 Supabase SQL Editor 中运行建表语句(支持触发器自动更新 updated_at)。

  3. 环境变量 .env

    DATABASE_URL=postgresql://postgres:password@host:5432/postgres?sslmode=require
    DATABASE_SSL=true
    DB_POOL_MAX=5
    PORT=3000
    
  4. app.module.ts 中连接数据库

    TypeOrmModule.forRootAsync({
      inject: [ConfigService],
      useFactory: (configService: ConfigService) => ({
        type: 'postgres',
        url: configService.get<string>('DATABASE_URL'),
        autoLoadEntities: true,
        synchronize: false,
        logging: true,
        ssl: configService.get('DATABASE_SSL') === 'true' ? { rejectUnauthorized: false } : false,
        keepConnectionAlive: true,
        extra: { max: Number(configService.get('DB_POOL_MAX') ?? 5) },
      }),
    })
    

三、代码结构一览

src/
├── purchase-orders/
│   ├── dto/
│   │   ├── create-purchase-order.dto.ts
│   │   ├── create-purchase-order-item.dto.ts
│   │   └── update-purchase-order.dto.ts
│   ├── entities/
│   │   ├── purchase-order.entity.ts
│   │   └── purchase-order-item.entity.ts
│   ├── purchase-orders.controller.ts
│   ├── purchase-orders.module.ts
│   └── purchase-orders.service.ts
├── common/
│   └── transformers/
│       └── numeric.transformer.ts
├── app.module.ts
├── main.ts
└── ...

四、实体(Entity):映射数据库表

主表 PurchaseOrder

@Entity({ name: 'purchase_orders' })
export class PurchaseOrder {
  @PrimaryGeneratedColumn('uuid')
  @ApiProperty({ description: '主键 ID', example: '6ef1bf49-89c5-4f1c-8c00-211b69bb2a15' })
  id!: string;

  @Column({ name: 'order_code', length: 32, unique: true })
  @ApiProperty({ description: '采购订单编号', example: 'PO-2025-0001' })
  orderCode!: string;

  // ...供应商、日期、金额、状态等字段(省略)

  @OneToMany(() => PurchaseOrderItem, (item) => item.purchaseOrder, { cascade: true })
  @ApiProperty({ description: '订单明细', type: () => PurchaseOrderItem, isArray: true })
  items!: PurchaseOrderItem[];

  @CreateDateColumn({ name: 'created_at' })
  createdAt!: Date;

  @UpdateDateColumn({ name: 'updated_at' })
  updatedAt!: Date;
}

从表 PurchaseOrderItem

@Entity({ name: 'purchase_order_items' })
export class PurchaseOrderItem {
  @PrimaryGeneratedColumn('uuid')
  id!: string;

  @ManyToOne(() => PurchaseOrder, (order) => order.items, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'purchase_order_id' })
  purchaseOrder!: PurchaseOrder;

  @Column({ name: 'line_no', type: 'int' })
  lineNo!: number;

  @Column({ name: 'sku_code', length: 64 })
  skuCode!: string;

  // ...数量、价格、税额等字段(省略)

  @CreateDateColumn({ name: 'created_at' })
  createdAt!: Date;

  @UpdateDateColumn({ name: 'updated_at' })
  updatedAt!: Date;
}

注意:使用 numeric 类型时,用 transformer 把字符串转为 number,避免前端收到字符串金额。


五、DTO(数据传输对象)+ 校验

利用 class-validator + class-transformer 限制前端传入参数:

export class CreatePurchaseOrderDto {
  @IsString()
  @IsNotEmpty()
  @MaxLength(32)
  @ApiProperty({ description: '采购订单编号', example: 'PO-2025-0001' })
  orderCode!: string;

  @IsString()
  @IsNotEmpty()
  @MaxLength(255)
  supplierName!: string;

  @IsArray()
  @ArrayMinSize(1)
  @Type(() => CreatePurchaseOrderItemDto)
  @ApiProperty({ description: '订单明细', type: () => CreatePurchaseOrderItemDto, isArray: true })
  items!: CreatePurchaseOrderItemDto[];

  // ...其他字段省略
}
  • CreatePurchaseOrderItemDto 定义明细字段;
  • UpdatePurchaseOrderDto 继承 PartialType,让字段可选;
  • 全局 ValidationPipe 开启 transformwhitelist,自动类型转换 + 防止多余参数。

六、Service 层:处理业务逻辑

PurchaseOrdersService 是核心:

  • create():事务内先保存主表,再保存明细;自动计算金额;
  • findAll()/findOne():预加载明细;
  • update():先删除旧明细,再插入新明细(简单方案);
  • remove():直接删除主表,数据库级联删除子项。

小技巧:金额计算集中在一个 prepareItems() 方法,保持逻辑清晰。


七、Controller:暴露 REST API

@ApiTags('采购订单')
@Controller('purchase-orders')
export class PurchaseOrdersController {
  constructor(private readonly service: PurchaseOrdersService) {}

  @Post()
  @ApiCreatedResponse({ type: PurchaseOrder })
  create(@Body() dto: CreatePurchaseOrderDto) {
    return this.service.create(dto);