feat(trade): 仓配可调配履约与三分支推单
CI / verify (pull_request) Has been cancelled

同城有仓按仓库绑定承运商自动推单或自管填单,无仓/跨城走总部快递;新增仓配注册表、FulfillmentService 及三端运单追踪。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-16 17:51:20 +08:00
parent f1dc23c54c
commit f6d97b4ee8
30 changed files with 1615 additions and 57 deletions
@@ -1,5 +1,10 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma, WarehouseManagerType, WarehouseStatus } from '@prisma/client';
import {
Prisma,
WarehouseFulfillmentMode,
WarehouseManagerType,
WarehouseStatus,
} from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { PartnerCityService } from './partner-city.service';
@@ -13,9 +18,27 @@ export type CreateCityWarehouseInput = {
managerType: WarehouseManagerType;
partnerAccountId?: bigint;
status?: WarehouseStatus;
fulfillmentMode?: WarehouseFulfillmentMode;
fulfillmentProviderId?: bigint;
manualCarrierLabel?: string;
manualQueryUrlTemplate?: string;
lng?: number;
lat?: number;
};
export type UpdateCityWarehouseInput = Partial<CreateCityWarehouseInput>;
export type UpdateCityWarehouseInput = Partial<
Omit<
CreateCityWarehouseInput,
'partnerAccountId' | 'fulfillmentProviderId' | 'manualCarrierLabel' | 'manualQueryUrlTemplate' | 'lng' | 'lat'
>
> & {
partnerAccountId?: bigint | null;
fulfillmentProviderId?: bigint | null;
manualCarrierLabel?: string | null;
manualQueryUrlTemplate?: string | null;
lng?: number | null;
lat?: number | null;
};
@Injectable()
export class CityWarehouseService {
@@ -29,6 +52,7 @@ export class CityWarehouseService {
where: { cityId },
include: {
partnerAccount: { select: { id: true, companyName: true } },
fulfillmentProvider: { select: { id: true, code: true, name: true } },
},
orderBy: { createdAt: 'desc' },
});
@@ -51,6 +75,7 @@ export class CityWarehouseService {
take: pageSize,
include: {
partnerAccount: { select: { id: true, companyName: true } },
fulfillmentProvider: { select: { id: true, code: true, name: true } },
city: { select: { id: true, name: true, code: true } },
},
orderBy: { createdAt: 'desc' },
@@ -73,6 +98,7 @@ export class CityWarehouseService {
async create(cityId: bigint, input: CreateCityWarehouseInput) {
await this.assertCityExists(cityId);
await this.validateManager(input.managerType, input.partnerAccountId, cityId);
await this.validateFulfillment(input.fulfillmentMode, input.fulfillmentProviderId);
const row = await this.prisma.cityWarehouse.create({
data: {
@@ -84,9 +110,17 @@ export class CityWarehouseService {
managerType: input.managerType,
partnerAccountId: input.managerType === 'PARTNER' ? input.partnerAccountId : null,
status: input.status ?? 'ACTIVE',
fulfillmentMode: input.fulfillmentMode ?? 'MANUAL',
fulfillmentProviderId:
input.fulfillmentMode === 'API_AUTO' ? input.fulfillmentProviderId : null,
manualCarrierLabel: input.manualCarrierLabel?.trim() || null,
manualQueryUrlTemplate: input.manualQueryUrlTemplate?.trim() || null,
lng: input.lng != null ? input.lng : null,
lat: input.lat != null ? input.lat : null,
},
include: {
partnerAccount: { select: { id: true, companyName: true } },
fulfillmentProvider: { select: { id: true, code: true, name: true } },
},
});
await this.syncManagedWarehouse(row.id, row.managerType as WarehouseManagerType, row.partnerAccountId);
@@ -102,8 +136,16 @@ export class CityWarehouseService {
managerType === 'PARTNER'
? input.partnerAccountId ?? current.partnerAccountId ?? undefined
: null;
const fulfillmentMode = input.fulfillmentMode ?? current.fulfillmentMode;
const fulfillmentProviderId =
fulfillmentMode === 'API_AUTO'
? input.fulfillmentProviderId !== undefined
? input.fulfillmentProviderId
: current.fulfillmentProviderId
: null;
await this.validateManager(managerType, partnerAccountId ?? undefined, current.cityId);
await this.validateFulfillment(fulfillmentMode, fulfillmentProviderId ?? undefined);
const row = await this.prisma.cityWarehouse.update({
where: { id },
@@ -117,9 +159,22 @@ export class CityWarehouseService {
? { partnerAccountId: managerType === 'PARTNER' ? partnerAccountId : null }
: {}),
...(input.status !== undefined ? { status: input.status } : {}),
...(input.fulfillmentMode !== undefined ? { fulfillmentMode } : {}),
...(input.fulfillmentMode !== undefined || input.fulfillmentProviderId !== undefined
? { fulfillmentProviderId }
: {}),
...(input.manualCarrierLabel !== undefined
? { manualCarrierLabel: input.manualCarrierLabel?.trim() || null }
: {}),
...(input.manualQueryUrlTemplate !== undefined
? { manualQueryUrlTemplate: input.manualQueryUrlTemplate?.trim() || null }
: {}),
...(input.lng !== undefined ? { lng: input.lng } : {}),
...(input.lat !== undefined ? { lat: input.lat } : {}),
},
include: {
partnerAccount: { select: { id: true, companyName: true } },
fulfillmentProvider: { select: { id: true, code: true, name: true } },
},
});
await this.syncManagedWarehouse(row.id, row.managerType as WarehouseManagerType, row.partnerAccountId);
@@ -170,6 +225,19 @@ export class CityWarehouseService {
}
}
private async validateFulfillment(
mode?: WarehouseFulfillmentMode,
providerId?: bigint,
) {
if (mode === 'API_AUTO') {
if (!providerId) throw new BadRequestException('API 自动推单须选择仓配承运商');
const provider = await this.prisma.fulfillmentProvider.findUnique({ where: { id: providerId } });
if (!provider || provider.status !== 'ACTIVE' || provider.type !== 'API') {
throw new BadRequestException('所选仓配承运商不可用');
}
}
}
private async assertCityExists(cityId: bigint) {
const city = await this.prisma.commonCity.findUnique({ where: { id: cityId } });
if (!city) throw new NotFoundException('开城城市不存在');
@@ -185,9 +253,16 @@ export class CityWarehouseService {
managerType: string;
partnerAccountId: bigint | null;
status: string;
fulfillmentMode: string;
fulfillmentProviderId: bigint | null;
manualCarrierLabel: string | null;
manualQueryUrlTemplate: string | null;
lng: Prisma.Decimal | null;
lat: Prisma.Decimal | null;
createdAt: Date;
updatedAt: Date;
partnerAccount?: { id: bigint; companyName: string | null } | null;
fulfillmentProvider?: { id: bigint; code: string; name: string } | null;
}) {
return serializeBigInt({
id: row.id.toString(),
@@ -200,6 +275,14 @@ export class CityWarehouseService {
partnerAccountId: row.partnerAccountId?.toString() ?? null,
partnerCompanyName: row.partnerAccount?.companyName ?? null,
status: row.status,
fulfillmentMode: row.fulfillmentMode,
fulfillmentProviderId: row.fulfillmentProviderId?.toString() ?? null,
fulfillmentProviderName: row.fulfillmentProvider?.name ?? null,
fulfillmentProviderCode: row.fulfillmentProvider?.code ?? null,
manualCarrierLabel: row.manualCarrierLabel,
manualQueryUrlTemplate: row.manualQueryUrlTemplate,
lng: row.lng != null ? Number(row.lng) : null,
lat: row.lat != null ? Number(row.lat) : null,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
});
@@ -101,9 +101,38 @@ export class PartnerCityService {
async buildPartnerOrderWhere(partnerAccountId: bigint): Promise<Prisma.OrderWhereInput> {
const primary = await this.resolvePrimaryAccount(partnerAccountId);
if (!primary.cityId) return { id: -1n };
const warehouseIds = await this.resolveManagedWarehouseIds(primary.id);
if (warehouseIds.length > 0) {
return { fulfillmentWarehouseId: { in: warehouseIds } };
}
return { cityId: primary.cityId };
}
/** 合伙人可管仓库:主账号 managedWarehouseId + 绑定为管仓合伙人的仓 */
async resolveManagedWarehouseIds(partnerAccountId: bigint): Promise<bigint[]> {
const primary = await this.resolvePrimaryAccount(partnerAccountId);
const ids = new Set<bigint>();
if (primary.managedWarehouseId) {
ids.add(primary.managedWarehouseId);
}
const managed = await this.prisma.cityWarehouse.findMany({
where: {
status: 'ACTIVE',
OR: [
{ partnerAccountId: primary.id },
...(primary.managedWarehouseId ? [{ id: primary.managedWarehouseId }] : []),
],
},
select: { id: true },
});
for (const row of managed) ids.add(row.id);
return [...ids];
}
async buildPartnerCityWhere(partnerAccountId: bigint): Promise<Prisma.CommonCityWhereInput> {
const primary = await this.resolvePrimaryAccount(partnerAccountId);
if (!primary.cityId) return { id: -1n };