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
View File
@@ -13,6 +13,7 @@ async function main() {
await prisma.storeAccount.deleteMany();
await prisma.store.deleteMany();
await prisma.partnerBill.deleteMany();
await prisma.fulfillmentProvider.deleteMany();
await prisma.cityWarehouse.deleteMany();
await prisma.partnerAccount.deleteMany();
const result = await prisma.commonCity.deleteMany();
+78 -29
View File
@@ -141,6 +141,21 @@ enum WarehouseStatus {
PAUSED
}
enum FulfillmentProviderType {
API
MANUAL
}
enum FulfillmentProviderStatus {
ACTIVE
DISABLED
}
enum WarehouseFulfillmentMode {
API_AUTO
MANUAL
}
enum PartnerStaffRole {
PARTNER
INTERNAL
@@ -496,25 +511,51 @@ model CommonCity {
@@map("common_city")
}
model CityWarehouse {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
cityId BigInt @map("city_id") @db.UnsignedBigInt
name String @db.VarChar(128)
address String @db.VarChar(256)
contactName String @map("contact_name") @db.VarChar(64)
contactPhone String @map("contact_phone") @db.VarChar(20)
managerType WarehouseManagerType @map("manager_type")
partnerAccountId BigInt? @map("partner_account_id") @db.UnsignedBigInt
status WarehouseStatus @default(ACTIVE)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
model FulfillmentProvider {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
code String @unique @db.VarChar(32)
name String @db.VarChar(128)
type FulfillmentProviderType
status FulfillmentProviderStatus @default(ACTIVE)
configJson String? @map("config_json") @db.Text
capabilitiesJson String? @map("capabilities_json") @db.Text
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
city CommonCity @relation(fields: [cityId], references: [id], onDelete: Cascade)
partnerAccount PartnerAccount? @relation("WarehouseManager", fields: [partnerAccountId], references: [id], onDelete: SetNull)
managedBy PartnerAccount? @relation("ManagedWarehouse")
warehouses CityWarehouse[]
deliveries OrderDelivery[]
@@map("common_fulfillment_provider")
}
model CityWarehouse {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
cityId BigInt @map("city_id") @db.UnsignedBigInt
name String @db.VarChar(128)
address String @db.VarChar(256)
contactName String @map("contact_name") @db.VarChar(64)
contactPhone String @map("contact_phone") @db.VarChar(20)
managerType WarehouseManagerType @map("manager_type")
partnerAccountId BigInt? @map("partner_account_id") @db.UnsignedBigInt
status WarehouseStatus @default(ACTIVE)
fulfillmentMode WarehouseFulfillmentMode @default(MANUAL) @map("fulfillment_mode")
fulfillmentProviderId BigInt? @map("fulfillment_provider_id") @db.UnsignedBigInt
manualCarrierLabel String? @map("manual_carrier_label") @db.VarChar(64)
manualQueryUrlTemplate String? @map("manual_query_url_template") @db.VarChar(512)
lng Decimal? @db.Decimal(10, 7)
lat Decimal? @db.Decimal(10, 7)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
city CommonCity @relation(fields: [cityId], references: [id], onDelete: Cascade)
partnerAccount PartnerAccount? @relation("WarehouseManager", fields: [partnerAccountId], references: [id], onDelete: SetNull)
managedBy PartnerAccount? @relation("ManagedWarehouse")
fulfillmentProvider FulfillmentProvider? @relation(fields: [fulfillmentProviderId], references: [id], onDelete: SetNull)
fulfilledOrders Order[] @relation("OrderFulfillmentWarehouse")
@@index([cityId])
@@index([partnerAccountId])
@@index([fulfillmentProviderId])
@@map("common_city_warehouse")
}
@@ -859,12 +900,14 @@ model Order {
payExpireAt DateTime? @map("pay_expire_at") @db.DateTime(3)
partnerAccountIdAtPay BigInt? @map("partner_account_id_at_pay") @db.UnsignedBigInt
orderCommissionRateAtPay Decimal? @map("order_commission_rate_at_pay") @db.Decimal(5, 4)
fulfillmentWarehouseId BigInt? @map("fulfillment_warehouse_id") @db.UnsignedBigInt
remark String? @db.VarChar(512)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
city CommonCity @relation(fields: [cityId], references: [id], onDelete: Restrict)
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
city CommonCity @relation(fields: [cityId], references: [id], onDelete: Restrict)
fulfillmentWarehouse CityWarehouse? @relation("OrderFulfillmentWarehouse", fields: [fulfillmentWarehouseId], references: [id], onDelete: SetNull)
originOrder Order? @relation("OrderReshipment", fields: [originOrderId], references: [id], onDelete: SetNull)
reshipments Order[] @relation("OrderReshipment")
promoCode CommonPromoCode? @relation(fields: [promoCodeId], references: [id], onDelete: SetNull)
@@ -880,24 +923,30 @@ model Order {
@@index([payExternalNo])
@@index([ipCity])
@@index([gpsCity])
@@index([fulfillmentWarehouseId])
@@map("user_order")
}
model OrderDelivery {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
orderId BigInt @unique @map("order_id") @db.UnsignedBigInt
provider DeliveryProvider
providerOrderNo String? @map("provider_order_no") @db.VarChar(64)
trackingNo String? @map("tracking_no") @db.VarChar(64)
outWarehouseAt DateTime? @map("out_warehouse_at") @db.DateTime(3)
shippingAt DateTime? @map("shipping_at") @db.DateTime(3)
deliveredAt DateTime? @map("delivered_at") @db.DateTime(3)
signPhotoResourceId BigInt? @map("sign_photo_resource_id") @db.UnsignedBigInt
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
orderId BigInt @unique @map("order_id") @db.UnsignedBigInt
provider DeliveryProvider
fulfillmentProviderId BigInt? @map("fulfillment_provider_id") @db.UnsignedBigInt
providerOrderNo String? @map("provider_order_no") @db.VarChar(64)
trackingNo String? @map("tracking_no") @db.VarChar(64)
logisticsCompany String? @map("logistics_company") @db.VarChar(64)
manualQueryUrl String? @map("manual_query_url") @db.VarChar(512)
outWarehouseAt DateTime? @map("out_warehouse_at") @db.DateTime(3)
shippingAt DateTime? @map("shipping_at") @db.DateTime(3)
deliveredAt DateTime? @map("delivered_at") @db.DateTime(3)
signPhotoResourceId BigInt? @map("sign_photo_resource_id") @db.UnsignedBigInt
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
signPhotoResource CommonResource? @relation("DeliverySignPhoto", fields: [signPhotoResourceId], references: [id], onDelete: SetNull)
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
fulfillmentProvider FulfillmentProvider? @relation(fields: [fulfillmentProviderId], references: [id], onDelete: SetNull)
signPhotoResource CommonResource? @relation("DeliverySignPhoto", fields: [signPhotoResourceId], references: [id], onDelete: SetNull)
@@index([fulfillmentProviderId])
@@map("user_order_delivery")
}
+25
View File
@@ -91,6 +91,8 @@ async function main() {
await prisma.cityWarehouse.deleteMany();
await prisma.fulfillmentProvider.deleteMany();
await prisma.partnerAccount.deleteMany();
await prisma.commonCity.deleteMany();
@@ -175,6 +177,21 @@ async function main() {
const xfxProvider = await prisma.fulfillmentProvider.create({
data: {
code: 'XFX',
name: '小飞侠',
type: 'API',
status: 'ACTIVE',
capabilitiesJson: JSON.stringify({
createShipment: true,
getTrack: true,
callback: true,
cancel: true,
}),
},
});
const warehouse = await prisma.cityWarehouse.create({
data: {
@@ -195,6 +212,14 @@ async function main() {
status: 'ACTIVE',
fulfillmentMode: 'API_AUTO',
fulfillmentProviderId: xfxProvider.id,
lng: 113.665,
lat: 34.757,
},
});
@@ -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);