仓库管理配置 小飞侠配置
CI / verify (pull_request) Has been cancelled

This commit is contained in:
2026-07-16 21:40:25 +08:00
parent f6d97b4ee8
commit bde85a7b82
15 changed files with 580 additions and 197 deletions
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { import {
Alert,
Button, Button,
Form, Form,
Input, Input,
@@ -17,6 +18,7 @@ import {
FULFILLMENT_PROVIDER_TYPE_LABELS, FULFILLMENT_PROVIDER_TYPE_LABELS,
FulfillmentProviderStatus, FulfillmentProviderStatus,
FulfillmentProviderType, FulfillmentProviderType,
isXfxProviderCode,
type FulfillmentProviderDto, type FulfillmentProviderDto,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
import { request } from '../lib/api'; import { request } from '../lib/api';
@@ -30,6 +32,12 @@ const STATUS_OPTIONS = Object.entries(FULFILLMENT_PROVIDER_STATUS_LABELS).map(([
value, value,
label, label,
})); }));
const SIGN_OPTIONS = [
{ value: 'MD5', label: 'MD5' },
{ value: 'HMAC-SHA256', label: 'HMAC-SHA256' },
];
const DEFAULT_XFX_API_URL = 'https://beta.51xiaoju.cn/app/api/interface.do';
export default function FulfillmentProvidersPage() { export default function FulfillmentProvidersPage() {
const [rows, setRows] = useState<FulfillmentProviderDto[]>([]); const [rows, setRows] = useState<FulfillmentProviderDto[]>([]);
@@ -37,6 +45,13 @@ export default function FulfillmentProvidersPage() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [editRow, setEditRow] = useState<FulfillmentProviderDto | null>(null); const [editRow, setEditRow] = useState<FulfillmentProviderDto | null>(null);
const [form] = Form.useForm(); const [form] = Form.useForm();
const watchedCode = Form.useWatch('code', form);
const watchedType = Form.useWatch('type', form);
const showXfxFields = useMemo(
() => isXfxProviderCode(String(watchedCode || '')) && watchedType === FulfillmentProviderType.API,
[watchedCode, watchedType],
);
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -56,48 +71,64 @@ export default function FulfillmentProvidersPage() {
setEditRow(null); setEditRow(null);
form.resetFields(); form.resetFields();
form.setFieldsValue({ form.setFieldsValue({
code: 'XFX',
name: '小飞侠',
type: FulfillmentProviderType.API, type: FulfillmentProviderType.API,
status: FulfillmentProviderStatus.ACTIVE, status: FulfillmentProviderStatus.ACTIVE,
capabilitiesJson: JSON.stringify( apiUrl: DEFAULT_XFX_API_URL,
{ createShipment: true, getTrack: true, callback: true }, signType: 'MD5',
null,
2,
),
}); });
setOpen(true); setOpen(true);
} }
function openEdit(row: FulfillmentProviderDto) { function openEdit(row: FulfillmentProviderDto) {
setEditRow(row); setEditRow(row);
const xfx = row.xiaofeixiaConfig;
form.setFieldsValue({ form.setFieldsValue({
code: row.code, code: row.code,
name: row.name, name: row.name,
type: row.type, type: row.type,
status: row.status, status: row.status,
capabilitiesJson: row.capabilities ? JSON.stringify(row.capabilities, null, 2) : '', apiUrl: xfx?.apiUrl || DEFAULT_XFX_API_URL,
configJson: '', mchId: xfx?.mchId || '',
apiKey: '',
signType: xfx?.signType || 'MD5',
appId: xfx?.appId || '',
}); });
setOpen(true); setOpen(true);
} }
async function submit() { async function submit() {
const v = await form.validateFields(); const v = await form.validateFields();
if (editRow) { const payload: Record<string, unknown> = {
await request(`/admin/fulfillment-providers/${editRow.id}`, {
method: 'PUT',
body: JSON.stringify({
name: v.name, name: v.name,
type: v.type, type: v.type,
status: v.status, status: v.status,
configJson: v.configJson || undefined, };
capabilitiesJson: v.capabilitiesJson || undefined,
}), if (isXfxProviderCode(String(v.code)) && v.type === FulfillmentProviderType.API) {
payload.xiaofeixiaConfig = {
apiUrl: v.apiUrl,
mchId: v.mchId,
apiKey: v.apiKey || undefined,
signType: v.signType,
appId: v.appId || undefined,
};
}
if (editRow) {
await request(`/admin/fulfillment-providers/${editRow.id}`, {
method: 'PUT',
body: JSON.stringify(payload),
}); });
message.success('已更新'); message.success('已更新');
} else { } else {
await request('/admin/fulfillment-providers', { await request('/admin/fulfillment-providers', {
method: 'POST', method: 'POST',
body: JSON.stringify(v), body: JSON.stringify({
code: v.code,
...payload,
}),
}); });
message.success('已创建'); message.success('已创建');
} }
@@ -123,14 +154,18 @@ export default function FulfillmentProvidersPage() {
), ),
}, },
{ {
title: '能力', title: '接口配置',
render: (_, row) => { render: (_, row) => {
const caps = row.capabilities; if (isXfxProviderCode(row.code)) {
if (!caps) return '—'; const cfg = row.xiaofeixiaConfig;
return Object.entries(caps) if (!cfg?.apiUrl) return <Tag></Tag>;
.filter(([, on]) => on) return (
.map(([k]) => k) <span title={cfg.apiUrl}>
.join('、') || ''; {cfg.hasApiKey ? '已配置' : '缺 Key'} · {cfg.mchId || '无商户号'}
</span>
);
}
return row.hasConfig ? '已配置' : '—';
}, },
}, },
{ title: '更新时间', dataIndex: 'updatedAt', width: 170, render: fmtTime }, { title: '更新时间', dataIndex: 'updatedAt', width: 170, render: fmtTime },
@@ -153,7 +188,7 @@ export default function FulfillmentProvidersPage() {
</Typography.Title> </Typography.Title>
<Typography.Text type="secondary"> <Typography.Text type="secondary">
使 API
</Typography.Text> </Typography.Text>
</div> </div>
<Button type="primary" onClick={openCreate}> <Button type="primary" onClick={openCreate}>
@@ -161,6 +196,13 @@ export default function FulfillmentProvidersPage() {
</Button> </Button>
</Space> </Space>
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="小飞侠配置已从「系统设置 → 同城配送」迁至本页。寄件地址以仓库联系人/地址为准。"
/>
<Table rowKey="id" loading={loading} columns={columns} dataSource={rows} pagination={false} /> <Table rowKey="id" loading={loading} columns={columns} dataSource={rows} pagination={false} />
<Modal <Modal
@@ -169,6 +211,7 @@ export default function FulfillmentProvidersPage() {
onCancel={() => setOpen(false)} onCancel={() => setOpen(false)}
onOk={() => void submit()} onOk={() => void submit()}
width={560} width={560}
destroyOnClose
> >
<Form form={form} layout="vertical"> <Form form={form} layout="vertical">
<Form.Item name="code" label="编码" rules={[{ required: true }]}> <Form.Item name="code" label="编码" rules={[{ required: true }]}>
@@ -183,12 +226,47 @@ export default function FulfillmentProvidersPage() {
<Form.Item name="status" label="状态" rules={[{ required: true }]}> <Form.Item name="status" label="状态" rules={[{ required: true }]}>
<Select options={STATUS_OPTIONS} /> <Select options={STATUS_OPTIONS} />
</Form.Item> </Form.Item>
<Form.Item name="configJson" label="凭证配置 JSON(可选)">
<Input.TextArea rows={3} placeholder="API 密钥等,仅存服务端" /> {showXfxFields && (
<>
<Typography.Title level={5} style={{ marginTop: 8 }}>
</Typography.Title>
<Form.Item
name="apiUrl"
label="API 地址"
rules={[{ required: true, message: '请填写小飞侠 API 地址' }]}
extra="推单请求将发往此地址"
>
<Input placeholder={DEFAULT_XFX_API_URL} />
</Form.Item> </Form.Item>
<Form.Item name="capabilitiesJson" label="能力配置 JSON"> <Form.Item
<Input.TextArea rows={4} /> name="mchId"
label="商户号"
rules={[{ required: true, message: '请填写商户号' }]}
>
<Input />
</Form.Item> </Form.Item>
<Form.Item
name="apiKey"
label="API Key"
rules={editRow ? [] : [{ required: true, message: '请填写 API Key' }]}
extra={
editRow?.xiaofeixiaConfig?.hasApiKey
? '已配置密钥;留空则保持不变'
: undefined
}
>
<Input.Password placeholder={editRow ? '留空则不修改' : '请输入'} />
</Form.Item>
<Form.Item name="signType" label="签名类型" initialValue="MD5">
<Select options={SIGN_OPTIONS} />
</Form.Item>
<Form.Item name="appId" label="AppID(可选)">
<Input />
</Form.Item>
</>
)}
</Form> </Form>
</Modal> </Modal>
</div> </div>
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { import {
Alert, Button, Card, Col, Descriptions, Form, Input, InputNumber, Row, Select, Space, Alert, Button, Card, Col, Descriptions, Form, Input, InputNumber, Row, Select, Space,
Tabs, Tag, Typography, message, Tabs, Tag, Typography, message,
@@ -15,6 +16,8 @@ type XfxConfig = {
hasApiKey: boolean; hasApiKey: boolean;
signType: string; signType: string;
ready: boolean; ready: boolean;
source?: string;
hint?: string;
}; };
type ApiResult = { type ApiResult = {
@@ -115,8 +118,9 @@ export default function XiaofeixiaTestPage() {
<div> <div>
<Typography.Title level={4}></Typography.Title> <Typography.Title level={4}></Typography.Title>
<Typography.Paragraph type="secondary"> <Typography.Paragraph type="secondary">
HQ APIcmd 100101~100301 HQ APIcmd 100101~100301
{' '}<code>XIAOFEIXIA_MCH_ID</code><code>XIAOFEIXIA_API_KEY</code> <Link to="/fulfillment-providers"></Link>
使
</Typography.Paragraph> </Typography.Paragraph>
{config && ( {config && (
@@ -132,13 +136,17 @@ export default function XiaofeixiaTestPage() {
<Descriptions.Item label="状态"> <Descriptions.Item label="状态">
{config.ready ? <Tag color="green"></Tag> : <Tag color="orange"></Tag>} {config.ready ? <Tag color="green"></Tag> : <Tag color="orange"></Tag>}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="来源" span={3}>
{config.source === 'fulfillment_provider' ? '仓配管理' : '环境变量(兼容)'}
{config.hint ? ` · ${config.hint}` : ''}
</Descriptions.Item>
</Descriptions> </Descriptions>
{!config.ready && ( {!config.ready && (
<Alert <Alert
type="warning" type="warning"
showIcon showIcon
style={{ marginTop: 12 }} style={{ marginTop: 12 }}
message="请在 server/dukang-api/.env 中配置 XIAOFEIXIA_MCH_ID 与 XIAOFEIXIA_API_KEY 后重启 API" message="请先在仓配管理中注册小飞侠并填写 API 地址、商户号与 API Key"
/> />
)} )}
</Card> </Card>
@@ -1,6 +1,24 @@
import type { FulfillmentProviderStatus, FulfillmentProviderType } from './enums'; import type { FulfillmentProviderStatus, FulfillmentProviderType } from './enums';
import type { WarehouseFulfillmentMode } from './enums'; import type { WarehouseFulfillmentMode } from './enums';
/** 小飞侠仓配凭证(存 FulfillmentProvider.configJson */
export interface XiaofeixiaProviderConfig {
apiUrl: string;
mchId: string;
apiKey: string;
signType?: 'MD5' | 'HMAC-SHA256';
appId?: string;
}
/** 回显用(不含明文 apiKey */
export interface XiaofeixiaProviderConfigPublic {
apiUrl: string;
mchId: string;
signType: 'MD5' | 'HMAC-SHA256';
appId?: string;
hasApiKey: boolean;
}
export interface FulfillmentProviderDto { export interface FulfillmentProviderDto {
id: string; id: string;
code: string; code: string;
@@ -14,6 +32,8 @@ export interface FulfillmentProviderDto {
cancel?: boolean; cancel?: boolean;
} | null; } | null;
hasConfig: boolean; hasConfig: boolean;
/** 小飞侠等承运商结构化配置(脱敏) */
xiaofeixiaConfig?: XiaofeixiaProviderConfigPublic | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -25,6 +45,8 @@ export interface CreateFulfillmentProviderInput {
status?: FulfillmentProviderStatus; status?: FulfillmentProviderStatus;
configJson?: string; configJson?: string;
capabilitiesJson?: string; capabilitiesJson?: string;
/** 结构化小飞侠配置;有则覆盖写入 configJson */
xiaofeixiaConfig?: Partial<XiaofeixiaProviderConfig>;
} }
export interface UpdateFulfillmentProviderInput { export interface UpdateFulfillmentProviderInput {
@@ -33,6 +55,7 @@ export interface UpdateFulfillmentProviderInput {
status?: FulfillmentProviderStatus; status?: FulfillmentProviderStatus;
configJson?: string; configJson?: string;
capabilitiesJson?: string; capabilitiesJson?: string;
xiaofeixiaConfig?: Partial<XiaofeixiaProviderConfig>;
} }
export interface ManualShipOrderInput { export interface ManualShipOrderInput {
@@ -49,3 +72,9 @@ export interface WarehouseFulfillmentConfig {
lng?: number | null; lng?: number | null;
lat?: number | null; lat?: number | null;
} }
export const XFX_PROVIDER_CODES = ['XFX', 'XIAOFEIXIA'] as const;
export function isXfxProviderCode(code: string): boolean {
return (XFX_PROVIDER_CODES as readonly string[]).includes(code.trim().toUpperCase());
}
+6
View File
@@ -189,6 +189,12 @@ async function main() {
callback: true, callback: true,
cancel: true, cancel: true,
}), }),
configJson: JSON.stringify({
apiUrl: 'https://beta.51xiaoju.cn/app/api/interface.do',
mchId: '',
apiKey: '',
signType: 'MD5',
}),
}, },
}); });
@@ -6,7 +6,6 @@ export const SYSTEM_CONFIG_GROUPS: SystemConfigGroupMeta[] = [
{ key: 'sms', label: '短信' }, { key: 'sms', label: '短信' },
{ key: 'wechat', label: '微信' }, { key: 'wechat', label: '微信' },
{ key: 'oss', label: '对象存储 OSS' }, { key: 'oss', label: '对象存储 OSS' },
{ key: 'courier', label: '同城配送' },
{ key: 'app', label: '应用链接' }, { key: 'app', label: '应用链接' },
{ key: 'deploy', label: '发布部署' }, { key: 'deploy', label: '发布部署' },
]; ];
@@ -16,7 +15,6 @@ const G = {
sms: 'sms', sms: 'sms',
wechat: 'wechat', wechat: 'wechat',
oss: 'oss', oss: 'oss',
courier: 'courier',
app: 'app', app: 'app',
deploy: 'deploy', deploy: 'deploy',
} as const; } as const;
@@ -79,19 +77,6 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
{ key: 'OSS_UPLOAD_EXPIRE_SECONDS', label: '直传凭证有效期(秒)', group: G.oss, type: 'number', requiresRestart: false }, { key: 'OSS_UPLOAD_EXPIRE_SECONDS', label: '直传凭证有效期(秒)', group: G.oss, type: 'number', requiresRestart: false },
{ key: 'OSS_MAX_UPLOAD_BYTES', label: '单文件上限(字节)', group: G.oss, type: 'number', requiresRestart: false }, { key: 'OSS_MAX_UPLOAD_BYTES', label: '单文件上限(字节)', group: G.oss, type: 'number', requiresRestart: false },
{ key: 'COURIER_PROVIDER', label: '配送服务商', group: G.courier, type: 'string', requiresRestart: true, placeholder: 'xiaofeixia' },
{ key: 'XIAOFEIXIA_API_URL', label: '小飞侠 API 地址', group: G.courier, type: 'string', requiresRestart: false },
{ key: 'XIAOFEIXIA_MCH_ID', label: '小飞侠商户号', group: G.courier, type: 'string', requiresRestart: false },
{ key: 'XIAOFEIXIA_API_KEY', label: '小飞侠 API Key', group: G.courier, type: 'password', secret: true, requiresRestart: false },
{ key: 'XIAOFEIXIA_SIGN_TYPE', label: '小飞侠签名类型', group: G.courier, type: 'string', requiresRestart: false, placeholder: 'MD5' },
{ key: 'XIAOFEIXIA_APP_ID', label: '小飞侠 AppID', group: G.courier, type: 'string', requiresRestart: false },
{ key: 'SHIP_FROM_NAME', label: '默认寄件人', group: G.courier, type: 'string', requiresRestart: false },
{ key: 'SHIP_FROM_MOBILE', label: '默认寄件手机', group: G.courier, type: 'string', requiresRestart: false },
{ key: 'SHIP_FROM_ADDRESS', label: '默认寄件地址', group: G.courier, type: 'string', requiresRestart: false },
{ key: 'SHIP_FROM_ADDRESS_DETAIL', label: '默认寄件门牌', group: G.courier, type: 'string', requiresRestart: false },
{ key: 'SHIP_FROM_LNG', label: '默认寄件经度', group: G.courier, type: 'string', requiresRestart: false },
{ key: 'SHIP_FROM_LAT', label: '默认寄件纬度', group: G.courier, type: 'string', requiresRestart: false },
{ key: 'USER_H5_URL', label: 'C 端 H5 落地页', group: G.app, type: 'string', requiresRestart: false }, { key: 'USER_H5_URL', label: 'C 端 H5 落地页', group: G.app, type: 'string', requiresRestart: false },
{ key: 'TENCENT_LBS_KEY', label: '腾讯位置服务 Key', group: G.app, type: 'password', secret: true, requiresRestart: false }, { key: 'TENCENT_LBS_KEY', label: '腾讯位置服务 Key', group: G.app, type: 'password', secret: true, requiresRestart: false },
@@ -2,6 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { COURIER_PROVIDER } from './courier.constants'; import { COURIER_PROVIDER } from './courier.constants';
import type { import type {
BatchShipmentQuery, BatchShipmentQuery,
CourierCallOptions,
CreateShipmentInput, CreateShipmentInput,
CreateShipmentResult, CreateShipmentResult,
DeliveryCoverageResult, DeliveryCoverageResult,
@@ -24,32 +25,32 @@ export class CourierService {
return this.provider.code; return this.provider.code;
} }
createShipment(input: CreateShipmentInput): Promise<CreateShipmentResult> { createShipment(input: CreateShipmentInput, options?: CourierCallOptions): Promise<CreateShipmentResult> {
return this.provider.createShipment(input); return this.provider.createShipment(input, options);
} }
cancelShipment(query: ShipmentQuery): Promise<void> { cancelShipment(query: ShipmentQuery, options?: CourierCallOptions): Promise<void> {
return this.provider.cancelShipment(query); return this.provider.cancelShipment(query, options);
} }
getShipment(query: ShipmentQuery): Promise<ShipmentDetail> { getShipment(query: ShipmentQuery, options?: CourierCallOptions): Promise<ShipmentDetail> {
return this.provider.getShipment(query); return this.provider.getShipment(query, options);
} }
batchGetShipments(query: BatchShipmentQuery): Promise<ShipmentDetail[]> { batchGetShipments(query: BatchShipmentQuery, options?: CourierCallOptions): Promise<ShipmentDetail[]> {
return this.provider.batchGetShipments(query); return this.provider.batchGetShipments(query, options);
} }
getTrack(query: ShipmentQuery): Promise<TrackNode[]> { getTrack(query: ShipmentQuery, options?: CourierCallOptions): Promise<TrackNode[]> {
return this.provider.getTrack(query); return this.provider.getTrack(query, options);
} }
checkDeliveryCoverage(toAddress: string): Promise<DeliveryCoverageResult> { checkDeliveryCoverage(toAddress: string, options?: CourierCallOptions): Promise<DeliveryCoverageResult> {
return this.provider.checkDeliveryCoverage(toAddress); return this.provider.checkDeliveryCoverage(toAddress, options);
} }
estimateFreight(weight: number): Promise<FreightEstimateResult> { estimateFreight(weight: number, options?: CourierCallOptions): Promise<FreightEstimateResult> {
return this.provider.estimateFreight(weight); return this.provider.estimateFreight(weight, options);
} }
buildTrackCallbackResponse(success?: boolean): TrackCallbackResponse { buildTrackCallbackResponse(success?: boolean): TrackCallbackResponse {
@@ -1,4 +1,10 @@
import { CourierProviderCode } from './courier.constants'; import { CourierProviderCode } from './courier.constants';
import type { XiaofeixiaConfig } from './courier.config';
/** 调用时覆盖默认 env 配置(仓配管理里维护的凭证) */
export interface CourierCallOptions {
xiaofeixia?: XiaofeixiaConfig;
}
export interface CourierCoordinate { export interface CourierCoordinate {
lng: number; lng: number;
@@ -106,12 +112,12 @@ export interface TrackCallbackResponse {
export interface ICourierProvider { export interface ICourierProvider {
readonly code: CourierProviderCode; readonly code: CourierProviderCode;
createShipment(input: CreateShipmentInput): Promise<CreateShipmentResult>; createShipment(input: CreateShipmentInput, options?: CourierCallOptions): Promise<CreateShipmentResult>;
cancelShipment(query: ShipmentQuery): Promise<void>; cancelShipment(query: ShipmentQuery, options?: CourierCallOptions): Promise<void>;
getShipment(query: ShipmentQuery): Promise<ShipmentDetail>; getShipment(query: ShipmentQuery, options?: CourierCallOptions): Promise<ShipmentDetail>;
batchGetShipments(query: BatchShipmentQuery): Promise<ShipmentDetail[]>; batchGetShipments(query: BatchShipmentQuery, options?: CourierCallOptions): Promise<ShipmentDetail[]>;
getTrack(query: ShipmentQuery): Promise<TrackNode[]>; getTrack(query: ShipmentQuery, options?: CourierCallOptions): Promise<TrackNode[]>;
checkDeliveryCoverage(toAddress: string): Promise<DeliveryCoverageResult>; checkDeliveryCoverage(toAddress: string, options?: CourierCallOptions): Promise<DeliveryCoverageResult>;
estimateFreight(weight: number): Promise<FreightEstimateResult>; estimateFreight(weight: number, options?: CourierCallOptions): Promise<FreightEstimateResult>;
buildTrackCallbackResponse(success?: boolean): TrackCallbackResponse; buildTrackCallbackResponse(success?: boolean): TrackCallbackResponse;
} }
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { CourierApiError } from '../courier.error'; import { CourierApiError } from '../courier.error';
import { CourierConfigService } from '../courier.config'; import { CourierConfigService, type XiaofeixiaConfig } from '../courier.config';
import { PrismaService } from '../../../common/prisma/prisma.module'; import { PrismaService } from '../../../common/prisma/prisma.module';
import { import {
logCourierCall, logCourierCall,
@@ -21,12 +21,12 @@ export class XiaofeixiaClient {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
) {} ) {}
async request<T>(cmd: string, bizParams: RequestParams): Promise<T> { async request<T>(cmd: string, bizParams: RequestParams, override?: XiaofeixiaConfig): Promise<T> {
const cfg = this.courierConfig.load().xiaofeixia; const cfg = override ?? this.courierConfig.load().xiaofeixia;
const scene = sceneForXfxCmd(cmd); const scene = sceneForXfxCmd(cmd);
const externalNo = this.pickExternalNo(bizParams); const externalNo = this.pickExternalNo(bizParams);
if (!cfg.mchId || !cfg.apiKey) { if (!cfg.mchId || !cfg.apiKey || !cfg.apiUrl) {
await logCourierCall(this.prisma, { await logCourierCall(this.prisma, {
scene, scene,
requestUrl: cfg.apiUrl || '(未配置)', requestUrl: cfg.apiUrl || '(未配置)',
@@ -36,7 +36,7 @@ export class XiaofeixiaClient {
externalNo, externalNo,
}); });
throw new CourierApiError( throw new CourierApiError(
'小飞侠商户配置不完整,请设置 XIAOFEIXIA_MCH_ID 与 XIAOFEIXIA_API_KEY', '小飞侠商户配置不完整,请在仓配管理中填写 API 地址、商户号与 API Key',
'CONFIG_ERROR', 'CONFIG_ERROR',
'XIAOFEIXIA', 'XIAOFEIXIA',
); );
@@ -3,6 +3,7 @@ import { CourierProviderCode } from '../courier.constants';
import { CourierApiError } from '../courier.error'; import { CourierApiError } from '../courier.error';
import type { import type {
BatchShipmentQuery, BatchShipmentQuery,
CourierCallOptions,
CreateShipmentInput, CreateShipmentInput,
CreateShipmentResult, CreateShipmentResult,
DeliveryCoverageResult, DeliveryCoverageResult,
@@ -29,8 +30,10 @@ export class XiaofeixiaProvider implements ICourierProvider {
constructor(private readonly client: XiaofeixiaClient) {} constructor(private readonly client: XiaofeixiaClient) {}
async createShipment(input: CreateShipmentInput): Promise<CreateShipmentResult> { async createShipment(input: CreateShipmentInput, options?: CourierCallOptions): Promise<CreateShipmentResult> {
const data = await this.client.request<XiaofeixiaCreateOrderData>(XIAOFEIXIA_CMD.CREATE_ORDER, { const data = await this.client.request<XiaofeixiaCreateOrderData>(
XIAOFEIXIA_CMD.CREATE_ORDER,
{
customerId: input.customerId, customerId: input.customerId,
outNumber: input.outNumber, outNumber: input.outNumber,
fromAddress: input.from.address, fromAddress: input.from.address,
@@ -50,7 +53,9 @@ export class XiaofeixiaProvider implements ICourierProvider {
collectionPrice: input.collectionPrice, collectionPrice: input.collectionPrice,
payMode: input.payMode, payMode: input.payMode,
remark: input.remark, remark: input.remark,
}); },
options?.xiaofeixia,
);
return { return {
providerShipmentId: data.id, providerShipmentId: data.id,
@@ -58,24 +63,32 @@ export class XiaofeixiaProvider implements ICourierProvider {
}; };
} }
async cancelShipment(query: ShipmentQuery): Promise<void> { async cancelShipment(query: ShipmentQuery, options?: CourierCallOptions): Promise<void> {
this.assertShipmentQuery(query); this.assertShipmentQuery(query);
await this.client.request(XIAOFEIXIA_CMD.CANCEL_ORDER, { await this.client.request(
XIAOFEIXIA_CMD.CANCEL_ORDER,
{
number: query.trackingNumber, number: query.trackingNumber,
outNumber: query.outNumber, outNumber: query.outNumber,
}); },
options?.xiaofeixia,
);
} }
async getShipment(query: ShipmentQuery): Promise<ShipmentDetail> { async getShipment(query: ShipmentQuery, options?: CourierCallOptions): Promise<ShipmentDetail> {
this.assertShipmentQuery(query); this.assertShipmentQuery(query);
const data = await this.client.request<XiaofeixiaOrderDetail>(XIAOFEIXIA_CMD.GET_ORDER, { const data = await this.client.request<XiaofeixiaOrderDetail>(
XIAOFEIXIA_CMD.GET_ORDER,
{
number: query.trackingNumber, number: query.trackingNumber,
outNumber: query.outNumber, outNumber: query.outNumber,
}); },
options?.xiaofeixia,
);
return this.mapOrderDetail(data); return this.mapOrderDetail(data);
} }
async batchGetShipments(query: BatchShipmentQuery): Promise<ShipmentDetail[]> { async batchGetShipments(query: BatchShipmentQuery, options?: CourierCallOptions): Promise<ShipmentDetail[]> {
const number = query.trackingNumbers?.join(','); const number = query.trackingNumbers?.join(',');
const outNumber = query.outNumbers?.join(','); const outNumber = query.outNumbers?.join(',');
@@ -83,27 +96,36 @@ export class XiaofeixiaProvider implements ICourierProvider {
throw new CourierApiError('运单号与商家单号至少传一个', '300000', this.code); throw new CourierApiError('运单号与商家单号至少传一个', '300000', this.code);
} }
const data = await this.client.request<XiaofeixiaOrderDetail[]>(XIAOFEIXIA_CMD.BATCH_GET_ORDER, { const data = await this.client.request<XiaofeixiaOrderDetail[]>(
XIAOFEIXIA_CMD.BATCH_GET_ORDER,
{
number, number,
outNumber, outNumber,
}); },
options?.xiaofeixia,
);
return (data ?? []).map((item) => this.mapOrderDetail(item)); return (data ?? []).map((item) => this.mapOrderDetail(item));
} }
async getTrack(query: ShipmentQuery): Promise<TrackNode[]> { async getTrack(query: ShipmentQuery, options?: CourierCallOptions): Promise<TrackNode[]> {
this.assertShipmentQuery(query); this.assertShipmentQuery(query);
const data = await this.client.request<XiaofeixiaTrackNode[]>(XIAOFEIXIA_CMD.TRACK_ROUTE, { const data = await this.client.request<XiaofeixiaTrackNode[]>(
XIAOFEIXIA_CMD.TRACK_ROUTE,
{
number: query.trackingNumber, number: query.trackingNumber,
outNumber: query.outNumber, outNumber: query.outNumber,
}); },
options?.xiaofeixia,
);
return data ?? []; return data ?? [];
} }
async checkDeliveryCoverage(toAddress: string): Promise<DeliveryCoverageResult> { async checkDeliveryCoverage(toAddress: string, options?: CourierCallOptions): Promise<DeliveryCoverageResult> {
const data = await this.client.request<XiaofeixiaDeliveryCoverageData>( const data = await this.client.request<XiaofeixiaDeliveryCoverageData>(
XIAOFEIXIA_CMD.DELIVERY_COVERAGE, XIAOFEIXIA_CMD.DELIVERY_COVERAGE,
{ toAddress }, { toAddress },
options?.xiaofeixia,
); );
return { return {
@@ -113,10 +135,11 @@ export class XiaofeixiaProvider implements ICourierProvider {
}; };
} }
async estimateFreight(weight: number): Promise<FreightEstimateResult> { async estimateFreight(weight: number, options?: CourierCallOptions): Promise<FreightEstimateResult> {
const data = await this.client.request<XiaofeixiaFreightEstimateData>( const data = await this.client.request<XiaofeixiaFreightEstimateData>(
XIAOFEIXIA_CMD.ESTIMATE_FREIGHT, XIAOFEIXIA_CMD.ESTIMATE_FREIGHT,
{ weight }, { weight },
options?.xiaofeixia,
); );
return { freightPrice: data.freightPrice }; return { freightPrice: data.freightPrice };
@@ -1,7 +1,13 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { FulfillmentProviderStatus, FulfillmentProviderType } from '@prisma/client'; import { FulfillmentProviderStatus, FulfillmentProviderType } from '@prisma/client';
import {
isXfxProviderCode,
type XiaofeixiaProviderConfig,
type XiaofeixiaProviderConfigPublic,
} from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module'; import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import type { XiaofeixiaConfig, XiaofeixiaSignType } from '../../integrations/courier/courier.config';
export type CreateFulfillmentProviderInput = { export type CreateFulfillmentProviderInput = {
code: string; code: string;
@@ -10,6 +16,7 @@ export type CreateFulfillmentProviderInput = {
status?: FulfillmentProviderStatus; status?: FulfillmentProviderStatus;
configJson?: string; configJson?: string;
capabilitiesJson?: string; capabilitiesJson?: string;
xiaofeixiaConfig?: Partial<XiaofeixiaProviderConfig>;
}; };
export type UpdateFulfillmentProviderInput = Partial<CreateFulfillmentProviderInput>; export type UpdateFulfillmentProviderInput = Partial<CreateFulfillmentProviderInput>;
@@ -21,6 +28,8 @@ type Capabilities = {
cancel?: boolean; cancel?: boolean;
}; };
const DEFAULT_XFX_API_URL = 'https://beta.51xiaoju.cn/app/api/interface.do';
@Injectable() @Injectable()
export class FulfillmentProviderService { export class FulfillmentProviderService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
@@ -46,6 +55,44 @@ export class FulfillmentProviderService {
return this.toDto(row); return this.toDto(row);
} }
/** 供推单使用:解析完整小飞侠凭证(含 apiKey) */
async resolveXiaofeixiaConfig(providerId: bigint): Promise<XiaofeixiaConfig> {
const row = await this.prisma.fulfillmentProvider.findUnique({ where: { id: providerId } });
if (!row) throw new NotFoundException('仓配承运商不存在');
if (!isXfxProviderCode(row.code)) {
throw new BadRequestException('该承运商不是小飞侠');
}
const cfg = this.parseXiaofeixiaConfig(row.configJson);
if (!cfg?.mchId || !cfg?.apiKey || !cfg?.apiUrl) {
throw new BadRequestException('小飞侠仓配配置不完整,请在仓配管理中填写 API 地址、商户号与 API Key');
}
return {
apiUrl: cfg.apiUrl,
mchId: cfg.mchId,
apiKey: cfg.apiKey,
signType: this.resolveSignType(cfg.signType),
appId: cfg.appId || undefined,
};
}
/** 取第一个启用的小飞侠承运商配置(联调/兼容) */
async resolveDefaultXiaofeixiaConfig(): Promise<XiaofeixiaConfig | null> {
const row = await this.prisma.fulfillmentProvider.findFirst({
where: {
status: 'ACTIVE',
type: 'API',
code: { in: ['XFX', 'XIAOFEIXIA'] },
},
orderBy: { updatedAt: 'desc' },
});
if (!row?.configJson) return null;
try {
return await this.resolveXiaofeixiaConfig(row.id);
} catch {
return null;
}
}
async create(input: CreateFulfillmentProviderInput) { async create(input: CreateFulfillmentProviderInput) {
const code = input.code.trim().toUpperCase(); const code = input.code.trim().toUpperCase();
if (!/^[A-Z0-9_]+$/.test(code)) { if (!/^[A-Z0-9_]+$/.test(code)) {
@@ -54,28 +101,54 @@ export class FulfillmentProviderService {
const existing = await this.prisma.fulfillmentProvider.findUnique({ where: { code } }); const existing = await this.prisma.fulfillmentProvider.findUnique({ where: { code } });
if (existing) throw new BadRequestException('承运商编码已存在'); if (existing) throw new BadRequestException('承运商编码已存在');
const configJson = this.resolveConfigJsonForWrite(code, null, input);
if (isXfxProviderCode(code) && input.type === 'API') {
this.assertXiaofeixiaConfigComplete(configJson, true);
}
const row = await this.prisma.fulfillmentProvider.create({ const row = await this.prisma.fulfillmentProvider.create({
data: { data: {
code, code,
name: input.name.trim(), name: input.name.trim(),
type: input.type, type: input.type,
status: input.status ?? 'ACTIVE', status: input.status ?? 'ACTIVE',
configJson: input.configJson?.trim() || null, configJson,
capabilitiesJson: input.capabilitiesJson?.trim() || null, capabilitiesJson:
input.capabilitiesJson?.trim() ||
(isXfxProviderCode(code)
? JSON.stringify({
createShipment: true,
getTrack: true,
callback: true,
cancel: true,
})
: null),
}, },
}); });
return this.toDto(row); return this.toDto(row);
} }
async update(id: bigint, input: UpdateFulfillmentProviderInput) { async update(id: bigint, input: UpdateFulfillmentProviderInput) {
await this.getById(id); const current = await this.prisma.fulfillmentProvider.findUnique({ where: { id } });
if (!current) throw new NotFoundException('仓配承运商不存在');
const code = current.code;
const configJson =
input.xiaofeixiaConfig !== undefined || input.configJson !== undefined
? this.resolveConfigJsonForWrite(code, current.configJson, input)
: undefined;
if (configJson !== undefined && isXfxProviderCode(code) && (input.type ?? current.type) === 'API') {
this.assertXiaofeixiaConfigComplete(configJson, false);
}
const row = await this.prisma.fulfillmentProvider.update({ const row = await this.prisma.fulfillmentProvider.update({
where: { id }, where: { id },
data: { data: {
...(input.name !== undefined ? { name: input.name.trim() } : {}), ...(input.name !== undefined ? { name: input.name.trim() } : {}),
...(input.type !== undefined ? { type: input.type } : {}), ...(input.type !== undefined ? { type: input.type } : {}),
...(input.status !== undefined ? { status: input.status } : {}), ...(input.status !== undefined ? { status: input.status } : {}),
...(input.configJson !== undefined ? { configJson: input.configJson?.trim() || null } : {}), ...(configJson !== undefined ? { configJson } : {}),
...(input.capabilitiesJson !== undefined ...(input.capabilitiesJson !== undefined
? { capabilitiesJson: input.capabilitiesJson?.trim() || null } ? { capabilitiesJson: input.capabilitiesJson?.trim() || null }
: {}), : {}),
@@ -93,6 +166,74 @@ export class FulfillmentProviderService {
} }
} }
parseXiaofeixiaConfig(raw: string | null): XiaofeixiaProviderConfig | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as Partial<XiaofeixiaProviderConfig>;
if (!parsed || typeof parsed !== 'object') return null;
return {
apiUrl: String(parsed.apiUrl ?? '').trim(),
mchId: String(parsed.mchId ?? '').trim(),
apiKey: String(parsed.apiKey ?? '').trim(),
signType: parsed.signType === 'HMAC-SHA256' ? 'HMAC-SHA256' : 'MD5',
appId: parsed.appId ? String(parsed.appId).trim() : undefined,
};
} catch {
return null;
}
}
private resolveConfigJsonForWrite(
code: string,
existingRaw: string | null,
input: CreateFulfillmentProviderInput | UpdateFulfillmentProviderInput,
): string | null {
if (isXfxProviderCode(code) && input.xiaofeixiaConfig) {
const existing = this.parseXiaofeixiaConfig(existingRaw);
const next: XiaofeixiaProviderConfig = {
apiUrl: (input.xiaofeixiaConfig.apiUrl ?? existing?.apiUrl ?? DEFAULT_XFX_API_URL).trim(),
mchId: (input.xiaofeixiaConfig.mchId ?? existing?.mchId ?? '').trim(),
apiKey: (input.xiaofeixiaConfig.apiKey || existing?.apiKey || '').trim(),
signType:
input.xiaofeixiaConfig.signType === 'HMAC-SHA256'
? 'HMAC-SHA256'
: input.xiaofeixiaConfig.signType === 'MD5'
? 'MD5'
: existing?.signType ?? 'MD5',
appId: (input.xiaofeixiaConfig.appId ?? existing?.appId)?.trim() || undefined,
};
return JSON.stringify(next);
}
if (input.configJson !== undefined) {
return input.configJson?.trim() || null;
}
return existingRaw;
}
private assertXiaofeixiaConfigComplete(configJson: string | null, requireApiKey: boolean) {
const cfg = this.parseXiaofeixiaConfig(configJson);
if (!cfg?.apiUrl) throw new BadRequestException('请填写小飞侠 API 地址');
if (!cfg.mchId) throw new BadRequestException('请填写小飞侠商户号');
if (requireApiKey && !cfg.apiKey) throw new BadRequestException('请填写小飞侠 API Key');
if (!requireApiKey && !cfg.apiKey) throw new BadRequestException('小飞侠 API Key 缺失,请重新填写');
}
private toPublicXiaofeixiaConfig(raw: string | null): XiaofeixiaProviderConfigPublic | null {
const cfg = this.parseXiaofeixiaConfig(raw);
if (!cfg) return null;
return {
apiUrl: cfg.apiUrl || DEFAULT_XFX_API_URL,
mchId: cfg.mchId,
signType: cfg.signType === 'HMAC-SHA256' ? 'HMAC-SHA256' : 'MD5',
appId: cfg.appId,
hasApiKey: Boolean(cfg.apiKey),
};
}
private resolveSignType(raw?: string): XiaofeixiaSignType {
return raw?.toUpperCase() === 'HMAC-SHA256' ? 'HMAC-SHA256' : 'MD5';
}
private toDto(row: { private toDto(row: {
id: bigint; id: bigint;
code: string; code: string;
@@ -112,6 +253,9 @@ export class FulfillmentProviderService {
status: row.status, status: row.status,
capabilities: this.parseCapabilities(row.capabilitiesJson), capabilities: this.parseCapabilities(row.capabilitiesJson),
hasConfig: Boolean(row.configJson), hasConfig: Boolean(row.configJson),
xiaofeixiaConfig: isXfxProviderCode(row.code)
? this.toPublicXiaofeixiaConfig(row.configJson)
: null,
createdAt: row.createdAt.toISOString(), createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(), updatedAt: row.updatedAt.toISOString(),
}); });
@@ -1,10 +1,12 @@
import { BadRequestException, Injectable, Logger, NotFoundException, Inject, forwardRef } from '@nestjs/common'; import { BadRequestException, Injectable, Logger, NotFoundException, Inject, forwardRef } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type { CityWarehouse, FulfillmentProvider, Order } from '@prisma/client'; import type { CityWarehouse, FulfillmentProvider, Order } from '@prisma/client';
import { isXfxProviderCode } from '@dukang/shared-types';
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';
import type { XiaofeixiaConfig } from '../../integrations/courier/courier.config';
import { TradeService } from '../trade/trade.service'; import { TradeService } from '../trade/trade.service';
import { FulfillmentProviderService } from './fulfillment-provider.service';
export type ManualShipInput = { export type ManualShipInput = {
logisticsCompany: string; logisticsCompany: string;
@@ -14,16 +16,14 @@ export type ManualShipInput = {
export type HqLogisticsShipInput = ManualShipInput; export type HqLogisticsShipInput = ManualShipInput;
const XFX_CODES = new Set(['XFX', 'XIAOFEIXIA']);
@Injectable() @Injectable()
export class FulfillmentService { export class FulfillmentService {
private readonly logger = new Logger(FulfillmentService.name); private readonly logger = new Logger(FulfillmentService.name);
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly config: ConfigService,
private readonly courier: CourierService, private readonly courier: CourierService,
private readonly fulfillmentProviderService: FulfillmentProviderService,
@Inject(forwardRef(() => TradeService)) @Inject(forwardRef(() => TradeService))
private readonly tradeService: TradeService, private readonly tradeService: TradeService,
) {} ) {}
@@ -69,18 +69,28 @@ export class FulfillmentService {
} }
async dispatchApiAuto(order: Order, warehouse: CityWarehouse, provider: FulfillmentProvider) { async dispatchApiAuto(order: Order, warehouse: CityWarehouse, provider: FulfillmentProvider) {
if (!XFX_CODES.has(provider.code)) { if (!isXfxProviderCode(provider.code)) {
this.logger.warn(`承运商 ${provider.code} 自动推单尚未实现,订单 ${order.orderNo} 保持待发货`); this.logger.warn(`承运商 ${provider.code} 自动推单尚未实现,订单 ${order.orderNo} 保持待发货`);
await this.ensureDeliveryRecord(order.id, 'MANUAL', provider.id); await this.ensureDeliveryRecord(order.id, 'MANUAL', provider.id);
return; return;
} }
const defaults = this.getShipDefaults(); let xfxConfig: XiaofeixiaConfig;
const fromLng = warehouse.lng != null ? Number(warehouse.lng) : defaults.fromLng; try {
const fromLat = warehouse.lat != null ? Number(warehouse.lat) : defaults.fromLat; 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;
try { try {
const result = await this.courier.createShipment({ const result = await this.courier.createShipment(
{
outNumber: order.orderNo, outNumber: order.orderNo,
from: { from: {
name: warehouse.contactName, name: warehouse.contactName,
@@ -97,10 +107,12 @@ export class FulfillmentService {
}, },
goodsName: order.productName, goodsName: order.productName,
goodsNum: order.quantity, goodsNum: order.quantity,
weight: defaults.weight, weight: 2,
payMode: defaults.payMode, payMode: CourierPayMode.SENDER,
remark: `仓配自动发货 ${order.orderNo}`, remark: `仓配自动发货 ${order.orderNo}`,
}); },
{ xiaofeixia: xfxConfig },
);
const now = new Date(); const now = new Date();
await this.prisma.$transaction(async (tx) => { await this.prisma.$transaction(async (tx) => {
@@ -196,10 +208,20 @@ export class FulfillmentService {
if (order.delivery.provider === 'XFX' && (order.delivery.trackingNo || order.orderNo)) { if (order.delivery.provider === 'XFX' && (order.delivery.trackingNo || order.orderNo)) {
try { try {
const nodes = await this.courier.getTrack({ const options = order.delivery.fulfillmentProviderId
? {
xiaofeixia: await this.fulfillmentProviderService.resolveXiaofeixiaConfig(
order.delivery.fulfillmentProviderId,
),
}
: undefined;
const nodes = await this.courier.getTrack(
{
trackingNumber: order.delivery.trackingNo ?? undefined, trackingNumber: order.delivery.trackingNo ?? undefined,
outNumber: order.orderNo, outNumber: order.orderNo,
}); },
options,
);
return { return {
nodes, nodes,
manualQueryUrl: order.delivery.manualQueryUrl, manualQueryUrl: order.delivery.manualQueryUrl,
@@ -208,7 +230,7 @@ export class FulfillmentService {
logisticsCompany: order.delivery.logisticsCompany, logisticsCompany: order.delivery.logisticsCompany,
}; };
} catch { } catch {
// fall through to manual fields // fall through
} }
} }
@@ -294,13 +316,4 @@ export class FulfillmentService {
if (!tpl) return undefined; if (!tpl) return undefined;
return tpl.replace(/\{trackingNo\}/g, encodeURIComponent(trackingNo)); 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,
};
}
} }
@@ -39,9 +39,15 @@ export class AdminFulfillmentProvidersController {
status: dto.status as FulfillmentProviderStatus | undefined, status: dto.status as FulfillmentProviderStatus | undefined,
configJson: dto.configJson, configJson: dto.configJson,
capabilitiesJson: dto.capabilitiesJson, capabilitiesJson: dto.capabilitiesJson,
xiaofeixiaConfig: dto.xiaofeixiaConfig,
}); });
} }
@Get(':id')
detail(@Param('id') id: string) {
return this.service.getById(BigInt(id));
}
@Put(':id') @Put(':id')
@HqOperation({ @HqOperation({
action: HqOperationAction.WAREHOUSE_UPDATE, action: HqOperationAction.WAREHOUSE_UPDATE,
@@ -56,6 +62,7 @@ export class AdminFulfillmentProvidersController {
status: dto.status as FulfillmentProviderStatus | undefined, status: dto.status as FulfillmentProviderStatus | undefined,
configJson: dto.configJson, configJson: dto.configJson,
capabilitiesJson: dto.capabilitiesJson, capabilitiesJson: dto.capabilitiesJson,
xiaofeixiaConfig: dto.xiaofeixiaConfig,
}); });
} }
} }
@@ -1,5 +1,4 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module'; import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { serializeBigInt } from '../../common/decorators/current-user.decorator';
@@ -11,6 +10,7 @@ import type { AdminOrdersQueryDto } from './dto/admin-query.dto';
import type { AdminShipOrderDto, HqLogisticsShipDto } from './dto/admin-mutate.dto'; import type { AdminShipOrderDto, HqLogisticsShipDto } from './dto/admin-mutate.dto';
import type { XiaofeixiaCreateShipmentDto } from './dto/admin-courier.dto'; import type { XiaofeixiaCreateShipmentDto } from './dto/admin-courier.dto';
import { FulfillmentService } from '../fulfillment/fulfillment.service'; import { FulfillmentService } from '../fulfillment/fulfillment.service';
import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service';
@Injectable() @Injectable()
export class AdminOrdersService { export class AdminOrdersService {
@@ -19,7 +19,7 @@ export class AdminOrdersService {
private readonly tradeService: TradeService, private readonly tradeService: TradeService,
private readonly xiaofeixiaService: AdminXiaofeixiaService, private readonly xiaofeixiaService: AdminXiaofeixiaService,
private readonly fulfillmentService: FulfillmentService, private readonly fulfillmentService: FulfillmentService,
private readonly config: ConfigService, private readonly fulfillmentProviderService: FulfillmentProviderService,
) {} ) {}
async list(query: AdminOrdersQueryDto) { async list(query: AdminOrdersQueryDto) {
@@ -96,21 +96,6 @@ export class AdminOrdersService {
return this.detail(id); return this.detail(id);
} }
getShipDefaults() {
return {
provider: 'XFX',
providerLabel: '小飞侠',
fromName: this.config.get<string>('SHIP_FROM_NAME') || '杜康仓库',
fromMobile: this.config.get<string>('SHIP_FROM_MOBILE') || '13800000000',
fromAddress: this.config.get<string>('SHIP_FROM_ADDRESS') || '河南省郑州市金水区',
fromAddressDetail: this.config.get<string>('SHIP_FROM_ADDRESS_DETAIL') || '杜康酒业仓',
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: '1',
};
}
async shipOrder(id: bigint, dto: AdminShipOrderDto) { async shipOrder(id: bigint, dto: AdminShipOrderDto) {
if (dto.provider !== 'XFX') { if (dto.provider !== 'XFX') {
throw new BadRequestException('暂仅支持小飞侠配送'); throw new BadRequestException('暂仅支持小飞侠配送');
@@ -118,7 +103,10 @@ export class AdminOrdersService {
const order = await this.prisma.order.findUnique({ const order = await this.prisma.order.findUnique({
where: { id }, where: { id },
include: { delivery: true }, include: {
delivery: true,
fulfillmentWarehouse: true,
},
}); });
if (!order) throw new NotFoundException('订单不存在'); if (!order) throw new NotFoundException('订单不存在');
@@ -129,7 +117,23 @@ export class AdminOrdersService {
throw new BadRequestException('该订单已有运单号,请勿重复发货'); throw new BadRequestException('该订单已有运单号,请勿重复发货');
} }
const defaults = this.getShipDefaults(); const warehouse = order.fulfillmentWarehouse;
const providerId =
order.delivery?.fulfillmentProviderId ??
warehouse?.fulfillmentProviderId ??
null;
let xfxConfig;
if (providerId) {
xfxConfig = await this.fulfillmentProviderService.resolveXiaofeixiaConfig(providerId);
} else {
xfxConfig = await this.fulfillmentProviderService.resolveDefaultXiaofeixiaConfig();
if (!xfxConfig) {
throw new BadRequestException('请先在仓配管理中注册并配置小飞侠承运商');
}
}
const defaults = this.getShipDefaults(warehouse);
const shipmentDto: XiaofeixiaCreateShipmentDto = { const shipmentDto: XiaofeixiaCreateShipmentDto = {
outNumber: order.orderNo, outNumber: order.orderNo,
fromName: dto.fromName || defaults.fromName, fromName: dto.fromName || defaults.fromName,
@@ -149,13 +153,14 @@ export class AdminOrdersService {
remark: dto.remark || `HQ发货 ${order.orderNo}`, remark: dto.remark || `HQ发货 ${order.orderNo}`,
}; };
const result = await this.xiaofeixiaService.createShipment(shipmentDto); const result = await this.xiaofeixiaService.createShipment(shipmentDto, xfxConfig);
if (!result.ok || !result.data) { if (!result.ok || !result.data) {
throw new BadRequestException(result.error || '小飞侠创建运单失败'); throw new BadRequestException(result.error || '小飞侠创建运单失败');
} }
const { providerShipmentId, trackingNumber } = result.data; const { providerShipmentId, trackingNumber } = result.data;
const now = new Date(); const now = new Date();
const resolvedProviderId = providerId;
await this.prisma.$transaction(async (tx) => { await this.prisma.$transaction(async (tx) => {
if (order.delivery) { if (order.delivery) {
@@ -163,6 +168,7 @@ export class AdminOrdersService {
where: { orderId: id }, where: { orderId: id },
data: { data: {
provider: 'XFX', provider: 'XFX',
fulfillmentProviderId: resolvedProviderId,
trackingNo: trackingNumber, trackingNo: trackingNumber,
providerOrderNo: String(providerShipmentId), providerOrderNo: String(providerShipmentId),
shippingAt: now, shippingAt: now,
@@ -173,6 +179,7 @@ export class AdminOrdersService {
data: { data: {
orderId: id, orderId: id,
provider: 'XFX', provider: 'XFX',
fulfillmentProviderId: resolvedProviderId,
trackingNo: trackingNumber, trackingNo: trackingNumber,
providerOrderNo: String(providerShipmentId), providerOrderNo: String(providerShipmentId),
shippingAt: now, shippingAt: now,
@@ -185,6 +192,40 @@ export class AdminOrdersService {
return this.detail(id); return this.detail(id);
} }
getShipDefaults(warehouse?: {
contactName: string;
contactPhone: string;
address: string;
name: string;
lng: { toNumber?: () => number } | number | null;
lat: { toNumber?: () => number } | number | null;
} | null) {
const lng =
warehouse?.lng != null
? typeof warehouse.lng === 'object' && warehouse.lng && 'toNumber' in warehouse.lng
? Number(warehouse.lng)
: Number(warehouse.lng)
: 113.665;
const lat =
warehouse?.lat != null
? typeof warehouse.lat === 'object' && warehouse.lat && 'toNumber' in warehouse.lat
? Number(warehouse.lat)
: Number(warehouse.lat)
: 34.757;
return {
provider: 'XFX',
providerLabel: '小飞侠',
fromName: warehouse?.contactName || '杜康仓库',
fromMobile: warehouse?.contactPhone || '13800000000',
fromAddress: warehouse?.address || '河南省郑州市金水区',
fromAddressDetail: warehouse?.name || '杜康酒业仓',
fromLng: lng,
fromLat: lat,
weight: 2,
payMode: '1',
};
}
/** 总部传统快递填单(同城无仓 / 跨城) */ /** 总部传统快递填单(同城无仓 / 跨城) */
async shipLogistics(id: bigint, dto: HqLogisticsShipDto) { async shipLogistics(id: bigint, dto: HqLogisticsShipDto) {
await this.fulfillmentService.shipHqLogistics(id, dto); await this.fulfillmentService.shipHqLogistics(id, dto);
@@ -1,5 +1,6 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { CourierConfigService } from '../../integrations/courier/courier.config'; import { CourierConfigService } from '../../integrations/courier/courier.config';
import type { XiaofeixiaConfig } from '../../integrations/courier/courier.config';
import { CourierApiError } from '../../integrations/courier/courier.error'; import { CourierApiError } from '../../integrations/courier/courier.error';
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';
@@ -8,6 +9,7 @@ import type {
CreateShipmentInput, CreateShipmentInput,
ShipmentQuery, ShipmentQuery,
} from '../../integrations/courier/courier.types'; } from '../../integrations/courier/courier.types';
import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service';
import type { import type {
XiaofeixiaBatchShipmentQueryDto, XiaofeixiaBatchShipmentQueryDto,
XiaofeixiaCheckCoverageDto, XiaofeixiaCheckCoverageDto,
@@ -27,48 +29,61 @@ export class AdminXiaofeixiaService {
constructor( constructor(
private readonly courier: CourierService, private readonly courier: CourierService,
private readonly courierConfig: CourierConfigService, private readonly courierConfig: CourierConfigService,
private readonly fulfillmentProviderService: FulfillmentProviderService,
) {} ) {}
getConfig() { async getConfig() {
const cfg = this.courierConfig.load(); const fromDb = await this.fulfillmentProviderService.resolveDefaultXiaofeixiaConfig();
const xfx = cfg.xiaofeixia; const envCfg = this.courierConfig.load().xiaofeixia;
const xfx = fromDb ?? envCfg;
return { return {
provider: cfg.provider, provider: this.courierConfig.load().provider,
activeProvider: this.courier.activeProvider, activeProvider: this.courier.activeProvider,
source: fromDb ? 'fulfillment_provider' : 'env',
apiUrl: xfx.apiUrl, apiUrl: xfx.apiUrl,
appId: xfx.appId ?? null, appId: xfx.appId ?? null,
mchId: xfx.mchId || null, mchId: xfx.mchId || null,
mchIdMasked: xfx.mchId ? maskSecret(xfx.mchId) : null, mchIdMasked: xfx.mchId ? maskSecret(xfx.mchId) : null,
hasApiKey: Boolean(xfx.apiKey), hasApiKey: Boolean(xfx.apiKey),
signType: xfx.signType, signType: xfx.signType,
ready: Boolean(xfx.mchId && xfx.apiKey), ready: Boolean(xfx.mchId && xfx.apiKey && xfx.apiUrl),
hint: fromDb
? '凭证来自仓配管理中启用的小飞侠承运商'
: '未在仓配管理配置,回退到环境变量(请迁移至仓配管理)',
}; };
} }
async estimateFreight(dto: XiaofeixiaEstimateFreightDto) { async estimateFreight(dto: XiaofeixiaEstimateFreightDto) {
return this.wrap(() => this.courier.estimateFreight(dto.weight)); const options = await this.callOptions();
return this.wrap(() => this.courier.estimateFreight(dto.weight, options));
} }
async checkCoverage(dto: XiaofeixiaCheckCoverageDto) { async checkCoverage(dto: XiaofeixiaCheckCoverageDto) {
return this.wrap(() => this.courier.checkDeliveryCoverage(dto.toAddress)); const options = await this.callOptions();
return this.wrap(() => this.courier.checkDeliveryCoverage(dto.toAddress, options));
} }
async createShipment(dto: XiaofeixiaCreateShipmentDto) { async createShipment(dto: XiaofeixiaCreateShipmentDto, xfxOverride?: XiaofeixiaConfig) {
const input = this.mapCreateInput(dto); const input = this.mapCreateInput(dto);
return this.wrap(() => this.courier.createShipment(input)); const options = xfxOverride
? { xiaofeixia: xfxOverride }
: await this.callOptions();
return this.wrap(() => this.courier.createShipment(input, options));
} }
async cancelShipment(dto: XiaofeixiaShipmentQueryDto) { async cancelShipment(dto: XiaofeixiaShipmentQueryDto) {
const query = this.mapShipmentQuery(dto); const query = this.mapShipmentQuery(dto);
const options = await this.callOptions();
return this.wrap(async () => { return this.wrap(async () => {
await this.courier.cancelShipment(query); await this.courier.cancelShipment(query, options);
return { cancelled: true }; return { cancelled: true };
}); });
} }
async getShipment(dto: XiaofeixiaShipmentQueryDto) { async getShipment(dto: XiaofeixiaShipmentQueryDto) {
const query = this.mapShipmentQuery(dto); const query = this.mapShipmentQuery(dto);
return this.wrap(() => this.courier.getShipment(query)); const options = await this.callOptions();
return this.wrap(() => this.courier.getShipment(query, options));
} }
async batchGetShipments(dto: XiaofeixiaBatchShipmentQueryDto) { async batchGetShipments(dto: XiaofeixiaBatchShipmentQueryDto) {
@@ -76,12 +91,19 @@ export class AdminXiaofeixiaService {
trackingNumbers: dto.trackingNumbers?.filter(Boolean), trackingNumbers: dto.trackingNumbers?.filter(Boolean),
outNumbers: dto.outNumbers?.filter(Boolean), outNumbers: dto.outNumbers?.filter(Boolean),
}; };
return this.wrap(() => this.courier.batchGetShipments(query)); const options = await this.callOptions();
return this.wrap(() => this.courier.batchGetShipments(query, options));
} }
async getTrack(dto: XiaofeixiaShipmentQueryDto) { async getTrack(dto: XiaofeixiaShipmentQueryDto) {
const query = this.mapShipmentQuery(dto); const query = this.mapShipmentQuery(dto);
return this.wrap(() => this.courier.getTrack(query)); const options = await this.callOptions();
return this.wrap(() => this.courier.getTrack(query, options));
}
private async callOptions() {
const fromDb = await this.fulfillmentProviderService.resolveDefaultXiaofeixiaConfig();
return fromDb ? { xiaofeixia: fromDb } : undefined;
} }
private mapShipmentQuery(dto: XiaofeixiaShipmentQueryDto): ShipmentQuery { private mapShipmentQuery(dto: XiaofeixiaShipmentQueryDto): ShipmentQuery {
@@ -583,6 +583,16 @@ export class CreateFulfillmentProviderDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
capabilitiesJson?: string; capabilitiesJson?: string;
@IsOptional()
@IsObject()
xiaofeixiaConfig?: {
apiUrl?: string;
mchId?: string;
apiKey?: string;
signType?: 'MD5' | 'HMAC-SHA256';
appId?: string;
};
} }
export class UpdateFulfillmentProviderDto { export class UpdateFulfillmentProviderDto {
@@ -605,6 +615,16 @@ export class UpdateFulfillmentProviderDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
capabilitiesJson?: string; capabilitiesJson?: string;
@IsOptional()
@IsObject()
xiaofeixiaConfig?: {
apiUrl?: string;
mchId?: string;
apiKey?: string;
signType?: 'MD5' | 'HMAC-SHA256';
appId?: string;
};
} }
export class ManualShipOrderDto { export class ManualShipOrderDto {