同城有仓按仓库绑定承运商自动推单或自管填单,无仓/跨城走总部快递;新增仓配注册表、FulfillmentService 及三端运单追踪。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 };
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { FulfillmentProviderStatus, FulfillmentProviderType } from '@prisma/client';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
export type CreateFulfillmentProviderInput = {
|
||||
code: string;
|
||||
name: string;
|
||||
type: FulfillmentProviderType;
|
||||
status?: FulfillmentProviderStatus;
|
||||
configJson?: string;
|
||||
capabilitiesJson?: string;
|
||||
};
|
||||
|
||||
export type UpdateFulfillmentProviderInput = Partial<CreateFulfillmentProviderInput>;
|
||||
|
||||
type Capabilities = {
|
||||
createShipment?: boolean;
|
||||
getTrack?: boolean;
|
||||
callback?: boolean;
|
||||
cancel?: boolean;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class FulfillmentProviderService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async listActiveApiProviders() {
|
||||
const rows = await this.prisma.fulfillmentProvider.findMany({
|
||||
where: { status: 'ACTIVE', type: 'API' },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
return rows.map((row) => this.toDto(row));
|
||||
}
|
||||
|
||||
async listAll() {
|
||||
const rows = await this.prisma.fulfillmentProvider.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return rows.map((row) => this.toDto(row));
|
||||
}
|
||||
|
||||
async getById(id: bigint) {
|
||||
const row = await this.prisma.fulfillmentProvider.findUnique({ where: { id } });
|
||||
if (!row) throw new NotFoundException('仓配承运商不存在');
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async create(input: CreateFulfillmentProviderInput) {
|
||||
const code = input.code.trim().toUpperCase();
|
||||
if (!/^[A-Z0-9_]+$/.test(code)) {
|
||||
throw new BadRequestException('承运商编码仅支持大写字母、数字和下划线');
|
||||
}
|
||||
const existing = await this.prisma.fulfillmentProvider.findUnique({ where: { code } });
|
||||
if (existing) throw new BadRequestException('承运商编码已存在');
|
||||
|
||||
const row = await this.prisma.fulfillmentProvider.create({
|
||||
data: {
|
||||
code,
|
||||
name: input.name.trim(),
|
||||
type: input.type,
|
||||
status: input.status ?? 'ACTIVE',
|
||||
configJson: input.configJson?.trim() || null,
|
||||
capabilitiesJson: input.capabilitiesJson?.trim() || null,
|
||||
},
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async update(id: bigint, input: UpdateFulfillmentProviderInput) {
|
||||
await this.getById(id);
|
||||
const row = await this.prisma.fulfillmentProvider.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(input.name !== undefined ? { name: input.name.trim() } : {}),
|
||||
...(input.type !== undefined ? { type: input.type } : {}),
|
||||
...(input.status !== undefined ? { status: input.status } : {}),
|
||||
...(input.configJson !== undefined ? { configJson: input.configJson?.trim() || null } : {}),
|
||||
...(input.capabilitiesJson !== undefined
|
||||
? { capabilitiesJson: input.capabilitiesJson?.trim() || null }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
parseCapabilities(raw: string | null): Capabilities | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as Capabilities;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private toDto(row: {
|
||||
id: bigint;
|
||||
code: string;
|
||||
name: string;
|
||||
type: string;
|
||||
status: string;
|
||||
configJson: string | null;
|
||||
capabilitiesJson: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}) {
|
||||
return serializeBigInt({
|
||||
id: row.id.toString(),
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
status: row.status,
|
||||
capabilities: this.parseCapabilities(row.capabilitiesJson),
|
||||
hasConfig: Boolean(row.configJson),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { IntegrationsModule } from '../../integrations/integrations.module';
|
||||
import { TradeModule } from '../trade/trade.module';
|
||||
import { FulfillmentProviderService } from './fulfillment-provider.service';
|
||||
import { FulfillmentService } from './fulfillment.service';
|
||||
|
||||
@Module({
|
||||
imports: [IntegrationsModule, forwardRef(() => TradeModule)],
|
||||
providers: [FulfillmentProviderService, FulfillmentService],
|
||||
exports: [FulfillmentProviderService, FulfillmentService],
|
||||
})
|
||||
export class FulfillmentModule {}
|
||||
@@ -0,0 +1,306 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException, Inject, forwardRef } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { CityWarehouse, FulfillmentProvider, Order } from '@prisma/client';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { CourierService } from '../../integrations/courier/courier.service';
|
||||
import { CourierPayMode } from '../../integrations/courier/courier.types';
|
||||
import { TradeService } from '../trade/trade.service';
|
||||
|
||||
export type ManualShipInput = {
|
||||
logisticsCompany: string;
|
||||
trackingNo: string;
|
||||
manualQueryUrl?: string;
|
||||
};
|
||||
|
||||
export type HqLogisticsShipInput = ManualShipInput;
|
||||
|
||||
const XFX_CODES = new Set(['XFX', 'XIAOFEIXIA']);
|
||||
|
||||
@Injectable()
|
||||
export class FulfillmentService {
|
||||
private readonly logger = new Logger(FulfillmentService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly courier: CourierService,
|
||||
@Inject(forwardRef(() => TradeService))
|
||||
private readonly tradeService: TradeService,
|
||||
) {}
|
||||
|
||||
async dispatchAfterPay(orderId: bigint) {
|
||||
const order = await this.prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
include: { delivery: true },
|
||||
});
|
||||
if (!order || order.payStatus !== 'PAID') return;
|
||||
|
||||
if (order.deliveryType === 'CROSS_CITY') {
|
||||
await this.ensureDeliveryRecord(orderId, 'MANUAL');
|
||||
return;
|
||||
}
|
||||
|
||||
const warehouse = await this.resolveWarehouseForLocalOrder(order.cityId);
|
||||
if (!warehouse) {
|
||||
await this.ensureDeliveryRecord(orderId, 'MANUAL');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.prisma.order.update({
|
||||
where: { id: orderId },
|
||||
data: { fulfillmentWarehouseId: warehouse.id },
|
||||
});
|
||||
|
||||
if (warehouse.fulfillmentMode === 'MANUAL') {
|
||||
await this.ensureDeliveryRecord(orderId, 'MANUAL');
|
||||
return;
|
||||
}
|
||||
|
||||
if (warehouse.fulfillmentMode === 'API_AUTO' && warehouse.fulfillmentProviderId) {
|
||||
const provider = await this.prisma.fulfillmentProvider.findUnique({
|
||||
where: { id: warehouse.fulfillmentProviderId },
|
||||
});
|
||||
if (!provider || provider.status !== 'ACTIVE' || provider.type !== 'API') {
|
||||
await this.ensureDeliveryRecord(orderId, 'MANUAL');
|
||||
return;
|
||||
}
|
||||
await this.dispatchApiAuto(order, warehouse, provider);
|
||||
}
|
||||
}
|
||||
|
||||
async dispatchApiAuto(order: Order, warehouse: CityWarehouse, provider: FulfillmentProvider) {
|
||||
if (!XFX_CODES.has(provider.code)) {
|
||||
this.logger.warn(`承运商 ${provider.code} 自动推单尚未实现,订单 ${order.orderNo} 保持待发货`);
|
||||
await this.ensureDeliveryRecord(order.id, 'MANUAL', provider.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const defaults = this.getShipDefaults();
|
||||
const fromLng = warehouse.lng != null ? Number(warehouse.lng) : defaults.fromLng;
|
||||
const fromLat = warehouse.lat != null ? Number(warehouse.lat) : defaults.fromLat;
|
||||
|
||||
try {
|
||||
const result = await this.courier.createShipment({
|
||||
outNumber: order.orderNo,
|
||||
from: {
|
||||
name: warehouse.contactName,
|
||||
mobile: warehouse.contactPhone,
|
||||
address: warehouse.address,
|
||||
addressDetail: warehouse.name,
|
||||
coordinate: { lng: fromLng, lat: fromLat },
|
||||
},
|
||||
to: {
|
||||
name: order.receiverName,
|
||||
mobile: order.receiverPhone,
|
||||
address: `${order.receiverProvince}${order.receiverCity}${order.receiverDistrict}`,
|
||||
addressDetail: order.receiverAddress,
|
||||
},
|
||||
goodsName: order.productName,
|
||||
goodsNum: order.quantity,
|
||||
weight: defaults.weight,
|
||||
payMode: defaults.payMode,
|
||||
remark: `仓配自动发货 ${order.orderNo}`,
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const delivery = await tx.orderDelivery.findUnique({ where: { orderId: order.id } });
|
||||
const data = {
|
||||
provider: 'XFX' as const,
|
||||
fulfillmentProviderId: provider.id,
|
||||
trackingNo: result.trackingNumber,
|
||||
providerOrderNo: String(result.providerShipmentId),
|
||||
shippingAt: now,
|
||||
};
|
||||
if (delivery) {
|
||||
await tx.orderDelivery.update({ where: { orderId: order.id }, data });
|
||||
} else {
|
||||
await tx.orderDelivery.create({ data: { orderId: order.id, ...data } });
|
||||
}
|
||||
await tx.logThirdParty.create({
|
||||
data: {
|
||||
provider: 'XFX',
|
||||
scene: 'ORDER_DISPATCH',
|
||||
refType: 'ORDER',
|
||||
refId: order.id,
|
||||
externalNo: result.trackingNumber,
|
||||
status: 'SUCCESS',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await this.tradeService.applyStatusTransition(order.id, order.status, 'SHIPPING', 'WAREHOUSE_AUTO');
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await this.logDispatchFailure(order, provider, message);
|
||||
await this.ensureDeliveryRecord(order.id, 'MANUAL', provider.id);
|
||||
}
|
||||
}
|
||||
|
||||
async shipManualByWarehouse(orderId: bigint, warehouseIds: bigint[], input: ManualShipInput) {
|
||||
const order = await this.prisma.order.findFirst({
|
||||
where: { id: orderId, fulfillmentWarehouseId: { in: warehouseIds } },
|
||||
include: { delivery: true },
|
||||
});
|
||||
if (!order) throw new NotFoundException('订单不存在或无权操作');
|
||||
if (!['PENDING_SHIP', 'OUT_WAREHOUSE'].includes(order.status)) {
|
||||
throw new BadRequestException('当前订单状态不可发货');
|
||||
}
|
||||
|
||||
const queryUrl =
|
||||
input.manualQueryUrl?.trim() ||
|
||||
(await this.buildQueryUrlFromTemplate(order.fulfillmentWarehouseId, input.trackingNo));
|
||||
|
||||
return this.applyManualShip(order, {
|
||||
logisticsCompany: input.logisticsCompany.trim(),
|
||||
trackingNo: input.trackingNo.trim(),
|
||||
manualQueryUrl: queryUrl,
|
||||
operator: 'WAREHOUSE_MANUAL',
|
||||
});
|
||||
}
|
||||
|
||||
async shipHqLogistics(orderId: bigint, input: HqLogisticsShipInput) {
|
||||
const order = await this.prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
include: { delivery: true },
|
||||
});
|
||||
if (!order) throw new NotFoundException('订单不存在');
|
||||
|
||||
const isHqQueue =
|
||||
order.deliveryType === 'CROSS_CITY' ||
|
||||
(order.deliveryType === 'LOCAL' && !order.fulfillmentWarehouseId);
|
||||
|
||||
if (!isHqQueue) throw new BadRequestException('该订单由仓配履约,请使用仓配发货');
|
||||
if (!['PENDING_SHIP', 'OUT_WAREHOUSE'].includes(order.status)) {
|
||||
throw new BadRequestException('当前订单状态不可发货');
|
||||
}
|
||||
if (order.delivery?.trackingNo) throw new BadRequestException('该订单已有运单号');
|
||||
|
||||
return this.applyManualShip(order, {
|
||||
logisticsCompany: input.logisticsCompany.trim(),
|
||||
trackingNo: input.trackingNo.trim(),
|
||||
manualQueryUrl: input.manualQueryUrl?.trim(),
|
||||
operator: 'HQ_LOGISTICS',
|
||||
provider: 'LOGISTICS',
|
||||
});
|
||||
}
|
||||
|
||||
async getOrderTrack(orderId: bigint) {
|
||||
const order = await this.prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
include: { delivery: true },
|
||||
});
|
||||
if (!order?.delivery) {
|
||||
return { nodes: [], manualQueryUrl: null };
|
||||
}
|
||||
|
||||
if (order.delivery.provider === 'XFX' && (order.delivery.trackingNo || order.orderNo)) {
|
||||
try {
|
||||
const nodes = await this.courier.getTrack({
|
||||
trackingNumber: order.delivery.trackingNo ?? undefined,
|
||||
outNumber: order.orderNo,
|
||||
});
|
||||
return {
|
||||
nodes,
|
||||
manualQueryUrl: order.delivery.manualQueryUrl,
|
||||
provider: order.delivery.provider,
|
||||
trackingNo: order.delivery.trackingNo,
|
||||
logisticsCompany: order.delivery.logisticsCompany,
|
||||
};
|
||||
} catch {
|
||||
// fall through to manual fields
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nodes: [],
|
||||
manualQueryUrl: order.delivery.manualQueryUrl,
|
||||
provider: order.delivery.provider,
|
||||
trackingNo: order.delivery.trackingNo,
|
||||
logisticsCompany: order.delivery.logisticsCompany,
|
||||
};
|
||||
}
|
||||
|
||||
private async applyManualShip(
|
||||
order: Order & { delivery: { trackingNo: string | null } | null },
|
||||
input: ManualShipInput & { operator: string; provider?: 'MANUAL' | 'LOGISTICS' },
|
||||
) {
|
||||
const now = new Date();
|
||||
const provider = input.provider ?? 'MANUAL';
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const delivery = await tx.orderDelivery.findUnique({ where: { orderId: order.id } });
|
||||
const data = {
|
||||
provider,
|
||||
logisticsCompany: input.logisticsCompany,
|
||||
trackingNo: input.trackingNo,
|
||||
manualQueryUrl: input.manualQueryUrl || null,
|
||||
shippingAt: now,
|
||||
};
|
||||
if (delivery) {
|
||||
await tx.orderDelivery.update({ where: { orderId: order.id }, data });
|
||||
} else {
|
||||
await tx.orderDelivery.create({ data: { orderId: order.id, ...data } });
|
||||
}
|
||||
});
|
||||
|
||||
await this.tradeService.applyStatusTransition(order.id, order.status, 'SHIPPING', input.operator);
|
||||
return this.prisma.order.findUnique({
|
||||
where: { id: order.id },
|
||||
include: { delivery: true, fulfillmentWarehouse: true },
|
||||
});
|
||||
}
|
||||
|
||||
private async resolveWarehouseForLocalOrder(cityId: bigint) {
|
||||
return this.prisma.cityWarehouse.findFirst({
|
||||
where: { cityId, status: 'ACTIVE' },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
private async ensureDeliveryRecord(
|
||||
orderId: bigint,
|
||||
provider: 'MANUAL' | 'LOGISTICS' | 'XFX',
|
||||
fulfillmentProviderId?: bigint,
|
||||
) {
|
||||
const existing = await this.prisma.orderDelivery.findUnique({ where: { orderId } });
|
||||
if (existing) return;
|
||||
await this.prisma.orderDelivery.create({
|
||||
data: {
|
||||
orderId,
|
||||
provider,
|
||||
...(fulfillmentProviderId ? { fulfillmentProviderId } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async logDispatchFailure(order: Order, provider: FulfillmentProvider, error: string) {
|
||||
await this.prisma.logThirdParty.create({
|
||||
data: {
|
||||
provider: 'XFX',
|
||||
scene: 'ORDER_DISPATCH',
|
||||
refType: 'ORDER',
|
||||
refId: order.id,
|
||||
status: 'FAILED',
|
||||
errorMessage: `[${provider.code}] ${error}`.slice(0, 512),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async buildQueryUrlFromTemplate(warehouseId: bigint | null, trackingNo: string) {
|
||||
if (!warehouseId) return undefined;
|
||||
const wh = await this.prisma.cityWarehouse.findUnique({ where: { id: warehouseId } });
|
||||
const tpl = wh?.manualQueryUrlTemplate;
|
||||
if (!tpl) return undefined;
|
||||
return tpl.replace(/\{trackingNo\}/g, encodeURIComponent(trackingNo));
|
||||
}
|
||||
|
||||
private getShipDefaults() {
|
||||
return {
|
||||
fromLng: Number(this.config.get<string>('SHIP_FROM_LNG') || 113.665),
|
||||
fromLat: Number(this.config.get<string>('SHIP_FROM_LAT') || 34.757),
|
||||
weight: 2,
|
||||
payMode: CourierPayMode.SENDER,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,22 @@ import { HqOperationAction } from '../../common/hq-operation/hq-operation.consta
|
||||
import { CityWarehouseService } from '../city-scope/city-warehouse.service';
|
||||
import { CreateCityWarehouseDto, UpdateCityWarehouseDto } from './dto/admin-mutate.dto';
|
||||
import { AdminCityWarehousesQueryDto } from './dto/admin-query.dto';
|
||||
import type { WarehouseManagerType, WarehouseStatus } from '@prisma/client';
|
||||
import type {
|
||||
WarehouseFulfillmentMode,
|
||||
WarehouseManagerType,
|
||||
WarehouseStatus,
|
||||
} from '@prisma/client';
|
||||
|
||||
function mapWarehouseFulfillment(dto: CreateCityWarehouseDto | UpdateCityWarehouseDto) {
|
||||
return {
|
||||
fulfillmentMode: dto.fulfillmentMode as WarehouseFulfillmentMode | undefined,
|
||||
fulfillmentProviderId: dto.fulfillmentProviderId ? BigInt(dto.fulfillmentProviderId) : undefined,
|
||||
manualCarrierLabel: dto.manualCarrierLabel ?? undefined,
|
||||
manualQueryUrlTemplate: dto.manualQueryUrlTemplate ?? undefined,
|
||||
lng: dto.lng ?? undefined,
|
||||
lat: dto.lat ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@Controller('admin/cities/:cityId/warehouses')
|
||||
@UseGuards(HqAuthGuard)
|
||||
@@ -33,6 +48,7 @@ export class AdminCityWarehousesController {
|
||||
managerType: dto.managerType as WarehouseManagerType,
|
||||
partnerAccountId: dto.partnerAccountId ? BigInt(dto.partnerAccountId) : undefined,
|
||||
status: dto.status as WarehouseStatus | undefined,
|
||||
...mapWarehouseFulfillment(dto),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -63,11 +79,22 @@ export class AdminCityWarehouseMutationsController {
|
||||
managerType: dto.managerType as WarehouseManagerType | undefined,
|
||||
partnerAccountId:
|
||||
dto.partnerAccountId === null
|
||||
? undefined
|
||||
? null
|
||||
: dto.partnerAccountId
|
||||
? BigInt(dto.partnerAccountId)
|
||||
: undefined,
|
||||
status: dto.status as WarehouseStatus | undefined,
|
||||
fulfillmentMode: dto.fulfillmentMode as WarehouseFulfillmentMode | undefined,
|
||||
fulfillmentProviderId:
|
||||
dto.fulfillmentProviderId === null
|
||||
? null
|
||||
: dto.fulfillmentProviderId
|
||||
? BigInt(dto.fulfillmentProviderId)
|
||||
: undefined,
|
||||
manualCarrierLabel: dto.manualCarrierLabel,
|
||||
manualQueryUrlTemplate: dto.manualQueryUrlTemplate,
|
||||
lng: dto.lng,
|
||||
lat: dto.lat,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Body, Controller, Get, Param, Post, Put, UseGuards } from '@nestjs/common';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
||||
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
||||
import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service';
|
||||
import {
|
||||
CreateFulfillmentProviderDto,
|
||||
UpdateFulfillmentProviderDto,
|
||||
} from './dto/admin-mutate.dto';
|
||||
import type { FulfillmentProviderStatus, FulfillmentProviderType } from '@prisma/client';
|
||||
|
||||
@Controller('admin/fulfillment-providers')
|
||||
@UseGuards(HqAuthGuard)
|
||||
export class AdminFulfillmentProvidersController {
|
||||
constructor(private readonly service: FulfillmentProviderService) {}
|
||||
|
||||
@Get()
|
||||
list() {
|
||||
return this.service.listAll();
|
||||
}
|
||||
|
||||
@Get('active-api')
|
||||
listActiveApi() {
|
||||
return this.service.listActiveApiProviders();
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HqOperation({
|
||||
action: HqOperationAction.WAREHOUSE_UPDATE,
|
||||
refType: 'FULFILLMENT_PROVIDER',
|
||||
refIdField: 'id',
|
||||
includeBody: true,
|
||||
})
|
||||
create(@Body() dto: CreateFulfillmentProviderDto) {
|
||||
return this.service.create({
|
||||
code: dto.code,
|
||||
name: dto.name,
|
||||
type: dto.type as FulfillmentProviderType,
|
||||
status: dto.status as FulfillmentProviderStatus | undefined,
|
||||
configJson: dto.configJson,
|
||||
capabilitiesJson: dto.capabilitiesJson,
|
||||
});
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.WAREHOUSE_UPDATE,
|
||||
refType: 'FULFILLMENT_PROVIDER',
|
||||
refIdParam: 'id',
|
||||
includeBody: true,
|
||||
})
|
||||
update(@Param('id') id: string, @Body() dto: UpdateFulfillmentProviderDto) {
|
||||
return this.service.update(BigInt(id), {
|
||||
name: dto.name,
|
||||
type: dto.type as FulfillmentProviderType | undefined,
|
||||
status: dto.status as FulfillmentProviderStatus | undefined,
|
||||
configJson: dto.configJson,
|
||||
capabilitiesJson: dto.capabilitiesJson,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
|
||||
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
||||
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
||||
import { AdminOrdersService } from './admin-orders.service';
|
||||
import { AdminShipOrderDto, BatchDeleteOrdersDto, UpdateOrderStatusDto } from './dto/admin-mutate.dto';
|
||||
import { AdminShipOrderDto, BatchDeleteOrdersDto, HqLogisticsShipDto, UpdateOrderStatusDto } from './dto/admin-mutate.dto';
|
||||
import { AdminOrdersQueryDto } from './dto/admin-query.dto';
|
||||
|
||||
@Controller('admin/orders')
|
||||
@@ -51,6 +51,17 @@ export class AdminOrdersController {
|
||||
return this.ordersService.shipOrder(BigInt(id), dto);
|
||||
}
|
||||
|
||||
@Post(':id/logistics-ship')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.ORDER_SHIP,
|
||||
refType: 'ORDER',
|
||||
refIdParam: 'id',
|
||||
includeBody: true,
|
||||
})
|
||||
shipLogistics(@Param('id') id: string, @Body() dto: HqLogisticsShipDto) {
|
||||
return this.ordersService.shipLogistics(BigInt(id), dto);
|
||||
}
|
||||
|
||||
/** preV1 调试:直接改订单状态,不走业务校验 */
|
||||
@Put(':id/status')
|
||||
@HqOperation({
|
||||
|
||||
@@ -8,8 +8,9 @@ import { mapOrderCompat, mapStatusLogCompat } from '../../common/compat/v31-comp
|
||||
import { TradeService } from '../trade/trade.service';
|
||||
import { AdminXiaofeixiaService } from './admin-xiaofeixia.service';
|
||||
import type { AdminOrdersQueryDto } from './dto/admin-query.dto';
|
||||
import type { AdminShipOrderDto } from './dto/admin-mutate.dto';
|
||||
import type { AdminShipOrderDto, HqLogisticsShipDto } from './dto/admin-mutate.dto';
|
||||
import type { XiaofeixiaCreateShipmentDto } from './dto/admin-courier.dto';
|
||||
import { FulfillmentService } from '../fulfillment/fulfillment.service';
|
||||
|
||||
@Injectable()
|
||||
export class AdminOrdersService {
|
||||
@@ -17,6 +18,7 @@ export class AdminOrdersService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly tradeService: TradeService,
|
||||
private readonly xiaofeixiaService: AdminXiaofeixiaService,
|
||||
private readonly fulfillmentService: FulfillmentService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
@@ -183,6 +185,12 @@ export class AdminOrdersService {
|
||||
return this.detail(id);
|
||||
}
|
||||
|
||||
/** 总部传统快递填单(同城无仓 / 跨城) */
|
||||
async shipLogistics(id: bigint, dto: HqLogisticsShipDto) {
|
||||
await this.fulfillmentService.shipHqLogistics(id, dto);
|
||||
return this.detail(id);
|
||||
}
|
||||
|
||||
async batchDeleteOrders(ids: bigint[]) {
|
||||
const uniqueIds = [...new Set(ids)];
|
||||
if (!uniqueIds.length) {
|
||||
|
||||
@@ -474,6 +474,30 @@ export class CreateCityWarehouseDto {
|
||||
@IsOptional()
|
||||
@IsIn(['ACTIVE', 'PAUSED'])
|
||||
status?: 'ACTIVE' | 'PAUSED';
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['API_AUTO', 'MANUAL'])
|
||||
fulfillmentMode?: 'API_AUTO' | 'MANUAL';
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
fulfillmentProviderId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
manualCarrierLabel?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
manualQueryUrlTemplate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
lng?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
lat?: number;
|
||||
}
|
||||
|
||||
export class UpdateCityWarehouseDto {
|
||||
@@ -505,8 +529,100 @@ export class UpdateCityWarehouseDto {
|
||||
@IsOptional()
|
||||
@IsIn(['ACTIVE', 'PAUSED'])
|
||||
status?: 'ACTIVE' | 'PAUSED';
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['API_AUTO', 'MANUAL'])
|
||||
fulfillmentMode?: 'API_AUTO' | 'MANUAL';
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== null)
|
||||
@IsString()
|
||||
fulfillmentProviderId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== null)
|
||||
@IsString()
|
||||
manualCarrierLabel?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== null)
|
||||
@IsString()
|
||||
manualQueryUrlTemplate?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== null)
|
||||
@IsNumber()
|
||||
lng?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== null)
|
||||
@IsNumber()
|
||||
lat?: number | null;
|
||||
}
|
||||
|
||||
export class CreateFulfillmentProviderDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
code: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name: string;
|
||||
|
||||
@IsIn(['API', 'MANUAL'])
|
||||
type: 'API' | 'MANUAL';
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['ACTIVE', 'DISABLED'])
|
||||
status?: 'ACTIVE' | 'DISABLED';
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
configJson?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
capabilitiesJson?: string;
|
||||
}
|
||||
|
||||
export class UpdateFulfillmentProviderDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['API', 'MANUAL'])
|
||||
type?: 'API' | 'MANUAL';
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['ACTIVE', 'DISABLED'])
|
||||
status?: 'ACTIVE' | 'DISABLED';
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
configJson?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
capabilitiesJson?: string;
|
||||
}
|
||||
|
||||
export class ManualShipOrderDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
logisticsCompany: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
trackingNo: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
manualQueryUrl?: string;
|
||||
}
|
||||
|
||||
export class HqLogisticsShipDto extends ManualShipOrderDto {}
|
||||
|
||||
export class CreateStoreMediaDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
||||
import { CityScopeModule } from '../city-scope/city-scope.module';
|
||||
import { IamModule } from '../iam/iam.module';
|
||||
import { TradeModule } from '../trade/trade.module';
|
||||
import { FulfillmentModule } from '../fulfillment/fulfillment.module';
|
||||
import { AdminDashboardController } from './admin-dashboard.controller';
|
||||
import { AdminDashboardService } from './admin-dashboard.service';
|
||||
import { AdminUsersController } from './admin-users.controller';
|
||||
@@ -54,9 +55,10 @@ import { AdminHqPermissionsService } from './admin-hq-permissions.service';
|
||||
import { AdminDeployController } from './admin-deploy.controller';
|
||||
import { AdminDeployService } from './admin-deploy.service';
|
||||
import { AdminSystemConfigController } from './admin-system-config.controller';
|
||||
import { AdminFulfillmentProvidersController } from './admin-fulfillment-providers.controller';
|
||||
|
||||
@Module({
|
||||
imports: [CityScopeModule, IamModule, TradeModule, BenefitModule, CommonModule, IntegrationsModule, RedeemModule],
|
||||
imports: [CityScopeModule, IamModule, TradeModule, FulfillmentModule, BenefitModule, CommonModule, IntegrationsModule, RedeemModule],
|
||||
controllers: [
|
||||
AdminDashboardController,
|
||||
AdminDeployController,
|
||||
@@ -89,6 +91,7 @@ import { AdminSystemConfigController } from './admin-system-config.controller';
|
||||
AdminWechatBindingsController,
|
||||
AdminHqPermissionsController,
|
||||
AdminSystemConfigController,
|
||||
AdminFulfillmentProvidersController,
|
||||
],
|
||||
providers: [
|
||||
AdminDashboardService,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
PartnerProxyOrderPreviewDto,
|
||||
PartnerProxyOrderSendSmsDto,
|
||||
} from './dto/partner-proxy-order.dto';
|
||||
import { ManualShipOrderDto } from '../ops/dto/admin-mutate.dto';
|
||||
|
||||
@Controller('trade/orders')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@@ -42,6 +43,11 @@ export class TradeController {
|
||||
return this.tradeService.getOrder(user.actorId, BigInt(id));
|
||||
}
|
||||
|
||||
@Get(':id/track')
|
||||
track(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.tradeService.getOrderTrack(user.actorId, BigInt(id));
|
||||
}
|
||||
|
||||
@Post(':id/pay')
|
||||
pay(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.tradeService.payOrder(user.actorId, BigInt(id), user.clientApp);
|
||||
@@ -91,6 +97,21 @@ export class PartnerOrderController {
|
||||
return this.tradeService.getPartnerOrder(user.actorId, BigInt(id));
|
||||
}
|
||||
|
||||
@Get(':id/track')
|
||||
track(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.tradeService.getPartnerOrderTrack(user.actorId, BigInt(id));
|
||||
}
|
||||
|
||||
@Post(':id/manual-ship')
|
||||
@RequirePartnerPermissions('warehouse:manage')
|
||||
manualShip(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() body: ManualShipOrderDto,
|
||||
) {
|
||||
return this.tradeService.partnerManualShip(user.actorId, BigInt(id), body);
|
||||
}
|
||||
|
||||
@Post(':id/mock-advance-delivery')
|
||||
mockAdvance(
|
||||
@CurrentUser() user: AuthUser,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { CatalogModule } from '../catalog/catalog.module';
|
||||
import { CommonModule } from '../common/common.module';
|
||||
import { CityScopeModule } from '../city-scope/city-scope.module';
|
||||
import { PromoModule } from '../promo/promo.module';
|
||||
import { FulfillmentModule } from '../fulfillment/fulfillment.module';
|
||||
import {
|
||||
TradeController,
|
||||
PartnerOrderController,
|
||||
@@ -24,6 +25,7 @@ import { TradeService } from './trade.service';
|
||||
CityScopeModule,
|
||||
PromoModule,
|
||||
forwardRef(() => BenefitModule),
|
||||
forwardRef(() => FulfillmentModule),
|
||||
CommonModule,
|
||||
],
|
||||
controllers: [
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
forwardRef,
|
||||
} from '@nestjs/common';
|
||||
import type { FreightPayType } from '@prisma/client';
|
||||
import {
|
||||
@@ -29,6 +30,7 @@ import { buildOrderClientLocationSnapshot } from '../../common/geo/client-locati
|
||||
import { extractClientIp } from '../../common/geo/client-ip.util';
|
||||
import { buildOrderStatusEvent, orderStatusLogWhere } from '../../common/event/event.helpers';
|
||||
import { mapOrderCompat, mapStatusLogCompat } from '../../common/compat/v31-compat';
|
||||
import { FulfillmentService } from '../fulfillment/fulfillment.service';
|
||||
import type { Request } from 'express';
|
||||
|
||||
@Injectable()
|
||||
@@ -45,6 +47,8 @@ export class TradeService {
|
||||
private readonly partnerCityService: PartnerCityService,
|
||||
private readonly authService: AuthService,
|
||||
private readonly promoCodeService: PromoCodeService,
|
||||
@Inject(forwardRef(() => FulfillmentService))
|
||||
private readonly fulfillmentService: FulfillmentService,
|
||||
) {}
|
||||
|
||||
async preview(userId: bigint, body: { productId: string; quantity: number; addressId?: string }) {
|
||||
@@ -268,8 +272,7 @@ export class TradeService {
|
||||
});
|
||||
});
|
||||
|
||||
await this.benefitService.grantOnOrderPaid(order.id);
|
||||
await this.deliveryProvider.scheduleAutoAdvance(order.id);
|
||||
await this.afterOrderPaid(order.id);
|
||||
|
||||
this.analyticsService.trackOneSafe(userId, 'USER_H5', {
|
||||
eventName: 'pay_success',
|
||||
@@ -281,6 +284,15 @@ export class TradeService {
|
||||
return this.getOrder(userId, orderId);
|
||||
}
|
||||
|
||||
private async afterOrderPaid(orderId: bigint) {
|
||||
await this.benefitService.grantOnOrderPaid(orderId);
|
||||
await this.fulfillmentService.dispatchAfterPay(orderId);
|
||||
const refreshed = await this.prisma.order.findUnique({ where: { id: orderId } });
|
||||
if (refreshed?.status === 'PENDING_SHIP') {
|
||||
await this.deliveryProvider.scheduleAutoAdvance(orderId);
|
||||
}
|
||||
}
|
||||
|
||||
/** 微信支付回调:幂等更新订单为已支付并发券 */
|
||||
async handlePaySuccess(params: {
|
||||
orderNo: string;
|
||||
@@ -361,8 +373,7 @@ export class TradeService {
|
||||
|
||||
const refreshed = await this.prisma.order.findUnique({ where: { id: order.id } });
|
||||
if (refreshed?.payStatus === 'PAID') {
|
||||
await this.benefitService.grantOnOrderPaid(order.id);
|
||||
await this.deliveryProvider.scheduleAutoAdvance(order.id);
|
||||
await this.afterOrderPaid(order.id);
|
||||
this.analyticsService.trackOneSafe(order.userId, 'USER_H5', {
|
||||
eventName: 'pay_success',
|
||||
refType: 'ORDER',
|
||||
@@ -401,6 +412,7 @@ export class TradeService {
|
||||
benefitCoupon: true,
|
||||
imageResource: true,
|
||||
product: true,
|
||||
fulfillmentWarehouse: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
if (!order) throw new NotFoundException('订单不存在');
|
||||
@@ -411,6 +423,15 @@ export class TradeService {
|
||||
return serializeBigInt(mapOrderCompat({ ...order, statusLogs: mapStatusLogCompat(statusLogs) }));
|
||||
}
|
||||
|
||||
async getOrderTrack(userId: bigint, orderId: bigint) {
|
||||
const order = await this.prisma.order.findFirst({
|
||||
where: { id: orderId, userId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!order) throw new NotFoundException('订单不存在');
|
||||
return this.fulfillmentService.getOrderTrack(orderId);
|
||||
}
|
||||
|
||||
async updateAddress(userId: bigint, orderId: bigint, body: Record<string, unknown>) {
|
||||
const order = await this.prisma.order.findFirst({ where: { id: orderId, userId } });
|
||||
if (!order) throw new NotFoundException('订单不存在');
|
||||
@@ -492,7 +513,7 @@ export class TradeService {
|
||||
const [list, total] = await Promise.all([
|
||||
this.prisma.order.findMany({
|
||||
where,
|
||||
include: { delivery: true, imageResource: true, user: { select: { phone: true, nickname: true } } },
|
||||
include: { delivery: true, imageResource: true, user: { select: { phone: true, nickname: true } }, fulfillmentWarehouse: { select: { id: true, name: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
@@ -507,7 +528,7 @@ export class TradeService {
|
||||
const partnerOrderWhere = await this.partnerCityService.buildPartnerOrderWhere(primary.id);
|
||||
const order = await this.prisma.order.findFirst({
|
||||
where: { id: orderId, ...partnerOrderWhere },
|
||||
include: { delivery: true, user: true, imageResource: true },
|
||||
include: { delivery: true, user: true, imageResource: true, fulfillmentWarehouse: { select: { id: true, name: true } } },
|
||||
});
|
||||
if (!order) throw new NotFoundException('订单不存在');
|
||||
const statusLogs = await this.prisma.commonEvent.findMany({
|
||||
@@ -517,6 +538,37 @@ export class TradeService {
|
||||
return serializeBigInt(mapOrderCompat({ ...order, statusLogs: mapStatusLogCompat(statusLogs) }));
|
||||
}
|
||||
|
||||
async partnerManualShip(
|
||||
partnerAccountId: bigint,
|
||||
orderId: bigint,
|
||||
input: { logisticsCompany: string; trackingNo: string; manualQueryUrl?: string },
|
||||
) {
|
||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||
const warehouseIds = await this.partnerCityService.resolveManagedWarehouseIds(primary.id);
|
||||
if (!warehouseIds.length) throw new BadRequestException('当前账号未绑定仓库');
|
||||
|
||||
await this.fulfillmentService.shipManualByWarehouse(orderId, warehouseIds, input);
|
||||
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
|
||||
partnerAccountId: primary.id,
|
||||
eventName: 'partner_order_ship',
|
||||
refType: 'ORDER',
|
||||
refId: orderId,
|
||||
extraJson: { mode: 'manual' },
|
||||
});
|
||||
return this.getPartnerOrder(partnerAccountId, orderId);
|
||||
}
|
||||
|
||||
async getPartnerOrderTrack(partnerAccountId: bigint, orderId: bigint) {
|
||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||
const partnerOrderWhere = await this.partnerCityService.buildPartnerOrderWhere(primary.id);
|
||||
const order = await this.prisma.order.findFirst({
|
||||
where: { id: orderId, ...partnerOrderWhere },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!order) throw new NotFoundException('订单不存在');
|
||||
return this.fulfillmentService.getOrderTrack(orderId);
|
||||
}
|
||||
|
||||
async advanceDelivery(partnerAccountId: bigint, orderId: bigint, targetStatus: string) {
|
||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||
const partnerOrderWhere = await this.partnerCityService.buildPartnerOrderWhere(primary.id);
|
||||
|
||||
Reference in New Issue
Block a user