feat(pre-planting): 支持省市名称存储,参照正常认种处理方式

- PurchasePrePlantingDto 添加可选字段 provinceName/cityName,
  与 SelectProvinceCityDto 保持一致,解决 NestJS forbidNonWhitelisted 400 错误
- pre_planting_positions 表新增 province_name/city_name 列(迁移)
- PrePlantingPosition aggregate 增加 provinceName/cityName 字段
- addPortions() 接受并存储省市名称
- getPosition() 返回 provinceName/cityName 供续购时显示

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-28 07:48:47 -08:00
parent 5aa17b05c5
commit 20b8d41212
7 changed files with 49 additions and 4 deletions

View File

@ -0,0 +1,3 @@
-- Add province_name and city_name to pre_planting_positions
ALTER TABLE "pre_planting_positions" ADD COLUMN IF NOT EXISTS "province_name" VARCHAR(50);
ALTER TABLE "pre_planting_positions" ADD COLUMN IF NOT EXISTS "city_name" VARCHAR(50);

View File

@ -453,7 +453,9 @@ model PrePlantingPosition {
// 省市 (首次购买时选择,后续复用)
provinceCode String? @map("province_code") @db.VarChar(10)
provinceName String? @map("province_name") @db.VarChar(50)
cityCode String? @map("city_code") @db.VarChar(10)
cityName String? @map("city_name") @db.VarChar(50)
// 首次购买时间 (1年冻结起点)
firstPurchaseAt DateTime? @map("first_purchase_at")

View File

@ -49,6 +49,8 @@ export class PrePlantingController {
dto.portionCount,
dto.provinceCode,
dto.cityCode,
dto.provinceName,
dto.cityName,
);
}

View File

