feat(trade): v4.0.13 收货禁全市与小飞侠拒单可感知
CI / verify (push) Waiting to run

拦截伪区县写入与下单;推单失败挂 fulfillmentHold,HQ 可见超区/推单失败。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-02 17:20:40 +08:00
parent 63fcf33416
commit c6b01def4c
18 changed files with 613 additions and 76 deletions
@@ -1,6 +1,10 @@
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 {
FULFILLMENT_HOLD_COURIER_DISPATCH_FAILED,
FULFILLMENT_HOLD_COURIER_OUT_OF_SERVICE,
isXfxProviderCode,
} from '@dukang/shared-types';
import {
BOTTLES_PER_BOX,
XFX_AUTO_DISPATCH_MAX_BOXES,
@@ -29,6 +33,7 @@ export type ManualShipInput = {
export type HqLogisticsShipInput = ManualShipInput;
export const FULFILLMENT_HOLD_LARGE_ORDER = 'LARGE_ORDER_GE_10_BOXES';
export { FULFILLMENT_HOLD_COURIER_OUT_OF_SERVICE, FULFILLMENT_HOLD_COURIER_DISPATCH_FAILED };
@Injectable()
export class FulfillmentService {
@@ -138,6 +143,7 @@ export class FulfillmentService {
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await this.logDispatchFailure(order, provider, message);
await this.markCourierDispatchHold(order.id, message);
await this.ensureDeliveryRecord(order.id, 'MANUAL', provider.id);
return;
}
@@ -213,6 +219,7 @@ export class FulfillmentService {
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await this.logDispatchFailure(order, provider, message);
await this.markCourierDispatchHold(order.id, message);
await this.ensureDeliveryRecord(order.id, 'MANUAL', provider.id);
}
}
@@ -559,6 +566,22 @@ export class FulfillmentService {
});
}
/** 自动推单失败:挂履约拦截,HQ 可见,避免静默 MANUAL 像「没推单」 */
private async markCourierDispatchHold(orderId: bigint, error: string) {
const outOfService = /超出服务区/.test(error);
const reason = outOfService
? FULFILLMENT_HOLD_COURIER_OUT_OF_SERVICE
: FULFILLMENT_HOLD_COURIER_DISPATCH_FAILED;
this.logger.warn(`承运商推单失败挂起:orderId=${orderId} reason=${reason} err=${error}`);
await this.prisma.order.update({
where: { id: orderId },
data: {
fulfillmentHold: true,
fulfillmentHoldReason: reason,
},
});
}
private async buildQueryUrlFromTemplate(warehouseId: bigint | null, trackingNo: string) {
if (!warehouseId) return undefined;
const wh = await this.prisma.cityWarehouse.findUnique({ where: { id: warehouseId } });
@@ -1,4 +1,5 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { validateShippingAddress } from '@dukang/domain';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
@@ -27,7 +28,25 @@ export class UserAddressService {
});
}
private assertShippingFields(body: Record<string, unknown>, existing?: {
province: string;
city: string;
district: string;
detail: string;
}) {
const check = validateShippingAddress({
province: body.province != null ? String(body.province) : existing?.province,
city: body.city != null ? String(body.city) : existing?.city,
district: body.district != null ? String(body.district) : existing?.district,
detail: body.detail != null ? String(body.detail) : existing?.detail,
});
if (!check.ok) {
throw new BadRequestException(check.message || '收货地址不完整');
}
}
async create(userId: bigint, body: Record<string, unknown>) {
this.assertShippingFields(body);
const isDefault = body.isDefault ? 1 : 0;
const address = await this.prisma.$transaction(async (tx) => {
if (isDefault) {
@@ -52,6 +71,7 @@ export class UserAddressService {
async update(userId: bigint, id: bigint, body: Record<string, unknown>) {
const existing = await this.prisma.userAddress.findFirst({ where: { id, userId } });
if (!existing) throw new NotFoundException('地址不存在');
this.assertShippingFields(body, existing);
const address = await this.prisma.$transaction(async (tx) => {
if (body.isDefault) {
await tx.userAddress.updateMany({ where: { userId }, data: { isDefault: 0 } });
@@ -14,6 +14,7 @@ import {
orderTabToStatuses,
toMinSaleQuantity,
validateMinPurchase,
validateShippingAddress,
} from '@dukang/domain';
import { loadAppConfig, ClientApp, WECHAT_AUTH_REQUIRED } from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
@@ -155,6 +156,19 @@ export class TradeService {
addressMessage = '该规格不支持跨城配送,请更换为开城城市内的收货地址';
}
}
if (addressOk && body.addressId) {
const address = await this.prisma.userAddress.findFirst({
where: { id: BigInt(body.addressId), userId },
});
if (address) {
const shipping = validateShippingAddress(address);
if (!shipping.ok) {
addressOk = false;
addressMessage = shipping.message || '请完善收货地址(需具体区县)';
}
}
}
}
const bottlesPerUnit = sale.bottlesPerUnit > 0 ? sale.bottlesPerUnit : 1;
@@ -276,6 +290,10 @@ export class TradeService {
where: { id: BigInt(body.addressId), userId },
});
if (!address) throw new BadRequestException('请选择收货地址');
const shipping = validateShippingAddress(address);
if (!shipping.ok) {
throw new BadRequestException(shipping.message || '请完善收货地址(需具体区县)');
}
receiverName = address.receiverName;
receiverPhone = address.phone;
receiverAddress = `${address.province}${address.city}${address.district}${address.detail}`;
@@ -1866,6 +1884,15 @@ export class TradeService {
if (!body.addressDetail?.trim()) {
throw new BadRequestException('请填写详细地址');
}
const shipping = validateShippingAddress({
province: receiverProvince,
city: receiverCity,
district: receiverDistrict,
detail: body.addressDetail,
});
if (!shipping.ok) {
throw new BadRequestException(shipping.message || '请完善收货地址(需具体区县)');
}
receiverAddress = `${receiverProvince}${receiverCity}${receiverDistrict}${body.addressDetail.trim()}`;
}
@@ -2281,6 +2308,15 @@ export class TradeService {
if (!body.addressDetail?.trim()) {
throw new BadRequestException('请填写详细地址');
}
const shipping = validateShippingAddress({
province: receiverProvince,
city: receiverCity,
district: receiverDistrict,
detail: body.addressDetail,
});
if (!shipping.ok) {
throw new BadRequestException(shipping.message || '请完善收货地址(需具体区县)');
}
receiverAddress = `${receiverProvince}${receiverCity}${receiverDistrict}${body.addressDetail.trim()}`;
}