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) provinceCode String? @map("province_code") @db.VarChar(10)
provinceName String? @map("province_name") @db.VarChar(50)
cityCode String? @map("city_code") @db.VarChar(10) cityCode String? @map("city_code") @db.VarChar(10)
cityName String? @map("city_name") @db.VarChar(50)
// 首次购买时间 (1年冻结起点) // 首次购买时间 (1年冻结起点)
firstPurchaseAt DateTime? @map("first_purchase_at") firstPurchaseAt DateTime? @map("first_purchase_at")

View File

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

View File

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

View File

@ -45,6 +45,8 @@ export class PrePlantingApplicationService {
portionCount: number, portionCount: number,
provinceCode: string, provinceCode: string,
cityCode: string, cityCode: string,
provinceName?: string,
cityName?: string,
): Promise<{ orderNo: string; merged: boolean; mergeNo?: string }> { ): Promise<{ orderNo: string; merged: boolean; mergeNo?: string }> {
this.logger.log( this.logger.log(
`[PRE-PLANTING] Purchase request: userId=${userId}, portions=${portionCount}, ` + `[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); order.markAsPaid(position.totalPortions, position.availablePortions);
@ -278,7 +280,9 @@ export class PrePlantingApplicationService {
mergedPortions: number; mergedPortions: number;
totalTreesMerged: number; totalTreesMerged: number;
provinceCode: string | null; provinceCode: string | null;
provinceName: string | null;
cityCode: string | null; cityCode: string | null;
cityName: string | null;
firstPurchaseAt: Date | null; firstPurchaseAt: Date | null;
} | null> { } | null> {
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
@ -290,7 +294,9 @@ export class PrePlantingApplicationService {
mergedPortions: position.mergedPortions, mergedPortions: position.mergedPortions,
totalTreesMerged: position.totalTreesMerged, totalTreesMerged: position.totalTreesMerged,
provinceCode: position.provinceCode, provinceCode: position.provinceCode,
provinceName: position.provinceName,
cityCode: position.cityCode, cityCode: position.cityCode,
cityName: position.cityName,
firstPurchaseAt: position.firstPurchaseAt, firstPurchaseAt: position.firstPurchaseAt,
}; };
}); });

View File

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

View File

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