系统判断配送限制
This commit is contained in:
@@ -146,6 +146,8 @@ export type AdminOrderRow = {
|
||||
city?: { id: string; name: string; code: string };
|
||||
fulfillmentWarehouseId?: string | null;
|
||||
fulfillmentWarehouse?: { id: string; name: string } | null;
|
||||
fulfillmentHold?: boolean;
|
||||
fulfillmentHoldReason?: string | null;
|
||||
user?: { id: string; userNo: string; phone: string | null; nickname: string | null };
|
||||
delivery?: {
|
||||
provider: string;
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
ORDER_STATUS_LABELS,
|
||||
fmtTime,
|
||||
} from '../lib/constants';
|
||||
import { FULFILLMENT_HOLD_REASON_LABELS } from '@dukang/shared-types';
|
||||
|
||||
type ShipDefaults = {
|
||||
provider: string;
|
||||
@@ -202,6 +203,7 @@ export default function OrdersPage() {
|
||||
if (values.status) qs.set('status', values.status);
|
||||
if (values.cityId) qs.set('cityId', values.cityId);
|
||||
if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone);
|
||||
if (values.fulfillmentHold) qs.set('fulfillmentHold', 'true');
|
||||
const res = await request<Paginated<AdminOrderRow>>(`/admin/orders?${qs}`);
|
||||
setData(res);
|
||||
} finally {
|
||||
@@ -395,9 +397,12 @@ export default function OrdersPage() {
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
render: (s) => (
|
||||
<Tag color={ORDER_STATUS_COLORS[s] || 'default'}>{ORDER_STATUS_LABELS[s] || s}</Tag>
|
||||
width: 140,
|
||||
render: (s, row) => (
|
||||
<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="收货手机">
|
||||
<Input allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item name="fulfillmentHold" label="大单拦截" valuePropName="checked">
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 140 }}
|
||||
placeholder="全部"
|
||||
options={[{ value: true, label: '仅待确认大单' }]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">查询</Button>
|
||||
@@ -550,9 +563,17 @@ export default function OrdersPage() {
|
||||
{detail.fulfillmentWarehouse?.name || '未分配'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={ORDER_STATUS_COLORS[detail.status] || 'default'}>
|
||||
{ORDER_STATUS_LABELS[detail.status] || detail.status}
|
||||
</Tag>
|
||||
<Space>
|
||||
<Tag color={ORDER_STATUS_COLORS[detail.status] || 'default'}>
|
||||
{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 label="用户">{detail.user?.userNo} / {detail.user?.nickname}</Descriptions.Item>
|
||||
<Descriptions.Item label="商品额">¥{detail.productAmount}</Descriptions.Item>
|
||||
@@ -864,6 +885,18 @@ export default function OrdersPage() {
|
||||
>
|
||||
{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.Item label="城市">{shipTarget.city?.name || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="收货">
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
calcBenefitAmount,
|
||||
calcRedeemSettleAmount,
|
||||
calcLogisticsFeeByBottles,
|
||||
calcOrderBoxCount,
|
||||
shouldHoldAutoCourierDispatch,
|
||||
validateMinPurchase,
|
||||
validateRedeemAmount,
|
||||
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', () => {
|
||||
const coupons = [
|
||||
{ id: '1', balance: 300, createdAt: 1 },
|
||||
|
||||
@@ -95,6 +95,30 @@ export function calcRedeemSettleAmount(amount: number, settlementRate: number):
|
||||
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元 */
|
||||
export type LogisticsPricingRule = {
|
||||
baseBottles: number;
|
||||
|
||||
@@ -34,6 +34,11 @@ export interface DeliveryDto {
|
||||
deliveredAt?: string;
|
||||
}
|
||||
|
||||
/** 大单拦截原因:≥10 箱不自动推小飞侠 */
|
||||
export const FULFILLMENT_HOLD_REASON_LABELS: Record<string, string> = {
|
||||
LARGE_ORDER_GE_10_BOXES: '大单≥10箱,待总部确认推单/自配送',
|
||||
};
|
||||
|
||||
export type PartnerProxyOrderProductOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -1150,6 +1150,9 @@ model Order {
|
||||
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
|
||||
/// 大单等场景拦截自动推承运商,待总部确认后推单或自配送
|
||||
fulfillmentHold Boolean @default(false) @map("fulfillment_hold")
|
||||
fulfillmentHoldReason String? @map("fulfillment_hold_reason") @db.VarChar(64)
|
||||
remark String? @db.VarChar(512)
|
||||
createdAt DateTime @default(now()) @map("created_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 type { CityWarehouse, FulfillmentProvider, Order } from '@prisma/client';
|
||||
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 { CourierService } from '../../integrations/courier/courier.service';
|
||||
import { CourierPayMode } from '../../integrations/courier/courier.types';
|
||||
@@ -16,6 +22,8 @@ export type ManualShipInput = {
|
||||
|
||||
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);
|
||||
@@ -64,10 +72,48 @@ export class FulfillmentService {
|
||||
await this.ensureDeliveryRecord(orderId, 'MANUAL');
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
if (!isXfxProviderCode(provider.code)) {
|
||||
this.logger.warn(`承运商 ${provider.code} 自动推单尚未实现,订单 ${order.orderNo} 保持待发货`);
|
||||
@@ -129,6 +175,10 @@ export class FulfillmentService {
|
||||
} 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',
|
||||
@@ -260,6 +310,10 @@ export class FulfillmentService {
|
||||
} 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);
|
||||
|
||||
@@ -34,6 +34,9 @@ export class AdminOrdersService {
|
||||
if (query.userId) where.userId = BigInt(query.userId);
|
||||
if (query.cityId) where.cityId = BigInt(query.cityId);
|
||||
if (query.receiverPhone) where.receiverPhone = { contains: query.receiverPhone };
|
||||
if (query.fulfillmentHold === true || query.fulfillmentHold === 'true') {
|
||||
where.fulfillmentHold = true;
|
||||
}
|
||||
if (query.createdFrom || query.createdTo) {
|
||||
where.createdAt = {};
|
||||
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');
|
||||
|
||||
@@ -60,6 +60,10 @@ export class AdminOrdersQueryDto extends PaginationQueryDto {
|
||||
@IsString()
|
||||
receiverPhone?: string;
|
||||
|
||||
/** 仅看大单拦截待总部确认:true / 1 */
|
||||
@IsOptional()
|
||||
fulfillmentHold?: string | boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
createdFrom?: string;
|
||||
|
||||
@@ -1172,7 +1172,12 @@ export class SettlementService {
|
||||
ok: true,
|
||||
skipped: Boolean(bill.skipped),
|
||||
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) {
|
||||
results.push({
|
||||
@@ -1419,7 +1424,23 @@ export class SettlementService {
|
||||
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) {
|
||||
const pricing =
|
||||
this.fulfillmentProviderService.parsePricingRules(p.pricingRulesJson) ??
|
||||
|
||||
+2
-1
@@ -131,6 +131,7 @@
|
||||
```
|
||||
|
||||
- **同城**:仓配履约——有仓且绑 API 承运商则自动推单(首期小飞侠);有仓选自管则管仓方手工填单;**无仓**则总部传统快递填单
|
||||
- **大单拦截**:同城订单 **≥10 箱(箱规 6 瓶,即 ≥60 瓶)** 不自动推小飞侠;订单打标「大单待确认」,由总部确认后推小飞侠或自配送(填快递单)
|
||||
- **跨城**:总部传统快递到付填单;订单佣金归总部
|
||||
- **现场提货**:支付后直接已完成;有现场推广码则订单佣金归码所属合伙人,无码归总部
|
||||
|
||||
@@ -212,7 +213,7 @@
|
||||
- 一城多仓;每仓最多关联 1 名管仓合伙人
|
||||
- **仓配管理**(总部):注册第三方履约接口(小飞侠、京东、顺丰等);启用后仓库方可选择;**承运商需配置银行账户、结算方式(充值/挂账月结)、计价标准**
|
||||
- **仓库设置**:履约方式 = API 自动推单(选已注册承运商)或 **自管**(手工填运单号 + 查询链接模板)
|
||||
- 同城有仓订单支付后自动按仓配置推单;自管仓由管仓合伙人/总部代填单
|
||||
- 同城有仓订单支付后自动按仓配置推单;自管仓由管仓合伙人/总部代填单;**≥10 箱大单除外**(见 §3.2)
|
||||
- 同城无仓 / 跨城:总部传统快递填单
|
||||
- 佣金与仓无关(订单佣金仍按 §3.3.1);仓用于履约与工单协同
|
||||
- 未关联合伙人的仓 → 总部直派
|
||||
|
||||
+2
-1
@@ -439,7 +439,8 @@
|
||||
|------|------|
|
||||
| 仓库 | 名称、地址、联系人;管仓方 = 总部直派 或 关联合伙人(每仓最多 1 名) |
|
||||
| 履约方式 | **API 自动推单**(选已注册承运商,首期小飞侠)或 **自管**(手工填运单号 + 查询链接模板) |
|
||||
| 仓配管理 | 注册第三方履约接口;启用后仓库才可选 |
|
||||
| 仓配管理 | 注册第三方履约接口;启用后仓库才可选;配置银行账户/结算/计价 |
|
||||
| 大单拦截 | 同城 ≥10 箱不自动推小飞侠,订单标「大单待确认」,总部确认推单或自配送 |
|
||||
|
||||
规则摘要:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user