系统判断配送限制

This commit is contained in:
2026-07-26 09:59:38 +08:00
parent 19cb312465
commit da21cd3daa
12 changed files with 176 additions and 10 deletions
+2
View File
@@ -146,6 +146,8 @@ export type AdminOrderRow = {
city?: { id: string; name: string; code: string }; city?: { id: string; name: string; code: string };
fulfillmentWarehouseId?: string | null; fulfillmentWarehouseId?: string | null;
fulfillmentWarehouse?: { id: string; name: string } | null; fulfillmentWarehouse?: { id: string; name: string } | null;
fulfillmentHold?: boolean;
fulfillmentHoldReason?: string | null;
user?: { id: string; userNo: string; phone: string | null; nickname: string | null }; user?: { id: string; userNo: string; phone: string | null; nickname: string | null };
delivery?: { delivery?: {
provider: string; provider: string;
+39 -6
View File
@@ -26,6 +26,7 @@ import {
ORDER_STATUS_LABELS, ORDER_STATUS_LABELS,
fmtTime, fmtTime,
} from '../lib/constants'; } from '../lib/constants';
import { FULFILLMENT_HOLD_REASON_LABELS } from '@dukang/shared-types';
type ShipDefaults = { type ShipDefaults = {
provider: string; provider: string;
@@ -202,6 +203,7 @@ export default function OrdersPage() {
if (values.status) qs.set('status', values.status); if (values.status) qs.set('status', values.status);
if (values.cityId) qs.set('cityId', values.cityId); if (values.cityId) qs.set('cityId', values.cityId);
if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone); if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone);
if (values.fulfillmentHold) qs.set('fulfillmentHold', 'true');
const res = await request<Paginated<AdminOrderRow>>(`/admin/orders?${qs}`); const res = await request<Paginated<AdminOrderRow>>(`/admin/orders?${qs}`);
setData(res); setData(res);
} finally { } finally {
@@ -395,9 +397,12 @@ export default function OrdersPage() {
{ {
title: '状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
width: 100, width: 140,
render: (s) => ( render: (s, row) => (
<Tag color={ORDER_STATUS_COLORS[s] || 'default'}>{ORDER_STATUS_LABELS[s] || s}</Tag> <Space size={4} wrap>
<Tag color={ORDER_STATUS_COLORS[s] || 'default'}>{ORDER_STATUS_LABELS[s] || s}</Tag>
{row.fulfillmentHold ? <Tag color="orange"></Tag> : null}
</Space>
), ),
}, },
{ {
@@ -477,6 +482,14 @@ export default function OrdersPage() {
<Form.Item name="receiverPhone" label="收货手机"> <Form.Item name="receiverPhone" label="收货手机">
<Input allowClear /> <Input allowClear />
</Form.Item> </Form.Item>
<Form.Item name="fulfillmentHold" label="大单拦截" valuePropName="checked">
<Select
allowClear
style={{ width: 140 }}
placeholder="全部"
options={[{ value: true, label: '仅待确认大单' }]}
/>
</Form.Item>
<Form.Item> <Form.Item>
<Space> <Space>
<Button type="primary" htmlType="submit"></Button> <Button type="primary" htmlType="submit"></Button>
@@ -550,9 +563,17 @@ export default function OrdersPage() {
{detail.fulfillmentWarehouse?.name || '未分配'} {detail.fulfillmentWarehouse?.name || '未分配'}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="状态"> <Descriptions.Item label="状态">
<Tag color={ORDER_STATUS_COLORS[detail.status] || 'default'}> <Space>
{ORDER_STATUS_LABELS[detail.status] || detail.status} <Tag color={ORDER_STATUS_COLORS[detail.status] || 'default'}>
</Tag> {ORDER_STATUS_LABELS[detail.status] || detail.status}
</Tag>
{detail.fulfillmentHold ? (
<Tag color="orange">
{FULFILLMENT_HOLD_REASON_LABELS[detail.fulfillmentHoldReason || ''] ||
'大单待确认'}
</Tag>
) : null}
</Space>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="用户">{detail.user?.userNo} / {detail.user?.nickname}</Descriptions.Item> <Descriptions.Item label="用户">{detail.user?.userNo} / {detail.user?.nickname}</Descriptions.Item>
<Descriptions.Item label="商品额">¥{detail.productAmount}</Descriptions.Item> <Descriptions.Item label="商品额">¥{detail.productAmount}</Descriptions.Item>
@@ -864,6 +885,18 @@ export default function OrdersPage() {
> >
{shipTarget && ( {shipTarget && (
<> <>
{shipTarget.fulfillmentHold ? (
<Alert
type="warning"
showIcon
style={{ marginBottom: 12 }}
message="大单已拦截自动推小飞侠"
description={
FULFILLMENT_HOLD_REASON_LABELS[shipTarget.fulfillmentHoldReason || ''] ||
'≥10箱订单需总部确认:可选仓推小飞侠,或改用快递自配送。'
}
/>
) : null}
<Descriptions size="small" column={1} style={{ marginBottom: 16 }}> <Descriptions size="small" column={1} style={{ marginBottom: 16 }}>
<Descriptions.Item label="城市">{shipTarget.city?.name || '—'}</Descriptions.Item> <Descriptions.Item label="城市">{shipTarget.city?.name || '—'}</Descriptions.Item>
<Descriptions.Item label="收货"> <Descriptions.Item label="收货">
+11
View File
@@ -3,6 +3,8 @@ import {
calcBenefitAmount, calcBenefitAmount,
calcRedeemSettleAmount, calcRedeemSettleAmount,
calcLogisticsFeeByBottles, calcLogisticsFeeByBottles,
calcOrderBoxCount,
shouldHoldAutoCourierDispatch,
validateMinPurchase, validateMinPurchase,
validateRedeemAmount, validateRedeemAmount,
allocateBenefitCoupons, allocateBenefitCoupons,
@@ -99,6 +101,15 @@ describe('calcLogisticsFeeByBottles', () => {
}); });
}); });
describe('shouldHoldAutoCourierDispatch', () => {
it('holds at 10 full boxes (60 bottles)', () => {
expect(shouldHoldAutoCourierDispatch(59)).toBe(false);
expect(shouldHoldAutoCourierDispatch(60)).toBe(true);
expect(shouldHoldAutoCourierDispatch(66)).toBe(true);
expect(calcOrderBoxCount(60)).toBe(10);
});
});
describe('allocateBenefitCoupons', () => { describe('allocateBenefitCoupons', () => {
const coupons = [ const coupons = [
{ id: '1', balance: 300, createdAt: 1 }, { id: '1', balance: 300, createdAt: 1 },
+24
View File
@@ -95,6 +95,30 @@ export function calcRedeemSettleAmount(amount: number, settlementRate: number):
return Math.round(amount * settlementRate * 100) / 100; return Math.round(amount * settlementRate * 100) / 100;
} }
/** 箱规默认瓶数(跨城起购 / 物流计价 / 大单拦截) */
export const BOTTLES_PER_BOX = 6;
/** 小飞侠自动推单上限箱数:达到该箱数起拦截,需总部确认后推单或自配送 */
export const XFX_AUTO_DISPATCH_MAX_BOXES = 10;
/** 按箱规向上取整得到箱数 */
export function calcOrderBoxCount(quantity: number, bottlesPerBox = BOTTLES_PER_BOX): number {
const qty = Math.floor(Number(quantity) || 0);
if (qty <= 0 || !(bottlesPerBox > 0)) return 0;
return Math.ceil(qty / bottlesPerBox);
}
/** 是否因大单拦截自动推承运商(默认 ≥10 箱 = 60 瓶) */
export function shouldHoldAutoCourierDispatch(
quantity: number,
options?: { bottlesPerBox?: number; maxBoxes?: number },
): boolean {
const bottlesPerBox = options?.bottlesPerBox ?? BOTTLES_PER_BOX;
const maxBoxes = options?.maxBoxes ?? XFX_AUTO_DISPATCH_MAX_BOXES;
const qty = Math.floor(Number(quantity) || 0);
return qty >= bottlesPerBox * maxBoxes;
}
/** 物流(快递)按瓶计价规则,如小飞侠:2瓶6元、加一瓶+2元、6瓶一箱14元 */ /** 物流(快递)按瓶计价规则,如小飞侠:2瓶6元、加一瓶+2元、6瓶一箱14元 */
export type LogisticsPricingRule = { export type LogisticsPricingRule = {
baseBottles: number; baseBottles: number;
+5
View File
@@ -34,6 +34,11 @@ export interface DeliveryDto {
deliveredAt?: string; deliveredAt?: string;
} }
/** 大单拦截原因:≥10 箱不自动推小飞侠 */
export const FULFILLMENT_HOLD_REASON_LABELS: Record<string, string> = {
LARGE_ORDER_GE_10_BOXES: '大单≥10箱,待总部确认推单/自配送',
};
export type PartnerProxyOrderProductOption = { export type PartnerProxyOrderProductOption = {
id: string; id: string;
name: string; name: string;
+3
View File
@@ -1150,6 +1150,9 @@ model Order {
partnerAccountIdAtPay BigInt? @map("partner_account_id_at_pay") @db.UnsignedBigInt partnerAccountIdAtPay BigInt? @map("partner_account_id_at_pay") @db.UnsignedBigInt
orderCommissionRateAtPay Decimal? @map("order_commission_rate_at_pay") @db.Decimal(5, 4) orderCommissionRateAtPay Decimal? @map("order_commission_rate_at_pay") @db.Decimal(5, 4)
fulfillmentWarehouseId BigInt? @map("fulfillment_warehouse_id") @db.UnsignedBigInt fulfillmentWarehouseId BigInt? @map("fulfillment_warehouse_id") @db.UnsignedBigInt
/// 大单等场景拦截自动推承运商,待总部确认后推单或自配送
fulfillmentHold Boolean @default(false) @map("fulfillment_hold")
fulfillmentHoldReason String? @map("fulfillment_hold_reason") @db.VarChar(64)
remark String? @db.VarChar(512) remark String? @db.VarChar(512)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
@@ -1,6 +1,12 @@
import { BadRequestException, Injectable, Logger, NotFoundException, Inject, forwardRef } from '@nestjs/common'; import { BadRequestException, Injectable, Logger, NotFoundException, Inject, forwardRef } from '@nestjs/common';
import type { CityWarehouse, FulfillmentProvider, Order } from '@prisma/client'; import type { CityWarehouse, FulfillmentProvider, Order } from '@prisma/client';
import { isXfxProviderCode } from '@dukang/shared-types'; import { isXfxProviderCode } from '@dukang/shared-types';
import {
BOTTLES_PER_BOX,
XFX_AUTO_DISPATCH_MAX_BOXES,
calcOrderBoxCount,
shouldHoldAutoCourierDispatch,
} from '@dukang/domain';
import { PrismaService } from '../../common/prisma/prisma.module'; import { PrismaService } from '../../common/prisma/prisma.module';
import { CourierService } from '../../integrations/courier/courier.service'; import { CourierService } from '../../integrations/courier/courier.service';
import { CourierPayMode } from '../../integrations/courier/courier.types'; import { CourierPayMode } from '../../integrations/courier/courier.types';
@@ -16,6 +22,8 @@ export type ManualShipInput = {
export type HqLogisticsShipInput = ManualShipInput; export type HqLogisticsShipInput = ManualShipInput;
export const FULFILLMENT_HOLD_LARGE_ORDER = 'LARGE_ORDER_GE_10_BOXES';
@Injectable() @Injectable()
export class FulfillmentService { export class FulfillmentService {
private readonly logger = new Logger(FulfillmentService.name); private readonly logger = new Logger(FulfillmentService.name);
@@ -64,10 +72,48 @@ export class FulfillmentService {
await this.ensureDeliveryRecord(orderId, 'MANUAL'); await this.ensureDeliveryRecord(orderId, 'MANUAL');
return; return;
} }
// 大单拦截:≥10 箱不自动推小飞侠,待总部确认后推单或自配送
if (shouldHoldAutoCourierDispatch(order.quantity)) {
const boxes = calcOrderBoxCount(order.quantity);
this.logger.warn(
`大单拦截自动推单:${order.orderNo} quantity=${order.quantity} 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: `大单拦截:${order.quantity}瓶(约${boxes}箱),需总部确认后推小飞侠或自配送`.slice(
0,
512,
),
},
});
return;
}
await this.dispatchApiAuto(order, warehouse, provider); 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: Order, warehouse: CityWarehouse, provider: FulfillmentProvider) { async dispatchApiAuto(order: Order, warehouse: CityWarehouse, provider: FulfillmentProvider) {
if (!isXfxProviderCode(provider.code)) { if (!isXfxProviderCode(provider.code)) {
this.logger.warn(`承运商 ${provider.code} 自动推单尚未实现,订单 ${order.orderNo} 保持待发货`); this.logger.warn(`承运商 ${provider.code} 自动推单尚未实现,订单 ${order.orderNo} 保持待发货`);
@@ -129,6 +175,10 @@ export class FulfillmentService {
} else { } else {
await tx.orderDelivery.create({ data: { orderId: order.id, ...data } }); 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({ await tx.logThirdParty.create({
data: { data: {
provider: 'XFX', provider: 'XFX',
@@ -260,6 +310,10 @@ export class FulfillmentService {
} else { } else {
await tx.orderDelivery.create({ data: { orderId: order.id, ...data } }); 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); await this.tradeService.applyStatusTransition(order.id, order.status, 'SHIPPING', input.operator);
@@ -34,6 +34,9 @@ export class AdminOrdersService {
if (query.userId) where.userId = BigInt(query.userId); if (query.userId) where.userId = BigInt(query.userId);
if (query.cityId) where.cityId = BigInt(query.cityId); if (query.cityId) where.cityId = BigInt(query.cityId);
if (query.receiverPhone) where.receiverPhone = { contains: query.receiverPhone }; if (query.receiverPhone) where.receiverPhone = { contains: query.receiverPhone };
if (query.fulfillmentHold === true || query.fulfillmentHold === 'true') {
where.fulfillmentHold = true;
}
if (query.createdFrom || query.createdTo) { if (query.createdFrom || query.createdTo) {
where.createdAt = {}; where.createdAt = {};
if (query.createdFrom) where.createdAt.gte = new Date(query.createdFrom); if (query.createdFrom) where.createdAt.gte = new Date(query.createdFrom);
@@ -256,6 +259,10 @@ export class AdminOrdersService {
}, },
}); });
} }
await tx.order.update({
where: { id },
data: { fulfillmentHold: false, fulfillmentHoldReason: null },
});
}); });
await this.tradeService.applyStatusTransition(id, order.status, 'SHIPPING', 'HQ_SHIP'); await this.tradeService.applyStatusTransition(id, order.status, 'SHIPPING', 'HQ_SHIP');
@@ -60,6 +60,10 @@ export class AdminOrdersQueryDto extends PaginationQueryDto {
@IsString() @IsString()
receiverPhone?: string; receiverPhone?: string;
/** 仅看大单拦截待总部确认:true / 1 */
@IsOptional()
fulfillmentHold?: string | boolean;
@IsOptional() @IsOptional()
@IsString() @IsString()
createdFrom?: string; createdFrom?: string;
@@ -1172,7 +1172,12 @@ export class SettlementService {
ok: true, ok: true,
skipped: Boolean(bill.skipped), skipped: Boolean(bill.skipped),
message: bill.reason, message: bill.reason,
billId: bill.bill?.id?.toString?.() ?? bill.bill?.id, billId:
bill.bill?.id != null
? typeof bill.bill.id === 'string'
? bill.bill.id
: String(bill.bill.id)
: undefined,
}); });
} catch (e) { } catch (e) {
results.push({ results.push({
@@ -1419,7 +1424,23 @@ export class SettlementService {
orderBy: { code: 'asc' }, orderBy: { code: 'asc' },
}); });
const rows = []; const rows: Array<{
providerId: string;
providerCode: string;
providerName: string;
settlementMethod: string;
prepaidBalance: number;
bankAccountName: string | null;
bankName: string | null;
bankAccountNo: string | null;
pricingRules: ReturnType<FulfillmentProviderService['parsePricingRules']>;
orderCount: number;
bottleCount: number;
logisticsAmount: number;
billId: string | null;
billStatus: string | null;
billAmount: number | null;
}> = [];
for (const p of providers) { for (const p of providers) {
const pricing = const pricing =
this.fulfillmentProviderService.parsePricingRules(p.pricingRulesJson) ?? this.fulfillmentProviderService.parsePricingRules(p.pricingRulesJson) ??
+2 -1
View File
@@ -131,6 +131,7 @@
``` ```
- **同城**:仓配履约——有仓且绑 API 承运商则自动推单(首期小飞侠);有仓选自管则管仓方手工填单;**无仓**则总部传统快递填单 - **同城**:仓配履约——有仓且绑 API 承运商则自动推单(首期小飞侠);有仓选自管则管仓方手工填单;**无仓**则总部传统快递填单
- **大单拦截**:同城订单 **≥10 箱(箱规 6 瓶,即 ≥60 瓶)** 不自动推小飞侠;订单打标「大单待确认」,由总部确认后推小飞侠或自配送(填快递单)
- **跨城**:总部传统快递到付填单;订单佣金归总部 - **跨城**:总部传统快递到付填单;订单佣金归总部
- **现场提货**:支付后直接已完成;有现场推广码则订单佣金归码所属合伙人,无码归总部 - **现场提货**:支付后直接已完成;有现场推广码则订单佣金归码所属合伙人,无码归总部
@@ -212,7 +213,7 @@
- 一城多仓;每仓最多关联 1 名管仓合伙人 - 一城多仓;每仓最多关联 1 名管仓合伙人
- **仓配管理**(总部):注册第三方履约接口(小飞侠、京东、顺丰等);启用后仓库方可选择;**承运商需配置银行账户、结算方式(充值/挂账月结)、计价标准** - **仓配管理**(总部):注册第三方履约接口(小飞侠、京东、顺丰等);启用后仓库方可选择;**承运商需配置银行账户、结算方式(充值/挂账月结)、计价标准**
- **仓库设置**:履约方式 = API 自动推单(选已注册承运商)或 **自管**(手工填运单号 + 查询链接模板) - **仓库设置**:履约方式 = API 自动推单(选已注册承运商)或 **自管**(手工填运单号 + 查询链接模板)
- 同城有仓订单支付后自动按仓配置推单;自管仓由管仓合伙人/总部代填单 - 同城有仓订单支付后自动按仓配置推单;自管仓由管仓合伙人/总部代填单;**≥10 箱大单除外**(见 §3.2)
- 同城无仓 / 跨城:总部传统快递填单 - 同城无仓 / 跨城:总部传统快递填单
- 佣金与仓无关(订单佣金仍按 §3.3.1);仓用于履约与工单协同 - 佣金与仓无关(订单佣金仍按 §3.3.1);仓用于履约与工单协同
- 未关联合伙人的仓 → 总部直派 - 未关联合伙人的仓 → 总部直派
+2 -1
View File
@@ -439,7 +439,8 @@
|------|------| |------|------|
| 仓库 | 名称、地址、联系人;管仓方 = 总部直派 或 关联合伙人(每仓最多 1 名) | | 仓库 | 名称、地址、联系人;管仓方 = 总部直派 或 关联合伙人(每仓最多 1 名) |
| 履约方式 | **API 自动推单**(选已注册承运商,首期小飞侠)或 **自管**(手工填运单号 + 查询链接模板) | | 履约方式 | **API 自动推单**(选已注册承运商,首期小飞侠)或 **自管**(手工填运单号 + 查询链接模板) |
| 仓配管理 | 注册第三方履约接口;启用后仓库才可选 | | 仓配管理 | 注册第三方履约接口;启用后仓库才可选;配置银行账户/结算/计价 |
| 大单拦截 | 同城 ≥10 箱不自动推小飞侠,订单标「大单待确认」,总部确认推单或自配送 |
规则摘要: 规则摘要: