diff --git a/apps/admin-web/src/App.tsx b/apps/admin-web/src/App.tsx index 1ab9e14..0e373ea 100644 --- a/apps/admin-web/src/App.tsx +++ b/apps/admin-web/src/App.tsx @@ -30,6 +30,7 @@ import ProductsPage from './pages/ProductsPage'; import ProductDetailTemplatesPage from './pages/ProductDetailTemplatesPage'; import ResourcesPage from './pages/ResourcesPage'; import StoreBillsPage from './pages/StoreBillsPage'; +import StoreWithdrawalsPage from './pages/StoreWithdrawalsPage'; import PartnerBillsPage from './pages/PartnerBillsPage'; import WineryBillsPage from './pages/WineryBillsPage'; import LogisticsBillsPage from './pages/LogisticsBillsPage'; @@ -96,11 +97,13 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/admin-web/src/layouts/AdminLayout.tsx b/apps/admin-web/src/layouts/AdminLayout.tsx index 8406a2a..b8240b9 100644 --- a/apps/admin-web/src/layouts/AdminLayout.tsx +++ b/apps/admin-web/src/layouts/AdminLayout.tsx @@ -74,6 +74,7 @@ const MENU_ITEMS: MenuProps['items'] = [ label: '财务', children: [ { key: '/finance/store-bills', label: '门店账单' }, + { key: '/finance/store-withdrawals', label: '门店提现审' }, { key: '/finance/partner-bills', label: '合伙人账单' }, { key: '/finance/winery-bills', label: '酒厂账单' }, { key: '/finance/logistics-bills', label: '物流对账' }, @@ -157,6 +158,7 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean { '/fulfillment-providers': 'partners', 'finance-group': 'finance', '/finance/store-bills': 'finance', + '/finance/store-withdrawals': 'finance', '/finance/partner-bills': 'finance', '/finance/winery-bills': 'finance', '/finance/logistics-bills': 'finance', diff --git a/apps/admin-web/src/lib/api.ts b/apps/admin-web/src/lib/api.ts index d7f5c9c..8abd79b 100644 --- a/apps/admin-web/src/lib/api.ts +++ b/apps/admin-web/src/lib/api.ts @@ -85,6 +85,8 @@ export type DashboardStats = { pendingBills?: number; pendingPartnerDraftBills?: number; openTickets?: number; + pendingStoreWithdrawals?: number; + overdueStoreWithdrawals?: number; ordersByStatus: Array<{ status: string; count: number }>; }; diff --git a/apps/admin-web/src/lib/storeCreate.ts b/apps/admin-web/src/lib/storeCreate.ts index 37fa198..555d155 100644 --- a/apps/admin-web/src/lib/storeCreate.ts +++ b/apps/admin-web/src/lib/storeCreate.ts @@ -29,6 +29,7 @@ export type StoreCreateForm = { settlementRate?: number; visibilityWhitelistEnabled?: boolean; visibilityPhones?: string[]; + withdrawWhitelistEnabled?: boolean; }; const PHONE_RE = /^1\d{10}$/; diff --git a/apps/admin-web/src/pages/DashboardPage.tsx b/apps/admin-web/src/pages/DashboardPage.tsx index 041a715..9a2d77c 100644 --- a/apps/admin-web/src/pages/DashboardPage.tsx +++ b/apps/admin-web/src/pages/DashboardPage.tsx @@ -717,6 +717,26 @@ export default function DashboardPage() { /> + + 去处理} + > + 0 ? '#cf1322' : (stats?.pendingStoreWithdrawals ?? 0) > 0 ? '#fa8c16' : undefined, + }} + /> + + {(stats?.overdueStoreWithdrawals ?? 0) > 0 + ? `超时未审 ${stats?.overdueStoreWithdrawals} 笔(FIN-003)` + : '工作日 T+0 审完'} + + + diff --git a/apps/admin-web/src/pages/StoreWithdrawalsPage.tsx b/apps/admin-web/src/pages/StoreWithdrawalsPage.tsx new file mode 100644 index 0000000..3c163e3 --- /dev/null +++ b/apps/admin-web/src/pages/StoreWithdrawalsPage.tsx @@ -0,0 +1,394 @@ +import { useEffect, useState } from 'react'; +import { + Button, + DatePicker, + Descriptions, + Drawer, + Form, + Input, + Modal, + Select, + Space, + Table, + Tag, + Typography, + message, +} from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import type { Dayjs } from 'dayjs'; +import { STORE_WITHDRAW_STATUS_LABELS, type StoreWithdrawStatus } from '@dukang/shared-types'; +import { request, type Paginated } from '../lib/api'; +import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants'; +import { useAdminList } from '../lib/useAdminList'; + +type Row = { + id: string; + withdrawNo: string; + amount: number; + payoutCount: number; + status: StoreWithdrawStatus; + appliedAt: string; + reviewedAt?: string | null; + paidAt?: string | null; + paymentRef?: string | null; + rejectReason?: string | null; + overdue?: boolean; + store?: { id: string; name: string; cityName: string; phone?: string }; +}; + +type StoreOption = { id: string; name: string; phone: string }; + +const STATUS_COLORS: Record = { + PENDING_REVIEW: 'orange', + REJECTED: 'red', + PAID: 'green', +}; + +export default function StoreWithdrawalsPage() { + const [form] = Form.useForm(); + const [filters, setFilters] = useState>({ status: 'PENDING_REVIEW' }); + const [stores, setStores] = useState([]); + const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( + '/admin/store-withdrawals', + () => { + const qs = new URLSearchParams(); + if (filters.status) qs.set('status', filters.status); + if (filters.storeId) qs.set('storeId', filters.storeId); + if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom); + if (filters.dateTo) qs.set('dateTo', filters.dateTo); + return qs; + }, + [filters], + ); + const [detail, setDetail] = useState | null>(null); + const [drawerOpen, setDrawerOpen] = useState(false); + const [overdueSummary, setOverdueSummary] = useState<{ + pendingCount: number; + overdueCount: number; + } | null>(null); + + useEffect(() => { + void request>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`) + .then((res) => setStores(res.items)) + .catch(() => {}); + void request<{ pendingCount: number; overdueCount: number }>( + '/admin/store-withdrawals/overdue-summary', + ) + .then(setOverdueSummary) + .catch(() => {}); + }, []); + + async function openDetail(id: string) { + const d = await request>(`/admin/store-withdrawals/${id}`); + setDetail(d); + setDrawerOpen(true); + } + + function approve(id: string) { + let paymentRef = ''; + Modal.confirm({ + title: '审核通过并标记已结算?', + content: ( + { + paymentRef = e.target.value; + }} + /> + ), + okText: '通过并结算', + cancelText: '取消', + onOk: async () => { + await request(`/admin/store-withdrawals/${id}/approve`, { + method: 'POST', + body: JSON.stringify({ paymentRef: paymentRef.trim() || undefined }), + }); + message.success('已通过并标记已结算'); + setDrawerOpen(false); + reload(); + void request<{ pendingCount: number; overdueCount: number }>( + '/admin/store-withdrawals/overdue-summary', + ) + .then(setOverdueSummary) + .catch(() => {}); + }, + }); + } + + function reject(id: string) { + let reason = ''; + Modal.confirm({ + title: '驳回提现申请?', + content: ( + { + reason = e.target.value; + }} + /> + ), + okText: '确认驳回', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + if (!reason.trim()) { + message.error('请填写驳回理由'); + throw new Error('reason required'); + } + await request(`/admin/store-withdrawals/${id}/reject`, { + method: 'POST', + body: JSON.stringify({ reason: reason.trim() }), + }); + message.success('已驳回'); + setDrawerOpen(false); + reload(); + }, + }); + } + + const columns: ColumnsType = [ + { + title: '提现单号', + dataIndex: 'withdrawNo', + width: 180, + render: (v, row) => ( + + void openDetail(row.id)}>{v} + {row.overdue ? 超时 : null} + + ), + }, + { + title: '门店', + dataIndex: ['store', 'name'], + width: 160, + render: (_, row) => ( +
+
{row.store?.name || '—'}
+ + {row.store?.cityName} {row.store?.phone} + +
+ ), + }, + { + title: '金额', + dataIndex: 'amount', + width: 110, + render: (v) => `¥${Number(v).toFixed(2)}`, + }, + { title: '明细笔数', dataIndex: 'payoutCount', width: 90 }, + { + title: '状态', + dataIndex: 'status', + width: 100, + render: (v: StoreWithdrawStatus) => ( + {STORE_WITHDRAW_STATUS_LABELS[v] ?? v} + ), + }, + { + title: '申请时间', + dataIndex: 'appliedAt', + width: 170, + render: (v) => fmtTime(v), + }, + { + title: '操作', + key: 'actions', + width: 180, + fixed: 'right', + render: (_, row) => ( + + + {row.status === 'PENDING_REVIEW' ? ( + <> + + + + ) : null} + + ), + }, + ]; + + const detailItems = (detail?.items as Array> | undefined) ?? []; + const storeAccount = detail?.storeAccount as + | { + name?: string; + phone?: string; + bankAccountName?: string; + bankAccountNo?: string; + bankBranch?: string; + } + | undefined; + + return ( +
+ + 门店提现审 + + {overdueSummary ? ( + + 待审 {overdueSummary.pendingCount} 笔 + {overdueSummary.overdueCount > 0 ? ( + + {' '} + · 超时未审 {overdueSummary.overdueCount} 笔(FIN-003) + + ) : null} + + ) : null} + +
{ + const range = v.range as [Dayjs, Dayjs] | undefined; + setFilters({ + status: v.status || '', + storeId: v.storeId || '', + dateFrom: range?.[0]?.format('YYYY-MM-DD') || '', + dateTo: range?.[1]?.format('YYYY-MM-DD') || '', + }); + }} + > + + ({ + value: s.id, + label: `${s.name} (${s.phone})`, + }))} + /> + + + + + + + +
+ + (row.overdue ? 'ant-table-row-selected' : '')} + pagination={{ + current: page, + pageSize, + total: data?.total ?? 0, + showSizeChanger: true, + onChange: (p, ps) => { + setPage(p); + setPageSize(ps); + }, + }} + /> + + setDrawerOpen(false)} + extra={ + detail?.status === 'PENDING_REVIEW' ? ( + + + + + ) : null + } + > + {detail ? ( + <> + + {String(detail.withdrawNo)} + + + {STORE_WITHDRAW_STATUS_LABELS[detail.status as StoreWithdrawStatus] ?? + String(detail.status)} + + {detail.overdue ? 超时 : null} + + ¥{Number(detail.amount).toFixed(2)} + {String(detail.payoutCount)} + {fmtTime(String(detail.appliedAt))} + {detail.rejectReason ? ( + {String(detail.rejectReason)} + ) : null} + {detail.paymentRef ? ( + {String(detail.paymentRef)} + ) : null} + + {storeAccount?.bankAccountName || '—'} + + + {storeAccount?.bankAccountNo || '—'} + + {storeAccount?.bankBranch || '—'} + + + + 关联结算明细 + +
String((r as { id?: string }).id)} + pagination={false} + dataSource={detailItems} + columns={[ + { + title: '核销单号', + render: (_, r) => { + const payout = (r as { storePayout?: { redeemRecord?: { redeemNo?: string } } }) + .storePayout; + return payout?.redeemRecord?.redeemNo || '—'; + }, + }, + { + title: '结算额', + render: (_, r) => { + const payout = (r as { storePayout?: { payoutAmount?: number } }).storePayout; + return `¥${Number(payout?.payoutAmount ?? 0).toFixed(2)}`; + }, + }, + ]} + /> + + ) : null} + + + ); +} diff --git a/apps/admin-web/src/pages/StoresPage.tsx b/apps/admin-web/src/pages/StoresPage.tsx index 1785d61..0e710f5 100644 --- a/apps/admin-web/src/pages/StoresPage.tsx +++ b/apps/admin-web/src/pages/StoresPage.tsx @@ -373,6 +373,7 @@ type StoreRow = { coverUrl: string | null; createdAt: string; visibilityWhitelistEnabled?: boolean; + withdrawWhitelistEnabled?: boolean; visibilityPhones?: string[]; cityRef?: { name: string; code: string }; partner?: { companyName: string }; @@ -592,6 +593,7 @@ export default function StoresPage() { bankAccountNo: account?.bankAccountNo || undefined, bankBranch: account?.bankBranch || undefined, visibilityWhitelistEnabled: !!d.visibilityWhitelistEnabled, + withdrawWhitelistEnabled: !!d.withdrawWhitelistEnabled, visibilityPhones: Array.isArray(d.visibilityPhones) ? (d.visibilityPhones as string[]) : [], @@ -635,6 +637,7 @@ export default function StoresPage() { bankAccountNo: v.bankAccountNo ?? null, bankBranch: v.bankBranch ?? null, visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled, + withdrawWhitelistEnabled: !!v.withdrawWhitelistEnabled, visibilityPhones: ((v.visibilityPhones as string[] | undefined) ?? []) .map((p) => String(p || '').replace(/\D/g, '').trim()) .filter(Boolean), @@ -811,6 +814,7 @@ export default function StoresPage() { bankBranch: values.bankBranch.trim(), settlementRate: Number(values.settlementRate ?? 60) / 100, visibilityWhitelistEnabled: !!values.visibilityWhitelistEnabled, + withdrawWhitelistEnabled: !!values.withdrawWhitelistEnabled, visibilityPhones: (values.visibilityPhones ?? []) .map((p: string) => String(p || '').replace(/\D/g, '').trim()) .filter(Boolean), @@ -1180,6 +1184,14 @@ export default function StoresPage() { + + + @@ -1426,6 +1438,14 @@ export default function StoresPage() { + + + } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/h5-shop/src/pages/MinePage.tsx b/apps/h5-shop/src/pages/MinePage.tsx index 72a0bf0..62a489e 100644 --- a/apps/h5-shop/src/pages/MinePage.tsx +++ b/apps/h5-shop/src/pages/MinePage.tsx @@ -141,6 +141,10 @@ export default function MinePage() { 切换门店 ) : null} + {profile?.isPrimary ? ( +

+ 结算提现 +

+ + +
+
+
+
+

可提未出账余额

+

+ ¥ {formatMoney(summary?.availableAmount ?? 0)} +

+
+
+

今日剩余额度

+

+ ¥ {formatMoney(summary?.remainingDailyLimit ?? 0)} +

+
+
+

+ + info + + 单日上限 ¥{formatMoney(summary?.dailyLimit ?? 5000)} + {summary && !summary.whitelistEnabled ? ' · 未开通未出账提现白名单' : ''} + {summary?.hasPendingRequest ? ' · 已有待审核申请' : ''} +

+
+ + {summary && !summary.isPrimary ? ( +

仅主账号可申请提现,店员可查看记录

+ ) : ( + + )} + + {msg ?

{msg}

: null} + + + +
+

提现记录

+ 共 {filtered.length} 笔 +
+ + {filtered.length === 0 ? ( +

暂无提现记录

+ ) : ( +
+ {filtered.map((r) => { + const status = r.status as StoreWithdrawStatus; + const badgeClass = + status === 'PAID' ? 'paid' : status === 'REJECTED' ? 'rejected' : 'pending'; + return ( +
+
+
+
+ + 单号 + + {r.withdrawNo} +
+

+ 申请时间:{' '} + {new Date(r.appliedAt) + .toLocaleString('zh-CN', { hour12: false }) + .slice(0, 16)} +

+
+ + {STORE_WITHDRAW_STATUS_LABELS[status] ?? status} + +
+
+
+

提现金额

+

¥{formatMoney(Number(r.amount))}

+
+
+

明细笔数

+

{r.payoutCount} 笔

+
+
+ {status === 'REJECTED' && r.rejectReason ? ( +
+

驳回原因: {r.rejectReason}

+
+ ) : null} + {status === 'PAID' && r.paidAt ? ( +
+

+ 结算时间:{' '} + {new Date(r.paidAt).toLocaleString('zh-CN', { hour12: false }).slice(0, 16)} +

+
+ ) : null} +
+ ); + })} +
+ )} +
+ + ); +} diff --git a/apps/h5-shop/src/styles.css b/apps/h5-shop/src/styles.css index 3144dc8..5c7e8bf 100644 --- a/apps/h5-shop/src/styles.css +++ b/apps/h5-shop/src/styles.css @@ -1714,6 +1714,46 @@ color: var(--color-aged-amber); } +.shop-record-badge.rejected { + background: rgba(180, 35, 24, 0.1); + color: var(--color-error-red, #b42318); +} + +.shop-withdraw-back { + border: none; + background: transparent; + padding: 4px; + display: inline-flex; + align-items: center; + color: inherit; + cursor: pointer; +} + +.shop-withdraw-btn { + width: 100%; + margin-top: 12px; + height: 44px; + border: none; + border-radius: 12px; + background: var(--color-primary, #8b1a1a); + color: #fff; + font-size: 16px; + font-weight: 600; + cursor: pointer; +} + +.shop-withdraw-btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.shop-withdraw-msg { + margin-top: 10px; + font-size: 13px; + color: var(--color-aged-amber); + text-align: center; +} + .shop-record-amounts { display: grid; grid-template-columns: 1fr 1fr; diff --git a/apps/mini-user/src/components/StoreRedeemMarquee.tsx b/apps/mini-user/src/components/StoreRedeemMarquee.tsx index 5767e98..eeeb542 100644 --- a/apps/mini-user/src/components/StoreRedeemMarquee.tsx +++ b/apps/mini-user/src/components/StoreRedeemMarquee.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState, type CSSProperties } from 'react'; +import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; import { View, Text } from '@tarojs/components'; import Taro, { useReady } from '@tarojs/taro'; @@ -6,7 +6,12 @@ type StoreRedeemMarqueeProps = { lines: string[]; }; -/** 估算单行像素宽(12px 字号,DOM 测量失败时兜底) */ +/** 飘动速度 px/s */ +const FLY_SPEED = 58; +const MIN_FLY_MS = 2200; +const PAUSE_MIN_MS = 1000; +const PAUSE_MAX_MS = 5000; + function estimateTextWidth(text: string): number { let w = 0; for (const ch of text) { @@ -15,111 +20,204 @@ function estimateTextWidth(text: string): number { return Math.max(Math.ceil(w), 80); } +function randomPauseMs() { + return PAUSE_MIN_MS + Math.floor(Math.random() * (PAUSE_MAX_MS - PAUSE_MIN_MS + 1)); +} + +function uid(prefix: string) { + return `${prefix}${Math.random().toString(36).slice(2, 10)}`; +} + +function createQuery() { + const page = Taro.getCurrentInstance().page; + return page ? Taro.createSelectorQuery().in(page) : Taro.createSelectorQuery(); +} + /** - * 门店核销动态走马灯(H5 + 微信小程序) - * - * - 用 Text 测自然内容宽(weapp 的 View 默认撑满父级,不能用来测宽) - * - 两段显式同宽 + 轨道宽 = 2×段宽,CSS translateX(-50%) 无缝循环 - * - 测量失败时用字宽估算,仍能滚动 + * 核销走马灯:单条从右向左位移飞出,间隔 1~5 秒随机再播下一条。 + * H5 / 微信小程序均用 JS translateX,不依赖 CSS animation / Intl。 */ export default function StoreRedeemMarquee({ lines }: StoreRedeemMarqueeProps) { - const segment = useMemo(() => { - const joined = lines - .map((s) => String(s || '').trim()) - .filter(Boolean) - .join('  '); - return joined ? `${joined}  ` : ''; - }, [lines]); - - const measureId = useMemo( - () => `sm${Math.random().toString(36).slice(2, 10)}`, - [segment], + const items = useMemo( + () => + lines + .map((s) => String(s || '').trim()) + .filter(Boolean), + [lines], ); - const [segWidth, setSegWidth] = useState(0); + const rootIdRef = useRef(uid('smr')); + const textIdRef = useRef(uid('smt')); + const indexRef = useRef(0); + const boxWidthRef = useRef(0); + const itemsKey = items.join('\n'); - const measure = useCallback(() => { - if (!segment) { - setSegWidth(0); - return; - } - const fallback = estimateTextWidth(segment); - Taro.nextTick(() => { - try { - Taro.createSelectorQuery() - .select(`#${measureId}`) - .boundingClientRect() - .exec((res) => { - const w = Number(res?.[0]?.width || 0); - // weapp 若误测成容器宽,会接近视口;用估算兜底纠正 - if (w > 8 && w < fallback * 2.5) { - setSegWidth(Math.ceil(w)); - } else { - setSegWidth(fallback); - } - }); - } catch { - setSegWidth(fallback); - } + const [displayIndex, setDisplayIndex] = useState(0); + const [offset, setOffset] = useState(9999); + const [ready, setReady] = useState(false); + + const measureBox = () => + new Promise((resolve) => { + Taro.nextTick(() => { + try { + createQuery() + .select(`#${rootIdRef.current}`) + .boundingClientRect() + .exec((res) => { + const box = Number(res?.[0]?.width || 0); + const next = box > 8 ? box : boxWidthRef.current || 300; + boxWidthRef.current = next; + resolve(next); + }); + } catch { + resolve(boxWidthRef.current || 300); + } + }); + }); + + const measureText = (text: string) => + new Promise((resolve) => { + const fallback = estimateTextWidth(text); + Taro.nextTick(() => { + try { + createQuery() + .select(`#${textIdRef.current}`) + .boundingClientRect() + .exec((res) => { + const tw = Number(res?.[0]?.width || 0); + if (tw > 8 && tw < fallback * 3) resolve(Math.ceil(tw)); + else resolve(fallback); + }); + } catch { + resolve(fallback); + } + }); }); - }, [segment, measureId]); useReady(() => { - measure(); + void measureBox().then(() => setReady(true)); }); useEffect(() => { - if (!segment) { - setSegWidth(0); - return; - } - setSegWidth(0); - measure(); - const t1 = setTimeout(measure, 100); - const t2 = setTimeout(measure, 350); - const t3 = setTimeout(() => { - setSegWidth((prev) => (prev > 0 ? prev : estimateTextWidth(segment))); - }, 600); - return () => { - clearTimeout(t1); - clearTimeout(t2); - clearTimeout(t3); - }; - }, [segment, measure]); + if (!items.length) return; - if (!segment) return null; + let cancelled = false; + const waiters = new Set>(); + let rafId = 0; + let tickTimer: ReturnType | undefined; - const running = segWidth > 0; - const durationSec = Math.max(10, Math.min(90, segWidth / 42)); - const trackStyle: CSSProperties | undefined = running - ? { - width: `${segWidth * 2}px`, - animationDuration: `${durationSec}s`, - // 微信 / Safari - WebkitAnimationDuration: `${durationSec}s`, + const sleep = (ms: number) => + new Promise((resolve) => { + const id = setTimeout(() => { + waiters.delete(id); + resolve(); + }, ms); + waiters.add(id); + }); + + const clearAnim = () => { + if (rafId) { + cancelAnimationFrame(rafId); + rafId = 0; } - : undefined; - const segStyle: CSSProperties | undefined = running - ? { width: `${segWidth}px` } - : undefined; + if (tickTimer) { + clearInterval(tickTimer); + tickTimer = undefined; + } + }; + + const fly = (start: number, end: number, durationMs: number) => + new Promise((resolve) => { + const began = Date.now(); + setOffset(start); + + const step = () => { + if (cancelled) { + clearAnim(); + resolve(); + return; + } + const t = Math.min(1, (Date.now() - began) / durationMs); + setOffset(start + (end - start) * t); + if (t >= 1) { + clearAnim(); + resolve(); + return; + } + if (typeof requestAnimationFrame === 'function') { + rafId = requestAnimationFrame(step); + } + }; + + clearAnim(); + if (typeof requestAnimationFrame === 'function') { + rafId = requestAnimationFrame(step); + } else { + tickTimer = setInterval(step, 32); + } + }); + + const loop = async () => { + indexRef.current = 0; + setDisplayIndex(0); + await sleep(60); + if (cancelled) return; + + while (!cancelled) { + if (!items.length) break; + + const idx = indexRef.current % items.length; + const text = items[idx]; + + setDisplayIndex(idx); + setOffset(9999); + await sleep(48); + if (cancelled) break; + + const box = await measureBox(); + const textW = await measureText(text); + if (cancelled) break; + + const start = box; + const end = -textW; + const distance = start - end; + const durationMs = Math.max(MIN_FLY_MS, Math.round((distance / FLY_SPEED) * 1000)); + + await fly(start, end, durationMs); + if (cancelled) break; + + // 飞出后随机停留 1~5 秒,再播下一条 + await sleep(randomPauseMs()); + if (cancelled) break; + + indexRef.current = (idx + 1) % items.length; + } + }; + + void loop(); + + return () => { + cancelled = true; + clearAnim(); + waiters.forEach(clearTimeout); + waiters.clear(); + }; + }, [itemsKey, items]); + + if (!items.length) return null; + + const current = items[displayIndex] || items[0]; + const textStyle: CSSProperties = { + transform: `translateX(${offset}px)`, + WebkitTransform: `translateX(${offset}px)`, + opacity: ready ? 1 : 0, + }; return ( - - - - {segment} - - - {segment} - - + + + {current} + ); } diff --git a/apps/mini-user/src/styles/store-detail.css b/apps/mini-user/src/styles/store-detail.css index b096314..05824d0 100644 --- a/apps/mini-user/src/styles/store-detail.css +++ b/apps/mini-user/src/styles/store-detail.css @@ -136,60 +136,23 @@ overflow: hidden; height: 40px; box-sizing: border-box; -} - -.store-detail-marquee-track { + position: relative; display: flex; - flex-direction: row; - flex-wrap: nowrap; align-items: center; - height: 20px; - transform: translateX(0); -} - -.store-detail-marquee-track.is-running { - animation-name: store-detail-marquee-scroll; - animation-timing-function: linear; - animation-iteration-count: infinite; - -webkit-animation-name: store-detail-marquee-scroll; - -webkit-animation-timing-function: linear; - -webkit-animation-iteration-count: infinite; -} - -.store-detail-marquee-seg { - display: inline-block; - flex: none; - box-sizing: content-box; - white-space: nowrap; - overflow: hidden; - vertical-align: top; } .store-detail-marquee-text { + position: absolute; + left: 0; + top: 50%; + margin-top: -10px; + display: inline-block; white-space: nowrap; font-size: 12px; line-height: 20px; color: #a61d24; -} - -@keyframes store-detail-marquee-scroll { - 0% { - transform: translateX(0); - } - 100% { - transform: translateX(-50%); - } -} - -@-webkit-keyframes store-detail-marquee-scroll { - 0% { - -webkit-transform: translateX(0); - transform: translateX(0); - } - 100% { - -webkit-transform: translateX(-50%); - transform: translateX(-50%); - } + will-change: transform; + pointer-events: none; } .store-detail-section { diff --git a/packages/domain/src/index.test.ts b/packages/domain/src/index.test.ts index 4b76900..5d2f8d5 100644 --- a/packages/domain/src/index.test.ts +++ b/packages/domain/src/index.test.ts @@ -15,6 +15,9 @@ import { classifyRedeemClientError, generateRedeemPendingNo, REDEEM_WEAKNET_FAIL_THRESHOLD, + sumUnbilledPayoutAmount, + validateStoreWithdraw, + pickPayoutsForWithdrawAmount, } from './index'; describe('calcBenefitAmount', () => { @@ -235,3 +238,58 @@ describe('generateRedeemPendingNo', () => { expect(REDEEM_WEAKNET_FAIL_THRESHOLD).toBe(5); }); }); + +describe('sumUnbilledPayoutAmount', () => { + it('sums payout amounts', () => { + expect(sumUnbilledPayoutAmount([{ payoutAmount: 60 }, { payoutAmount: 40.5 }])).toBe(100.5); + }); +}); + +describe('validateStoreWithdraw', () => { + const base = { + whitelistEnabled: true, + availableAmount: 1000, + requestAmount: 200, + todayApplied: 0, + dailyLimit: 5000, + hasPendingRequest: false, + hasBankAccount: true, + }; + + it('rejects non-whitelist stores', () => { + expect(validateStoreWithdraw({ ...base, whitelistEnabled: false }).ok).toBe(false); + }); + + it('rejects when daily limit exceeded', () => { + expect( + validateStoreWithdraw({ ...base, todayApplied: 4900, requestAmount: 200, dailyLimit: 5000 }).ok, + ).toBe(false); + }); + + it('rejects pending request / missing bank / over available', () => { + expect(validateStoreWithdraw({ ...base, hasPendingRequest: true }).ok).toBe(false); + expect(validateStoreWithdraw({ ...base, hasBankAccount: false }).ok).toBe(false); + expect(validateStoreWithdraw({ ...base, requestAmount: 1001 }).ok).toBe(false); + }); + + it('accepts valid request', () => { + expect(validateStoreWithdraw(base).ok).toBe(true); + }); +}); + +describe('pickPayoutsForWithdrawAmount', () => { + it('picks FIFO exact match', () => { + const rows = [{ payoutAmount: 60 }, { payoutAmount: 40 }, { payoutAmount: 30 }]; + const r = pickPayoutsForWithdrawAmount(rows, 100); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.selected).toHaveLength(2); + expect(r.amount).toBe(100); + } + }); + + it('rejects non-exact match', () => { + const rows = [{ payoutAmount: 60 }, { payoutAmount: 40 }]; + expect(pickPayoutsForWithdrawAmount(rows, 50).ok).toBe(false); + }); +}); diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index 971efd6..99d5957 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -96,6 +96,85 @@ export function calcRedeemSettleAmount(amount: number, settlementRate: number): return Math.round(amount * settlementRate * 100) / 100; } +export function sumUnbilledPayoutAmount(payouts: Array<{ payoutAmount: number }>): number { + return Math.round(payouts.reduce((sum, p) => sum + Number(p.payoutAmount || 0), 0) * 100) / 100; +} + +export type ValidateStoreWithdrawInput = { + whitelistEnabled: boolean; + availableAmount: number; + requestAmount: number; + todayApplied: number; + dailyLimit: number; + hasPendingRequest: boolean; + hasBankAccount: boolean; +}; + +/** 门店未出账提现护栏(FIN-001/002 + 幂等/账户) */ +export function validateStoreWithdraw( + input: ValidateStoreWithdrawInput, +): { ok: boolean; message?: string } { + if (!input.whitelistEnabled) { + return { ok: false, message: '该门店未开通未出账提现(需总部白名单)' }; + } + if (!input.hasBankAccount) { + return { ok: false, message: '请先完善入驻收款账户后再提现' }; + } + if (input.hasPendingRequest) { + return { ok: false, message: '已有待审核提现申请,请等待处理完成' }; + } + if (!(input.requestAmount > 0)) { + return { ok: false, message: '提现金额必须大于 0' }; + } + if (input.requestAmount > input.availableAmount + 1e-9) { + return { ok: false, message: '提现金额不能超过可提未出账余额' }; + } + const remaining = Math.round((input.dailyLimit - input.todayApplied) * 100) / 100; + if (remaining <= 0) { + return { ok: false, message: `已达单店单日提现上限 ¥${input.dailyLimit.toFixed(2)}` }; + } + if (input.requestAmount > remaining + 1e-9) { + return { + ok: false, + message: `超过单日剩余额度 ¥${remaining.toFixed(2)}(上限 ¥${input.dailyLimit.toFixed(2)})`, + }; + } + return { ok: true }; +} + +/** + * 按 FIFO 选取未出账 payout,使合计尽量等于 targetAmount(不超过目标)。 + * 若无法精确凑齐,返回合计 ≤ target 的最长前缀子集。 + */ +export function pickPayoutsForWithdrawAmount( + payoutsAsc: T[], + targetAmount: number, +): { ok: true; selected: T[]; amount: number } | { ok: false; message: string } { + if (!(targetAmount > 0)) return { ok: false, message: '提现金额必须大于 0' }; + const selected: T[] = []; + let sum = 0; + for (const p of payoutsAsc) { + const next = Math.round((sum + Number(p.payoutAmount)) * 100) / 100; + if (next <= targetAmount + 1e-9) { + selected.push(p); + sum = next; + if (Math.abs(sum - targetAmount) < 1e-9) break; + } else { + break; + } + } + if (!selected.length) { + return { ok: false, message: '无可匹配的未出账结算明细,请调整提现金额' }; + } + if (Math.abs(sum - targetAmount) > 1e-9) { + return { + ok: false, + message: `无法按 ¥${targetAmount.toFixed(2)} 精确匹配明细,请按可提总额全额提现或调整金额`, + }; + } + return { ok: true, selected, amount: sum }; +} + const TIME_HM_RE = /^([01]\d|2[0-3]):([0-5]\d)$/; export type BusinessHoursSegment = { open: string; close: string }; diff --git a/packages/shared-types/src/hq-permissions.ts b/packages/shared-types/src/hq-permissions.ts index d092923..953ee28 100644 --- a/packages/shared-types/src/hq-permissions.ts +++ b/packages/shared-types/src/hq-permissions.ts @@ -32,6 +32,7 @@ export const HQ_PERMISSION_CATALOG = [ { key: 'system_settings_app', label: '应用链接', group: '系统设置' }, { key: 'system_settings_deploy', label: '发布部署', group: '系统设置' }, { key: 'system_settings_winery_bank', label: '酒厂银行账户', group: '系统设置' }, + { key: 'system_settings_finance', label: '财务结算', group: '系统设置' }, ] as const; export type HqPermissionKey = (typeof HQ_PERMISSION_CATALOG)[number]['key']; @@ -61,6 +62,7 @@ export const SYSTEM_CONFIG_GROUP_PERMISSION: Record = { app: 'system_settings_app', deploy: 'system_settings_deploy', winery_bank: 'system_settings_winery_bank', + finance: 'system_settings_finance', }; export const SYSTEM_SETTINGS_PERMISSION_KEYS = Object.values( @@ -127,6 +129,7 @@ export const HQ_ROLE_DEFAULT_PERMISSIONS: Record = { 'invoices', 'logs', 'system_settings_winery_bank', + 'system_settings_finance', ], CUSTOMER_SERVICE: [ 'dashboard', diff --git a/packages/shared-types/src/settlement.ts b/packages/shared-types/src/settlement.ts index c7f66c3..64361b8 100644 --- a/packages/shared-types/src/settlement.ts +++ b/packages/shared-types/src/settlement.ts @@ -1,5 +1,49 @@ export type FinancePayStatus = 'UNPAID' | 'PAID'; +/** 门店未出账提现单日上限默认值(FIN-002,可配置) */ +export const DEFAULT_STORE_WITHDRAW_DAILY_LIMIT = 5000; + +export type StoreWithdrawStatus = 'PENDING_REVIEW' | 'REJECTED' | 'PAID'; + +export const STORE_WITHDRAW_STATUS_LABELS: Record = { + PENDING_REVIEW: '待审核', + REJECTED: '已驳回', + PAID: '已结算', +}; + +export interface StoreWithdrawBankAccountDto { + bankAccountName?: string | null; + bankAccountNo?: string | null; + bankBranch?: string | null; +} + +export interface StoreWithdrawSummaryDto { + availableAmount: number; + pendingReviewAmount: number; + todayAppliedAmount: number; + dailyLimit: number; + remainingDailyLimit: number; + whitelistEnabled: boolean; + isPrimary: boolean; + hasBankAccount: boolean; + hasPendingRequest: boolean; + bankAccount?: StoreWithdrawBankAccountDto | null; +} + +export interface StoreWithdrawRequestDto { + id: string; + withdrawNo: string; + storeId: string; + amount: number; + payoutCount: number; + status: StoreWithdrawStatus; + rejectReason?: string | null; + appliedAt: string; + reviewedAt?: string | null; + paidAt?: string | null; + paymentRef?: string | null; +} + export interface StorePayoutDto { id: string; redeemAmount: number; diff --git a/scripts/smoke-v3.mjs b/scripts/smoke-v3.mjs index 266d560..78b935e 100644 --- a/scripts/smoke-v3.mjs +++ b/scripts/smoke-v3.mjs @@ -135,38 +135,107 @@ async function main() { '/shop/auth/login/sms', '/shop/auth/sms/send', ); - const shopMe = await req('SHOP_H5', '/shop/auth/me', { token: shopLogin.accessToken }); + let shopToken = shopLogin.accessToken; + let shopMe = await req('SHOP_H5', '/shop/auth/me', { token: shopToken }); if (!shopMe?.phone) throw new Error('Shop /shop/auth/me failed'); const refreshed = await req('SHOP_H5', '/shop/auth/token/refresh', { method: 'POST', body: JSON.stringify({ refreshToken: shopLogin.refreshToken }), }); if (!refreshed?.accessToken) throw new Error('Shop token refresh failed'); + shopToken = refreshed.accessToken; + shopMe = await req('SHOP_H5', '/shop/auth/me', { token: shopToken }); + if (!shopMe.storeId && shopMe.stores?.[0]?.storeId) { + const selected = await req('SHOP_H5', '/shop/auth/select-store', { + method: 'POST', + token: shopToken, + body: JSON.stringify({ storeId: shopMe.stores[0].storeId }), + }); + shopToken = selected.accessToken || shopToken; + shopMe = await req('SHOP_H5', '/shop/auth/me', { token: shopToken }); + } + if (!shopMe.storeId) throw new Error('Shop storeId missing after select-store'); const preview = await req('SHOP_H5', '/shop/redeem/preview', { method: 'POST', - token: shopLogin.accessToken, + token: shopToken, body: JSON.stringify({ token: directToken.token }), }); if (!preview.amount) throw new Error('Redeem preview failed'); await req('SHOP_H5', '/shop/redeem/confirm', { method: 'POST', - token: shopLogin.accessToken, + token: shopToken, body: JSON.stringify({ token: directToken.token }), }); - console.log('9. Admin login + store payout'); + console.log('9. Store withdraw (OPT-010)'); const admin = await adminLogin(); - const payouts = await req('HQ_WEB', '/admin/store-payouts?status=PENDING', { - token: admin.accessToken, + const shopStoreId = shopMe.storeId || shopMe.stores?.[0]?.storeId; + if (!shopStoreId) throw new Error('Shop storeId missing after login'); + + const noWhitelistMsg = await expectFail('SHOP_H5', '/shop/withdraw', { + method: 'POST', + token: shopToken, + body: JSON.stringify({}), }); - if (!payouts.items?.length) throw new Error('Expected pending store payout'); - const payoutId = payouts.items[0].id; - await req('HQ_WEB', `/admin/store-payouts/${payoutId}/confirm`, { + if (!String(noWhitelistMsg).includes('白名单')) { + throw new Error(`Expected FIN-001 whitelist reject, got: ${noWhitelistMsg}`); + } + + await req('HQ_WEB', `/admin/stores/${shopStoreId}`, { + method: 'PUT', + token: admin.accessToken, + body: JSON.stringify({ withdrawWhitelistEnabled: true }), + }); + + const withdrawSummary = await req('SHOP_H5', '/shop/withdraw/summary', { + token: shopToken, + }); + if (!(withdrawSummary.availableAmount > 0)) { + throw new Error('Expected available withdraw amount after redeem'); + } + if (!withdrawSummary.whitelistEnabled) { + throw new Error('Expected withdraw whitelist enabled'); + } + + const applied = await req('SHOP_H5', '/shop/withdraw', { + method: 'POST', + token: shopToken, + body: JSON.stringify({}), + }); + if (applied.status !== 'PENDING_REVIEW') { + throw new Error(`Expected PENDING_REVIEW withdraw, got ${applied.status}`); + } + + const pendingDup = await expectFail('SHOP_H5', '/shop/withdraw', { + method: 'POST', + token: shopToken, + body: JSON.stringify({}), + }); + if (!String(pendingDup).includes('待审核')) { + throw new Error(`Expected pending-request reject, got: ${pendingDup}`); + } + + await req('HQ_WEB', `/admin/store-withdrawals/${applied.id}/approve`, { method: 'POST', token: admin.accessToken, - body: JSON.stringify({ remark: 'smoke confirm' }), + body: JSON.stringify({ paymentRef: 'smoke-withdraw' }), }); + const withdrawList = await req('SHOP_H5', '/shop/withdraw/requests?status=PAID', { + token: shopToken, + }); + const settled = withdrawList.items?.find((w) => w.id === applied.id); + if (!settled || settled.status !== 'PAID') { + throw new Error('Expected settled withdraw request on shop side'); + } + + const overdue = await req('HQ_WEB', '/admin/store-withdrawals/overdue-summary', { + token: admin.accessToken, + }); + if (typeof overdue.pendingCount !== 'number') { + throw new Error('overdue-summary missing pendingCount'); + } + console.log('10. Refund ticket flow'); const order2 = await req('USER_H5', '/trade/orders', { method: 'POST', diff --git a/server/dukang-api/prisma/schema.prisma b/server/dukang-api/prisma/schema.prisma index 8adc5ef..f391dfd 100644 --- a/server/dukang-api/prisma/schema.prisma +++ b/server/dukang-api/prisma/schema.prisma @@ -314,6 +314,12 @@ enum StorePayoutStatus { PAID } +enum StoreWithdrawStatus { + PENDING_REVIEW + REJECTED + PAID +} + enum ThirdPartyProvider { WECHAT_PAY WECHAT_REFUND @@ -1064,6 +1070,8 @@ model Store { settlementRate Decimal @default(0.60) @map("settlement_rate") @db.Decimal(5, 4) /// Online test: only listed phones can see store on C-end when enabled visibilityWhitelistEnabled Boolean @default(false) @map("visibility_whitelist_enabled") + /// FIN-001:允许未出账手动提现的白名单门店 + withdrawWhitelistEnabled Boolean @default(false) @map("withdraw_whitelist_enabled") createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) @@ -1077,6 +1085,7 @@ model Store { ratings StoreRating[] payouts StorePayout[] storeBills StoreBill[] + withdrawRequests StoreWithdrawRequest[] visibilityPhones StoreVisibilityPhone[] @@index([cityId, status]) @@ -1121,6 +1130,7 @@ model StoreAccount { childAccounts StoreAccount[] @relation("StoreAccountHierarchy") bindings StoreAccountStore[] redeemPendingRecords RedeemPendingRecord[] + withdrawRequests StoreWithdrawRequest[] @@index([parentAccountId]) @@map("store_account") @@ -1432,12 +1442,52 @@ model StorePayout { redeemRecord RedeemRecord @relation(fields: [redeemRecordId], references: [id], onDelete: Restrict) store Store @relation(fields: [storeId], references: [id], onDelete: Restrict) storeBill StoreBill? @relation(fields: [storeBillId], references: [id], onDelete: SetNull) + withdrawItem StoreWithdrawPayoutItem? @@index([storeId, status]) @@index([storeBillId]) @@map("store_payout") } +model StoreWithdrawRequest { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + withdrawNo String @unique @map("withdraw_no") @db.VarChar(32) + storeId BigInt @map("store_id") @db.UnsignedBigInt + storeAccountId BigInt @map("store_account_id") @db.UnsignedBigInt + amount Decimal @db.Decimal(10, 2) + payoutCount Int @default(0) @map("payout_count") + status StoreWithdrawStatus @default(PENDING_REVIEW) + rejectReason String? @map("reject_reason") @db.VarChar(512) + appliedAt DateTime @default(now()) @map("applied_at") @db.DateTime(3) + reviewedAt DateTime? @map("reviewed_at") @db.DateTime(3) + reviewedByHqId BigInt? @map("reviewed_by_hq_id") @db.UnsignedBigInt + paidAt DateTime? @map("paid_at") @db.DateTime(3) + paymentRef String? @map("payment_ref") @db.VarChar(128) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + + store Store @relation(fields: [storeId], references: [id], onDelete: Restrict) + storeAccount StoreAccount @relation(fields: [storeAccountId], references: [id], onDelete: Restrict) + items StoreWithdrawPayoutItem[] + + @@index([storeId, status]) + @@index([status, appliedAt]) + @@map("store_withdraw_request") +} + +model StoreWithdrawPayoutItem { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + withdrawRequestId BigInt @map("withdraw_request_id") @db.UnsignedBigInt + storePayoutId BigInt @unique @map("store_payout_id") @db.UnsignedBigInt + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + + withdrawRequest StoreWithdrawRequest @relation(fields: [withdrawRequestId], references: [id], onDelete: Cascade) + storePayout StorePayout @relation(fields: [storePayoutId], references: [id], onDelete: Restrict) + + @@index([withdrawRequestId]) + @@map("store_withdraw_payout_item") +} + model WineryBill { id BigInt @id @default(autoincrement()) @db.UnsignedBigInt billNo String @unique @map("bill_no") @db.VarChar(32) diff --git a/server/dukang-api/src/common/guards/shop-primary.guard.ts b/server/dukang-api/src/common/guards/shop-primary.guard.ts new file mode 100644 index 0000000..4699ae2 --- /dev/null +++ b/server/dukang-api/src/common/guards/shop-primary.guard.ts @@ -0,0 +1,30 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, +} from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.module'; +import type { AuthUser } from './jwt-auth.guard'; + +/** 门店主账号专用(提现等资金操作) */ +@Injectable() +export class ShopPrimaryGuard implements CanActivate { + constructor(private readonly prisma: PrismaService) {} + + async canActivate(context: ExecutionContext): Promise { + const req = context.switchToHttp().getRequest(); + const user = req.user as AuthUser | undefined; + if (!user || user.actorType !== 'STORE') { + throw new ForbiddenException('仅门店主账号可操作'); + } + const account = await this.prisma.storeAccount.findUnique({ + where: { id: user.actorId }, + select: { isPrimary: true }, + }); + if (!account || account.isPrimary !== 1) { + throw new ForbiddenException('仅门店主账号可申请提现'); + } + return true; + } +} diff --git a/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts b/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts index db4cbf9..4b38f17 100644 --- a/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts +++ b/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts @@ -61,6 +61,8 @@ export const HqOperationAction = { STORE_PAYOUT_BATCH_CONFIRM: 'STORE_PAYOUT_BATCH_CONFIRM', STORE_BILL_CONFIRM: 'STORE_BILL_CONFIRM', STORE_BILL_BATCH_CONFIRM: 'STORE_BILL_BATCH_CONFIRM', + STORE_WITHDRAW_APPROVE: 'STORE_WITHDRAW_APPROVE', + STORE_WITHDRAW_REJECT: 'STORE_WITHDRAW_REJECT', PARTNER_BILL_GENERATE: 'PARTNER_BILL_GENERATE', PARTNER_BILL_SEND: 'PARTNER_BILL_SEND', PARTNER_BILL_BATCH_SEND: 'PARTNER_BILL_BATCH_SEND', @@ -164,6 +166,8 @@ export const HQ_OPERATION_ACTION_LABELS: Record = { [HqOperationAction.STORE_PAYOUT_BATCH_CONFIRM]: '批量门店打款', [HqOperationAction.STORE_BILL_CONFIRM]: '门店对账单确认打款', [HqOperationAction.STORE_BILL_BATCH_CONFIRM]: '批量门店对账单打款', + [HqOperationAction.STORE_WITHDRAW_APPROVE]: '门店提现审核通过', + [HqOperationAction.STORE_WITHDRAW_REJECT]: '门店提现驳回', [HqOperationAction.PARTNER_BILL_GENERATE]: '生成合伙人账单', [HqOperationAction.PARTNER_BILL_SEND]: '发送合伙人账单', [HqOperationAction.PARTNER_BILL_BATCH_SEND]: '批量发送合伙人账单', diff --git a/server/dukang-api/src/common/system-config/system-config.registry.ts b/server/dukang-api/src/common/system-config/system-config.registry.ts index aca17f6..f094d21 100644 --- a/server/dukang-api/src/common/system-config/system-config.registry.ts +++ b/server/dukang-api/src/common/system-config/system-config.registry.ts @@ -10,6 +10,7 @@ export const SYSTEM_CONFIG_GROUPS: SystemConfigGroupMeta[] = [ { key: 'app', label: '应用链接' }, { key: 'deploy', label: '发布部署' }, { key: 'winery_bank', label: '酒厂银行账户' }, + { key: 'finance', label: '财务结算' }, ]; const G = { @@ -21,6 +22,7 @@ const G = { app: 'app', deploy: 'deploy', winery_bank: 'winery_bank', + finance: 'finance', } as const; /** HQ 可维护字段(不含 NODE_ENV / DATABASE_URL / JWT 等基础设施项) */ @@ -187,6 +189,15 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [ type: 'string', requiresRestart: false, }, + { + key: 'STORE_WITHDRAW_DAILY_LIMIT', + label: '门店未出账提现单日上限(元)', + group: G.finance, + type: 'number', + requiresRestart: false, + description: 'FIN-002:单店单日提现上限,默认 5000', + placeholder: '5000', + }, ]; /** 已从 HQ 配置移除、仅保留在 .env 的键(启动时从 DB 清理) */ diff --git a/server/dukang-api/src/jobs/settlement.scheduler.ts b/server/dukang-api/src/jobs/settlement.scheduler.ts index 28bc154..07a7037 100644 --- a/server/dukang-api/src/jobs/settlement.scheduler.ts +++ b/server/dukang-api/src/jobs/settlement.scheduler.ts @@ -7,6 +7,7 @@ import { AlertService } from '../common/alert/alert.service'; * 财务对账单定时任务(Asia/Shanghai) * - 每日 08:00:酒厂日账单 + 门店日账单(统计昨日 00:00~今日 00:00) * - 每月 1 日 08:00:合伙人上一自然月账单 + 物流承运商上一自然月对账 + * - 工作日 18:05:门店提现 T+0 审完预警(FIN-003) */ @Injectable() export class SettlementScheduler { @@ -48,6 +49,34 @@ export class SettlementScheduler { } } + /** FIN-003:工作日 18:05 扫描超时未审门店提现 */ + @Cron('5 18 * * 1-5', { timeZone: 'Asia/Shanghai' }) + async handleWithdrawOverdueAlert() { + this.logger.log('Store withdraw overdue scan start'); + try { + const summary = await this.settlementService.scanOverdueStoreWithdrawals(); + this.logger.log(`Store withdraw overdue: ${JSON.stringify(summary)}`); + if (summary.overdueCount > 0) { + this.alert.notify({ + level: 'P1', + category: 'finance', + title: '门店提现超时未审', + detail: `待审 ${summary.pendingCount} 笔,超时 ${summary.overdueCount} 笔,超时金额 ¥${summary.overdueAmount.toFixed(2)}`, + dedupeKey: `job_store_withdraw_overdue|${new Date().toISOString().slice(0, 10)}`, + }); + } + } catch (e) { + this.logger.error('Store withdraw overdue scan failed', e instanceof Error ? e.stack : e); + this.alert.notify({ + level: 'P1', + category: 'job', + title: '门店提现超时扫描失败', + detail: e instanceof Error ? e.message : String(e), + dedupeKey: `job_store_withdraw_overdue|${new Date().toISOString().slice(0, 10)}`, + }); + } + } + @Cron('0 8 1 * *', { timeZone: 'Asia/Shanghai' }) async handleMonthlyPartnerBills() { this.logger.log('Monthly partner bills job start'); diff --git a/server/dukang-api/src/modules/iam/iam.module.ts b/server/dukang-api/src/modules/iam/iam.module.ts index 5b74df9..74ec9b9 100644 --- a/server/dukang-api/src/modules/iam/iam.module.ts +++ b/server/dukang-api/src/modules/iam/iam.module.ts @@ -23,6 +23,7 @@ import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard'; import { PartnerPermissionGuard } from '../../common/guards/partner-permission.guard'; import { ShopStoreGuard } from '../../common/guards/shop-store.guard'; +import { ShopPrimaryGuard } from '../../common/guards/shop-primary.guard'; import { StoreMembershipService } from '../../common/guards/store-membership.service'; import { HqPermissionGuard, @@ -63,6 +64,7 @@ import { CommonModule } from '../common/common.module'; PartnerPrimaryGuard, PartnerPermissionGuard, ShopStoreGuard, + ShopPrimaryGuard, HqPermissionsResolver, HqPermissionGuard, ], @@ -80,6 +82,7 @@ import { CommonModule } from '../common/common.module'; PartnerPrimaryGuard, PartnerPermissionGuard, ShopStoreGuard, + ShopPrimaryGuard, HqPermissionsResolver, HqPermissionGuard, ], diff --git a/server/dukang-api/src/modules/ops/admin-dashboard.service.ts b/server/dukang-api/src/modules/ops/admin-dashboard.service.ts index 21520a7..1d120e5 100644 --- a/server/dukang-api/src/modules/ops/admin-dashboard.service.ts +++ b/server/dukang-api/src/modules/ops/admin-dashboard.service.ts @@ -40,6 +40,21 @@ function num(v: Prisma.Decimal | number | string | null | undefined): number { return typeof v === 'number' ? v : Number(v); } +function isWithdrawOverdue(appliedAt: Date, now = new Date()): boolean { + const day = appliedAt.getDay(); + if (day === 0 || day === 6) return false; + const deadline = new Date( + appliedAt.getFullYear(), + appliedAt.getMonth(), + appliedAt.getDate(), + 18, + 0, + 0, + 0, + ); + return now.getTime() > deadline.getTime(); +} + @Injectable() export class AdminDashboardService { constructor(private readonly prisma: PrismaService) {} @@ -63,6 +78,7 @@ export class AdminDashboardService { pendingBills, pendingPartnerDraftBills, openTickets, + pendingWithdrawRows, ] = await Promise.all([ this.prisma.user.count({ where: { status: 1, mergedIntoUserId: null } }), this.prisma.user.count({ @@ -85,8 +101,18 @@ export class AdminDashboardService { this.prisma.partnerBill.count({ where: { status: 'UNPAID' } }), this.prisma.partnerBill.count({ where: { status: 'PENDING_REVIEW' } }), this.prisma.commonTicket.count({ where: { status: { in: ['PENDING', 'OPEN'] } } }), + this.prisma.storeWithdrawRequest.findMany({ + where: { status: 'PENDING_REVIEW' }, + select: { appliedAt: true }, + }), ]); + const now = new Date(); + const pendingStoreWithdrawals = pendingWithdrawRows.length; + const overdueStoreWithdrawals = pendingWithdrawRows.filter((r) => + isWithdrawOverdue(r.appliedAt, now), + ).length; + return { usersTotal, guestUsers, @@ -101,6 +127,8 @@ export class AdminDashboardService { pendingBills, pendingPartnerDraftBills, openTickets, + pendingStoreWithdrawals, + overdueStoreWithdrawals, ordersByStatus: ordersByStatus.map((row) => ({ status: row.status, count: row._count.status, diff --git a/server/dukang-api/src/modules/ops/admin-stores.service.ts b/server/dukang-api/src/modules/ops/admin-stores.service.ts index ea5b1a6..5735df4 100644 --- a/server/dukang-api/src/modules/ops/admin-stores.service.ts +++ b/server/dukang-api/src/modules/ops/admin-stores.service.ts @@ -357,6 +357,9 @@ export class AdminStoresService { ...(dto.visibilityWhitelistEnabled !== undefined ? { visibilityWhitelistEnabled: !!dto.visibilityWhitelistEnabled } : {}), + ...(dto.withdrawWhitelistEnabled !== undefined + ? { withdrawWhitelistEnabled: !!dto.withdrawWhitelistEnabled } + : {}), ...(latitude != null && longitude != null ? { latitude, longitude } : {}), }, }); @@ -516,6 +519,7 @@ export class AdminStoresService { openTime2: openTime2 || null, closeTime2: closeTime2 || null, visibilityWhitelistEnabled: whitelistEnabled, + withdrawWhitelistEnabled: !!dto.withdrawWhitelistEnabled, ...(latitude != null && longitude != null ? { latitude, longitude } : {}), status: 'OPEN', auditStatus: 'APPROVED', 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 6adcf75..6016b0e 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 @@ -138,6 +138,11 @@ export class CreateStoreDto { @IsArray() @IsString({ each: true }) visibilityPhones?: string[]; + + /** FIN-001:允许未出账手动提现 */ + @IsOptional() + @IsBoolean() + withdrawWhitelistEnabled?: boolean; } export class UpdateStoreDto { @@ -237,6 +242,11 @@ export class UpdateStoreDto { @IsArray() @IsString({ each: true }) visibilityPhones?: string[]; + + /** FIN-001:允许未出账手动提现 */ + @IsOptional() + @IsBoolean() + withdrawWhitelistEnabled?: boolean; } export class CreateStoreAccountDto { diff --git a/server/dukang-api/src/modules/settlement/settlement.controller.ts b/server/dukang-api/src/modules/settlement/settlement.controller.ts index e4c942f..58e63e6 100644 --- a/server/dukang-api/src/modules/settlement/settlement.controller.ts +++ b/server/dukang-api/src/modules/settlement/settlement.controller.ts @@ -5,6 +5,7 @@ import { SettlementService } from './settlement.service'; import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard'; import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard'; import { ShopStoreGuard } from '../../common/guards/shop-store.guard'; +import { ShopPrimaryGuard } from '../../common/guards/shop-primary.guard'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; import { HqOperation } from '../../common/hq-operation/hq-operation.decorator'; @@ -59,6 +60,98 @@ export class ShopPayoutController { } } +@Controller('shop/withdraw') +@UseGuards(JwtAuthGuard, ShopStoreGuard) +export class ShopWithdrawController { + constructor(private readonly settlementService: SettlementService) {} + + @Get('summary') + summary(@CurrentUser() user: AuthUser) { + return this.settlementService.getShopWithdrawSummary(user.actorId, user.storeId!); + } + + @Get('requests') + requests( + @CurrentUser() user: AuthUser, + @Query('page') page = '1', + @Query('pageSize') pageSize = '20', + @Query('status') status?: string, + ) { + return this.settlementService.listShopWithdrawRequests(user.actorId, user.storeId!, { + page: Number(page), + pageSize: Number(pageSize), + status, + }); + } + + @Post() + @UseGuards(ShopPrimaryGuard) + apply(@CurrentUser() user: AuthUser, @Body() body: { amount?: number }) { + return this.settlementService.createStoreWithdrawRequest(user.actorId, user.storeId!, body); + } +} + +@Controller('admin/store-withdrawals') +@UseGuards(HqAuthGuard) +export class AdminStoreWithdrawController { + constructor(private readonly settlementService: SettlementService) {} + + @Get('overdue-summary') + overdueSummary() { + return this.settlementService.getStoreWithdrawOverdueSummary(); + } + + @Get() + list(@Query() query: Record) { + return this.settlementService.listAdminStoreWithdrawals({ + page: query.page ? Number(query.page) : 1, + pageSize: query.pageSize ? Number(query.pageSize) : 20, + status: query.status, + storeId: query.storeId, + dateFrom: query.dateFrom, + dateTo: query.dateTo, + }); + } + + @Get(':id') + detail(@Param('id') id: string) { + return this.settlementService.getAdminStoreWithdrawal(BigInt(id)); + } + + @Post(':id/approve') + @HqOperation({ + action: HqOperationAction.STORE_WITHDRAW_APPROVE, + refType: 'STORE_WITHDRAW', + refIdParam: 'id', + includeBody: true, + }) + approve( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() body: { paymentRef?: string }, + ) { + return this.settlementService.approveStoreWithdraw(BigInt(id), user.actorId, body); + } + + @Post(':id/reject') + @HqOperation({ + action: HqOperationAction.STORE_WITHDRAW_REJECT, + refType: 'STORE_WITHDRAW', + refIdParam: 'id', + includeBody: true, + }) + reject( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() body: { reason?: string }, + ) { + if (!body?.reason?.trim()) { + throw new BadRequestException('请填写驳回理由'); + } + return this.settlementService.rejectStoreWithdraw(BigInt(id), user.actorId, body.reason); + } +} + @Controller('admin/store-payouts') @UseGuards(HqAuthGuard) export class AdminStorePayoutController { diff --git a/server/dukang-api/src/modules/settlement/settlement.module.ts b/server/dukang-api/src/modules/settlement/settlement.module.ts index 53e7f66..e0348e9 100644 --- a/server/dukang-api/src/modules/settlement/settlement.module.ts +++ b/server/dukang-api/src/modules/settlement/settlement.module.ts @@ -9,10 +9,12 @@ import { AdminPartnerBillController, AdminStoreBillController, AdminStorePayoutController, + AdminStoreWithdrawController, AdminWineryBillController, PartnerMeController, SettlementController, ShopPayoutController, + ShopWithdrawController, } from './settlement.controller'; @Module({ @@ -21,6 +23,8 @@ import { SettlementController, PartnerMeController, ShopPayoutController, + ShopWithdrawController, + AdminStoreWithdrawController, AdminStorePayoutController, AdminStoreBillController, AdminPartnerBillController, diff --git a/server/dukang-api/src/modules/settlement/settlement.service.ts b/server/dukang-api/src/modules/settlement/settlement.service.ts index 6ffa219..d3736df 100644 --- a/server/dukang-api/src/modules/settlement/settlement.service.ts +++ b/server/dukang-api/src/modules/settlement/settlement.service.ts @@ -1,7 +1,17 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; -import { DEFAULT_XFX_LOGISTICS_PRICING, WINERY_SETTLEMENT_RATE } from '@dukang/shared-types'; -import { calcLogisticsFeeByBottles, type LogisticsPricingRule } from '@dukang/domain'; +import { + DEFAULT_STORE_WITHDRAW_DAILY_LIMIT, + DEFAULT_XFX_LOGISTICS_PRICING, + WINERY_SETTLEMENT_RATE, +} from '@dukang/shared-types'; +import { + calcLogisticsFeeByBottles, + pickPayoutsForWithdrawAmount, + sumUnbilledPayoutAmount, + validateStoreWithdraw, + type LogisticsPricingRule, +} from '@dukang/domain'; import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { AnalyticsService } from '../analytics/analytics.service'; @@ -33,6 +43,28 @@ function round2(n: number) { return Math.round(n * 100) / 100; } +function getStoreWithdrawDailyLimit(): number { + const raw = process.env.STORE_WITHDRAW_DAILY_LIMIT; + const n = raw != null && raw !== '' ? Number(raw) : DEFAULT_STORE_WITHDRAW_DAILY_LIMIT; + return Number.isFinite(n) && n > 0 ? n : DEFAULT_STORE_WITHDRAW_DAILY_LIMIT; +} + +/** 工作日 18:00 前未审完视为 FIN-003 超时(Asia/Shanghai 自然日) */ +function isWithdrawOverdue(appliedAt: Date, now = new Date()): boolean { + const day = appliedAt.getDay(); // 0 Sun … 6 Sat + if (day === 0 || day === 6) return false; + const deadline = new Date( + appliedAt.getFullYear(), + appliedAt.getMonth(), + appliedAt.getDate(), + 18, + 0, + 0, + 0, + ); + return now.getTime() > deadline.getTime(); +} + @Injectable() export class SettlementService { constructor( @@ -99,6 +131,440 @@ export class SettlementService { return serializeBigInt({ items, total, page, pageSize }); } + // ─── Store withdraw (未出账手动提现) ───────────────── + + private async assertShopStoreAccess(storeAccountId: bigint, storeId: bigint) { + await this.prisma.storeAccountStore.findUniqueOrThrow({ + where: { storeAccountId_storeId: { storeAccountId, storeId } }, + }); + } + + private async listAvailableUnbilledPayouts(storeId: bigint) { + return this.prisma.storePayout.findMany({ + where: { + storeId, + status: 'PENDING', + storeBillId: null, + withdrawItem: null, + }, + orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], + }); + } + + private async todayWithdrawAppliedAmount(storeId: bigint, now = new Date()) { + const start = startOfDay(now); + const end = new Date(start); + end.setDate(end.getDate() + 1); + const agg = await this.prisma.storeWithdrawRequest.aggregate({ + where: { + storeId, + status: { in: ['PENDING_REVIEW', 'PAID'] }, + appliedAt: { gte: start, lt: end }, + }, + _sum: { amount: true }, + }); + return Number(agg._sum.amount ?? 0); + } + + async getShopWithdrawSummary(storeAccountId: bigint, storeId: bigint) { + await this.assertShopStoreAccess(storeAccountId, storeId); + const [store, account, available, pending, todayApplied] = await Promise.all([ + this.prisma.store.findUniqueOrThrow({ + where: { id: storeId }, + select: { withdrawWhitelistEnabled: true }, + }), + this.prisma.storeAccount.findUniqueOrThrow({ + where: { id: storeAccountId }, + select: { + isPrimary: true, + bankAccountName: true, + bankAccountNo: true, + bankBranch: true, + }, + }), + this.listAvailableUnbilledPayouts(storeId), + this.prisma.storeWithdrawRequest.findFirst({ + where: { storeId, status: 'PENDING_REVIEW' }, + select: { id: true, amount: true }, + }), + this.todayWithdrawAppliedAmount(storeId), + ]); + + const availableAmount = sumUnbilledPayoutAmount( + available.map((p) => ({ payoutAmount: Number(p.payoutAmount) })), + ); + const dailyLimit = getStoreWithdrawDailyLimit(); + const hasBankAccount = !!( + account.bankAccountName?.trim() && + account.bankAccountNo?.trim() + ); + + return { + availableAmount, + pendingReviewAmount: pending ? Number(pending.amount) : 0, + todayAppliedAmount: todayApplied, + dailyLimit, + remainingDailyLimit: Math.max(0, round2(dailyLimit - todayApplied)), + whitelistEnabled: store.withdrawWhitelistEnabled, + isPrimary: account.isPrimary === 1, + hasBankAccount, + hasPendingRequest: !!pending, + bankAccount: { + bankAccountName: account.bankAccountName, + bankAccountNo: account.bankAccountNo, + bankBranch: account.bankBranch, + }, + }; + } + + async createStoreWithdrawRequest( + storeAccountId: bigint, + storeId: bigint, + dto?: { amount?: number }, + ) { + await this.assertShopStoreAccess(storeAccountId, storeId); + + const [store, account, available, pending, todayApplied] = await Promise.all([ + this.prisma.store.findUniqueOrThrow({ + where: { id: storeId }, + select: { withdrawWhitelistEnabled: true }, + }), + this.prisma.storeAccount.findUniqueOrThrow({ + where: { id: storeAccountId }, + select: { + isPrimary: true, + bankAccountName: true, + bankAccountNo: true, + }, + }), + this.listAvailableUnbilledPayouts(storeId), + this.prisma.storeWithdrawRequest.findFirst({ + where: { storeId, status: 'PENDING_REVIEW' }, + select: { id: true }, + }), + this.todayWithdrawAppliedAmount(storeId), + ]); + + if (account.isPrimary !== 1) { + throw new BadRequestException('仅门店主账号可申请提现'); + } + + const availableAmount = sumUnbilledPayoutAmount( + available.map((p) => ({ payoutAmount: Number(p.payoutAmount) })), + ); + const dailyLimit = getStoreWithdrawDailyLimit(); + const hasBankAccount = !!( + account.bankAccountName?.trim() && + account.bankAccountNo?.trim() + ); + const requestAmount = + dto?.amount != null && Number.isFinite(Number(dto.amount)) + ? round2(Number(dto.amount)) + : availableAmount; + + const guard = validateStoreWithdraw({ + whitelistEnabled: store.withdrawWhitelistEnabled, + availableAmount, + requestAmount, + todayApplied, + dailyLimit, + hasPendingRequest: !!pending, + hasBankAccount, + }); + if (!guard.ok) throw new BadRequestException(guard.message); + + const picked = pickPayoutsForWithdrawAmount( + available.map((p) => ({ id: p.id, payoutAmount: Number(p.payoutAmount) })), + requestAmount, + ); + if (!picked.ok) throw new BadRequestException(picked.message); + + const created = await this.prisma.$transaction(async (tx) => { + const stillPending = await tx.storeWithdrawRequest.findFirst({ + where: { storeId, status: 'PENDING_REVIEW' }, + select: { id: true }, + }); + if (stillPending) { + throw new BadRequestException('已有待审核提现申请,请等待处理完成'); + } + + const payoutIds = picked.selected.map((p) => p.id); + const locked = await tx.storePayout.findMany({ + where: { + id: { in: payoutIds }, + storeId, + status: 'PENDING', + storeBillId: null, + withdrawItem: null, + }, + select: { id: true, payoutAmount: true }, + }); + if (locked.length !== payoutIds.length) { + throw new BadRequestException('可提余额已变化,请刷新后重试'); + } + const amount = round2(locked.reduce((s, p) => s + Number(p.payoutAmount), 0)); + + const req = await tx.storeWithdrawRequest.create({ + data: { + withdrawNo: generateBillNo('SW'), + storeId, + storeAccountId, + amount, + payoutCount: locked.length, + status: 'PENDING_REVIEW', + }, + }); + await tx.storeWithdrawPayoutItem.createMany({ + data: locked.map((p) => ({ + withdrawRequestId: req.id, + storePayoutId: p.id, + })), + }); + return req; + }); + + this.analyticsService.trackStoreOneSafe(storeAccountId, 'SHOP_H5', { + storeId, + eventName: 'store_withdraw_applied', + refType: 'STORE_WITHDRAW', + refId: created.id, + extraJson: { + amount: Number(created.amount), + payoutCount: created.payoutCount, + }, + }); + + return serializeBigInt(created); + } + + async listShopWithdrawRequests( + storeAccountId: bigint, + storeId: bigint, + query: { page?: number; pageSize?: number; status?: string }, + ) { + await this.assertShopStoreAccess(storeAccountId, storeId); + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 20; + const where: Prisma.StoreWithdrawRequestWhereInput = { storeId }; + if (query.status) { + where.status = query.status as 'PENDING_REVIEW' | 'REJECTED' | 'PAID'; + } + const [items, total] = await Promise.all([ + this.prisma.storeWithdrawRequest.findMany({ + where, + orderBy: { appliedAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + this.prisma.storeWithdrawRequest.count({ where }), + ]); + return serializeBigInt({ items, total, page, pageSize }); + } + + async listAdminStoreWithdrawals(query: { + page?: number; + pageSize?: number; + status?: string; + storeId?: string; + dateFrom?: string; + dateTo?: string; + }) { + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 20; + const where: Prisma.StoreWithdrawRequestWhereInput = {}; + if (query.status) { + where.status = query.status as 'PENDING_REVIEW' | 'REJECTED' | 'PAID'; + } + if (query.storeId) where.storeId = BigInt(query.storeId); + if (query.dateFrom || query.dateTo) { + where.appliedAt = {}; + if (query.dateFrom) where.appliedAt.gte = new Date(query.dateFrom); + if (query.dateTo) { + const end = new Date(query.dateTo); + end.setHours(23, 59, 59, 999); + where.appliedAt.lte = end; + } + } + + const [items, total, aggregates] = await Promise.all([ + this.prisma.storeWithdrawRequest.findMany({ + where, + orderBy: [{ appliedAt: 'desc' }, { id: 'desc' }], + skip: (page - 1) * pageSize, + take: pageSize, + include: { + store: { select: { id: true, name: true, cityName: true, phone: true } }, + }, + }), + this.prisma.storeWithdrawRequest.count({ where }), + this.prisma.storeWithdrawRequest.aggregate({ + where, + _sum: { amount: true }, + _count: true, + }), + ]); + + const now = new Date(); + const mapped = items.map((row) => ({ + ...row, + overdue: + row.status === 'PENDING_REVIEW' ? isWithdrawOverdue(row.appliedAt, now) : false, + })); + + return serializeBigInt({ + items: mapped, + total, + page, + pageSize, + summary: { + count: aggregates._count, + totalAmount: Number(aggregates._sum.amount ?? 0), + }, + }); + } + + async getAdminStoreWithdrawal(id: bigint) { + const row = await this.prisma.storeWithdrawRequest.findUnique({ + where: { id }, + include: { + store: { + select: { + id: true, + name: true, + cityName: true, + phone: true, + withdrawWhitelistEnabled: true, + }, + }, + storeAccount: { + select: { + id: true, + name: true, + phone: true, + bankAccountName: true, + bankAccountNo: true, + bankBranch: true, + }, + }, + items: { + include: { + storePayout: { + include: { + redeemRecord: { select: { redeemNo: true, amount: true, createdAt: true } }, + }, + }, + }, + }, + }, + }); + if (!row) throw new NotFoundException('提现申请不存在'); + return serializeBigInt({ + ...row, + overdue: + row.status === 'PENDING_REVIEW' ? isWithdrawOverdue(row.appliedAt) : false, + }); + } + + async approveStoreWithdraw( + id: bigint, + hqAccountId: bigint, + dto?: { paymentRef?: string }, + ) { + const row = await this.prisma.storeWithdrawRequest.findUnique({ + where: { id }, + include: { items: { select: { storePayoutId: true } } }, + }); + if (!row) throw new NotFoundException('提现申请不存在'); + if (row.status !== 'PENDING_REVIEW') { + throw new BadRequestException('仅待审核提现可审核通过'); + } + + const paidAt = new Date(); + const updated = await this.prisma.$transaction(async (tx) => { + const req = await tx.storeWithdrawRequest.update({ + where: { id }, + data: { + status: 'PAID', + reviewedAt: paidAt, + reviewedByHqId: hqAccountId, + paidAt, + paymentRef: dto?.paymentRef?.trim() || null, + }, + }); + await tx.storePayout.updateMany({ + where: { + id: { in: row.items.map((i) => i.storePayoutId) }, + status: 'PENDING', + }, + data: { status: 'PAID', paidAt }, + }); + return req; + }); + + this.analyticsService.trackStoreOneSafe(undefined, 'HQ_WEB', { + storeId: row.storeId, + eventName: 'store_withdraw_paid', + refType: 'STORE_WITHDRAW', + refId: id, + extraJson: { + amount: Number(row.amount), + paymentRef: dto?.paymentRef, + }, + }); + + return serializeBigInt(updated); + } + + async rejectStoreWithdraw(id: bigint, hqAccountId: bigint, reason: string) { + const row = await this.prisma.storeWithdrawRequest.findUnique({ + where: { id }, + }); + if (!row) throw new NotFoundException('提现申请不存在'); + if (row.status !== 'PENDING_REVIEW') { + throw new BadRequestException('仅待审核提现可驳回'); + } + const rejectReason = reason.trim(); + if (!rejectReason) throw new BadRequestException('请填写驳回理由'); + + const updated = await this.prisma.$transaction(async (tx) => { + const req = await tx.storeWithdrawRequest.update({ + where: { id }, + data: { + status: 'REJECTED', + rejectReason, + reviewedAt: new Date(), + reviewedByHqId: hqAccountId, + }, + }); + // 释放 payout 锁定,允许再次提现 + await tx.storeWithdrawPayoutItem.deleteMany({ where: { withdrawRequestId: id } }); + return req; + }); + + return serializeBigInt(updated); + } + + async getStoreWithdrawOverdueSummary() { + const pending = await this.prisma.storeWithdrawRequest.findMany({ + where: { status: 'PENDING_REVIEW' }, + select: { id: true, appliedAt: true, amount: true }, + }); + const now = new Date(); + const overdue = pending.filter((r) => isWithdrawOverdue(r.appliedAt, now)); + return { + pendingCount: pending.length, + overdueCount: overdue.length, + overdueAmount: round2(overdue.reduce((s, r) => s + Number(r.amount), 0)), + pendingAmount: round2(pending.reduce((s, r) => s + Number(r.amount), 0)), + }; + } + + /** FIN-003:工作日 18:00 扫描超时未审提现 */ + async scanOverdueStoreWithdrawals() { + const summary = await this.getStoreWithdrawOverdueSummary(); + return summary; + } + async listAdminStorePayouts(query: { page?: number; pageSize?: number; @@ -277,6 +743,9 @@ export class SettlementService { where: { storeBillId: null, createdAt: { gte: start, lt: end }, + // 排除已锁定在待审提现单中的明细,避免出账与提现双占 + withdrawItem: null, + status: 'PENDING', }, include: { store: { select: { id: true, settlementRate: true } } }, });