cc0c0a6ef8
同城 MANUAL/ZZXFX 按小飞侠价规计费,路由查询回退仓配凭证;HQ 配送单补商品、用户和收货地址。门店核销回跳与小程序核销码一并带上。 Co-authored-by: Cursor <cursoragent@cursor.com>
570 lines
20 KiB
TypeScript
570 lines
20 KiB
TypeScript
import { BadRequestException, Injectable, Logger, NotFoundException, Inject, forwardRef } from '@nestjs/common';
|
||
import type { CityWarehouse, FulfillmentProvider, Order } from '@prisma/client';
|
||
import { isXfxProviderCode } from '@dukang/shared-types';
|
||
import {
|
||
BOTTLES_PER_BOX,
|
||
XFX_AUTO_DISPATCH_MAX_BOXES,
|
||
calcOrderBoxCount,
|
||
shouldHoldAutoCourierDispatch,
|
||
toBottleQuantity,
|
||
} from '@dukang/domain';
|
||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||
import { CourierService } from '../../integrations/courier/courier.service';
|
||
import { CourierPayMode, type TrackNode } from '../../integrations/courier/courier.types';
|
||
import type { XiaofeixiaConfig } from '../../integrations/courier/courier.config';
|
||
import { OSS_PROVIDER } from '../../integrations/integrations.constants';
|
||
import type { IOssProvider } from '../../integrations/oss/oss.interface';
|
||
import { TradeService } from '../trade/trade.service';
|
||
import { FulfillmentProviderService } from './fulfillment-provider.service';
|
||
import { buildXfxGoodsPayload } from './xfx-goods.util';
|
||
|
||
type OrderForXfxDispatch = Order & { product?: { spec: string } | null };
|
||
|
||
export type ManualShipInput = {
|
||
logisticsCompany: string;
|
||
trackingNo: string;
|
||
manualQueryUrl?: string;
|
||
};
|
||
|
||
export type HqLogisticsShipInput = ManualShipInput;
|
||
|
||
export const FULFILLMENT_HOLD_LARGE_ORDER = 'LARGE_ORDER_GE_10_BOXES';
|
||
|
||
@Injectable()
|
||
export class FulfillmentService {
|
||
private readonly logger = new Logger(FulfillmentService.name);
|
||
|
||
constructor(
|
||
private readonly prisma: PrismaService,
|
||
private readonly courier: CourierService,
|
||
private readonly fulfillmentProviderService: FulfillmentProviderService,
|
||
@Inject(OSS_PROVIDER) private readonly oss: IOssProvider,
|
||
@Inject(forwardRef(() => TradeService))
|
||
private readonly tradeService: TradeService,
|
||
) {}
|
||
|
||
async dispatchAfterPay(orderId: bigint) {
|
||
const order = await this.prisma.order.findUnique({
|
||
where: { id: orderId },
|
||
include: { delivery: true, product: { select: { spec: 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;
|
||
}
|
||
|
||
// 大单拦截:≥10 箱不自动推小飞侠,待总部确认后推单或自配送(按瓶当量)
|
||
const bottleQty = toBottleQuantity(
|
||
order.quantity,
|
||
order.bottlesPerUnit > 0 ? order.bottlesPerUnit : 1,
|
||
);
|
||
if (shouldHoldAutoCourierDispatch(bottleQty)) {
|
||
const boxes = calcOrderBoxCount(bottleQty);
|
||
this.logger.warn(
|
||
`大单拦截自动推单:${order.orderNo} quantity=${order.quantity}×${order.bottlesPerUnit} bottles≈${boxes}箱(阈值 ${XFX_AUTO_DISPATCH_MAX_BOXES}箱/${BOTTLES_PER_BOX}瓶)`,
|
||
);
|
||
await this.prisma.order.update({
|
||
where: { id: orderId },
|
||
data: {
|
||
fulfillmentHold: true,
|
||
fulfillmentHoldReason: FULFILLMENT_HOLD_LARGE_ORDER,
|
||
},
|
||
});
|
||
await this.ensureDeliveryRecord(orderId, 'MANUAL', provider.id);
|
||
await this.prisma.logThirdParty.create({
|
||
data: {
|
||
provider: 'XFX',
|
||
scene: 'ORDER_DISPATCH_HOLD',
|
||
refType: 'ORDER',
|
||
refId: order.id,
|
||
status: 'PENDING',
|
||
errorMessage: `大单拦截:${bottleQty}瓶(约${boxes}箱),需总部确认后推小飞侠或自配送`.slice(
|
||
0,
|
||
512,
|
||
),
|
||
},
|
||
});
|
||
return;
|
||
}
|
||
|
||
await this.dispatchApiAuto(order, warehouse, provider);
|
||
}
|
||
}
|
||
|
||
async clearFulfillmentHold(orderId: bigint) {
|
||
await this.prisma.order.update({
|
||
where: { id: orderId },
|
||
data: { fulfillmentHold: false, fulfillmentHoldReason: null },
|
||
});
|
||
}
|
||
|
||
async dispatchApiAuto(order: OrderForXfxDispatch, warehouse: CityWarehouse, provider: FulfillmentProvider) {
|
||
if (!isXfxProviderCode(provider.code)) {
|
||
this.logger.warn(`承运商 ${provider.code} 自动推单尚未实现,订单 ${order.orderNo} 保持待发货`);
|
||
await this.ensureDeliveryRecord(order.id, 'MANUAL', provider.id);
|
||
return;
|
||
}
|
||
|
||
let xfxConfig: XiaofeixiaConfig;
|
||
try {
|
||
xfxConfig = await this.fulfillmentProviderService.resolveXiaofeixiaConfig(provider.id);
|
||
} 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);
|
||
return;
|
||
}
|
||
|
||
const fromLng = warehouse.lng != null ? Number(warehouse.lng) : 113.665;
|
||
const fromLat = warehouse.lat != null ? Number(warehouse.lat) : 34.757;
|
||
const { goodsName, goodsNum } = buildXfxGoodsPayload({
|
||
productName: order.productName,
|
||
productSpec: order.productSpec,
|
||
physicalSpec: order.product?.spec,
|
||
quantity: order.quantity,
|
||
bottlesPerUnit: order.bottlesPerUnit,
|
||
});
|
||
|
||
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,
|
||
goodsNum,
|
||
weight: 2,
|
||
payMode: CourierPayMode.SENDER,
|
||
remark: `仓配自动发货 ${order.orderNo}`,
|
||
},
|
||
{ xiaofeixia: xfxConfig },
|
||
);
|
||
|
||
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.order.update({
|
||
where: { id: order.id },
|
||
data: { fulfillmentHold: false, fulfillmentHoldReason: null },
|
||
});
|
||
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('订单不存在');
|
||
|
||
// HQ 可对任意待发货单填快递单号(含仓配单手动填单)
|
||
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: {
|
||
include: {
|
||
signPhotoResource: true,
|
||
fulfillmentProvider: { select: { id: true, code: true } },
|
||
},
|
||
},
|
||
fulfillmentWarehouse: { select: { fulfillmentProviderId: true } },
|
||
},
|
||
});
|
||
const base = {
|
||
nodes: [] as TrackNode[],
|
||
signPhotoUrls: [] as string[],
|
||
estimatedArrival: null as { arriveTime: string; siteName?: string } | null,
|
||
manualQueryUrl: order?.delivery?.manualQueryUrl ?? null,
|
||
provider: order?.delivery?.provider,
|
||
trackingNo: order?.delivery?.trackingNo ?? null,
|
||
logisticsCompany: order?.delivery?.logisticsCompany ?? null,
|
||
queryError: null as string | null,
|
||
};
|
||
if (!order?.delivery) {
|
||
return base;
|
||
}
|
||
|
||
const providerCode = order.delivery.fulfillmentProvider?.code || String(order.delivery.provider || '');
|
||
const isXfx =
|
||
order.delivery.provider === 'XFX' ||
|
||
isXfxProviderCode(providerCode) ||
|
||
order.deliveryType === 'LOCAL';
|
||
const canQueryCourier = isXfx && !!(order.delivery.trackingNo || order.orderNo);
|
||
|
||
if (canQueryCourier) {
|
||
try {
|
||
const xiaofeixia = await this.resolveTrackXiaofeixiaConfig({
|
||
delivery: order.delivery,
|
||
fulfillmentWarehouse: order.fulfillmentWarehouse,
|
||
});
|
||
const options = xiaofeixia ? { xiaofeixia } : undefined;
|
||
const shipmentQuery = {
|
||
trackingNumber: order.delivery.trackingNo ?? undefined,
|
||
outNumber: order.orderNo,
|
||
};
|
||
|
||
const [trackResult, signPhotoDataUris] = await Promise.all([
|
||
this.courier
|
||
.getTrack(shipmentQuery, options)
|
||
.then((nodes) => ({ nodes: Array.isArray(nodes) ? nodes : [], error: null as string | null }))
|
||
.catch((err: unknown) => ({
|
||
nodes: [] as TrackNode[],
|
||
error: err instanceof Error ? err.message : '查询路由失败',
|
||
})),
|
||
this.courier.getSignPhotos(shipmentQuery, options).catch(() => [] as string[]),
|
||
]);
|
||
|
||
base.nodes = this.sortTrackNodesOldestFirst(trackResult.nodes);
|
||
base.queryError = trackResult.error;
|
||
base.signPhotoUrls = await this.resolveSignPhotoUrls(order, signPhotoDataUris);
|
||
|
||
if (this.shouldFetchEstimatedArrival(order.status, base.nodes)) {
|
||
const toAddress = this.buildReceiverAddress(order);
|
||
if (toAddress) {
|
||
try {
|
||
const coverage = await this.courier.checkDeliveryCoverage(toAddress, options);
|
||
base.estimatedArrival = {
|
||
arriveTime: coverage.arriveTime,
|
||
siteName: coverage.siteName,
|
||
};
|
||
} catch {
|
||
// 预估送达失败不阻断
|
||
}
|
||
}
|
||
}
|
||
} catch (err) {
|
||
base.queryError = err instanceof Error ? err.message : '查询路由失败';
|
||
}
|
||
}
|
||
|
||
if (base.signPhotoUrls.length === 0 && order.delivery.signPhotoResource?.url) {
|
||
base.signPhotoUrls = [order.delivery.signPhotoResource.url];
|
||
}
|
||
|
||
return {
|
||
...base,
|
||
manualQueryUrl: order.delivery.manualQueryUrl,
|
||
provider: order.delivery.provider,
|
||
trackingNo: order.delivery.trackingNo,
|
||
logisticsCompany: order.delivery.logisticsCompany,
|
||
};
|
||
}
|
||
|
||
private async resolveTrackXiaofeixiaConfig(order: {
|
||
delivery: { fulfillmentProviderId: bigint | null } | null;
|
||
fulfillmentWarehouse?: { fulfillmentProviderId: bigint | null } | null;
|
||
}): Promise<XiaofeixiaConfig | null> {
|
||
const ids = [
|
||
order.delivery?.fulfillmentProviderId,
|
||
order.fulfillmentWarehouse?.fulfillmentProviderId,
|
||
].filter((id): id is bigint => id != null);
|
||
const seen = new Set<string>();
|
||
for (const id of ids) {
|
||
const key = String(id);
|
||
if (seen.has(key)) continue;
|
||
seen.add(key);
|
||
try {
|
||
return await this.fulfillmentProviderService.resolveXiaofeixiaConfig(id);
|
||
} catch {
|
||
continue;
|
||
}
|
||
}
|
||
return this.fulfillmentProviderService.resolveDefaultXiaofeixiaConfig();
|
||
}
|
||
|
||
private sortTrackNodesOldestFirst(nodes: TrackNode[]): TrackNode[] {
|
||
return [...nodes].sort((a, b) => {
|
||
const ta = new Date(a.createTime).getTime();
|
||
const tb = new Date(b.createTime).getTime();
|
||
if (Number.isNaN(ta) && Number.isNaN(tb)) return 0;
|
||
if (Number.isNaN(ta)) return 1;
|
||
if (Number.isNaN(tb)) return -1;
|
||
return ta - tb;
|
||
});
|
||
}
|
||
|
||
private buildReceiverAddress(order: {
|
||
receiverProvince?: string | null;
|
||
receiverCity?: string | null;
|
||
receiverDistrict?: string | null;
|
||
receiverAddress?: string | null;
|
||
}) {
|
||
const region = [order.receiverProvince, order.receiverCity, order.receiverDistrict]
|
||
.filter(Boolean)
|
||
.join('');
|
||
const detail = (order.receiverAddress || '').trim();
|
||
if (!region && !detail) return '';
|
||
if (region && detail.startsWith(region)) return detail;
|
||
return `${region}${detail}`;
|
||
}
|
||
|
||
private shouldFetchEstimatedArrival(status: string, nodes: TrackNode[]) {
|
||
if (!['OUT_WAREHOUSE', 'SHIPPING', 'SHIPPED'].includes(status)) return false;
|
||
if (nodes.length === 0) return true;
|
||
return !nodes.some(
|
||
(node) =>
|
||
node.statusName?.includes('签收') ||
|
||
node.trackInfo?.includes('签收') ||
|
||
node.statusName?.includes('派件'),
|
||
);
|
||
}
|
||
|
||
private async resolveSignPhotoUrls(
|
||
order: {
|
||
id: bigint;
|
||
delivery: {
|
||
id: bigint;
|
||
signPhotoResourceId: bigint | null;
|
||
signPhotoResource: { url: string } | null;
|
||
} | null;
|
||
},
|
||
dataUris: string[],
|
||
): Promise<string[]> {
|
||
if (!order.delivery || dataUris.length === 0) {
|
||
return order.delivery?.signPhotoResource?.url ? [order.delivery.signPhotoResource.url] : [];
|
||
}
|
||
|
||
const urls: string[] = [];
|
||
let firstResourceId: bigint | null = order.delivery.signPhotoResourceId;
|
||
|
||
for (let i = 0; i < dataUris.length; i += 1) {
|
||
const parsed = this.parseDataUri(dataUris[i]);
|
||
if (!parsed) continue;
|
||
|
||
const result = await this.oss.putObject({
|
||
bizType: 'SIGN_PHOTO',
|
||
mediaType: 'IMAGE',
|
||
fileName: `sign-${order.id}-${i + 1}.${parsed.ext}`,
|
||
buffer: parsed.buffer,
|
||
mimeType: parsed.mimeType,
|
||
});
|
||
urls.push(result.url);
|
||
|
||
if (!firstResourceId) {
|
||
const resource = await this.prisma.commonResource.create({
|
||
data: {
|
||
ownerType: 'ORDER',
|
||
ownerId: order.id,
|
||
bizType: 'SIGN_PHOTO',
|
||
mediaType: 'IMAGE',
|
||
ossBucket: result.bucket,
|
||
ossKey: result.ossKey,
|
||
url: result.url,
|
||
fileName: `sign-${order.id}-${i + 1}.${parsed.ext}`,
|
||
fileSize: BigInt(parsed.buffer.length),
|
||
mimeType: parsed.mimeType,
|
||
status: 'ACTIVE',
|
||
},
|
||
});
|
||
firstResourceId = resource.id;
|
||
}
|
||
}
|
||
|
||
if (firstResourceId && firstResourceId !== order.delivery.signPhotoResourceId) {
|
||
await this.prisma.orderDelivery.update({
|
||
where: { id: order.delivery.id },
|
||
data: { signPhotoResourceId: firstResourceId },
|
||
});
|
||
}
|
||
|
||
return urls;
|
||
}
|
||
|
||
private parseDataUri(dataUri: string): { buffer: Buffer; mimeType: string; ext: string } | null {
|
||
const trimmed = dataUri.trim();
|
||
const match = /^data:([^;]+);base64,(.+)$/i.exec(trimmed);
|
||
const base64 = match ? match[2] : trimmed;
|
||
const mimeType = match?.[1] || 'image/png';
|
||
try {
|
||
const buffer = Buffer.from(base64, 'base64');
|
||
if (!buffer.length) return null;
|
||
const ext = mimeType.includes('jpeg') || mimeType.includes('jpg') ? 'jpg' : 'png';
|
||
return { buffer, mimeType, ext };
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
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 tx.order.update({
|
||
where: { id: order.id },
|
||
data: { fulfillmentHold: false, fulfillmentHoldReason: null },
|
||
});
|
||
});
|
||
|
||
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));
|
||
}
|
||
}
|