用 NestJS 打造采购订单主从 CRUD:从零到上线的完整实战
关键词:NestJS、TypeORM、PostgreSQL、Supabase、Vercel、Swagger、单元测试
目标读者:有前端或全栈背景、想快速上手 NestJS 后端开发的同学
一、项目背景与需求分析
采购订单(主表)与采购订单明细(从表)是企业 ERP 系统的通用场景。我们要实现:
- 采购订单主表,记录供应商、下单日期、金额等信息;
- 订单明细(从表),包含物料编码、数量、单价、税额等;
- 完整的 CRUD:创建、查询(列表/详情)、更新、删除;
- 数据库使用 Supabase 提供的 PostgreSQL;
- 前端调用方式:REST API;
- 同时提供 Swagger 文档、在线测试页面、自动化测试以及 Vercel 部署方案。
数据模型(简化版)
| 表 | 关键字段 | 说明 |
|---|---|---|
purchase_orders | order_code、supplier_name、order_date、total_amount、status | 主表,记录一张订单的核心数据 |
purchase_order_items | purchase_order_id、line_no、sku_code、quantity、unit_price | 从表,记录订单明细 |
| 关联方式 | 外键 purchase_order_items.purchase_order_id → purchase_orders.id | 一对多关系 |
二、项目准备:搭建 NestJS + TypeORM 环境
-
安装依赖
npm install @nestjs/typeorm typeorm pg @nestjs/config class-validator class-transformer @nestjs/swagger swagger-ui-express -
数据库配置
在 Supabase SQL Editor 中运行建表语句(支持触发器自动更新
updated_at)。 -
环境变量
.envDATABASE_URL=postgresql://postgres:password@host:5432/postgres?sslmode=require DATABASE_SSL=true DB_POOL_MAX=5 PORT=3000 -
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开启transform、whitelist,自动类型转换 + 防止多余参数。
六、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);