@ -1,5 +1,5 @@
import { IsInt, IsString, Min, Max, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { IsInt, IsString, Min, Max, IsNotEmpty, IsOptional, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class PurchasePrePlantingDto {
@ApiProperty({ description: '购买份数', example: 1, minimum: 1, maximum: 5 })
@ -13,8 +13,20 @@ export class PurchasePrePlantingDto {
@IsNotEmpty()
provinceCode: string;
@ApiPropertyOptional({ description: '省名称', example: '广东省' })
@IsOptional()
@IsString()
@MaxLength(50)
provinceName?: string;
@ApiProperty({ description: '市代码', example: '4401' })
@IsString()
@IsNotEmpty()
cityCode: string;
@ApiPropertyOptional({ description: '市名称', example: '广州市' })
@IsOptional()
@IsString()
@MaxLength(50)
cityName?: string;
}

View File

@ -45,6 +45,8 @@ export class PrePlantingApplicationService {
portionCount: number,
provinceCode: string,
cityCode: string,
provinceName?: string,
cityName?: string,
): Promise<{ orderNo: string; merged: boolean; mergeNo?: string }> {
this.logger.log(
`[PRE-PLANTING] Purchase request: userId=${userId}, portions=${portionCount}, ` +
@ -98,7 +100,7 @@ export class PrePlantingApplicationService {
}
// 增加份数
position.addPortions(portionCount, provinceCode, cityCode);
position.addPortions(portionCount, provinceCode, cityCode, provinceName, cityName);
// 标记订单为已支付
order.markAsPaid(position.totalPortions, position.availablePortions);
@ -278,7 +280,9 @@ export class PrePlantingApplicationService {
mergedPortions: number;
totalTreesMerged: number;
provinceCode: string | null;
provinceName: string | null;
cityCode: string | null;
cityName: string | null;
firstPurchaseAt: Date | null;
} | null> {
return this.prisma.$transaction(async (tx) => {
@ -290,7 +294,9 @@ export class PrePlantingApplicationService {
mergedPortions: position.mergedPortions,
totalTreesMerged: position.totalTreesMerged,
provinceCode: position.provinceCode,
provinceName: position.provinceName,
cityCode: position.cityCode,
cityName: position.cityName,
firstPurchaseAt: position.firstPurchaseAt,
};
});

View File

@ -9,7 +9,9 @@ export interface PrePlantingPositionData {
mergedPortions: number;
totalTreesMerged: number;
provinceCode?: string | null;
provinceName?: string | null;
cityCode?: string | null;
cityName?: string | null;
firstPurchaseAt?: Date | null;
createdAt?: Date;
}
@ -23,7 +25,9 @@ export class PrePlantingPosition {
private _mergedPortions: number;
private _totalTreesMerged: number;
private _provinceCode: string | null;
private _provinceName: string | null;
private _cityCode: string | null;
private _cityName: string | null;
private _firstPurchaseAt: Date | null;
private readonly _createdAt: Date;
@ -36,7 +40,9 @@ export class PrePlantingPosition {
this._mergedPortions = 0;
this._totalTreesMerged = 0;
this._provinceCode = null;
this._provinceName = null;
this._cityCode = null;
this._cityName = null;
this._firstPurchaseAt = null;
this._createdAt = createdAt || new Date();
}
@ -53,7 +59,9 @@ export class PrePlantingPosition {
position._mergedPortions = data.mergedPortions;
position._totalTreesMerged = data.totalTreesMerged;
position._provinceCode = data.provinceCode || null;
position._provinceName = data.provinceName || null;
position._cityCode = data.cityCode || null;
position._cityName = data.cityName || null;
position._firstPurchaseAt = data.firstPurchaseAt || null;
return position;
}
@ -61,7 +69,7 @@ export class PrePlantingPosition {
/**
*
*/
addPortions(count: number, provinceCode: string, cityCode: string): void {
addPortions(count: number, provinceCode: string, cityCode: string, provinceName?: string, cityName?: string): void {
this._totalPortions += count;
this._availablePortions += count;
@ -70,6 +78,8 @@ export class PrePlantingPosition {
this._firstPurchaseAt = new Date();
this._provinceCode = provinceCode;
this._cityCode = cityCode;
this._provinceName = provinceName || null;
this._cityName = cityName || null;
}
}
@ -113,7 +123,9 @@ export class PrePlantingPosition {
get mergedPortions(): number { return this._mergedPortions; }
get totalTreesMerged(): number { return this._totalTreesMerged; }
get provinceCode(): string | null { return this._provinceCode; }
get provinceName(): string | null { return this._provinceName; }
get cityCode(): string | null { return this._cityCode; }
get cityName(): string | null { return this._cityName; }
get firstPurchaseAt(): Date | null { return this._firstPurchaseAt; }
get createdAt(): Date { return this._createdAt; }
@ -127,7 +139,9 @@ export class PrePlantingPosition {
mergedPortions: this._mergedPortions,
totalTreesMerged: this._totalTreesMerged,
provinceCode: this._provinceCode,
provinceName: this._provinceName,
cityCode: this._cityCode,
cityName: this._cityName,
firstPurchaseAt: this._firstPurchaseAt,
createdAt: this._createdAt,
};

View File

@ -24,7 +24,9 @@ export class PrePlantingPositionRepository {
mergedPortions: data.mergedPortions,
totalTreesMerged: data.totalTreesMerged,
provinceCode: data.provinceCode || null,
provinceName: data.provinceName || null,
cityCode: data.cityCode || null,
cityName: data.cityName || null,
firstPurchaseAt: data.firstPurchaseAt || null,
},
});
@ -38,7 +40,9 @@ export class PrePlantingPositionRepository {
mergedPortions: data.mergedPortions,
totalTreesMerged: data.totalTreesMerged,
provinceCode: data.provinceCode || null,
provinceName: data.provinceName || null,
cityCode: data.cityCode || null,
cityName: data.cityName || null,
firstPurchaseAt: data.firstPurchaseAt || null,
},
});
@ -97,7 +101,9 @@ export class PrePlantingPositionRepository {
mergedPortions: record.mergedPortions,
totalTreesMerged: record.totalTreesMerged,
provinceCode: record.provinceCode,
provinceName: record.provinceName,
cityCode: record.cityCode,
cityName: record.cityName,
firstPurchaseAt: record.firstPurchaseAt,
createdAt: record.createdAt,
};