diff --git a/apps/admin-web/src/App.tsx b/apps/admin-web/src/App.tsx index 86d1122..7b80897 100644 --- a/apps/admin-web/src/App.tsx +++ b/apps/admin-web/src/App.tsx @@ -18,6 +18,7 @@ import HqAccountsPage from './pages/HqAccountsPage'; import CitiesPage from './pages/CitiesPage'; import CityPartnersPage from './pages/CityPartnersPage'; import CityWarehousesPage from './pages/CityWarehousesPage'; +import FulfillmentProvidersPage from './pages/FulfillmentProvidersPage'; import StoreMediaPage from './pages/StoreMediaPage'; import PromoCodesPage from './pages/PromoCodesPage'; import PromoCodeDetailLayout from './pages/promo/PromoCodeDetailLayout'; @@ -74,6 +75,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/admin-web/src/layouts/AdminLayout.tsx b/apps/admin-web/src/layouts/AdminLayout.tsx index fc5b93b..ea65028 100644 --- a/apps/admin-web/src/layouts/AdminLayout.tsx +++ b/apps/admin-web/src/layouts/AdminLayout.tsx @@ -56,6 +56,7 @@ const MENU_ITEMS: MenuProps['items'] = [ { key: '/cities', label: '城市' }, { key: '/city-partners', label: '城市合伙人' }, { key: '/city-warehouses', label: '仓库' }, + { key: '/fulfillment-providers', label: '仓配管理' }, ], }, { diff --git a/apps/admin-web/src/pages/CityWarehousesPage.tsx b/apps/admin-web/src/pages/CityWarehousesPage.tsx index c063654..095f2a7 100644 --- a/apps/admin-web/src/pages/CityWarehousesPage.tsx +++ b/apps/admin-web/src/pages/CityWarehousesPage.tsx @@ -6,6 +6,7 @@ import { Descriptions, Form, Input, + InputNumber, Modal, Popconfirm, Select, @@ -17,10 +18,13 @@ import { } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { + WAREHOUSE_FULFILLMENT_MODE_LABELS, WAREHOUSE_MANAGER_LABELS, WAREHOUSE_STATUS_LABELS, + WarehouseFulfillmentMode, WarehouseManagerType, WarehouseStatus, + type FulfillmentProviderDto, } from '@dukang/shared-types'; import { request, type Paginated } from '../lib/api'; import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants'; @@ -39,6 +43,13 @@ type Row = { partnerAccountId: string | null; partnerCompanyName?: string | null; status: string; + fulfillmentMode?: string; + fulfillmentProviderId?: string | null; + fulfillmentProviderName?: string | null; + manualCarrierLabel?: string | null; + manualQueryUrlTemplate?: string | null; + lng?: number | null; + lat?: number | null; createdAt: string; }; @@ -48,6 +59,54 @@ type PartnerOption = { id: string; companyName: string }; const MANAGER_OPTIONS = Object.entries(WAREHOUSE_MANAGER_LABELS).map(([value, label]) => ({ value, label })); const STATUS_OPTIONS = Object.entries(WAREHOUSE_STATUS_LABELS).map(([value, label]) => ({ value, label })); +const FULFILLMENT_MODE_OPTIONS = Object.entries(WAREHOUSE_FULFILLMENT_MODE_LABELS).map(([value, label]) => ({ + value, + label, +})); + +function FulfillmentFields({ + mode, + providerOptions, +}: { + mode: WarehouseFulfillmentMode; + providerOptions: FulfillmentProviderDto[]; +}) { + return ( + <> + {mode === WarehouseFulfillmentMode.API_AUTO && ( + + + + + + + + )} + + + + + + + + + + ); +} + export default function CityWarehousesPage() { const [filterForm] = Form.useForm(); const [createForm] = Form.useForm(); @@ -73,6 +132,13 @@ export default function CityWarehousesPage() { const [createManagerType, setCreateManagerType] = useState(WarehouseManagerType.HQ); const [editManagerType, setEditManagerType] = useState(WarehouseManagerType.HQ); const [createCityId, setCreateCityId] = useState(); + const [createFulfillmentMode, setCreateFulfillmentMode] = useState( + WarehouseFulfillmentMode.MANUAL, + ); + const [editFulfillmentMode, setEditFulfillmentMode] = useState( + WarehouseFulfillmentMode.MANUAL, + ); + const [providerOptions, setProviderOptions] = useState([]); const loadCities = useCallback(async () => { const res = await request>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`); @@ -88,6 +154,9 @@ export default function CityWarehousesPage() { useEffect(() => { void loadCities(); + void request('/admin/fulfillment-providers/active-api') + .then(setProviderOptions) + .catch(() => {}); }, [loadCities]); async function openEdit(row: Row) { @@ -102,7 +171,14 @@ export default function CityWarehousesPage() { managerType: row.managerType, partnerAccountId: row.partnerAccountId, status: row.status, + fulfillmentMode: row.fulfillmentMode ?? WarehouseFulfillmentMode.MANUAL, + fulfillmentProviderId: row.fulfillmentProviderId ?? undefined, + manualCarrierLabel: row.manualCarrierLabel ?? undefined, + manualQueryUrlTemplate: row.manualQueryUrlTemplate ?? undefined, + lng: row.lng ?? undefined, + lat: row.lat ?? undefined, }); + setEditFulfillmentMode((row.fulfillmentMode as WarehouseFulfillmentMode) ?? WarehouseFulfillmentMode.MANUAL); setEditOpen(true); } @@ -149,6 +225,14 @@ export default function CityWarehousesPage() { '—' ), }, + { + title: '履约', + width: 120, + render: (_, row) => + row.fulfillmentMode === 'API_AUTO' + ? row.fulfillmentProviderName || 'API' + : WAREHOUSE_FULFILLMENT_MODE_LABELS[WarehouseFulfillmentMode.MANUAL], + }, { title: '状态', dataIndex: 'status', @@ -341,6 +425,13 @@ export default function CityWarehousesPage() { setCreateFulfillmentMode(v)} + /> + + @@ -399,6 +490,13 @@ export default function CityWarehousesPage() { setEditFulfillmentMode(v)} + /> + + diff --git a/apps/admin-web/src/pages/FulfillmentProvidersPage.tsx b/apps/admin-web/src/pages/FulfillmentProvidersPage.tsx new file mode 100644 index 0000000..6533f96 --- /dev/null +++ b/apps/admin-web/src/pages/FulfillmentProvidersPage.tsx @@ -0,0 +1,196 @@ +import { useCallback, useEffect, useState } from 'react'; +import { + Button, + Form, + Input, + Modal, + Select, + Space, + Table, + Tag, + Typography, + message, +} from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { + FULFILLMENT_PROVIDER_STATUS_LABELS, + FULFILLMENT_PROVIDER_TYPE_LABELS, + FulfillmentProviderStatus, + FulfillmentProviderType, + type FulfillmentProviderDto, +} from '@dukang/shared-types'; +import { request } from '../lib/api'; +import { fmtTime } from '../lib/constants'; + +const TYPE_OPTIONS = Object.entries(FULFILLMENT_PROVIDER_TYPE_LABELS).map(([value, label]) => ({ + value, + label, +})); +const STATUS_OPTIONS = Object.entries(FULFILLMENT_PROVIDER_STATUS_LABELS).map(([value, label]) => ({ + value, + label, +})); + +export default function FulfillmentProvidersPage() { + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(false); + const [open, setOpen] = useState(false); + const [editRow, setEditRow] = useState(null); + const [form] = Form.useForm(); + + const load = useCallback(async () => { + setLoading(true); + try { + const res = await request('/admin/fulfillment-providers'); + setRows(res); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + function openCreate() { + setEditRow(null); + form.resetFields(); + form.setFieldsValue({ + type: FulfillmentProviderType.API, + status: FulfillmentProviderStatus.ACTIVE, + capabilitiesJson: JSON.stringify( + { createShipment: true, getTrack: true, callback: true }, + null, + 2, + ), + }); + setOpen(true); + } + + function openEdit(row: FulfillmentProviderDto) { + setEditRow(row); + form.setFieldsValue({ + code: row.code, + name: row.name, + type: row.type, + status: row.status, + capabilitiesJson: row.capabilities ? JSON.stringify(row.capabilities, null, 2) : '', + configJson: '', + }); + setOpen(true); + } + + async function submit() { + const v = await form.validateFields(); + if (editRow) { + await request(`/admin/fulfillment-providers/${editRow.id}`, { + method: 'PUT', + body: JSON.stringify({ + name: v.name, + type: v.type, + status: v.status, + configJson: v.configJson || undefined, + capabilitiesJson: v.capabilitiesJson || undefined, + }), + }); + message.success('已更新'); + } else { + await request('/admin/fulfillment-providers', { + method: 'POST', + body: JSON.stringify(v), + }); + message.success('已创建'); + } + setOpen(false); + void load(); + } + + const columns: ColumnsType = [ + { title: '编码', dataIndex: 'code', width: 100 }, + { title: '名称', dataIndex: 'name' }, + { + title: '类型', + dataIndex: 'type', + render: (v) => FULFILLMENT_PROVIDER_TYPE_LABELS[v as FulfillmentProviderType] || v, + }, + { + title: '状态', + dataIndex: 'status', + render: (v) => ( + + {FULFILLMENT_PROVIDER_STATUS_LABELS[v as FulfillmentProviderStatus] || v} + + ), + }, + { + title: '能力', + render: (_, row) => { + const caps = row.capabilities; + if (!caps) return '—'; + return Object.entries(caps) + .filter(([, on]) => on) + .map(([k]) => k) + .join('、') || '—'; + }, + }, + { title: '更新时间', dataIndex: 'updatedAt', width: 170, render: fmtTime }, + { + title: '操作', + width: 80, + render: (_, row) => ( + + ), + }, + ]; + + return ( +
+ +
+ + 仓配管理 + + + 注册第三方履约接口后,仓库设置中方可选择对应承运商 + +
+ +
+ + + + setOpen(false)} + onOk={() => void submit()} + width={560} + > +
+ + + + + + + + + + + + + + + + +
+ + ); +} diff --git a/apps/admin-web/src/pages/OrdersPage.tsx b/apps/admin-web/src/pages/OrdersPage.tsx index b518c7f..405b0c6 100644 --- a/apps/admin-web/src/pages/OrdersPage.tsx +++ b/apps/admin-web/src/pages/OrdersPage.tsx @@ -51,6 +51,8 @@ type OrderDetail = AdminOrderRow & { productAmount?: number; freightAmount?: number; benefitAmount?: number; + deliveryType?: string; + fulfillmentWarehouseId?: string | null; paidAt?: string | null; payExpireAt?: string | null; items?: Array>; @@ -63,6 +65,7 @@ type OrderDetail = AdminOrderRow & { export default function OrdersPage() { const [form] = Form.useForm(); const [shipForm] = Form.useForm(); + const [logisticsForm] = Form.useForm(); const [data, setData] = useState | null>(null); const [loading, setLoading] = useState(false); const [page, setPage] = useState(1); @@ -74,6 +77,7 @@ export default function OrdersPage() { const [batchDeleting, setBatchDeleting] = useState(false); const [shipDefaults, setShipDefaults] = useState(null); const [shipping, setShipping] = useState(false); + const [logisticsShipping, setLogisticsShipping] = useState(false); const selectedOrders = useMemo( () => (data?.items ?? []).filter((row) => selectedRowKeys.includes(row.id)), @@ -143,6 +147,25 @@ export default function OrdersPage() { } } + async function submitLogisticsShip() { + if (!detail) return; + const values = await logisticsForm.validateFields(); + setLogisticsShipping(true); + try { + const res = await request(`/admin/orders/${detail.id}/logistics-ship`, { + method: 'POST', + body: JSON.stringify(values), + }); + message.success('快递单已录入'); + setDetail(res); + void load(); + } catch (e) { + message.error(e instanceof Error ? e.message : '填单失败'); + } finally { + setLogisticsShipping(false); + } + } + async function confirmBatchDelete() { if (!selectedRowKeys.length) return; setBatchDeleting(true); @@ -324,16 +347,55 @@ export default function OrdersPage() { {detail.delivery && ( - {DELIVERY_PROVIDER_LABELS[detail.delivery.provider] || detail.delivery.provider} + {detail.delivery.logisticsCompany || + DELIVERY_PROVIDER_LABELS[detail.delivery.provider] || + detail.delivery.provider} {detail.delivery.trackingNo || '—'} {detail.delivery.providerOrderNo || '—'} + {detail.delivery.manualQueryUrl && ( + + + 打开物流查询 + + + )} )} {['PENDING_SHIP', 'OUT_WAREHOUSE'].includes(detail.status) && !detail.delivery?.trackingNo && (
- 发货 + {(detail.deliveryType === 'CROSS_CITY' || !detail.fulfillmentWarehouseId) && ( + <> + 总部快递填单 + + 适用于跨城订单或同城无仓订单 + +
+ + + + + + + + + + + + + )} + + {detail.fulfillmentWarehouseId && ( + <> + + 仓配小飞侠重试 + + + 仓配订单通常支付后自动推单;失败时可手动重试 +
setShipForm((f) => ({ ...f, logisticsCompany: e.target.value }))} + placeholder="如 顺丰速运" + /> + + + +
+ + +
+
+ + )} ); } diff --git a/apps/h5-partner/src/styles.css b/apps/h5-partner/src/styles.css index 09ad919..be60113 100644 --- a/apps/h5-partner/src/styles.css +++ b/apps/h5-partner/src/styles.css @@ -3129,6 +3129,50 @@ body { justify-content: center; } +.partner-ship-modal-backdrop { + position: fixed; + inset: 0; + z-index: 200; + background: rgba(0, 0, 0, 0.45); + display: flex; + align-items: flex-end; + justify-content: center; + padding: 16px; +} + +.partner-ship-modal { + width: 100%; + max-width: 420px; + background: #fff; + border-radius: var(--radius-lg) var(--radius-lg) 0 0; + padding: 20px; +} + +.partner-ship-field { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 12px; + font-size: 13px; +} + +.partner-ship-field input { + padding: 10px 12px; + border: 1px solid var(--color-outline-variant); + border-radius: var(--radius-sm); +} + +.partner-ship-actions { + display: flex; + gap: 12px; + margin-top: 20px; +} + +.partner-ship-actions .partner-btn-secondary, +.partner-ship-actions .partner-btn-primary { + flex: 1; +} + /* 微信 H5 系统标题已展示:隐藏页内重复标题,保留返回键与操作区 */ .app-page-title { display: none !important; diff --git a/apps/h5-user/src/pages/OrderDetailPage.tsx b/apps/h5-user/src/pages/OrderDetailPage.tsx index daa0676..53304d4 100644 --- a/apps/h5-user/src/pages/OrderDetailPage.tsx +++ b/apps/h5-user/src/pages/OrderDetailPage.tsx @@ -18,6 +18,15 @@ type OrderItem = { type OrderDelivery = { provider?: string; + trackingNo?: string; + logisticsCompany?: string; + manualQueryUrl?: string; + providerOrderNo?: string; +}; + +type TrackNode = { + trackInfo?: string; + createdAt?: string; }; type OrderPayment = { @@ -101,8 +110,10 @@ function progressActiveIndex(status: string) { } } -function deliveryProviderLabel(provider?: string) { - if (!provider || provider === 'MOCK' || provider === 'XIAOFEIXIA') return '小飞侠配送'; +function deliveryProviderLabel(provider?: string, company?: string) { + if (company) return company; + if (!provider || provider === 'MOCK' || provider === 'XFX' || provider === 'XIAOFEIXIA') return '小飞侠配送'; + if (provider === 'LOGISTICS') return '快递配送'; return provider; } @@ -124,13 +135,27 @@ export default function OrderDetailPage() { const [copyHint, setCopyHint] = useState(''); const [shareToast, setShareToast] = useState(''); const [confirming, setConfirming] = useState(false); + const [trackNodes, setTrackNodes] = useState([]); const isReship = order?.orderType === 'RESHIPMENT'; + async function loadTrack() { + if (!id) return; + try { + const data = await request<{ nodes?: TrackNode[] }>('USER_H5', `/trade/orders/${id}/track`); + setTrackNodes(data.nodes ?? []); + } catch { + setTrackNodes([]); + } + } + async function loadOrder() { if (!id) return; const data = await request('USER_H5', `/trade/orders/${id}`); setOrder(data); + if (data.delivery?.trackingNo || data.delivery?.provider === 'XFX') { + void loadTrack(); + } } useEffect(() => { @@ -353,11 +378,49 @@ export default function OrderDetailPage() {
配送方式 - {deliveryProviderLabel(order.delivery?.provider)} + + {deliveryProviderLabel(order.delivery?.provider, order.delivery?.logisticsCompany)} +
+ {order.delivery?.trackingNo && ( +
+ 运单号 + {order.delivery.trackingNo} +
+ )} + {order.delivery?.manualQueryUrl && ( +
+ 物流查询 + + 查看物流 + +
+ )} + {trackNodes.length > 0 && ( +
+

+ timeline + 物流动态 +

+
+ {trackNodes.map((node, index) => ( +
+ {node.createdAt ? formatDateTime(node.createdAt) : '—'} + {node.trackInfo || '—'} +
+ ))} +
+
+ )} + {!isReship && (

diff --git a/packages/shared-types/src/city-warehouse.ts b/packages/shared-types/src/city-warehouse.ts index 72d2b0b..a36978b 100644 --- a/packages/shared-types/src/city-warehouse.ts +++ b/packages/shared-types/src/city-warehouse.ts @@ -1,4 +1,4 @@ -import type { WarehouseManagerType, WarehouseStatus } from './enums'; +import type { WarehouseFulfillmentMode, WarehouseManagerType, WarehouseStatus } from './enums'; export interface CityWarehouseDto { id: string; @@ -11,6 +11,14 @@ export interface CityWarehouseDto { partnerAccountId: string | null; partnerCompanyName?: string | null; status: WarehouseStatus; + fulfillmentMode: WarehouseFulfillmentMode; + fulfillmentProviderId: string | null; + fulfillmentProviderName?: string | null; + fulfillmentProviderCode?: string | null; + manualCarrierLabel?: string | null; + manualQueryUrlTemplate?: string | null; + lng?: number | null; + lat?: number | null; createdAt: string; updatedAt: string; } @@ -23,6 +31,12 @@ export interface CreateCityWarehouseInput { managerType: WarehouseManagerType; partnerAccountId?: string; status?: WarehouseStatus; + fulfillmentMode?: WarehouseFulfillmentMode; + fulfillmentProviderId?: string; + manualCarrierLabel?: string; + manualQueryUrlTemplate?: string; + lng?: number; + lat?: number; } export interface UpdateCityWarehouseInput { @@ -33,4 +47,10 @@ export interface UpdateCityWarehouseInput { managerType?: WarehouseManagerType; partnerAccountId?: string | null; status?: WarehouseStatus; + fulfillmentMode?: WarehouseFulfillmentMode; + fulfillmentProviderId?: string | null; + manualCarrierLabel?: string | null; + manualQueryUrlTemplate?: string | null; + lng?: number | null; + lat?: number | null; } diff --git a/packages/shared-types/src/enums.ts b/packages/shared-types/src/enums.ts index 7a3f121..35f1f81 100644 --- a/packages/shared-types/src/enums.ts +++ b/packages/shared-types/src/enums.ts @@ -168,6 +168,36 @@ export const WAREHOUSE_STATUS_LABELS: Record = { [WarehouseStatus.PAUSED]: '暂停', }; +export enum FulfillmentProviderType { + API = 'API', + MANUAL = 'MANUAL', +} + +export enum FulfillmentProviderStatus { + ACTIVE = 'ACTIVE', + DISABLED = 'DISABLED', +} + +export enum WarehouseFulfillmentMode { + API_AUTO = 'API_AUTO', + MANUAL = 'MANUAL', +} + +export const FULFILLMENT_PROVIDER_TYPE_LABELS: Record = { + [FulfillmentProviderType.API]: 'API 对接', + [FulfillmentProviderType.MANUAL]: '自管', +}; + +export const FULFILLMENT_PROVIDER_STATUS_LABELS: Record = { + [FulfillmentProviderStatus.ACTIVE]: '启用', + [FulfillmentProviderStatus.DISABLED]: '停用', +}; + +export const WAREHOUSE_FULFILLMENT_MODE_LABELS: Record = { + [WarehouseFulfillmentMode.API_AUTO]: 'API 自动推单', + [WarehouseFulfillmentMode.MANUAL]: '自管手工填单', +}; + export enum AccountStatus { ACTIVE = 'ACTIVE', DISABLED = 'DISABLED', diff --git a/packages/shared-types/src/fulfillment-provider.ts b/packages/shared-types/src/fulfillment-provider.ts new file mode 100644 index 0000000..2883eab --- /dev/null +++ b/packages/shared-types/src/fulfillment-provider.ts @@ -0,0 +1,51 @@ +import type { FulfillmentProviderStatus, FulfillmentProviderType } from './enums'; +import type { WarehouseFulfillmentMode } from './enums'; + +export interface FulfillmentProviderDto { + id: string; + code: string; + name: string; + type: FulfillmentProviderType; + status: FulfillmentProviderStatus; + capabilities?: { + createShipment?: boolean; + getTrack?: boolean; + callback?: boolean; + cancel?: boolean; + } | null; + hasConfig: boolean; + createdAt: string; + updatedAt: string; +} + +export interface CreateFulfillmentProviderInput { + code: string; + name: string; + type: FulfillmentProviderType; + status?: FulfillmentProviderStatus; + configJson?: string; + capabilitiesJson?: string; +} + +export interface UpdateFulfillmentProviderInput { + name?: string; + type?: FulfillmentProviderType; + status?: FulfillmentProviderStatus; + configJson?: string; + capabilitiesJson?: string; +} + +export interface ManualShipOrderInput { + logisticsCompany: string; + trackingNo: string; + manualQueryUrl?: string; +} + +export interface WarehouseFulfillmentConfig { + fulfillmentMode: WarehouseFulfillmentMode; + fulfillmentProviderId?: string | null; + manualCarrierLabel?: string | null; + manualQueryUrlTemplate?: string | null; + lng?: number | null; + lat?: number | null; +} diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index ae2578f..8ffd900 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -18,5 +18,6 @@ export * from './partner'; export * from './shop'; export * from './city-partner'; export * from './city-warehouse'; +export * from './fulfillment-provider'; export * from './system-config'; export * from './legal'; diff --git a/server/dukang-api/prisma/clear-cities.ts b/server/dukang-api/prisma/clear-cities.ts index e0c528e..c76b043 100644 --- a/server/dukang-api/prisma/clear-cities.ts +++ b/server/dukang-api/prisma/clear-cities.ts @@ -13,6 +13,7 @@ async function main() { await prisma.storeAccount.deleteMany(); await prisma.store.deleteMany(); await prisma.partnerBill.deleteMany(); + await prisma.fulfillmentProvider.deleteMany(); await prisma.cityWarehouse.deleteMany(); await prisma.partnerAccount.deleteMany(); const result = await prisma.commonCity.deleteMany(); diff --git a/server/dukang-api/prisma/schema.prisma b/server/dukang-api/prisma/schema.prisma index 78912e8..856da00 100644 --- a/server/dukang-api/prisma/schema.prisma +++ b/server/dukang-api/prisma/schema.prisma @@ -141,6 +141,21 @@ enum WarehouseStatus { PAUSED } +enum FulfillmentProviderType { + API + MANUAL +} + +enum FulfillmentProviderStatus { + ACTIVE + DISABLED +} + +enum WarehouseFulfillmentMode { + API_AUTO + MANUAL +} + enum PartnerStaffRole { PARTNER INTERNAL @@ -496,25 +511,51 @@ model CommonCity { @@map("common_city") } -model CityWarehouse { - id BigInt @id @default(autoincrement()) @db.UnsignedBigInt - cityId BigInt @map("city_id") @db.UnsignedBigInt - name String @db.VarChar(128) - address String @db.VarChar(256) - contactName String @map("contact_name") @db.VarChar(64) - contactPhone String @map("contact_phone") @db.VarChar(20) - managerType WarehouseManagerType @map("manager_type") - partnerAccountId BigInt? @map("partner_account_id") @db.UnsignedBigInt - status WarehouseStatus @default(ACTIVE) - createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) - updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) +model FulfillmentProvider { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + code String @unique @db.VarChar(32) + name String @db.VarChar(128) + type FulfillmentProviderType + status FulfillmentProviderStatus @default(ACTIVE) + configJson String? @map("config_json") @db.Text + capabilitiesJson String? @map("capabilities_json") @db.Text + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) - city CommonCity @relation(fields: [cityId], references: [id], onDelete: Cascade) - partnerAccount PartnerAccount? @relation("WarehouseManager", fields: [partnerAccountId], references: [id], onDelete: SetNull) - managedBy PartnerAccount? @relation("ManagedWarehouse") + warehouses CityWarehouse[] + deliveries OrderDelivery[] + + @@map("common_fulfillment_provider") +} + +model CityWarehouse { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + cityId BigInt @map("city_id") @db.UnsignedBigInt + name String @db.VarChar(128) + address String @db.VarChar(256) + contactName String @map("contact_name") @db.VarChar(64) + contactPhone String @map("contact_phone") @db.VarChar(20) + managerType WarehouseManagerType @map("manager_type") + partnerAccountId BigInt? @map("partner_account_id") @db.UnsignedBigInt + status WarehouseStatus @default(ACTIVE) + fulfillmentMode WarehouseFulfillmentMode @default(MANUAL) @map("fulfillment_mode") + fulfillmentProviderId BigInt? @map("fulfillment_provider_id") @db.UnsignedBigInt + manualCarrierLabel String? @map("manual_carrier_label") @db.VarChar(64) + manualQueryUrlTemplate String? @map("manual_query_url_template") @db.VarChar(512) + lng Decimal? @db.Decimal(10, 7) + lat Decimal? @db.Decimal(10, 7) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + + city CommonCity @relation(fields: [cityId], references: [id], onDelete: Cascade) + partnerAccount PartnerAccount? @relation("WarehouseManager", fields: [partnerAccountId], references: [id], onDelete: SetNull) + managedBy PartnerAccount? @relation("ManagedWarehouse") + fulfillmentProvider FulfillmentProvider? @relation(fields: [fulfillmentProviderId], references: [id], onDelete: SetNull) + fulfilledOrders Order[] @relation("OrderFulfillmentWarehouse") @@index([cityId]) @@index([partnerAccountId]) + @@index([fulfillmentProviderId]) @@map("common_city_warehouse") } @@ -859,12 +900,14 @@ model Order { payExpireAt DateTime? @map("pay_expire_at") @db.DateTime(3) 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 remark String? @db.VarChar(512) createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) - user User @relation(fields: [userId], references: [id], onDelete: Restrict) - city CommonCity @relation(fields: [cityId], references: [id], onDelete: Restrict) + user User @relation(fields: [userId], references: [id], onDelete: Restrict) + city CommonCity @relation(fields: [cityId], references: [id], onDelete: Restrict) + fulfillmentWarehouse CityWarehouse? @relation("OrderFulfillmentWarehouse", fields: [fulfillmentWarehouseId], references: [id], onDelete: SetNull) originOrder Order? @relation("OrderReshipment", fields: [originOrderId], references: [id], onDelete: SetNull) reshipments Order[] @relation("OrderReshipment") promoCode CommonPromoCode? @relation(fields: [promoCodeId], references: [id], onDelete: SetNull) @@ -880,24 +923,30 @@ model Order { @@index([payExternalNo]) @@index([ipCity]) @@index([gpsCity]) + @@index([fulfillmentWarehouseId]) @@map("user_order") } model OrderDelivery { - id BigInt @id @default(autoincrement()) @db.UnsignedBigInt - orderId BigInt @unique @map("order_id") @db.UnsignedBigInt - provider DeliveryProvider - providerOrderNo String? @map("provider_order_no") @db.VarChar(64) - trackingNo String? @map("tracking_no") @db.VarChar(64) - outWarehouseAt DateTime? @map("out_warehouse_at") @db.DateTime(3) - shippingAt DateTime? @map("shipping_at") @db.DateTime(3) - deliveredAt DateTime? @map("delivered_at") @db.DateTime(3) - signPhotoResourceId BigInt? @map("sign_photo_resource_id") @db.UnsignedBigInt - updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + orderId BigInt @unique @map("order_id") @db.UnsignedBigInt + provider DeliveryProvider + fulfillmentProviderId BigInt? @map("fulfillment_provider_id") @db.UnsignedBigInt + providerOrderNo String? @map("provider_order_no") @db.VarChar(64) + trackingNo String? @map("tracking_no") @db.VarChar(64) + logisticsCompany String? @map("logistics_company") @db.VarChar(64) + manualQueryUrl String? @map("manual_query_url") @db.VarChar(512) + outWarehouseAt DateTime? @map("out_warehouse_at") @db.DateTime(3) + shippingAt DateTime? @map("shipping_at") @db.DateTime(3) + deliveredAt DateTime? @map("delivered_at") @db.DateTime(3) + signPhotoResourceId BigInt? @map("sign_photo_resource_id") @db.UnsignedBigInt + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) - order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) - signPhotoResource CommonResource? @relation("DeliverySignPhoto", fields: [signPhotoResourceId], references: [id], onDelete: SetNull) + order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) + fulfillmentProvider FulfillmentProvider? @relation(fields: [fulfillmentProviderId], references: [id], onDelete: SetNull) + signPhotoResource CommonResource? @relation("DeliverySignPhoto", fields: [signPhotoResourceId], references: [id], onDelete: SetNull) + @@index([fulfillmentProviderId]) @@map("user_order_delivery") } diff --git a/server/dukang-api/prisma/seed-v31.ts b/server/dukang-api/prisma/seed-v31.ts index 57c698c..f798a86 100644 --- a/server/dukang-api/prisma/seed-v31.ts +++ b/server/dukang-api/prisma/seed-v31.ts @@ -91,6 +91,8 @@ async function main() { await prisma.cityWarehouse.deleteMany(); + await prisma.fulfillmentProvider.deleteMany(); + await prisma.partnerAccount.deleteMany(); await prisma.commonCity.deleteMany(); @@ -175,6 +177,21 @@ async function main() { + const xfxProvider = await prisma.fulfillmentProvider.create({ + data: { + code: 'XFX', + name: '小飞侠', + type: 'API', + status: 'ACTIVE', + capabilitiesJson: JSON.stringify({ + createShipment: true, + getTrack: true, + callback: true, + cancel: true, + }), + }, + }); + const warehouse = await prisma.cityWarehouse.create({ data: { @@ -195,6 +212,14 @@ async function main() { status: 'ACTIVE', + fulfillmentMode: 'API_AUTO', + + fulfillmentProviderId: xfxProvider.id, + + lng: 113.665, + + lat: 34.757, + }, }); diff --git a/server/dukang-api/src/modules/city-scope/city-warehouse.service.ts b/server/dukang-api/src/modules/city-scope/city-warehouse.service.ts index 11160aa..faf8128 100644 --- a/server/dukang-api/src/modules/city-scope/city-warehouse.service.ts +++ b/server/dukang-api/src/modules/city-scope/city-warehouse.service.ts @@ -1,5 +1,10 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; -import { Prisma, WarehouseManagerType, WarehouseStatus } from '@prisma/client'; +import { + Prisma, + WarehouseFulfillmentMode, + WarehouseManagerType, + WarehouseStatus, +} from '@prisma/client'; import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { PartnerCityService } from './partner-city.service'; @@ -13,9 +18,27 @@ export type CreateCityWarehouseInput = { managerType: WarehouseManagerType; partnerAccountId?: bigint; status?: WarehouseStatus; + fulfillmentMode?: WarehouseFulfillmentMode; + fulfillmentProviderId?: bigint; + manualCarrierLabel?: string; + manualQueryUrlTemplate?: string; + lng?: number; + lat?: number; }; -export type UpdateCityWarehouseInput = Partial; +export type UpdateCityWarehouseInput = Partial< + Omit< + CreateCityWarehouseInput, + 'partnerAccountId' | 'fulfillmentProviderId' | 'manualCarrierLabel' | 'manualQueryUrlTemplate' | 'lng' | 'lat' + > +> & { + partnerAccountId?: bigint | null; + fulfillmentProviderId?: bigint | null; + manualCarrierLabel?: string | null; + manualQueryUrlTemplate?: string | null; + lng?: number | null; + lat?: number | null; +}; @Injectable() export class CityWarehouseService { @@ -29,6 +52,7 @@ export class CityWarehouseService { where: { cityId }, include: { partnerAccount: { select: { id: true, companyName: true } }, + fulfillmentProvider: { select: { id: true, code: true, name: true } }, }, orderBy: { createdAt: 'desc' }, }); @@ -51,6 +75,7 @@ export class CityWarehouseService { take: pageSize, include: { partnerAccount: { select: { id: true, companyName: true } }, + fulfillmentProvider: { select: { id: true, code: true, name: true } }, city: { select: { id: true, name: true, code: true } }, }, orderBy: { createdAt: 'desc' }, @@ -73,6 +98,7 @@ export class CityWarehouseService { async create(cityId: bigint, input: CreateCityWarehouseInput) { await this.assertCityExists(cityId); await this.validateManager(input.managerType, input.partnerAccountId, cityId); + await this.validateFulfillment(input.fulfillmentMode, input.fulfillmentProviderId); const row = await this.prisma.cityWarehouse.create({ data: { @@ -84,9 +110,17 @@ export class CityWarehouseService { managerType: input.managerType, partnerAccountId: input.managerType === 'PARTNER' ? input.partnerAccountId : null, status: input.status ?? 'ACTIVE', + fulfillmentMode: input.fulfillmentMode ?? 'MANUAL', + fulfillmentProviderId: + input.fulfillmentMode === 'API_AUTO' ? input.fulfillmentProviderId : null, + manualCarrierLabel: input.manualCarrierLabel?.trim() || null, + manualQueryUrlTemplate: input.manualQueryUrlTemplate?.trim() || null, + lng: input.lng != null ? input.lng : null, + lat: input.lat != null ? input.lat : null, }, include: { partnerAccount: { select: { id: true, companyName: true } }, + fulfillmentProvider: { select: { id: true, code: true, name: true } }, }, }); await this.syncManagedWarehouse(row.id, row.managerType as WarehouseManagerType, row.partnerAccountId); @@ -102,8 +136,16 @@ export class CityWarehouseService { managerType === 'PARTNER' ? input.partnerAccountId ?? current.partnerAccountId ?? undefined : null; + const fulfillmentMode = input.fulfillmentMode ?? current.fulfillmentMode; + const fulfillmentProviderId = + fulfillmentMode === 'API_AUTO' + ? input.fulfillmentProviderId !== undefined + ? input.fulfillmentProviderId + : current.fulfillmentProviderId + : null; await this.validateManager(managerType, partnerAccountId ?? undefined, current.cityId); + await this.validateFulfillment(fulfillmentMode, fulfillmentProviderId ?? undefined); const row = await this.prisma.cityWarehouse.update({ where: { id }, @@ -117,9 +159,22 @@ export class CityWarehouseService { ? { partnerAccountId: managerType === 'PARTNER' ? partnerAccountId : null } : {}), ...(input.status !== undefined ? { status: input.status } : {}), + ...(input.fulfillmentMode !== undefined ? { fulfillmentMode } : {}), + ...(input.fulfillmentMode !== undefined || input.fulfillmentProviderId !== undefined + ? { fulfillmentProviderId } + : {}), + ...(input.manualCarrierLabel !== undefined + ? { manualCarrierLabel: input.manualCarrierLabel?.trim() || null } + : {}), + ...(input.manualQueryUrlTemplate !== undefined + ? { manualQueryUrlTemplate: input.manualQueryUrlTemplate?.trim() || null } + : {}), + ...(input.lng !== undefined ? { lng: input.lng } : {}), + ...(input.lat !== undefined ? { lat: input.lat } : {}), }, include: { partnerAccount: { select: { id: true, companyName: true } }, + fulfillmentProvider: { select: { id: true, code: true, name: true } }, }, }); await this.syncManagedWarehouse(row.id, row.managerType as WarehouseManagerType, row.partnerAccountId); @@ -170,6 +225,19 @@ export class CityWarehouseService { } } + private async validateFulfillment( + mode?: WarehouseFulfillmentMode, + providerId?: bigint, + ) { + if (mode === 'API_AUTO') { + if (!providerId) throw new BadRequestException('API 自动推单须选择仓配承运商'); + const provider = await this.prisma.fulfillmentProvider.findUnique({ where: { id: providerId } }); + if (!provider || provider.status !== 'ACTIVE' || provider.type !== 'API') { + throw new BadRequestException('所选仓配承运商不可用'); + } + } + } + private async assertCityExists(cityId: bigint) { const city = await this.prisma.commonCity.findUnique({ where: { id: cityId } }); if (!city) throw new NotFoundException('开城城市不存在'); @@ -185,9 +253,16 @@ export class CityWarehouseService { managerType: string; partnerAccountId: bigint | null; status: string; + fulfillmentMode: string; + fulfillmentProviderId: bigint | null; + manualCarrierLabel: string | null; + manualQueryUrlTemplate: string | null; + lng: Prisma.Decimal | null; + lat: Prisma.Decimal | null; createdAt: Date; updatedAt: Date; partnerAccount?: { id: bigint; companyName: string | null } | null; + fulfillmentProvider?: { id: bigint; code: string; name: string } | null; }) { return serializeBigInt({ id: row.id.toString(), @@ -200,6 +275,14 @@ export class CityWarehouseService { partnerAccountId: row.partnerAccountId?.toString() ?? null, partnerCompanyName: row.partnerAccount?.companyName ?? null, status: row.status, + fulfillmentMode: row.fulfillmentMode, + fulfillmentProviderId: row.fulfillmentProviderId?.toString() ?? null, + fulfillmentProviderName: row.fulfillmentProvider?.name ?? null, + fulfillmentProviderCode: row.fulfillmentProvider?.code ?? null, + manualCarrierLabel: row.manualCarrierLabel, + manualQueryUrlTemplate: row.manualQueryUrlTemplate, + lng: row.lng != null ? Number(row.lng) : null, + lat: row.lat != null ? Number(row.lat) : null, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), }); diff --git a/server/dukang-api/src/modules/city-scope/partner-city.service.ts b/server/dukang-api/src/modules/city-scope/partner-city.service.ts index 5095396..461b6ca 100644 --- a/server/dukang-api/src/modules/city-scope/partner-city.service.ts +++ b/server/dukang-api/src/modules/city-scope/partner-city.service.ts @@ -101,9 +101,38 @@ export class PartnerCityService { async buildPartnerOrderWhere(partnerAccountId: bigint): Promise { const primary = await this.resolvePrimaryAccount(partnerAccountId); if (!primary.cityId) return { id: -1n }; + + const warehouseIds = await this.resolveManagedWarehouseIds(primary.id); + if (warehouseIds.length > 0) { + return { fulfillmentWarehouseId: { in: warehouseIds } }; + } return { cityId: primary.cityId }; } + /** 合伙人可管仓库:主账号 managedWarehouseId + 绑定为管仓合伙人的仓 */ + async resolveManagedWarehouseIds(partnerAccountId: bigint): Promise { + const primary = await this.resolvePrimaryAccount(partnerAccountId); + const ids = new Set(); + + if (primary.managedWarehouseId) { + ids.add(primary.managedWarehouseId); + } + + const managed = await this.prisma.cityWarehouse.findMany({ + where: { + status: 'ACTIVE', + OR: [ + { partnerAccountId: primary.id }, + ...(primary.managedWarehouseId ? [{ id: primary.managedWarehouseId }] : []), + ], + }, + select: { id: true }, + }); + for (const row of managed) ids.add(row.id); + + return [...ids]; + } + async buildPartnerCityWhere(partnerAccountId: bigint): Promise { const primary = await this.resolvePrimaryAccount(partnerAccountId); if (!primary.cityId) return { id: -1n }; diff --git a/server/dukang-api/src/modules/fulfillment/fulfillment-provider.service.ts b/server/dukang-api/src/modules/fulfillment/fulfillment-provider.service.ts new file mode 100644 index 0000000..a4b4042 --- /dev/null +++ b/server/dukang-api/src/modules/fulfillment/fulfillment-provider.service.ts @@ -0,0 +1,119 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { FulfillmentProviderStatus, FulfillmentProviderType } from '@prisma/client'; +import { PrismaService } from '../../common/prisma/prisma.module'; +import { serializeBigInt } from '../../common/decorators/current-user.decorator'; + +export type CreateFulfillmentProviderInput = { + code: string; + name: string; + type: FulfillmentProviderType; + status?: FulfillmentProviderStatus; + configJson?: string; + capabilitiesJson?: string; +}; + +export type UpdateFulfillmentProviderInput = Partial; + +type Capabilities = { + createShipment?: boolean; + getTrack?: boolean; + callback?: boolean; + cancel?: boolean; +}; + +@Injectable() +export class FulfillmentProviderService { + constructor(private readonly prisma: PrismaService) {} + + async listActiveApiProviders() { + const rows = await this.prisma.fulfillmentProvider.findMany({ + where: { status: 'ACTIVE', type: 'API' }, + orderBy: { name: 'asc' }, + }); + return rows.map((row) => this.toDto(row)); + } + + async listAll() { + const rows = await this.prisma.fulfillmentProvider.findMany({ + orderBy: { createdAt: 'desc' }, + }); + return rows.map((row) => this.toDto(row)); + } + + async getById(id: bigint) { + const row = await this.prisma.fulfillmentProvider.findUnique({ where: { id } }); + if (!row) throw new NotFoundException('仓配承运商不存在'); + return this.toDto(row); + } + + async create(input: CreateFulfillmentProviderInput) { + const code = input.code.trim().toUpperCase(); + if (!/^[A-Z0-9_]+$/.test(code)) { + throw new BadRequestException('承运商编码仅支持大写字母、数字和下划线'); + } + const existing = await this.prisma.fulfillmentProvider.findUnique({ where: { code } }); + if (existing) throw new BadRequestException('承运商编码已存在'); + + const row = await this.prisma.fulfillmentProvider.create({ + data: { + code, + name: input.name.trim(), + type: input.type, + status: input.status ?? 'ACTIVE', + configJson: input.configJson?.trim() || null, + capabilitiesJson: input.capabilitiesJson?.trim() || null, + }, + }); + return this.toDto(row); + } + + async update(id: bigint, input: UpdateFulfillmentProviderInput) { + await this.getById(id); + const row = await this.prisma.fulfillmentProvider.update({ + where: { id }, + data: { + ...(input.name !== undefined ? { name: input.name.trim() } : {}), + ...(input.type !== undefined ? { type: input.type } : {}), + ...(input.status !== undefined ? { status: input.status } : {}), + ...(input.configJson !== undefined ? { configJson: input.configJson?.trim() || null } : {}), + ...(input.capabilitiesJson !== undefined + ? { capabilitiesJson: input.capabilitiesJson?.trim() || null } + : {}), + }, + }); + return this.toDto(row); + } + + parseCapabilities(raw: string | null): Capabilities | null { + if (!raw) return null; + try { + return JSON.parse(raw) as Capabilities; + } catch { + return null; + } + } + + private toDto(row: { + id: bigint; + code: string; + name: string; + type: string; + status: string; + configJson: string | null; + capabilitiesJson: string | null; + createdAt: Date; + updatedAt: Date; + }) { + return serializeBigInt({ + id: row.id.toString(), + code: row.code, + name: row.name, + type: row.type, + status: row.status, + capabilities: this.parseCapabilities(row.capabilitiesJson), + hasConfig: Boolean(row.configJson), + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }); + } +} diff --git a/server/dukang-api/src/modules/fulfillment/fulfillment.module.ts b/server/dukang-api/src/modules/fulfillment/fulfillment.module.ts new file mode 100644 index 0000000..a86a87e --- /dev/null +++ b/server/dukang-api/src/modules/fulfillment/fulfillment.module.ts @@ -0,0 +1,12 @@ +import { Module, forwardRef } from '@nestjs/common'; +import { IntegrationsModule } from '../../integrations/integrations.module'; +import { TradeModule } from '../trade/trade.module'; +import { FulfillmentProviderService } from './fulfillment-provider.service'; +import { FulfillmentService } from './fulfillment.service'; + +@Module({ + imports: [IntegrationsModule, forwardRef(() => TradeModule)], + providers: [FulfillmentProviderService, FulfillmentService], + exports: [FulfillmentProviderService, FulfillmentService], +}) +export class FulfillmentModule {} diff --git a/server/dukang-api/src/modules/fulfillment/fulfillment.service.ts b/server/dukang-api/src/modules/fulfillment/fulfillment.service.ts new file mode 100644 index 0000000..90ce54f --- /dev/null +++ b/server/dukang-api/src/modules/fulfillment/fulfillment.service.ts @@ -0,0 +1,306 @@ +import { BadRequestException, Injectable, Logger, NotFoundException, Inject, forwardRef } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import type { CityWarehouse, FulfillmentProvider, Order } from '@prisma/client'; +import { PrismaService } from '../../common/prisma/prisma.module'; +import { CourierService } from '../../integrations/courier/courier.service'; +import { CourierPayMode } from '../../integrations/courier/courier.types'; +import { TradeService } from '../trade/trade.service'; + +export type ManualShipInput = { + logisticsCompany: string; + trackingNo: string; + manualQueryUrl?: string; +}; + +export type HqLogisticsShipInput = ManualShipInput; + +const XFX_CODES = new Set(['XFX', 'XIAOFEIXIA']); + +@Injectable() +export class FulfillmentService { + private readonly logger = new Logger(FulfillmentService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly config: ConfigService, + private readonly courier: CourierService, + @Inject(forwardRef(() => TradeService)) + private readonly tradeService: TradeService, + ) {} + + async dispatchAfterPay(orderId: bigint) { + const order = await this.prisma.order.findUnique({ + where: { id: orderId }, + include: { delivery: true }, + }); + if (!order || order.payStatus !== 'PAID') return; + + if (order.deliveryType === 'CROSS_CITY') { + await this.ensureDeliveryRecord(orderId, 'MANUAL'); + return; + } + + const warehouse = await this.resolveWarehouseForLocalOrder(order.cityId); + if (!warehouse) { + await this.ensureDeliveryRecord(orderId, 'MANUAL'); + return; + } + + await this.prisma.order.update({ + where: { id: orderId }, + data: { fulfillmentWarehouseId: warehouse.id }, + }); + + if (warehouse.fulfillmentMode === 'MANUAL') { + await this.ensureDeliveryRecord(orderId, 'MANUAL'); + return; + } + + if (warehouse.fulfillmentMode === 'API_AUTO' && warehouse.fulfillmentProviderId) { + const provider = await this.prisma.fulfillmentProvider.findUnique({ + where: { id: warehouse.fulfillmentProviderId }, + }); + if (!provider || provider.status !== 'ACTIVE' || provider.type !== 'API') { + await this.ensureDeliveryRecord(orderId, 'MANUAL'); + return; + } + await this.dispatchApiAuto(order, warehouse, provider); + } + } + + async dispatchApiAuto(order: Order, warehouse: CityWarehouse, provider: FulfillmentProvider) { + if (!XFX_CODES.has(provider.code)) { + this.logger.warn(`承运商 ${provider.code} 自动推单尚未实现,订单 ${order.orderNo} 保持待发货`); + await this.ensureDeliveryRecord(order.id, 'MANUAL', provider.id); + return; + } + + const defaults = this.getShipDefaults(); + const fromLng = warehouse.lng != null ? Number(warehouse.lng) : defaults.fromLng; + const fromLat = warehouse.lat != null ? Number(warehouse.lat) : defaults.fromLat; + + try { + const result = await this.courier.createShipment({ + outNumber: order.orderNo, + from: { + name: warehouse.contactName, + mobile: warehouse.contactPhone, + address: warehouse.address, + addressDetail: warehouse.name, + coordinate: { lng: fromLng, lat: fromLat }, + }, + to: { + name: order.receiverName, + mobile: order.receiverPhone, + address: `${order.receiverProvince}${order.receiverCity}${order.receiverDistrict}`, + addressDetail: order.receiverAddress, + }, + goodsName: order.productName, + goodsNum: order.quantity, + weight: defaults.weight, + payMode: defaults.payMode, + remark: `仓配自动发货 ${order.orderNo}`, + }); + + const now = new Date(); + await this.prisma.$transaction(async (tx) => { + const delivery = await tx.orderDelivery.findUnique({ where: { orderId: order.id } }); + const data = { + provider: 'XFX' as const, + fulfillmentProviderId: provider.id, + trackingNo: result.trackingNumber, + providerOrderNo: String(result.providerShipmentId), + shippingAt: now, + }; + if (delivery) { + await tx.orderDelivery.update({ where: { orderId: order.id }, data }); + } else { + await tx.orderDelivery.create({ data: { orderId: order.id, ...data } }); + } + await tx.logThirdParty.create({ + data: { + provider: 'XFX', + scene: 'ORDER_DISPATCH', + refType: 'ORDER', + refId: order.id, + externalNo: result.trackingNumber, + status: 'SUCCESS', + }, + }); + }); + + await this.tradeService.applyStatusTransition(order.id, order.status, 'SHIPPING', 'WAREHOUSE_AUTO'); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + await this.logDispatchFailure(order, provider, message); + await this.ensureDeliveryRecord(order.id, 'MANUAL', provider.id); + } + } + + async shipManualByWarehouse(orderId: bigint, warehouseIds: bigint[], input: ManualShipInput) { + const order = await this.prisma.order.findFirst({ + where: { id: orderId, fulfillmentWarehouseId: { in: warehouseIds } }, + include: { delivery: true }, + }); + if (!order) throw new NotFoundException('订单不存在或无权操作'); + if (!['PENDING_SHIP', 'OUT_WAREHOUSE'].includes(order.status)) { + throw new BadRequestException('当前订单状态不可发货'); + } + + const queryUrl = + input.manualQueryUrl?.trim() || + (await this.buildQueryUrlFromTemplate(order.fulfillmentWarehouseId, input.trackingNo)); + + return this.applyManualShip(order, { + logisticsCompany: input.logisticsCompany.trim(), + trackingNo: input.trackingNo.trim(), + manualQueryUrl: queryUrl, + operator: 'WAREHOUSE_MANUAL', + }); + } + + async shipHqLogistics(orderId: bigint, input: HqLogisticsShipInput) { + const order = await this.prisma.order.findUnique({ + where: { id: orderId }, + include: { delivery: true }, + }); + if (!order) throw new NotFoundException('订单不存在'); + + const isHqQueue = + order.deliveryType === 'CROSS_CITY' || + (order.deliveryType === 'LOCAL' && !order.fulfillmentWarehouseId); + + if (!isHqQueue) throw new BadRequestException('该订单由仓配履约,请使用仓配发货'); + if (!['PENDING_SHIP', 'OUT_WAREHOUSE'].includes(order.status)) { + throw new BadRequestException('当前订单状态不可发货'); + } + if (order.delivery?.trackingNo) throw new BadRequestException('该订单已有运单号'); + + return this.applyManualShip(order, { + logisticsCompany: input.logisticsCompany.trim(), + trackingNo: input.trackingNo.trim(), + manualQueryUrl: input.manualQueryUrl?.trim(), + operator: 'HQ_LOGISTICS', + provider: 'LOGISTICS', + }); + } + + async getOrderTrack(orderId: bigint) { + const order = await this.prisma.order.findUnique({ + where: { id: orderId }, + include: { delivery: true }, + }); + if (!order?.delivery) { + return { nodes: [], manualQueryUrl: null }; + } + + if (order.delivery.provider === 'XFX' && (order.delivery.trackingNo || order.orderNo)) { + try { + const nodes = await this.courier.getTrack({ + trackingNumber: order.delivery.trackingNo ?? undefined, + outNumber: order.orderNo, + }); + return { + nodes, + manualQueryUrl: order.delivery.manualQueryUrl, + provider: order.delivery.provider, + trackingNo: order.delivery.trackingNo, + logisticsCompany: order.delivery.logisticsCompany, + }; + } catch { + // fall through to manual fields + } + } + + return { + nodes: [], + manualQueryUrl: order.delivery.manualQueryUrl, + provider: order.delivery.provider, + trackingNo: order.delivery.trackingNo, + logisticsCompany: order.delivery.logisticsCompany, + }; + } + + private async applyManualShip( + order: Order & { delivery: { trackingNo: string | null } | null }, + input: ManualShipInput & { operator: string; provider?: 'MANUAL' | 'LOGISTICS' }, + ) { + const now = new Date(); + const provider = input.provider ?? 'MANUAL'; + + await this.prisma.$transaction(async (tx) => { + const delivery = await tx.orderDelivery.findUnique({ where: { orderId: order.id } }); + const data = { + provider, + logisticsCompany: input.logisticsCompany, + trackingNo: input.trackingNo, + manualQueryUrl: input.manualQueryUrl || null, + shippingAt: now, + }; + if (delivery) { + await tx.orderDelivery.update({ where: { orderId: order.id }, data }); + } else { + await tx.orderDelivery.create({ data: { orderId: order.id, ...data } }); + } + }); + + await this.tradeService.applyStatusTransition(order.id, order.status, 'SHIPPING', input.operator); + return this.prisma.order.findUnique({ + where: { id: order.id }, + include: { delivery: true, fulfillmentWarehouse: true }, + }); + } + + private async resolveWarehouseForLocalOrder(cityId: bigint) { + return this.prisma.cityWarehouse.findFirst({ + where: { cityId, status: 'ACTIVE' }, + orderBy: { createdAt: 'asc' }, + }); + } + + private async ensureDeliveryRecord( + orderId: bigint, + provider: 'MANUAL' | 'LOGISTICS' | 'XFX', + fulfillmentProviderId?: bigint, + ) { + const existing = await this.prisma.orderDelivery.findUnique({ where: { orderId } }); + if (existing) return; + await this.prisma.orderDelivery.create({ + data: { + orderId, + provider, + ...(fulfillmentProviderId ? { fulfillmentProviderId } : {}), + }, + }); + } + + private async logDispatchFailure(order: Order, provider: FulfillmentProvider, error: string) { + await this.prisma.logThirdParty.create({ + data: { + provider: 'XFX', + scene: 'ORDER_DISPATCH', + refType: 'ORDER', + refId: order.id, + status: 'FAILED', + errorMessage: `[${provider.code}] ${error}`.slice(0, 512), + }, + }); + } + + private async buildQueryUrlFromTemplate(warehouseId: bigint | null, trackingNo: string) { + if (!warehouseId) return undefined; + const wh = await this.prisma.cityWarehouse.findUnique({ where: { id: warehouseId } }); + const tpl = wh?.manualQueryUrlTemplate; + if (!tpl) return undefined; + return tpl.replace(/\{trackingNo\}/g, encodeURIComponent(trackingNo)); + } + + private getShipDefaults() { + return { + fromLng: Number(this.config.get('SHIP_FROM_LNG') || 113.665), + fromLat: Number(this.config.get('SHIP_FROM_LAT') || 34.757), + weight: 2, + payMode: CourierPayMode.SENDER, + }; + } +} diff --git a/server/dukang-api/src/modules/ops/admin-city-warehouses.controller.ts b/server/dukang-api/src/modules/ops/admin-city-warehouses.controller.ts index 8df8da0..c600b86 100644 --- a/server/dukang-api/src/modules/ops/admin-city-warehouses.controller.ts +++ b/server/dukang-api/src/modules/ops/admin-city-warehouses.controller.ts @@ -5,7 +5,22 @@ import { HqOperationAction } from '../../common/hq-operation/hq-operation.consta import { CityWarehouseService } from '../city-scope/city-warehouse.service'; import { CreateCityWarehouseDto, UpdateCityWarehouseDto } from './dto/admin-mutate.dto'; import { AdminCityWarehousesQueryDto } from './dto/admin-query.dto'; -import type { WarehouseManagerType, WarehouseStatus } from '@prisma/client'; +import type { + WarehouseFulfillmentMode, + WarehouseManagerType, + WarehouseStatus, +} from '@prisma/client'; + +function mapWarehouseFulfillment(dto: CreateCityWarehouseDto | UpdateCityWarehouseDto) { + return { + fulfillmentMode: dto.fulfillmentMode as WarehouseFulfillmentMode | undefined, + fulfillmentProviderId: dto.fulfillmentProviderId ? BigInt(dto.fulfillmentProviderId) : undefined, + manualCarrierLabel: dto.manualCarrierLabel ?? undefined, + manualQueryUrlTemplate: dto.manualQueryUrlTemplate ?? undefined, + lng: dto.lng ?? undefined, + lat: dto.lat ?? undefined, + }; +} @Controller('admin/cities/:cityId/warehouses') @UseGuards(HqAuthGuard) @@ -33,6 +48,7 @@ export class AdminCityWarehousesController { managerType: dto.managerType as WarehouseManagerType, partnerAccountId: dto.partnerAccountId ? BigInt(dto.partnerAccountId) : undefined, status: dto.status as WarehouseStatus | undefined, + ...mapWarehouseFulfillment(dto), }); } } @@ -63,11 +79,22 @@ export class AdminCityWarehouseMutationsController { managerType: dto.managerType as WarehouseManagerType | undefined, partnerAccountId: dto.partnerAccountId === null - ? undefined + ? null : dto.partnerAccountId ? BigInt(dto.partnerAccountId) : undefined, status: dto.status as WarehouseStatus | undefined, + fulfillmentMode: dto.fulfillmentMode as WarehouseFulfillmentMode | undefined, + fulfillmentProviderId: + dto.fulfillmentProviderId === null + ? null + : dto.fulfillmentProviderId + ? BigInt(dto.fulfillmentProviderId) + : undefined, + manualCarrierLabel: dto.manualCarrierLabel, + manualQueryUrlTemplate: dto.manualQueryUrlTemplate, + lng: dto.lng, + lat: dto.lat, }); } diff --git a/server/dukang-api/src/modules/ops/admin-fulfillment-providers.controller.ts b/server/dukang-api/src/modules/ops/admin-fulfillment-providers.controller.ts new file mode 100644 index 0000000..98fd5d2 --- /dev/null +++ b/server/dukang-api/src/modules/ops/admin-fulfillment-providers.controller.ts @@ -0,0 +1,61 @@ +import { Body, Controller, Get, Param, Post, Put, UseGuards } from '@nestjs/common'; +import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; +import { HqOperation } from '../../common/hq-operation/hq-operation.decorator'; +import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants'; +import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service'; +import { + CreateFulfillmentProviderDto, + UpdateFulfillmentProviderDto, +} from './dto/admin-mutate.dto'; +import type { FulfillmentProviderStatus, FulfillmentProviderType } from '@prisma/client'; + +@Controller('admin/fulfillment-providers') +@UseGuards(HqAuthGuard) +export class AdminFulfillmentProvidersController { + constructor(private readonly service: FulfillmentProviderService) {} + + @Get() + list() { + return this.service.listAll(); + } + + @Get('active-api') + listActiveApi() { + return this.service.listActiveApiProviders(); + } + + @Post() + @HqOperation({ + action: HqOperationAction.WAREHOUSE_UPDATE, + refType: 'FULFILLMENT_PROVIDER', + refIdField: 'id', + includeBody: true, + }) + create(@Body() dto: CreateFulfillmentProviderDto) { + return this.service.create({ + code: dto.code, + name: dto.name, + type: dto.type as FulfillmentProviderType, + status: dto.status as FulfillmentProviderStatus | undefined, + configJson: dto.configJson, + capabilitiesJson: dto.capabilitiesJson, + }); + } + + @Put(':id') + @HqOperation({ + action: HqOperationAction.WAREHOUSE_UPDATE, + refType: 'FULFILLMENT_PROVIDER', + refIdParam: 'id', + includeBody: true, + }) + update(@Param('id') id: string, @Body() dto: UpdateFulfillmentProviderDto) { + return this.service.update(BigInt(id), { + name: dto.name, + type: dto.type as FulfillmentProviderType | undefined, + status: dto.status as FulfillmentProviderStatus | undefined, + configJson: dto.configJson, + capabilitiesJson: dto.capabilitiesJson, + }); + } +} diff --git a/server/dukang-api/src/modules/ops/admin-orders.controller.ts b/server/dukang-api/src/modules/ops/admin-orders.controller.ts index 036007c..eb9031b 100644 --- a/server/dukang-api/src/modules/ops/admin-orders.controller.ts +++ b/server/dukang-api/src/modules/ops/admin-orders.controller.ts @@ -4,7 +4,7 @@ import { SuperAdminGuard } from '../../common/guards/super-admin.guard'; import { HqOperation } from '../../common/hq-operation/hq-operation.decorator'; import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants'; import { AdminOrdersService } from './admin-orders.service'; -import { AdminShipOrderDto, BatchDeleteOrdersDto, UpdateOrderStatusDto } from './dto/admin-mutate.dto'; +import { AdminShipOrderDto, BatchDeleteOrdersDto, HqLogisticsShipDto, UpdateOrderStatusDto } from './dto/admin-mutate.dto'; import { AdminOrdersQueryDto } from './dto/admin-query.dto'; @Controller('admin/orders') @@ -51,6 +51,17 @@ export class AdminOrdersController { return this.ordersService.shipOrder(BigInt(id), dto); } + @Post(':id/logistics-ship') + @HqOperation({ + action: HqOperationAction.ORDER_SHIP, + refType: 'ORDER', + refIdParam: 'id', + includeBody: true, + }) + shipLogistics(@Param('id') id: string, @Body() dto: HqLogisticsShipDto) { + return this.ordersService.shipLogistics(BigInt(id), dto); + } + /** preV1 调试:直接改订单状态,不走业务校验 */ @Put(':id/status') @HqOperation({ diff --git a/server/dukang-api/src/modules/ops/admin-orders.service.ts b/server/dukang-api/src/modules/ops/admin-orders.service.ts index 8a1f0b9..f36e2ab 100644 --- a/server/dukang-api/src/modules/ops/admin-orders.service.ts +++ b/server/dukang-api/src/modules/ops/admin-orders.service.ts @@ -8,8 +8,9 @@ import { mapOrderCompat, mapStatusLogCompat } from '../../common/compat/v31-comp import { TradeService } from '../trade/trade.service'; import { AdminXiaofeixiaService } from './admin-xiaofeixia.service'; import type { AdminOrdersQueryDto } from './dto/admin-query.dto'; -import type { AdminShipOrderDto } from './dto/admin-mutate.dto'; +import type { AdminShipOrderDto, HqLogisticsShipDto } from './dto/admin-mutate.dto'; import type { XiaofeixiaCreateShipmentDto } from './dto/admin-courier.dto'; +import { FulfillmentService } from '../fulfillment/fulfillment.service'; @Injectable() export class AdminOrdersService { @@ -17,6 +18,7 @@ export class AdminOrdersService { private readonly prisma: PrismaService, private readonly tradeService: TradeService, private readonly xiaofeixiaService: AdminXiaofeixiaService, + private readonly fulfillmentService: FulfillmentService, private readonly config: ConfigService, ) {} @@ -183,6 +185,12 @@ export class AdminOrdersService { return this.detail(id); } + /** 总部传统快递填单(同城无仓 / 跨城) */ + async shipLogistics(id: bigint, dto: HqLogisticsShipDto) { + await this.fulfillmentService.shipHqLogistics(id, dto); + return this.detail(id); + } + async batchDeleteOrders(ids: bigint[]) { const uniqueIds = [...new Set(ids)]; if (!uniqueIds.length) { diff --git a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts index 1064ab8..c9d3e30 100644 --- a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts +++ b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts @@ -474,6 +474,30 @@ export class CreateCityWarehouseDto { @IsOptional() @IsIn(['ACTIVE', 'PAUSED']) status?: 'ACTIVE' | 'PAUSED'; + + @IsOptional() + @IsIn(['API_AUTO', 'MANUAL']) + fulfillmentMode?: 'API_AUTO' | 'MANUAL'; + + @IsOptional() + @IsString() + fulfillmentProviderId?: string; + + @IsOptional() + @IsString() + manualCarrierLabel?: string; + + @IsOptional() + @IsString() + manualQueryUrlTemplate?: string; + + @IsOptional() + @IsNumber() + lng?: number; + + @IsOptional() + @IsNumber() + lat?: number; } export class UpdateCityWarehouseDto { @@ -505,8 +529,100 @@ export class UpdateCityWarehouseDto { @IsOptional() @IsIn(['ACTIVE', 'PAUSED']) status?: 'ACTIVE' | 'PAUSED'; + + @IsOptional() + @IsIn(['API_AUTO', 'MANUAL']) + fulfillmentMode?: 'API_AUTO' | 'MANUAL'; + + @IsOptional() + @ValidateIf((_, v) => v !== null) + @IsString() + fulfillmentProviderId?: string | null; + + @IsOptional() + @ValidateIf((_, v) => v !== null) + @IsString() + manualCarrierLabel?: string | null; + + @IsOptional() + @ValidateIf((_, v) => v !== null) + @IsString() + manualQueryUrlTemplate?: string | null; + + @IsOptional() + @ValidateIf((_, v) => v !== null) + @IsNumber() + lng?: number | null; + + @IsOptional() + @ValidateIf((_, v) => v !== null) + @IsNumber() + lat?: number | null; } +export class CreateFulfillmentProviderDto { + @IsString() + @IsNotEmpty() + code: string; + + @IsString() + @IsNotEmpty() + name: string; + + @IsIn(['API', 'MANUAL']) + type: 'API' | 'MANUAL'; + + @IsOptional() + @IsIn(['ACTIVE', 'DISABLED']) + status?: 'ACTIVE' | 'DISABLED'; + + @IsOptional() + @IsString() + configJson?: string; + + @IsOptional() + @IsString() + capabilitiesJson?: string; +} + +export class UpdateFulfillmentProviderDto { + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsIn(['API', 'MANUAL']) + type?: 'API' | 'MANUAL'; + + @IsOptional() + @IsIn(['ACTIVE', 'DISABLED']) + status?: 'ACTIVE' | 'DISABLED'; + + @IsOptional() + @IsString() + configJson?: string; + + @IsOptional() + @IsString() + capabilitiesJson?: string; +} + +export class ManualShipOrderDto { + @IsString() + @IsNotEmpty() + logisticsCompany: string; + + @IsString() + @IsNotEmpty() + trackingNo: string; + + @IsOptional() + @IsString() + manualQueryUrl?: string; +} + +export class HqLogisticsShipDto extends ManualShipOrderDto {} + export class CreateStoreMediaDto { @IsString() @IsNotEmpty() diff --git a/server/dukang-api/src/modules/ops/ops.module.ts b/server/dukang-api/src/modules/ops/ops.module.ts index 5561259..4c823a7 100644 --- a/server/dukang-api/src/modules/ops/ops.module.ts +++ b/server/dukang-api/src/modules/ops/ops.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { CityScopeModule } from '../city-scope/city-scope.module'; import { IamModule } from '../iam/iam.module'; import { TradeModule } from '../trade/trade.module'; +import { FulfillmentModule } from '../fulfillment/fulfillment.module'; import { AdminDashboardController } from './admin-dashboard.controller'; import { AdminDashboardService } from './admin-dashboard.service'; import { AdminUsersController } from './admin-users.controller'; @@ -54,9 +55,10 @@ import { AdminHqPermissionsService } from './admin-hq-permissions.service'; import { AdminDeployController } from './admin-deploy.controller'; import { AdminDeployService } from './admin-deploy.service'; import { AdminSystemConfigController } from './admin-system-config.controller'; +import { AdminFulfillmentProvidersController } from './admin-fulfillment-providers.controller'; @Module({ - imports: [CityScopeModule, IamModule, TradeModule, BenefitModule, CommonModule, IntegrationsModule, RedeemModule], + imports: [CityScopeModule, IamModule, TradeModule, FulfillmentModule, BenefitModule, CommonModule, IntegrationsModule, RedeemModule], controllers: [ AdminDashboardController, AdminDeployController, @@ -89,6 +91,7 @@ import { AdminSystemConfigController } from './admin-system-config.controller'; AdminWechatBindingsController, AdminHqPermissionsController, AdminSystemConfigController, + AdminFulfillmentProvidersController, ], providers: [ AdminDashboardService, diff --git a/server/dukang-api/src/modules/trade/trade.controller.ts b/server/dukang-api/src/modules/trade/trade.controller.ts index 2713874..9028752 100644 --- a/server/dukang-api/src/modules/trade/trade.controller.ts +++ b/server/dukang-api/src/modules/trade/trade.controller.ts @@ -11,6 +11,7 @@ import { PartnerProxyOrderPreviewDto, PartnerProxyOrderSendSmsDto, } from './dto/partner-proxy-order.dto'; +import { ManualShipOrderDto } from '../ops/dto/admin-mutate.dto'; @Controller('trade/orders') @UseGuards(JwtAuthGuard) @@ -42,6 +43,11 @@ export class TradeController { return this.tradeService.getOrder(user.actorId, BigInt(id)); } + @Get(':id/track') + track(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.tradeService.getOrderTrack(user.actorId, BigInt(id)); + } + @Post(':id/pay') pay(@CurrentUser() user: AuthUser, @Param('id') id: string) { return this.tradeService.payOrder(user.actorId, BigInt(id), user.clientApp); @@ -91,6 +97,21 @@ export class PartnerOrderController { return this.tradeService.getPartnerOrder(user.actorId, BigInt(id)); } + @Get(':id/track') + track(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.tradeService.getPartnerOrderTrack(user.actorId, BigInt(id)); + } + + @Post(':id/manual-ship') + @RequirePartnerPermissions('warehouse:manage') + manualShip( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() body: ManualShipOrderDto, + ) { + return this.tradeService.partnerManualShip(user.actorId, BigInt(id), body); + } + @Post(':id/mock-advance-delivery') mockAdvance( @CurrentUser() user: AuthUser, diff --git a/server/dukang-api/src/modules/trade/trade.module.ts b/server/dukang-api/src/modules/trade/trade.module.ts index 921d140..c848b92 100644 --- a/server/dukang-api/src/modules/trade/trade.module.ts +++ b/server/dukang-api/src/modules/trade/trade.module.ts @@ -7,6 +7,7 @@ import { CatalogModule } from '../catalog/catalog.module'; import { CommonModule } from '../common/common.module'; import { CityScopeModule } from '../city-scope/city-scope.module'; import { PromoModule } from '../promo/promo.module'; +import { FulfillmentModule } from '../fulfillment/fulfillment.module'; import { TradeController, PartnerOrderController, @@ -24,6 +25,7 @@ import { TradeService } from './trade.service'; CityScopeModule, PromoModule, forwardRef(() => BenefitModule), + forwardRef(() => FulfillmentModule), CommonModule, ], controllers: [ diff --git a/server/dukang-api/src/modules/trade/trade.service.ts b/server/dukang-api/src/modules/trade/trade.service.ts index d68707e..31be90d 100644 --- a/server/dukang-api/src/modules/trade/trade.service.ts +++ b/server/dukang-api/src/modules/trade/trade.service.ts @@ -3,6 +3,7 @@ import { Inject, Injectable, NotFoundException, + forwardRef, } from '@nestjs/common'; import type { FreightPayType } from '@prisma/client'; import { @@ -29,6 +30,7 @@ import { buildOrderClientLocationSnapshot } from '../../common/geo/client-locati import { extractClientIp } from '../../common/geo/client-ip.util'; import { buildOrderStatusEvent, orderStatusLogWhere } from '../../common/event/event.helpers'; import { mapOrderCompat, mapStatusLogCompat } from '../../common/compat/v31-compat'; +import { FulfillmentService } from '../fulfillment/fulfillment.service'; import type { Request } from 'express'; @Injectable() @@ -45,6 +47,8 @@ export class TradeService { private readonly partnerCityService: PartnerCityService, private readonly authService: AuthService, private readonly promoCodeService: PromoCodeService, + @Inject(forwardRef(() => FulfillmentService)) + private readonly fulfillmentService: FulfillmentService, ) {} async preview(userId: bigint, body: { productId: string; quantity: number; addressId?: string }) { @@ -268,8 +272,7 @@ export class TradeService { }); }); - await this.benefitService.grantOnOrderPaid(order.id); - await this.deliveryProvider.scheduleAutoAdvance(order.id); + await this.afterOrderPaid(order.id); this.analyticsService.trackOneSafe(userId, 'USER_H5', { eventName: 'pay_success', @@ -281,6 +284,15 @@ export class TradeService { return this.getOrder(userId, orderId); } + private async afterOrderPaid(orderId: bigint) { + await this.benefitService.grantOnOrderPaid(orderId); + await this.fulfillmentService.dispatchAfterPay(orderId); + const refreshed = await this.prisma.order.findUnique({ where: { id: orderId } }); + if (refreshed?.status === 'PENDING_SHIP') { + await this.deliveryProvider.scheduleAutoAdvance(orderId); + } + } + /** 微信支付回调:幂等更新订单为已支付并发券 */ async handlePaySuccess(params: { orderNo: string; @@ -361,8 +373,7 @@ export class TradeService { const refreshed = await this.prisma.order.findUnique({ where: { id: order.id } }); if (refreshed?.payStatus === 'PAID') { - await this.benefitService.grantOnOrderPaid(order.id); - await this.deliveryProvider.scheduleAutoAdvance(order.id); + await this.afterOrderPaid(order.id); this.analyticsService.trackOneSafe(order.userId, 'USER_H5', { eventName: 'pay_success', refType: 'ORDER', @@ -401,6 +412,7 @@ export class TradeService { benefitCoupon: true, imageResource: true, product: true, + fulfillmentWarehouse: { select: { id: true, name: true } }, }, }); if (!order) throw new NotFoundException('订单不存在'); @@ -411,6 +423,15 @@ export class TradeService { return serializeBigInt(mapOrderCompat({ ...order, statusLogs: mapStatusLogCompat(statusLogs) })); } + async getOrderTrack(userId: bigint, orderId: bigint) { + const order = await this.prisma.order.findFirst({ + where: { id: orderId, userId }, + select: { id: true }, + }); + if (!order) throw new NotFoundException('订单不存在'); + return this.fulfillmentService.getOrderTrack(orderId); + } + async updateAddress(userId: bigint, orderId: bigint, body: Record) { const order = await this.prisma.order.findFirst({ where: { id: orderId, userId } }); if (!order) throw new NotFoundException('订单不存在'); @@ -492,7 +513,7 @@ export class TradeService { const [list, total] = await Promise.all([ this.prisma.order.findMany({ where, - include: { delivery: true, imageResource: true, user: { select: { phone: true, nickname: true } } }, + include: { delivery: true, imageResource: true, user: { select: { phone: true, nickname: true } }, fulfillmentWarehouse: { select: { id: true, name: true } } }, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, @@ -507,7 +528,7 @@ export class TradeService { const partnerOrderWhere = await this.partnerCityService.buildPartnerOrderWhere(primary.id); const order = await this.prisma.order.findFirst({ where: { id: orderId, ...partnerOrderWhere }, - include: { delivery: true, user: true, imageResource: true }, + include: { delivery: true, user: true, imageResource: true, fulfillmentWarehouse: { select: { id: true, name: true } } }, }); if (!order) throw new NotFoundException('订单不存在'); const statusLogs = await this.prisma.commonEvent.findMany({ @@ -517,6 +538,37 @@ export class TradeService { return serializeBigInt(mapOrderCompat({ ...order, statusLogs: mapStatusLogCompat(statusLogs) })); } + async partnerManualShip( + partnerAccountId: bigint, + orderId: bigint, + input: { logisticsCompany: string; trackingNo: string; manualQueryUrl?: string }, + ) { + const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId); + const warehouseIds = await this.partnerCityService.resolveManagedWarehouseIds(primary.id); + if (!warehouseIds.length) throw new BadRequestException('当前账号未绑定仓库'); + + await this.fulfillmentService.shipManualByWarehouse(orderId, warehouseIds, input); + this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', { + partnerAccountId: primary.id, + eventName: 'partner_order_ship', + refType: 'ORDER', + refId: orderId, + extraJson: { mode: 'manual' }, + }); + return this.getPartnerOrder(partnerAccountId, orderId); + } + + async getPartnerOrderTrack(partnerAccountId: bigint, orderId: bigint) { + const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId); + const partnerOrderWhere = await this.partnerCityService.buildPartnerOrderWhere(primary.id); + const order = await this.prisma.order.findFirst({ + where: { id: orderId, ...partnerOrderWhere }, + select: { id: true }, + }); + if (!order) throw new NotFoundException('订单不存在'); + return this.fulfillmentService.getOrderTrack(orderId); + } + async advanceDelivery(partnerAccountId: bigint, orderId: bigint, targetStatus: string) { const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId); const partnerOrderWhere = await this.partnerCityService.buildPartnerOrderWhere(primary.id); diff --git a/杜康好客-v3-PRD.md b/杜康好客-v3-PRD.md index 36fa540..e2a75b8 100644 --- a/杜康好客-v3-PRD.md +++ b/杜康好客-v3-PRD.md @@ -130,8 +130,8 @@ └─ 30 分钟未付取消 ``` -- **同城**:推小飞侠(已取货拍照→已发出→已送达拍照);送达未确认 24h 自动完成 -- **跨城**:推总部物流(到付);订单佣金归总部 +- **同城**:仓配履约——有仓且绑 API 承运商则自动推单(首期小飞侠);有仓选自管则管仓方手工填单;**无仓**则总部传统快递填单 +- **跨城**:总部传统快递到付填单;订单佣金归总部 - **现场提货**:支付后直接已完成;有现场推广码则订单佣金归码所属合伙人,无码归总部 ### 3.3 佣金与结算 @@ -207,10 +207,14 @@ | 破损退货 | 同意/驳回 | 负责仓取回→退款 | | 退货退款 | 同意/驳回 | 通知归属合伙人+负责仓取回→退款 | -### 3.7 城市多仓(Wave 3) +### 3.7 城市多仓与仓配(Wave 3) - 一城多仓;每仓最多关联 1 名管仓合伙人 -- 佣金与仓无关;仓用于工单协同 +- **仓配管理**(总部):注册第三方履约接口(小飞侠、京东、顺丰等);启用后仓库方可选择 +- **仓库设置**:履约方式 = API 自动推单(选已注册承运商)或 **自管**(手工填运单号 + 查询链接模板) +- 同城有仓订单支付后自动按仓配置推单;自管仓由管仓合伙人/总部代填单 +- 同城无仓 / 跨城:总部传统快递填单 +- 佣金与仓无关(订单佣金仍按 §3.3.1);仓用于履约与工单协同 - 未关联合伙人的仓 → 总部直派 ### 3.8 弱网核销兜底(Wave 3 · OPT-006)