发布商品,商品图片使用oss服务器地址
This commit is contained in:
@@ -11,7 +11,7 @@ Conventional Commits,scope = 端或模块名:
|
||||
|
||||
```
|
||||
feat(trade): add order preview API
|
||||
fix(redeem): enforce 500 yuan cap
|
||||
fix(redeem): validate redeem amount against balance
|
||||
chore(shared-types): add OrderStatus enum
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master, develop]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
- run: pnpm install
|
||||
- run: pnpm db:generate
|
||||
- run: pnpm test
|
||||
- run: pnpm lint
|
||||
@@ -8,8 +8,9 @@
|
||||
|
||||
| 阶段 | 手册 | 说明 |
|
||||
|------|------|------|
|
||||
| **当前 preV1** | [`杜康好客-preV1编码手册.md`](./杜康好客-preV1编码手册.md) | 三端 H5 + Mock 联调,同库同 API |
|
||||
| **目标 V2** | [`杜康好客-V2编码手册.md`](./杜康好客-V2编码手册.md) | 四端小程序 + 真实第三方(唯一事实源) |
|
||||
| **当前 V3 交付** | [`杜康好客-v3编码手册.md`](./杜康好客-v3编码手册.md) | 业务闭环交付验收标准 |
|
||||
| **preV1 历史** | [`杜康好客-preV1编码手册.md`](./杜康好客-preV1编码手册.md) | Mock 联调裁剪(历史参考) |
|
||||
| **V2 蓝图** | [`杜康好客-V2编码手册.md`](./杜康好客-V2编码手册.md) | 完整产品蓝图(与 V3 冲突时 V3 优先) |
|
||||
|
||||
**禁止**:臆造 PRD 未定义规则;依赖 `doc/` 下过时文档;跨 OWNER 直写他人 Prisma 表。
|
||||
|
||||
@@ -33,6 +34,7 @@ pnpm dev:partner # :5175
|
||||
pnpm dev:admin # preV1 HQ 替代(内部工具,非 V2 小程序)
|
||||
|
||||
# 验证
|
||||
node scripts/smoke-v3.mjs
|
||||
node scripts/smoke-prev1.mjs
|
||||
pnpm lint && pnpm test
|
||||
```
|
||||
@@ -43,11 +45,12 @@ Mock 验证码:`123456`。测试账号见 [`README.md`](./README.md)。
|
||||
|
||||
## 文档优先级(冲突时)
|
||||
|
||||
1. `杜康好客-V2编码手册.md` §二 — 业务规则
|
||||
2. `杜康好客-preV1编码手册.md` — preV1 裁剪(Mock / Flag)
|
||||
3. [`conventions.md`](./conventions.md) — 协作与模块边界
|
||||
4. V2 手册 §四 §五 §六 — 架构 / DB / API
|
||||
5. `pages/{user,shop,partner}/` + [`pages/ROUTE_MAP.md`](./pages/ROUTE_MAP.md) — UI 参照
|
||||
1. `杜康好客-v3编码手册.md` — V3 交付业务规则与验收
|
||||
2. `杜康好客-V2编码手册.md` §二 — 完整蓝图(与 V3 冲突时 V3 优先)
|
||||
3. `杜康好客-preV1编码手册.md` — preV1 裁剪(历史参考)
|
||||
4. [`conventions.md`](./conventions.md) — 协作与模块边界
|
||||
5. V2 手册 §四 §五 §六 — 架构 / DB / API
|
||||
6. `pages/{user,shop,partner}/` + [`pages/ROUTE_MAP.md`](./pages/ROUTE_MAP.md) — UI 参照
|
||||
|
||||
## 团队与 OWNER 边界(2 人)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 杜康好客 preV1
|
||||
# 杜康好客 V3 交付版
|
||||
|
||||
三端 H5 + NestJS Mock 联调版。完整规格见 `杜康好客-V2编码手册.md`,preV1 裁剪见 `杜康好客-preV1编码手册.md`。
|
||||
四端 H5/WebAdmin + NestJS 单体 API。**V3 交付验收**以 [`杜康好客-v3编码手册.md`](./杜康好客-v3编码手册.md) 为准;V2 为完整蓝图参考;preV1 为 Mock 联调历史参考。
|
||||
|
||||
## 快速启动
|
||||
|
||||
@@ -53,9 +53,12 @@ pnpm dev:partner # http://localhost:5175
|
||||
## 主链路冒烟
|
||||
|
||||
```bash
|
||||
node scripts/smoke-prev1.mjs
|
||||
node scripts/smoke-v3.mjs # V3 交付验收
|
||||
node scripts/smoke-prev1.mjs # preV1 历史回归
|
||||
```
|
||||
|
||||
验收清单见 [`V3_ACCEPTANCE_CHECKLIST.md`](./V3_ACCEPTANCE_CHECKLIST.md)。
|
||||
|
||||
## 目录结构
|
||||
|
||||
- `apps/h5-user` · `apps/h5-shop` · `apps/h5-partner` — 三端 H5(Vite + React)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# V3 验收清单
|
||||
|
||||
> 业务事实源:[杜康好客-v3编码手册.md](./杜康好客-v3编码手册.md)
|
||||
> 自动化:`node scripts/smoke-v3.mjs`(Mock 环境)
|
||||
> 历史回归:`node scripts/smoke-prev1.mjs`(preV1 单链路)
|
||||
|
||||
## 必过主链路
|
||||
|
||||
| # | 验收项 | Owner | API / 页面 | 自动化 |
|
||||
|---|--------|-------|------------|--------|
|
||||
| 1 | C 端手机号登录 | jacy | `POST /auth/login/sms` · h5-user `/login` | smoke-v3 |
|
||||
| 2 | 首页郑州 4 商品 | jacy | `GET /catalog/products?cityCode=` · h5-user `/` | smoke-v3 |
|
||||
| 3 | 同城 1 瓶失败 | jacy | `POST /trade/orders/preview` | smoke-v3 |
|
||||
| 4 | 同城 2 瓶成功 | jacy | `POST /trade/orders` | smoke-v3 |
|
||||
| 5 | 跨城 5/6 瓶边界 | jacy | preview + create | smoke-v3 |
|
||||
| 6 | 支付后发权益 | jacy | pay + benefit | smoke-v3 |
|
||||
| 7 | 直接核销(无 couponId) | jacy + 刘京尧 | `POST /redeem/tokens` | smoke-v3 |
|
||||
| 8 | 单据核销 cap | jacy + 刘京尧 | `POST /redeem/tokens` + couponId | smoke-v3 |
|
||||
| 9 | 门店扫码确认 | 刘京尧 | `POST /shop/redeem/confirm` · h5-shop | smoke-v3 |
|
||||
| 10 | store_payout 生成 | jacy | settlement | smoke-v3 |
|
||||
| 11 | 关闭门店不可核销 | 刘京尧 | store status + redeem | smoke-v3 |
|
||||
| 12 | 录店审核后 C 端可见 | 刘京尧 + jacy | admin stores audit · h5-partner | 手动 |
|
||||
| 13 | 配送推进完成 | jacy | jobs / delivery callback | smoke-v3 |
|
||||
| 14 | 退款工单一致 | jacy | tickets + benefit void | smoke-v3 |
|
||||
| 15 | 门店 T+1 打款确认 | jacy | admin store-payouts | smoke-v3 |
|
||||
| 16 | 合伙人 T+30 账单 | jacy | admin partner-bills | smoke-v3 |
|
||||
|
||||
## 必过后台链路
|
||||
|
||||
| # | 验收项 | 页面 |
|
||||
|---|--------|------|
|
||||
| 1 | WebAdmin 登录 | admin-web `/login` |
|
||||
| 2 | 商品 CRUD | `/products` |
|
||||
| 3 | 审核门店 | `/stores` |
|
||||
| 4 | 订单与配送 | `/orders` · `/deliveries` |
|
||||
| 5 | 权益/核销/打款 | `/benefit/*` · `/redeem-records` · `/store-payouts` |
|
||||
| 6 | 退款/补发工单 | `/tickets` |
|
||||
| 7 | 第三方日志 | `/third-party-logs` |
|
||||
| 8 | 财务导出 | `/partner-bills` export |
|
||||
|
||||
## 环境矩阵
|
||||
|
||||
| 环境 | MOCK_SMS | MOCK_PAY | MOCK_DELIVERY_AUTO | 说明 |
|
||||
|------|----------|----------|-------------------|------|
|
||||
| 本地开发 | true | true | true | 固定验证码 123456 |
|
||||
| 测试沙箱 | false | false | false | 微信/配送沙箱 |
|
||||
| 生产 | false | false | false | 真实第三方 |
|
||||
|
||||
## 合伙人端交接(刘京尧)
|
||||
|
||||
Jacy 侧交付:`/partner/*` 后端 API、shared-types DTO、smoke 断言。
|
||||
刘京尧负责:`apps/h5-partner` 页面接入与验收。
|
||||
@@ -18,6 +18,10 @@ import CitiesPage from './pages/CitiesPage';
|
||||
import StoreMediaPage from './pages/StoreMediaPage';
|
||||
import ProductsPage from './pages/ProductsPage';
|
||||
import ResourcesPage from './pages/ResourcesPage';
|
||||
import StorePayoutsPage from './pages/StorePayoutsPage';
|
||||
import PartnerBillsPage from './pages/PartnerBillsPage';
|
||||
import TicketsPage from './pages/TicketsPage';
|
||||
import ThirdPartyLogsPage from './pages/ThirdPartyLogsPage';
|
||||
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
if (!getToken()) return <Navigate to="/login" replace />;
|
||||
@@ -49,6 +53,10 @@ export default function App() {
|
||||
<Route path="/benefit/coupons" element={<BenefitCouponsPage />} />
|
||||
<Route path="/benefit/ledgers" element={<BenefitLedgersPage />} />
|
||||
<Route path="/redeem-records" element={<RedeemRecordsPage />} />
|
||||
<Route path="/store-payouts" element={<StorePayoutsPage />} />
|
||||
<Route path="/partner-bills" element={<PartnerBillsPage />} />
|
||||
<Route path="/tickets" element={<TicketsPage />} />
|
||||
<Route path="/third-party-logs" element={<ThirdPartyLogsPage />} />
|
||||
<Route path="/deliveries" element={<DeliveriesPage />} />
|
||||
<Route path="/hq-accounts" element={<HqAccountsPage />} />
|
||||
</Route>
|
||||
|
||||
@@ -52,8 +52,12 @@ const MENU_ITEMS: MenuProps['items'] = [
|
||||
{ key: '/benefit/coupons', label: '权益券' },
|
||||
{ key: '/benefit/ledgers', label: '流水' },
|
||||
{ key: '/redeem-records', label: '核销记录' },
|
||||
{ key: '/store-payouts', label: '门店打款' },
|
||||
],
|
||||
},
|
||||
{ key: '/partner-bills', icon: <TeamOutlined />, label: '合伙人结算' },
|
||||
{ key: '/tickets', icon: <CarOutlined />, label: '工单中心' },
|
||||
{ key: '/third-party-logs', icon: <CloudUploadOutlined />, label: '第三方日志' },
|
||||
{ key: '/deliveries', icon: <CarOutlined />, label: '配送单' },
|
||||
{ key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Form, Input, Select, Table, Typography, message, Modal, DatePicker } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import dayjs from 'dayjs';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
billNo: string;
|
||||
orderCommission: number;
|
||||
redeemCommission: number;
|
||||
totalAmount: number;
|
||||
status: string;
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
partner?: { companyName: string };
|
||||
};
|
||||
|
||||
export default function PartnerBillsPage() {
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/partner-bills',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
if (filters.partnerId) qs.set('partnerId', filters.partnerId);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [genOpen, setGenOpen] = useState(false);
|
||||
const [genForm] = Form.useForm();
|
||||
|
||||
async function generateBill(values: { partnerId: string; month: dayjs.Dayjs }) {
|
||||
await request('/admin/partner-bills/generate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
partnerId: values.partnerId,
|
||||
year: values.month.year(),
|
||||
month: values.month.month() + 1,
|
||||
}),
|
||||
});
|
||||
message.success('账单已生成');
|
||||
setGenOpen(false);
|
||||
reload();
|
||||
}
|
||||
|
||||
async function confirmBill(id: string) {
|
||||
await request(`/admin/partner-bills/${id}/confirm`, { method: 'POST' });
|
||||
message.success('已确认');
|
||||
reload();
|
||||
}
|
||||
|
||||
async function markPaid(id: string) {
|
||||
await request(`/admin/partner-bills/${id}/mark-paid`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ paymentRef: `PAY-${Date.now()}` }),
|
||||
});
|
||||
message.success('已标记打款');
|
||||
reload();
|
||||
}
|
||||
|
||||
async function exportCsv() {
|
||||
const result = await request<{ csv: string }>('/admin/partner-bills/export');
|
||||
const blob = new Blob([result.csv], { type: 'text/csv;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'partner-bills.csv';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '账单号', dataIndex: 'billNo', width: 180 },
|
||||
{ title: '合伙人', dataIndex: ['partner', 'companyName'] },
|
||||
{ title: '订单佣金', dataIndex: 'orderCommission', width: 100, render: (v) => `¥${v}` },
|
||||
{ title: '核销佣金', dataIndex: 'redeemCommission', width: 100, render: (v) => `¥${v}` },
|
||||
{ title: '合计', dataIndex: 'totalAmount', width: 100, render: (v) => `¥${v}` },
|
||||
{ title: '状态', dataIndex: 'status', width: 90 },
|
||||
{ title: '周期', width: 200, render: (_, r) => `${fmtTime(r.periodStart).slice(0, 10)} ~ ${fmtTime(r.periodEnd).slice(0, 10)}` },
|
||||
{
|
||||
title: '操作', width: 180,
|
||||
render: (_, row) => (
|
||||
<>
|
||||
{row.status === 'DRAFT' && <Button type="link" size="small" onClick={() => confirmBill(row.id)}>确认</Button>}
|
||||
{row.status === 'CONFIRMED' && <Button type="link" size="small" onClick={() => markPaid(row.id)}>标记打款</Button>}
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4}>合伙人结算(T+30)</Typography.Title>
|
||||
<div style={{ marginBottom: 16, display: 'flex', gap: 8 }}>
|
||||
<Button type="primary" onClick={() => setGenOpen(true)}>生成账单</Button>
|
||||
<Button onClick={exportCsv}>导出 CSV</Button>
|
||||
</div>
|
||||
<Form layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select allowClear style={{ width: 120 }} options={[
|
||||
{ value: 'DRAFT', label: '草稿' },
|
||||
{ value: 'CONFIRMED', label: '已确认' },
|
||||
{ value: 'PAID', label: '已打款' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="partnerId" label="合伙人ID"><Input allowClear /></Form.Item>
|
||||
<Button type="primary" htmlType="submit">筛选</Button>
|
||||
</Form>
|
||||
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1100 }}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
<Modal title="生成合伙人账单" open={genOpen} onCancel={() => setGenOpen(false)} footer={null}>
|
||||
<Form form={genForm} layout="vertical" onFinish={generateBill}>
|
||||
<Form.Item name="partnerId" label="合伙人 ID" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="month" label="账单月份" rules={[{ required: true }]}><DatePicker picker="month" style={{ width: '100%' }} /></Form.Item>
|
||||
<Button type="primary" htmlType="submit" block>生成</Button>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Descriptions, Drawer, Form, Input, Select, Table, Typography, message } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
redeemAmount: number;
|
||||
payoutAmount: number;
|
||||
status: string;
|
||||
expectedPayAt: string;
|
||||
paidAt?: string;
|
||||
store?: { name: string; cityName: string };
|
||||
redeemRecord?: { redeemNo: string };
|
||||
};
|
||||
|
||||
export default function StorePayoutsPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/store-payouts',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
if (filters.storeId) qs.set('storeId', filters.storeId);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
async function confirmPayout(id: string) {
|
||||
await request(`/admin/store-payouts/${id}/confirm`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ remark: '财务确认打款' }),
|
||||
});
|
||||
message.success('已确认打款');
|
||||
reload();
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '门店', dataIndex: ['store', 'name'] },
|
||||
{ title: '城市', dataIndex: ['store', 'cityName'], width: 100 },
|
||||
{ title: '核销额', dataIndex: 'redeemAmount', width: 90, render: (v) => `¥${v}` },
|
||||
{ title: '打款额', dataIndex: 'payoutAmount', width: 90, render: (v) => `¥${v}` },
|
||||
{ title: '状态', dataIndex: 'status', width: 90 },
|
||||
{ title: '预计打款', dataIndex: 'expectedPayAt', width: 160, render: fmtTime },
|
||||
{ title: '实际打款', dataIndex: 'paidAt', width: 160, render: (v) => (v ? fmtTime(String(v)) : '—') },
|
||||
{
|
||||
title: '操作', width: 140,
|
||||
render: (_, row) => (
|
||||
<>
|
||||
<Button type="link" size="small" onClick={async () => {
|
||||
setDetail(await request(`/admin/store-payouts/${row.id}`));
|
||||
setDrawerOpen(true);
|
||||
}}>详情</Button>
|
||||
{row.status === 'PENDING' && (
|
||||
<Button type="link" size="small" onClick={() => confirmPayout(row.id)}>确认打款</Button>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4}>门店打款(T+1)</Typography.Title>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select allowClear style={{ width: 120 }} options={[
|
||||
{ value: 'PENDING', label: '待打款' },
|
||||
{ value: 'PAID', label: '已打款' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="storeId" label="门店ID"><Input allowClear /></Form.Item>
|
||||
<Button type="primary" htmlType="submit">筛选</Button>
|
||||
</Form>
|
||||
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1000 }}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
<Drawer title="打款详情" width={520} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
|
||||
{detail && (
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="核销额">¥{String(detail.redeemAmount)}</Descriptions.Item>
|
||||
<Descriptions.Item label="打款额">¥{String(detail.payoutAmount)}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">{String(detail.status)}</Descriptions.Item>
|
||||
<Descriptions.Item label="预计打款">{fmtTime(String(detail.expectedPayAt))}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Descriptions, Drawer, Form, Input, Table, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
provider: string;
|
||||
scene: string;
|
||||
refType: string;
|
||||
refId: string;
|
||||
externalNo?: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export default function ThirdPartyLogsPage() {
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
|
||||
'/common/third-party-logs',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.provider) qs.set('provider', filters.provider);
|
||||
if (filters.refId) qs.set('refId', filters.refId);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: 'Provider', dataIndex: 'provider', width: 120 },
|
||||
{ title: '场景', dataIndex: 'scene', width: 120 },
|
||||
{ title: '关联', width: 140, render: (_, r) => `${r.refType}#${r.refId}` },
|
||||
{ title: '外部单号', dataIndex: 'externalNo', width: 180 },
|
||||
{ title: '状态', dataIndex: 'status', width: 90 },
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作', width: 80,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={async () => {
|
||||
setDetail(await request(`/common/third-party-logs/${row.id}`));
|
||||
setDrawerOpen(true);
|
||||
}}>详情</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4}>第三方日志</Typography.Title>
|
||||
<Form layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form.Item name="provider" label="Provider"><Input allowClear placeholder="WECHAT_PAY" /></Form.Item>
|
||||
<Form.Item name="refId" label="关联ID"><Input allowClear /></Form.Item>
|
||||
<Button type="primary" htmlType="submit">筛选</Button>
|
||||
</Form>
|
||||
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 900 }}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
<Drawer title="日志详情" width={520} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
|
||||
{detail && (
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="Provider">{String(detail.provider)}</Descriptions.Item>
|
||||
<Descriptions.Item label="场景">{String(detail.scene)}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">{String(detail.status)}</Descriptions.Item>
|
||||
<Descriptions.Item label="外部单号">{String(detail.externalNo ?? '—')}</Descriptions.Item>
|
||||
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt))}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Descriptions, Drawer, Form, Select, Table, Typography, message } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
ticketNo: string;
|
||||
ticketType: string;
|
||||
status: string;
|
||||
refType: string;
|
||||
refId: string;
|
||||
remark?: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export default function TicketsPage() {
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/tickets',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.ticketType) qs.set('ticketType', filters.ticketType);
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
async function approve(id: string) {
|
||||
await request(`/admin/tickets/${id}/approve`, { method: 'POST', body: JSON.stringify({}) });
|
||||
message.success('已审批通过');
|
||||
reload();
|
||||
setDrawerOpen(false);
|
||||
}
|
||||
|
||||
async function reject(id: string) {
|
||||
await request(`/admin/tickets/${id}/reject`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ remark: '驳回' }),
|
||||
});
|
||||
message.success('已驳回');
|
||||
reload();
|
||||
setDrawerOpen(false);
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '工单号', dataIndex: 'ticketNo', width: 180 },
|
||||
{ title: '类型', dataIndex: 'ticketType', width: 100 },
|
||||
{ title: '状态', dataIndex: 'status', width: 90 },
|
||||
{ title: '关联', width: 140, render: (_, r) => `${r.refType}#${r.refId}` },
|
||||
{ title: '备注', dataIndex: 'remark', ellipsis: true },
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作', width: 80,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={async () => {
|
||||
setDetail(await request(`/admin/tickets/${row.id}`));
|
||||
setDrawerOpen(true);
|
||||
}}>详情</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4}>工单中心</Typography.Title>
|
||||
<Form layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form.Item name="ticketType" label="类型">
|
||||
<Select allowClear style={{ width: 120 }} options={[
|
||||
{ value: 'REFUND', label: '退款' },
|
||||
{ value: 'RESHIPMENT', label: '补发' },
|
||||
{ value: 'ALERT', label: '异常' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态"><Input allowClear placeholder="PENDING" /></Form.Item>
|
||||
<Button type="primary" htmlType="submit">筛选</Button>
|
||||
</Form>
|
||||
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 900 }}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
<Drawer title="工单详情" width={480} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||
extra={detail && (detail.status === 'PENDING' || detail.status === 'OPEN') ? (
|
||||
<>
|
||||
<Button type="primary" onClick={() => approve(String(detail.id))} style={{ marginRight: 8 }}>通过</Button>
|
||||
<Button danger onClick={() => reject(String(detail.id))}>驳回</Button>
|
||||
</>
|
||||
) : null}>
|
||||
{detail && (
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="工单号">{String(detail.ticketNo)}</Descriptions.Item>
|
||||
<Descriptions.Item label="类型">{String(detail.ticketType)}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">{String(detail.status)}</Descriptions.Item>
|
||||
<Descriptions.Item label="关联">{String(detail.refType)} #{String(detail.refId)}</Descriptions.Item>
|
||||
<Descriptions.Item label="备注">{String(detail.remark ?? '—')}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,14 @@ function formatAmount(n: number) {
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
type Preview = {
|
||||
amount: number;
|
||||
expireInSeconds: number;
|
||||
user?: { userNo?: string; phone?: string; nickname?: string };
|
||||
redeemType?: string;
|
||||
boundStoreId?: string | null;
|
||||
};
|
||||
|
||||
export default function RedeemConfirmPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
@@ -13,11 +21,15 @@ export default function RedeemConfirmPage() {
|
||||
const [msg, setMsg] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [storeName, setStoreName] = useState('');
|
||||
const [previewAmount, setPreviewAmount] = useState(100);
|
||||
const [preview, setPreview] = useState<Preview | null>(null);
|
||||
const [storeClosed, setStoreClosed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
request<Record<string, unknown>>('SHOP_H5', '/shop/store')
|
||||
.then((s) => setStoreName(String(s.name || '当前门店')))
|
||||
.then((s) => {
|
||||
setStoreName(String(s.name || '当前门店'));
|
||||
if (s.status && s.status !== 'OPEN') setStoreClosed(true);
|
||||
})
|
||||
.catch(() => setStoreName('当前门店'));
|
||||
}, []);
|
||||
|
||||
@@ -26,7 +38,27 @@ export default function RedeemConfirmPage() {
|
||||
if (scanned) setToken(scanned);
|
||||
}, [searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token.trim()) {
|
||||
setPreview(null);
|
||||
return;
|
||||
}
|
||||
request<Preview>('SHOP_H5', '/shop/redeem/preview', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ token }),
|
||||
})
|
||||
.then(setPreview)
|
||||
.catch((e) => {
|
||||
setPreview(null);
|
||||
setMsg(e instanceof Error ? e.message : '无法预览核销码');
|
||||
});
|
||||
}, [token]);
|
||||
|
||||
async function confirm() {
|
||||
if (storeClosed) {
|
||||
setMsg('门店未营业,无法核销');
|
||||
return;
|
||||
}
|
||||
if (!token.trim()) {
|
||||
setMsg('请在开发者选项中输入核销码');
|
||||
return;
|
||||
@@ -47,6 +79,9 @@ export default function RedeemConfirmPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const previewAmount = preview?.amount ?? 0;
|
||||
const userLabel = preview?.user?.nickname || preview?.user?.phone || preview?.user?.userNo || '待扫码确认';
|
||||
|
||||
return (
|
||||
<div className="shop-redeem-page">
|
||||
<header className="shop-redeem-header">
|
||||
@@ -57,6 +92,9 @@ export default function RedeemConfirmPage() {
|
||||
</header>
|
||||
|
||||
<main className="shop-redeem-main">
|
||||
{storeClosed && (
|
||||
<p className="shop-redeem-error" style={{ marginBottom: 12 }}>门店当前未营业,无法核销</p>
|
||||
)}
|
||||
<section className="shop-redeem-card">
|
||||
<div className="shop-redeem-banner">
|
||||
<div className="shop-redeem-banner-icon">
|
||||
@@ -75,7 +113,7 @@ export default function RedeemConfirmPage() {
|
||||
<span>下单用户</span>
|
||||
</div>
|
||||
<span className="headline-md" style={{ fontFamily: 'var(--font-headline)', fontWeight: 600 }}>
|
||||
待扫码确认
|
||||
{userLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -91,7 +129,7 @@ export default function RedeemConfirmPage() {
|
||||
<span className="material-symbols-outlined shop-fill-icon" style={{ fontSize: 16, color: 'var(--color-aged-amber)' }}>
|
||||
confirmation_number
|
||||
</span>
|
||||
好客权益
|
||||
好客权益 · {preview?.redeemType === 'COUPON' ? '单据核销' : '直接核销'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -102,8 +140,14 @@ export default function RedeemConfirmPage() {
|
||||
</div>
|
||||
<div className="shop-redeem-detail-row">
|
||||
<span>有效期</span>
|
||||
<span>永久</span>
|
||||
<span>{preview ? `${preview.expireInSeconds} 秒` : '—'}</span>
|
||||
</div>
|
||||
{preview?.boundStoreId && (
|
||||
<div className="shop-redeem-detail-row">
|
||||
<span>绑定门店</span>
|
||||
<span>仅限指定门店</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{msg && <p className="shop-redeem-error">{msg}</p>}
|
||||
@@ -111,13 +155,13 @@ export default function RedeemConfirmPage() {
|
||||
<button
|
||||
type="button"
|
||||
className={`shop-redeem-confirm-btn${loading ? ' success' : ''}`}
|
||||
disabled={loading}
|
||||
disabled={loading || storeClosed || !preview}
|
||||
onClick={confirm}
|
||||
>
|
||||
<span className="material-symbols-outlined shop-fill-icon">
|
||||
{loading ? 'sync' : 'check_circle'}
|
||||
</span>
|
||||
<span>{loading ? '正在核销...' : `确认核销 ¥${formatAmount(previewAmount)}`}</span>
|
||||
<span>{loading ? '正在核销...' : preview ? `确认核销 ¥${formatAmount(previewAmount)}` : '等待扫码'}</span>
|
||||
</button>
|
||||
|
||||
<p className="shop-redeem-hint">请核对金额后点击确认</p>
|
||||
@@ -127,10 +171,7 @@ export default function RedeemConfirmPage() {
|
||||
<div className="shop-redeem-dev-body">
|
||||
<input
|
||||
value={token}
|
||||
onChange={(e) => {
|
||||
setToken(e.target.value);
|
||||
if (e.target.value) setPreviewAmount(100);
|
||||
}}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
placeholder="粘贴用户核销码"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,44 +1,47 @@
|
||||
/** Stitch 用户端-商品详情页 原型图(杜康·白水古酿) */
|
||||
const STITCH_CAROUSEL = [
|
||||
'https://lh3.googleusercontent.com/aida-public/AB6AXuDiJm2VWrwCv8wnC-dBgSfvlf66izs6faELgWXlyIAUpYWOKrLwfeyB0c0XT0vmDVJnfIkzbLNm_4NYASwH_ce7BotJDLCJcd3SfnxKIe7eso-c4mzzR-4LTv4y3ELhpXHfxVyu-5LVUEwuofvUuzdJELV6CK4MIcLW_9rMaOuXSADfz0mpP-MspQvhKhxJ0wpdAiBxBq8rqHNSKjx8dU7lcVc_smZGunmtkbhmnjAn4JU8nCDnvuDU5HECng82FbFM6rzdkFHoYR0',
|
||||
'https://lh3.googleusercontent.com/aida-public/AB6AXuDzBBRn0yOqhRJ4CbTEOx1aF4fJVIhsbIZgFR9RgdB5E0xcs_RdR1khLyR0OzysGzkW_tnrZTb0avVEZ91Nd81KRItrlTjrEFvrYj0Qag45iRo5wioY8E2gK5NGhILvDpWxakuSPIGGp00nLY_5HuuLwr-0_8ZabaUFAR4C9loXIX_lgCAgRMt7An_H0AitIOBvwOfNVTMkz-P7dXQFzSUvYpFvcmvzAOIWsbipnTrgNU5H8Os37-soM-eWUCfNtJUaD_uqQ3mwJI8',
|
||||
'https://lh3.googleusercontent.com/aida-public/AB6AXuDSSmH1ygwminKXiiIqOymnukbKJfnhfHnmCJTbNN2BEN2yF3vPtoMYOBAsDHxuldT9xg_ZBZhjh6QJjabvhu_HFB3WcNU53q_AjsD0mVWXInongiXqjOh8R-B2QW9Jfs786j3TSi2gVE57Ad1WskJji-xytI3aFEuk873xGXgdkn6EgzoAMOsKRaWF27DE3GBa48qAARYR92aEyMU_hcte6L2lkaF9brXshSmujiA_3ACK21TLsT2DCJ1Djacvh25J0LZ8v7BYVkk',
|
||||
] as const;
|
||||
/** 商品无图时的占位图 */
|
||||
export const PRODUCT_IMAGE_FALLBACK = '/images/1.png';
|
||||
|
||||
const STITCH_DETAIL = [
|
||||
'https://lh3.googleusercontent.com/aida-public/AB6AXuA2-IVt-apnEkj9QQ4rkjN5lb0oymgiJX1XfzAH8pRzSFzMVjYDtlMWE8GwpS7I6sth7CXJiKwNm9c-hpsYKqQ6pyb48yUO6NG8vky4E6qCjwgaCsCnlvoOVLroG4bmmL16-xl4-o28ZMvtzMoCKIiUK-_dQF7lx66nwnxFOP7PcUddDK-UItoO-Gp5iqxf6kp_-t_tjoPpo_ba25DBPG1QThlI8IJYqb9bNng5mIQzdnNul24rBy_JmgS5nsaYK0Wvo7907WT3ch4',
|
||||
'https://lh3.googleusercontent.com/aida-public/AB6AXuBe0yoN6VDKu2SVqwOy9RedE-6Rh56-a_5ygo5raDOU3Y65m1fz9hNmqbrIpvTW8RsqAwPd3zfHnw96Bb4Ct7jtmq-pil1MEvPBL4G3C8Sym_LVuEzK---hgdim1wVx-qP1v1EPex2fXpVQEY27rEVINVaXk2L1F5elWKQhMHWVXjU8B2jvtyNzmlXBpynsocnCgcwM4RhaqYdVf1JZxcfScmJ34dO3QAUIli-RPzEYynLtnW2x4lEbRrpAdBnK2fuSggLnJU1nfSw',
|
||||
] as const;
|
||||
export type ProductImageSource = {
|
||||
mainImageUrl?: string | null;
|
||||
carouselUrls?: string[] | null;
|
||||
detailImageUrls?: string[] | null;
|
||||
};
|
||||
|
||||
/** 本地商品图占位 */
|
||||
export const PRODUCT_IMAGE_INDEX = [
|
||||
'/images/1.png',
|
||||
'/images/2.png',
|
||||
'/images/3.png',
|
||||
] as const;
|
||||
|
||||
export function getProductImages(productIndex = 0): string[] {
|
||||
if (productIndex === 0) {
|
||||
return [...STITCH_CAROUSEL.slice(0, 2)];
|
||||
function uniqueUrls(urls: Array<string | null | undefined>) {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const url of urls) {
|
||||
if (!url || seen.has(url)) continue;
|
||||
seen.add(url);
|
||||
result.push(url);
|
||||
}
|
||||
const img = PRODUCT_IMAGE_INDEX[productIndex % PRODUCT_IMAGE_INDEX.length] ?? '/images/1.png';
|
||||
return [img];
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getProductMainImage(productIndex = 0): string {
|
||||
if (productIndex === 0) return STITCH_CAROUSEL[0];
|
||||
return PRODUCT_IMAGE_INDEX[productIndex % PRODUCT_IMAGE_INDEX.length] ?? '/images/1.png';
|
||||
/** 首页/列表轮播图:优先 CAROUSEL,否则封面 */
|
||||
export function getProductImages(source?: ProductImageSource | null): string[] {
|
||||
const carousel = uniqueUrls(source?.carouselUrls ?? []);
|
||||
if (carousel.length > 0) return carousel;
|
||||
|
||||
const main = source?.mainImageUrl;
|
||||
if (main) return [main];
|
||||
|
||||
return [PRODUCT_IMAGE_FALLBACK];
|
||||
}
|
||||
|
||||
/** 详情页轮播(首商品用 Stitch 三图,其余单图) */
|
||||
export function getProductCarouselImages(productIndex = 0): string[] {
|
||||
if (productIndex === 0) return [...STITCH_CAROUSEL];
|
||||
const img = getProductMainImage(productIndex);
|
||||
return [img];
|
||||
/** 单张主图:封面优先 */
|
||||
export function getProductMainImage(source?: ProductImageSource | null): string {
|
||||
return source?.mainImageUrl ?? source?.carouselUrls?.[0] ?? PRODUCT_IMAGE_FALLBACK;
|
||||
}
|
||||
|
||||
/** 详情页顶部轮播 */
|
||||
export function getProductCarouselImages(source?: ProductImageSource | null): string[] {
|
||||
const carousel = uniqueUrls(source?.carouselUrls ?? []);
|
||||
if (carousel.length > 0) return carousel;
|
||||
return getProductImages(source);
|
||||
}
|
||||
|
||||
/** 详情页图文长图 */
|
||||
export function getProductDetailImages(productIndex = 0): string[] {
|
||||
if (productIndex === 0) return [...STITCH_DETAIL];
|
||||
return [];
|
||||
export function getProductDetailImages(source?: ProductImageSource | null): string[] {
|
||||
return uniqueUrls(source?.detailImageUrls ?? []);
|
||||
}
|
||||
|
||||
@@ -13,27 +13,50 @@ type Product = {
|
||||
subtitle: string;
|
||||
price: number;
|
||||
benefitDisplay: number;
|
||||
mainImageUrl: string;
|
||||
mainImageUrl?: string | null;
|
||||
carouselUrls?: string[] | null;
|
||||
detailImageUrls?: string[] | null;
|
||||
aromaType: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
type City = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
const AROMA_TABS = [
|
||||
{ key: 'QINGXIANG', label: '清香型', open: true },
|
||||
{ key: 'JIANGXIANG', label: '酱香型', open: false },
|
||||
{ key: 'NONGXIANG', label: '浓香型', open: false },
|
||||
];
|
||||
|
||||
const CITY_STORAGE_KEY = 'dukang_selected_city';
|
||||
|
||||
export default function HomePage() {
|
||||
const [tab, setTab] = useState('QINGXIANG');
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [cities, setCities] = useState<City[]>([]);
|
||||
const [cityCode, setCityCode] = useState(() => localStorage.getItem(CITY_STORAGE_KEY) || '410100');
|
||||
const [toast, setToast] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
request<Product[]>('USER_H5', '/catalog/products').then(setProducts);
|
||||
request<City[]>('USER_H5', '/catalog/cities').then((list) => {
|
||||
setCities(list);
|
||||
if (!list.some((c) => c.code === cityCode) && list[0]) {
|
||||
setCityCode(list[0].code);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cityCode) return;
|
||||
localStorage.setItem(CITY_STORAGE_KEY, cityCode);
|
||||
request<Product[]>('USER_H5', `/catalog/products?cityCode=${encodeURIComponent(cityCode)}`).then(setProducts);
|
||||
}, [cityCode]);
|
||||
|
||||
function showToast(message: string) {
|
||||
setToast(message);
|
||||
window.setTimeout(() => setToast(''), 2200);
|
||||
@@ -47,6 +70,7 @@ export default function HomePage() {
|
||||
setTab(key);
|
||||
}
|
||||
|
||||
const selectedCity = cities.find((c) => c.code === cityCode);
|
||||
const filtered = products.filter((p) => p.aromaType === tab);
|
||||
const onSale = tab === 'QINGXIANG';
|
||||
|
||||
@@ -57,7 +81,16 @@ export default function HomePage() {
|
||||
extra={(
|
||||
<div className="tab-main-city">
|
||||
<span className="material-symbols-outlined">location_on</span>
|
||||
<span>郑州市</span>
|
||||
<select
|
||||
value={cityCode}
|
||||
onChange={(e) => setCityCode(e.target.value)}
|
||||
style={{ border: 'none', background: 'transparent', font: 'inherit', color: 'inherit' }}
|
||||
>
|
||||
{cities.map((c) => (
|
||||
<option key={c.code} value={c.code}>{c.name}</option>
|
||||
))}
|
||||
{!cities.length && <option value={cityCode}>{selectedCity?.name ?? '郑州市'}</option>}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
@@ -78,10 +111,10 @@ export default function HomePage() {
|
||||
<section className="home-product-list">
|
||||
{!onSale && <div className="home-empty">该香型暂未上线,敬请期待</div>}
|
||||
{onSale &&
|
||||
filtered.map((p, index) => (
|
||||
filtered.map((p) => (
|
||||
<article key={p.id} className="home-product-card">
|
||||
<Link to={`/product/${p.id}`} className="home-product-link">
|
||||
<ProductCarousel images={getProductImages(index)} alt={p.name} />
|
||||
<ProductCarousel images={getProductImages(p)} alt={p.name} />
|
||||
<div className="home-product-body">
|
||||
<div className="home-product-row">
|
||||
<h3 className="home-product-name">{p.name}</h3>
|
||||
|
||||
@@ -4,8 +4,6 @@ import SubPageHeader from '../components/SubPageHeader';
|
||||
import AppImage from '@dukang/shared-ui/AppImage';
|
||||
import { request } from '../lib/api';
|
||||
import { buildProductDetailUrl } from '../lib/navigation';
|
||||
import { STITCH_ORDER_PRODUCT_IMAGE } from '../lib/order-images';
|
||||
import { tryGetClientGpsLocation } from '../lib/client-location';
|
||||
import { getProductMainImage } from '../lib/product-images';
|
||||
import PhoneVerifySheet from '../components/PhoneVerifySheet';
|
||||
import { useUserSession } from '../contexts/UserSessionContext';
|
||||
@@ -27,6 +25,8 @@ type PreviewProduct = {
|
||||
spec: string;
|
||||
subtitle?: string;
|
||||
price: number;
|
||||
mainImageUrl?: string | null;
|
||||
carouselUrls?: string[] | null;
|
||||
};
|
||||
|
||||
type OrderPreview = {
|
||||
@@ -113,9 +113,7 @@ export default function OrderConfirmPage() {
|
||||
|
||||
const isCross = forceCross || preview?.deliveryType === 'CROSS_CITY';
|
||||
const minQty = isCross ? (preview?.city?.crossMinQty ?? 6) : (preview?.city?.localMinQty ?? 2);
|
||||
const productIndex = productId ? Math.max(0, Number(productId) - 1) : 0;
|
||||
const productImage =
|
||||
productIndex === 0 ? STITCH_ORDER_PRODUCT_IMAGE : getProductMainImage(productIndex);
|
||||
const productImage = preview?.product ? getProductMainImage(preview.product) : getProductMainImage();
|
||||
|
||||
async function doSubmit() {
|
||||
const clientLocation = await tryGetClientGpsLocation();
|
||||
|
||||
@@ -145,6 +145,7 @@ export default function OrderDetailPage() {
|
||||
const productImage = item?.productImage || STITCH_ORDER_PRODUCT_IMAGE;
|
||||
const canEditAddress = order ? EDITABLE_STATUSES.has(order.status) : false;
|
||||
const canConfirmReceive = order?.status === 'PENDING_RECEIVE' && !isReship;
|
||||
const canRefund = order && ['PENDING_SHIP', 'PENDING_RECEIVE', 'COMPLETED', 'OUT_WAREHOUSE', 'SHIPPING'].includes(order.status);
|
||||
const productTotal = Number(order?.productAmount ?? order?.payAmount ?? 0);
|
||||
const freightTotal = Number(order?.freightAmount ?? 0);
|
||||
|
||||
@@ -174,6 +175,22 @@ export default function OrderDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function requestRefund() {
|
||||
if (!id || !canRefund) return;
|
||||
setConfirming(true);
|
||||
try {
|
||||
await request('USER_H5', `/trade/orders/${id}/refund-requests`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ remark: '用户申请退款' }),
|
||||
});
|
||||
await loadOrder();
|
||||
} catch (e) {
|
||||
window.alert(e instanceof Error ? e.message : '申请退款失败');
|
||||
} finally {
|
||||
setConfirming(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!order) return <div className="empty">加载中...</div>;
|
||||
|
||||
return (
|
||||
@@ -380,6 +397,11 @@ export default function OrderDetailPage() {
|
||||
<span className="material-symbols-outlined">headset_mic</span>
|
||||
联系客服
|
||||
</button>
|
||||
{canRefund && order?.status !== 'REFUNDING' && order?.status !== 'REFUNDED' && (
|
||||
<button type="button" className="order-detail-action-outline" disabled={confirming} onClick={requestRefund}>
|
||||
申请退款
|
||||
</button>
|
||||
)}
|
||||
{canConfirmReceive && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -4,8 +4,9 @@ import AppImage from '@dukang/shared-ui/AppImage';
|
||||
import ProductCarousel from '../components/ProductCarousel';
|
||||
import { request } from '../lib/api';
|
||||
import { getProductCarouselImages, getProductDetailImages } from '../lib/product-images';
|
||||
import type { ProductImageSource } from '../lib/product-images';
|
||||
|
||||
type Product = {
|
||||
type Product = ProductImageSource & {
|
||||
name: string;
|
||||
subtitle?: string;
|
||||
price: number;
|
||||
@@ -22,7 +23,6 @@ export default function ProductDetailPage() {
|
||||
const navigate = useNavigate();
|
||||
const [product, setProduct] = useState<Product | null>(null);
|
||||
const [headerSolid, setHeaderSolid] = useState(false);
|
||||
const imageIndex = id ? Math.max(0, Number(id) - 1) : 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (id) request<Product>('USER_H5', `/catalog/products/${id}`).then(setProduct);
|
||||
@@ -39,8 +39,8 @@ export default function ProductDetailPage() {
|
||||
if (!product) return <div className="empty">加载中...</div>;
|
||||
|
||||
const benefit = Number(product.benefitAmount ?? product.price);
|
||||
const carouselImages = getProductCarouselImages(imageIndex);
|
||||
const detailImages = getProductDetailImages(imageIndex);
|
||||
const carouselImages = getProductCarouselImages(product);
|
||||
const detailImages = getProductDetailImages(product);
|
||||
|
||||
return (
|
||||
<div className="product-detail-page">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
|
||||
@@ -5,6 +6,19 @@ export default function RedeemCodePage() {
|
||||
const navigate = useNavigate();
|
||||
const token = sessionStorage.getItem('redeemToken') || '';
|
||||
const amount = sessionStorage.getItem('redeemAmount') || '0';
|
||||
const [secondsLeft, setSecondsLeft] = useState(300);
|
||||
|
||||
useEffect(() => {
|
||||
const expireAt = sessionStorage.getItem('redeemExpireAt');
|
||||
if (!expireAt) return;
|
||||
const tick = () => {
|
||||
const left = Math.max(0, Math.floor((new Date(expireAt).getTime() - Date.now()) / 1000));
|
||||
setSecondsLeft(left);
|
||||
};
|
||||
tick();
|
||||
const id = window.setInterval(tick, 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="page-no-tab" style={{ textAlign: 'center' }}>
|
||||
@@ -14,10 +28,14 @@ export default function RedeemCodePage() {
|
||||
<div className="label-md text-muted">核销金额</div>
|
||||
<div className="amount-xl" style={{ margin: '8px 0 24px' }}>¥{amount}</div>
|
||||
<div className="code-box">{token}</div>
|
||||
<p className="label-md text-muted" style={{ marginTop: 16 }}>5 分钟内有效</p>
|
||||
<p className="label-md text-muted" style={{ marginTop: 16 }}>
|
||||
{secondsLeft > 0 ? `${secondsLeft} 秒后过期` : '已过期,请重新生成'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="page-actions">
|
||||
<button type="button" className="btn btn-outline btn-block" onClick={() => navigate('/redeem/success')}>模拟核销完成</button>
|
||||
<button type="button" className="btn btn-outline btn-block" onClick={() => navigate('/benefit')}>
|
||||
返回权益页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+3
-2
@@ -1,7 +1,8 @@
|
||||
# 杜康好客 · 协同与开发规范
|
||||
|
||||
> **编码事实源**:根目录 [`杜康好客-V2编码手册.md`](./杜康好客-V2编码手册.md)(PRD + DB v3.1 + API + 计划 + 架构)
|
||||
> **preV1 Mock 联调**:[`杜康好客-preV1编码手册.md`](./杜康好客-preV1编码手册.md)
|
||||
> **V3 交付验收**:[`杜康好客-v3编码手册.md`](./杜康好客-v3编码手册.md)
|
||||
> **完整蓝图**:[`杜康好客-V2编码手册.md`](./杜康好客-V2编码手册.md)(与 V3 冲突时 V3 优先)
|
||||
> **preV1 历史**:[`杜康好客-preV1编码手册.md`](./杜康好客-preV1编码手册.md)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import js from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import globals from 'globals';
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: [
|
||||
'**/dist/**',
|
||||
'**/node_modules/**',
|
||||
'**/prisma/**',
|
||||
'pages/**',
|
||||
'deploy/**',
|
||||
'**/*.config.js',
|
||||
'**/*.config.ts',
|
||||
],
|
||||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
'prefer-const': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['scripts/**/*.mjs'],
|
||||
languageOptions: {
|
||||
globals: globals.node,
|
||||
},
|
||||
},
|
||||
);
|
||||
+12
-2
@@ -1,6 +1,12 @@
|
||||
{
|
||||
"name": "dukang-haoke",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.17.0",
|
||||
"eslint": "^9.17.0",
|
||||
"globals": "^15.14.0",
|
||||
"typescript-eslint": "^8.18.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "pnpm -r --parallel dev",
|
||||
"dev:api": "pnpm --filter @dukang/api dev",
|
||||
@@ -9,7 +15,8 @@
|
||||
"dev:partner": "pnpm --filter @dukang/h5-partner dev",
|
||||
"dev:admin": "pnpm --filter @dukang/admin-web dev",
|
||||
"build": "pnpm -r build",
|
||||
"lint": "pnpm -r lint",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "pnpm -r exec tsc --noEmit",
|
||||
"test": "pnpm -r test",
|
||||
"db:generate": "pnpm --filter @dukang/api prisma:generate",
|
||||
"db:migrate": "pnpm --filter @dukang/api prisma:migrate",
|
||||
@@ -17,7 +24,10 @@
|
||||
"db:sync-benefit": "pnpm --filter @dukang/api prisma:sync-benefit",
|
||||
"db:validate": "pnpm --filter @dukang/api prisma:validate",
|
||||
"oss:cors": "node scripts/configure-oss-cors.mjs",
|
||||
"sync:stitch": "node scripts/sync-stitch.mjs"
|
||||
"sync:stitch": "node scripts/sync-stitch.mjs",
|
||||
"smoke": "node scripts/smoke-v3.mjs",
|
||||
"smoke:v3": "node scripts/smoke-v3.mjs",
|
||||
"smoke:prev1": "node scripts/smoke-prev1.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"test": "vitest run",
|
||||
"lint": "echo ok"
|
||||
"lint": "eslint src"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.4.5",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"lint": "echo ok"
|
||||
"lint": "eslint src"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.4.5"
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
export interface BenefitCouponDto {
|
||||
id: string;
|
||||
couponNo: string;
|
||||
balance: number;
|
||||
totalAmount: number;
|
||||
status: string;
|
||||
sourceProduct?: string;
|
||||
}
|
||||
|
||||
export interface BenefitSummaryDto {
|
||||
totalBalance: number;
|
||||
couponCount: number;
|
||||
}
|
||||
|
||||
export interface BenefitLedgerDto {
|
||||
id: string;
|
||||
type: string;
|
||||
amount: number;
|
||||
balanceAfter: number;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export interface CityDto {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
province: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface ProductDto {
|
||||
id: string;
|
||||
skuCode: string;
|
||||
name: string;
|
||||
subtitle?: string | null;
|
||||
price: number;
|
||||
benefitAmount: number;
|
||||
benefitDisplay?: number;
|
||||
aromaType: string;
|
||||
status: string;
|
||||
/** 封面图(common_resource COVER / cover_resource_id) */
|
||||
mainImageUrl?: string | null;
|
||||
/** 轮播图(bizType=CAROUSEL;无则回退封面) */
|
||||
carouselUrls?: string[];
|
||||
/** 详情长图(bizType=DETAIL 或 detailContent JSON) */
|
||||
detailImageUrls?: string[];
|
||||
}
|
||||
|
||||
export interface ProductListQuery {
|
||||
cityCode?: string;
|
||||
aromaType?: string;
|
||||
}
|
||||
@@ -2,3 +2,10 @@ export * from './enums';
|
||||
export * from './api';
|
||||
export * from './config';
|
||||
export * from './wechat';
|
||||
export * from './catalog';
|
||||
export * from './trade';
|
||||
export * from './benefit';
|
||||
export * from './redeem';
|
||||
export * from './settlement';
|
||||
export * from './ops';
|
||||
export * from './ticket';
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export interface AdminListQuery {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface AdminPageResult<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface ExportRequest {
|
||||
format?: 'csv';
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export interface RedeemTokenRequest {
|
||||
amount: number;
|
||||
couponId?: string;
|
||||
storeId?: string;
|
||||
}
|
||||
|
||||
export interface RedeemTokenResult {
|
||||
token: string;
|
||||
expireAt: string;
|
||||
amount: number;
|
||||
boundStoreId?: string | null;
|
||||
}
|
||||
|
||||
export interface RedeemPreviewDto {
|
||||
token: string;
|
||||
amount: number;
|
||||
expireInSeconds: number;
|
||||
redeemType?: 'DIRECT' | 'COUPON';
|
||||
boundStoreId?: string | null;
|
||||
}
|
||||
|
||||
export interface RedeemRecordDto {
|
||||
id: string;
|
||||
redeemNo: string;
|
||||
amount: number;
|
||||
settleAmount: number;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export interface StorePayoutDto {
|
||||
id: string;
|
||||
redeemAmount: number;
|
||||
payoutAmount: number;
|
||||
status: 'PENDING' | 'PAID';
|
||||
expectedPayAt: string;
|
||||
paidAt?: string | null;
|
||||
}
|
||||
|
||||
export interface PartnerBillDto {
|
||||
id: string;
|
||||
billNo: string;
|
||||
orderCommission: number;
|
||||
redeemCommission: number;
|
||||
totalAmount: number;
|
||||
status: 'DRAFT' | 'CONFIRMED' | 'PAID';
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
}
|
||||
|
||||
export interface PartnerBillDetailDto extends PartnerBillDto {
|
||||
partnerId: string;
|
||||
confirmedAt?: string | null;
|
||||
paidAt?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export type TicketTypeDto = 'REFUND' | 'RESHIPMENT' | 'ALERT';
|
||||
|
||||
export interface TicketDto {
|
||||
id: string;
|
||||
ticketNo: string;
|
||||
ticketType: TicketTypeDto;
|
||||
status: string;
|
||||
refType: string;
|
||||
refId: string;
|
||||
remark?: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface TicketActionRequest {
|
||||
remark?: string;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
export interface OrderDto {
|
||||
id: string;
|
||||
orderNo: string;
|
||||
status: string;
|
||||
payStatus: string;
|
||||
payAmount: number;
|
||||
benefitAmount: number;
|
||||
quantity: number;
|
||||
productName: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface OrderPreviewRequest {
|
||||
productId: string;
|
||||
quantity: number;
|
||||
addressId?: string;
|
||||
}
|
||||
|
||||
export interface OrderPreviewResult {
|
||||
productAmount: number;
|
||||
payAmount: number;
|
||||
benefitAmount: number;
|
||||
deliveryType: 'LOCAL' | 'CROSS_CITY';
|
||||
}
|
||||
|
||||
export interface PayOrderResult {
|
||||
mode?: 'jsapi' | 'mock';
|
||||
orderId?: string;
|
||||
}
|
||||
|
||||
export interface DeliveryDto {
|
||||
provider?: string;
|
||||
shippingAt?: string;
|
||||
deliveredAt?: string;
|
||||
}
|
||||
Generated
+667
-1
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,178 @@
|
||||
const API = process.env.SMOKE_API ?? 'http://localhost:3000/api/v1';
|
||||
|
||||
async function req(clientApp, path, options = {}) {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Client-App': clientApp,
|
||||
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
|
||||
};
|
||||
const res = await fetch(`${API}${path}`, { ...options, headers, body: options.body });
|
||||
const json = await res.json();
|
||||
if (json.code !== 0) throw new Error(`${path}: ${json.message}`);
|
||||
return json.data;
|
||||
}
|
||||
|
||||
async function expectFail(clientApp, path, options = {}) {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Client-App': clientApp,
|
||||
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
|
||||
};
|
||||
const res = await fetch(`${API}${path}`, { ...options, headers, body: options.body });
|
||||
const json = await res.json();
|
||||
if (json.code === 0) throw new Error(`${path}: expected failure`);
|
||||
return json.message;
|
||||
}
|
||||
|
||||
async function adminLogin() {
|
||||
return req('HQ_WEB', '/admin/auth/login/sms', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone: '13600000001', code: '123456' }),
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('1. Health');
|
||||
await req('USER_H5', '/health');
|
||||
|
||||
console.log('2. User login');
|
||||
const userLogin = await req('USER_H5', '/auth/login/sms', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone: '13800000001', code: '123456' }),
|
||||
});
|
||||
const userToken = userLogin.accessToken;
|
||||
|
||||
console.log('3. Cities + products');
|
||||
const cities = await req('USER_H5', '/catalog/cities');
|
||||
const cityCode = cities[0]?.code ?? 'ZZ';
|
||||
const products = await req('USER_H5', `/catalog/products?cityCode=${cityCode}`);
|
||||
if (products.length < 4) throw new Error(`Expected >=4 products, got ${products.length}`);
|
||||
|
||||
console.log('4. Min purchase boundaries');
|
||||
const addr = await req('USER_H5', '/user/addresses', {
|
||||
method: 'POST',
|
||||
token: userToken,
|
||||
body: JSON.stringify({
|
||||
receiverName: '测试',
|
||||
phone: '13800000001',
|
||||
province: '河南省',
|
||||
city: '郑州市',
|
||||
district: '金水区',
|
||||
detail: 'V3冒烟地址',
|
||||
isDefault: true,
|
||||
}),
|
||||
});
|
||||
await expectFail('USER_H5', '/trade/orders/preview', {
|
||||
method: 'POST',
|
||||
token: userToken,
|
||||
body: JSON.stringify({ productId: products[0].id, quantity: 1, addressId: addr.id }),
|
||||
});
|
||||
|
||||
const preview2 = await req('USER_H5', '/trade/orders/preview', {
|
||||
method: 'POST',
|
||||
token: userToken,
|
||||
body: JSON.stringify({ productId: products[0].id, quantity: 2, addressId: addr.id }),
|
||||
});
|
||||
if (preview2.payAmount <= 0) throw new Error('Preview qty=2 failed');
|
||||
|
||||
console.log('5. Create order + pay + benefit');
|
||||
const order = await req('USER_H5', '/trade/orders', {
|
||||
method: 'POST',
|
||||
token: userToken,
|
||||
body: JSON.stringify({ productId: products[0].id, quantity: 2, addressId: addr.id }),
|
||||
});
|
||||
await req('USER_H5', `/trade/orders/${order.id}/pay`, { method: 'POST', token: userToken });
|
||||
const paidOrder = await req('USER_H5', `/trade/orders/${order.id}`, { token: userToken });
|
||||
if (paidOrder.status !== 'PENDING_SHIP') throw new Error('Order not PENDING_SHIP after pay');
|
||||
const coupons = await req('USER_H5', '/benefit/coupons', { token: userToken });
|
||||
if (!coupons.length) throw new Error('No coupon after pay');
|
||||
const summary = await req('USER_H5', '/benefit/summary', { token: userToken });
|
||||
|
||||
console.log('6. Direct redeem (no couponId)');
|
||||
const directAmount = Math.min(100, Number(summary.totalBalance ?? summary.balance ?? 100));
|
||||
const directToken = await req('USER_H5', '/redeem/tokens', {
|
||||
method: 'POST',
|
||||
token: userToken,
|
||||
body: JSON.stringify({ amount: directAmount }),
|
||||
});
|
||||
if (!directToken.token) throw new Error('Direct redeem token missing');
|
||||
|
||||
console.log('7. Coupon redeem cap');
|
||||
await expectFail('USER_H5', '/redeem/tokens', {
|
||||
method: 'POST',
|
||||
token: userToken,
|
||||
body: JSON.stringify({ couponId: coupons[0].id, amount: Number(coupons[0].balance) + 1 }),
|
||||
});
|
||||
|
||||
console.log('8. Shop confirm redeem');
|
||||
const shopLogin = await req('SHOP_H5', '/shop/auth/login/sms', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone: '13900000001', code: '123456' }),
|
||||
});
|
||||
const preview = await req('SHOP_H5', '/shop/redeem/preview', {
|
||||
method: 'POST',
|
||||
token: shopLogin.accessToken,
|
||||
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,
|
||||
body: JSON.stringify({ token: directToken.token }),
|
||||
});
|
||||
|
||||
console.log('9. Admin login + store payout');
|
||||
const admin = await adminLogin();
|
||||
const payouts = await req('HQ_WEB', '/admin/store-payouts?status=PENDING', {
|
||||
token: admin.accessToken,
|
||||
});
|
||||
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`, {
|
||||
method: 'POST',
|
||||
token: admin.accessToken,
|
||||
body: JSON.stringify({ remark: 'smoke confirm' }),
|
||||
});
|
||||
|
||||
console.log('10. Refund ticket flow');
|
||||
const order2 = await req('USER_H5', '/trade/orders', {
|
||||
method: 'POST',
|
||||
token: userToken,
|
||||
body: JSON.stringify({ productId: products[0].id, quantity: 2, addressId: addr.id }),
|
||||
});
|
||||
await req('USER_H5', `/trade/orders/${order2.id}/pay`, { method: 'POST', token: userToken });
|
||||
await req('USER_H5', `/trade/orders/${order2.id}/refund-requests`, {
|
||||
method: 'POST',
|
||||
token: userToken,
|
||||
body: JSON.stringify({ remark: 'smoke refund' }),
|
||||
});
|
||||
const tickets = await req('HQ_WEB', '/admin/tickets?ticketType=REFUND', {
|
||||
token: admin.accessToken,
|
||||
});
|
||||
const refundTicket = tickets.items?.find((t) => t.refId === order2.id);
|
||||
if (!refundTicket) throw new Error('Refund ticket not created');
|
||||
await req('HQ_WEB', `/admin/tickets/${refundTicket.id}/approve`, {
|
||||
method: 'POST',
|
||||
token: admin.accessToken,
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
console.log('11. Partner bill');
|
||||
const partners = await req('HQ_WEB', '/admin/partners', { token: admin.accessToken });
|
||||
const partnerId = partners.items?.[0]?.id;
|
||||
if (partnerId) {
|
||||
const now = new Date();
|
||||
await req('HQ_WEB', '/admin/partner-bills/generate', {
|
||||
method: 'POST',
|
||||
token: admin.accessToken,
|
||||
body: JSON.stringify({ partnerId, year: now.getFullYear(), month: now.getMonth() + 1 }),
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n✅ V3 smoke passed');
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('❌ V3 smoke failed:', e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -6,7 +6,7 @@
|
||||
"build": "nest build",
|
||||
"dev": "nest start --watch",
|
||||
"start": "node dist/main",
|
||||
"lint": "echo ok",
|
||||
"lint": "eslint src",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:validate": "prisma validate",
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { IntegrationsModule } from '../integrations/integrations.module';
|
||||
import { TradeModule } from '../modules/trade/trade.module';
|
||||
import { PrismaModule } from '../common/prisma/prisma.module';
|
||||
import { WechatPayCallbackController } from './wechat-pay.controller';
|
||||
import { WechatRefundCallbackController } from './wechat-refund.controller';
|
||||
import { DeliveryCallbackController } from './delivery-track.controller';
|
||||
|
||||
@Module({
|
||||
imports: [IntegrationsModule, TradeModule],
|
||||
controllers: [WechatPayCallbackController],
|
||||
imports: [IntegrationsModule, TradeModule, PrismaModule],
|
||||
controllers: [WechatPayCallbackController, WechatRefundCallbackController, DeliveryCallbackController],
|
||||
})
|
||||
export class CallbacksModule {}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Body, Controller, Post } from '@nestjs/common';
|
||||
import { TradeService } from '../modules/trade/trade.service';
|
||||
import { CourierService } from '../integrations/courier/courier.service';
|
||||
import { PrismaService } from '../common/prisma/prisma.module';
|
||||
|
||||
@Controller('callbacks/delivery')
|
||||
export class DeliveryCallbackController {
|
||||
constructor(
|
||||
private readonly tradeService: TradeService,
|
||||
private readonly courier: CourierService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
@Post('track')
|
||||
async track(@Body() body: { orderNo?: string; orderId?: string; status?: string }) {
|
||||
if (!body.orderId && !body.orderNo) {
|
||||
return this.courier.buildTrackCallbackResponse(false);
|
||||
}
|
||||
const order = body.orderId
|
||||
? await this.prisma.order.findUnique({ where: { id: BigInt(body.orderId) } })
|
||||
: await this.prisma.order.findUnique({ where: { orderNo: body.orderNo! } });
|
||||
if (!order) return this.courier.buildTrackCallbackResponse(false);
|
||||
|
||||
const statusMap: Record<string, string> = {
|
||||
SHIPPED: 'SHIPPING',
|
||||
OUT_WAREHOUSE: 'OUT_WAREHOUSE',
|
||||
DELIVERED: 'COMPLETED',
|
||||
COMPLETED: 'COMPLETED',
|
||||
};
|
||||
const target = statusMap[body.status ?? ''] ?? body.status;
|
||||
if (target && target !== order.status) {
|
||||
await this.tradeService.applyStatusTransition(order.id, order.status, target, 'DELIVERY_CALLBACK');
|
||||
}
|
||||
return this.courier.buildTrackCallbackResponse(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Controller, Headers, Post, Req, Res } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import { PrismaService } from '../common/prisma/prisma.module';
|
||||
|
||||
@Controller('callbacks/wechat')
|
||||
export class WechatRefundCallbackController {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
@Post('refund')
|
||||
async refundNotify(
|
||||
@Req() req: Request,
|
||||
@Headers() _headers: Record<string, string | string[] | undefined>,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
try {
|
||||
const body = typeof req.body === 'object' ? req.body : {};
|
||||
const outRefundNo = String((body as Record<string, unknown>).out_refund_no ?? '');
|
||||
const refundId = String((body as Record<string, unknown>).refund_id ?? outRefundNo);
|
||||
|
||||
const existing = await this.prisma.logThirdParty.findFirst({
|
||||
where: { provider: 'WECHAT_REFUND', externalNo: refundId, status: 'SUCCESS' },
|
||||
});
|
||||
if (existing) {
|
||||
return res.status(200).json({ code: 'SUCCESS', message: '成功' });
|
||||
}
|
||||
|
||||
await this.prisma.logThirdParty.create({
|
||||
data: {
|
||||
provider: 'WECHAT_REFUND',
|
||||
scene: 'ORDER_REFUND_CALLBACK',
|
||||
refType: 'TICKET',
|
||||
refId: BigInt(0),
|
||||
externalNo: refundId,
|
||||
status: 'SUCCESS',
|
||||
},
|
||||
});
|
||||
|
||||
return res.status(200).json({ code: 'SUCCESS', message: '成功' });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '处理失败';
|
||||
return res.status(500).json({ code: 'FAIL', message });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,4 +26,8 @@ export class RedisService {
|
||||
async del(key: string) {
|
||||
await this.redis.del(key);
|
||||
}
|
||||
|
||||
async ttl(key: string): Promise<number> {
|
||||
return this.redis.ttl(key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { calcBenefitAmount, calcBenefitSummary, generateCouponNo } from '@dukang/domain';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { buildBenefitLedgerEvent, benefitLedgerWhere } from '../../common/event/event.helpers';
|
||||
|
||||
export type CouponAllocation = { couponId: string; amount: number };
|
||||
|
||||
@Injectable()
|
||||
export class BenefitService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
@@ -85,4 +88,73 @@ export class BenefitService {
|
||||
});
|
||||
return serializeBigInt({ coupon, ledgers });
|
||||
}
|
||||
|
||||
/** 核销扣减券余额(乐观锁),由 redeem 模块调用 */
|
||||
async deductCoupons(
|
||||
tx: Prisma.TransactionClient,
|
||||
allocations: CouponAllocation[],
|
||||
refType: 'STORE',
|
||||
refId: bigint,
|
||||
) {
|
||||
for (const alloc of allocations) {
|
||||
const coupon = await tx.benefitCoupon.findUniqueOrThrow({
|
||||
where: { id: BigInt(alloc.couponId) },
|
||||
});
|
||||
const allocAmount = alloc.amount;
|
||||
const updated = await tx.benefitCoupon.updateMany({
|
||||
where: { id: coupon.id, version: coupon.version, balance: { gte: allocAmount } },
|
||||
data: {
|
||||
usedAmount: { increment: allocAmount },
|
||||
balance: { decrement: allocAmount },
|
||||
version: { increment: 1 },
|
||||
status: Number(coupon.balance) - allocAmount <= 0 ? 'USED_UP' : 'ACTIVE',
|
||||
},
|
||||
});
|
||||
if (updated.count === 0) throw new Error('BENEFIT_DEDUCT_CONFLICT');
|
||||
|
||||
const newBalance = Number(coupon.balance) - allocAmount;
|
||||
await tx.commonEvent.create({
|
||||
data: buildBenefitLedgerEvent({
|
||||
userId: coupon.userId,
|
||||
couponId: coupon.id,
|
||||
type: 'REDEEM',
|
||||
amount: -allocAmount,
|
||||
balanceAfter: newBalance,
|
||||
refType,
|
||||
refId,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 退款作废权益 */
|
||||
async voidCouponsOnRefund(orderId: bigint) {
|
||||
const coupons = await this.prisma.benefitCoupon.findMany({
|
||||
where: { orderId, status: { in: ['ACTIVE', 'USED_UP'] } },
|
||||
});
|
||||
for (const coupon of coupons) {
|
||||
const balance = Number(coupon.balance);
|
||||
if (balance <= 0 && coupon.status === 'USED_UP') continue;
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.benefitCoupon.update({
|
||||
where: { id: coupon.id },
|
||||
data: { status: 'VOID', balance: 0 },
|
||||
});
|
||||
if (balance > 0) {
|
||||
await tx.commonEvent.create({
|
||||
data: buildBenefitLedgerEvent({
|
||||
userId: coupon.userId,
|
||||
couponId: coupon.id,
|
||||
type: 'REFUND_VOID',
|
||||
amount: -balance,
|
||||
balanceAfter: 0,
|
||||
refType: 'ORDER',
|
||||
refId: orderId,
|
||||
remark: '退款作废权益',
|
||||
}),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ export class CatalogController {
|
||||
}
|
||||
|
||||
@Get('products')
|
||||
products(@Query('aromaType') aromaType?: string) {
|
||||
return this.catalogService.listProducts(aromaType);
|
||||
products(@Query('aromaType') aromaType?: string, @Query('cityCode') cityCode?: string) {
|
||||
return this.catalogService.listProducts(aromaType, cityCode);
|
||||
}
|
||||
|
||||
@Get('products/:id')
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { CommonProductItem, CommonResource } from '@prisma/client';
|
||||
|
||||
export type ProductMediaDto = {
|
||||
mainImageUrl: string | null;
|
||||
carouselUrls: string[];
|
||||
detailImageUrls: string[];
|
||||
};
|
||||
|
||||
type ProductWithCover = CommonProductItem & {
|
||||
coverResource?: { url: string } | null;
|
||||
};
|
||||
|
||||
function urlsFromResources(resources: CommonResource[], bizType: 'CAROUSEL' | 'DETAIL') {
|
||||
return resources
|
||||
.filter((r) => r.bizType === bizType && r.url)
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map((r) => r.url);
|
||||
}
|
||||
|
||||
export function mapProductMedia(
|
||||
product: ProductWithCover,
|
||||
extraResources: CommonResource[] = [],
|
||||
): ProductMediaDto {
|
||||
const mainImageUrl = product.coverResource?.url ?? null;
|
||||
const carouselFromDb = urlsFromResources(extraResources, 'CAROUSEL');
|
||||
const detailFromDb = urlsFromResources(extraResources, 'DETAIL');
|
||||
|
||||
const detailFromJson = parseDetailContentImages(product.detailContent);
|
||||
|
||||
const carouselUrls =
|
||||
carouselFromDb.length > 0
|
||||
? carouselFromDb
|
||||
: mainImageUrl
|
||||
? [mainImageUrl]
|
||||
: [];
|
||||
|
||||
const detailImageUrls =
|
||||
detailFromDb.length > 0
|
||||
? detailFromDb
|
||||
: detailFromJson;
|
||||
|
||||
return { mainImageUrl, carouselUrls, detailImageUrls };
|
||||
}
|
||||
|
||||
function parseDetailContentImages(detailContent: unknown): string[] {
|
||||
if (!detailContent || typeof detailContent !== 'object') return [];
|
||||
const record = detailContent as Record<string, unknown>;
|
||||
const images = record.images ?? record.detailImages ?? record.detailImageUrls;
|
||||
if (!Array.isArray(images)) return [];
|
||||
return images.filter((item): item is string => typeof item === 'string' && item.length > 0);
|
||||
}
|
||||
|
||||
export function groupResourcesByProductId(resources: CommonResource[]) {
|
||||
const map = new Map<string, CommonResource[]>();
|
||||
for (const resource of resources) {
|
||||
const key = resource.ownerId.toString();
|
||||
const list = map.get(key) ?? [];
|
||||
list.push(resource);
|
||||
map.set(key, list);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { groupResourcesByProductId, mapProductMedia } from './catalog.mapper';
|
||||
|
||||
@Injectable()
|
||||
export class CatalogService {
|
||||
@@ -10,24 +11,50 @@ export class CatalogService {
|
||||
const cities = await this.prisma.commonCity.findMany({
|
||||
where: { status: 'ACTIVE' },
|
||||
include: { partner: { select: { companyName: true } } },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
return serializeBigInt(cities);
|
||||
}
|
||||
|
||||
async listProducts(aromaType?: string) {
|
||||
async listProducts(aromaType?: string, cityCode?: string) {
|
||||
if (cityCode) {
|
||||
const city = await this.prisma.commonCity.findFirst({
|
||||
where: { code: cityCode, status: 'ACTIVE' },
|
||||
});
|
||||
if (!city) throw new BadRequestException('该城市暂未开城');
|
||||
}
|
||||
|
||||
const products = await this.prisma.commonProductItem.findMany({
|
||||
where: { status: 'ON_SALE', ...(aromaType ? { aromaType: aromaType as never } : {}) },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
include: { coverResource: true },
|
||||
});
|
||||
|
||||
const productIds = products.map((p) => p.id);
|
||||
const resources = productIds.length
|
||||
? await this.prisma.commonResource.findMany({
|
||||
where: {
|
||||
ownerType: 'PRODUCT',
|
||||
ownerId: { in: productIds },
|
||||
status: 'ACTIVE',
|
||||
bizType: { in: ['CAROUSEL', 'DETAIL'] },
|
||||
},
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
})
|
||||
: [];
|
||||
const resourceMap = groupResourcesByProductId(resources);
|
||||
|
||||
return serializeBigInt(
|
||||
products.map((p) => ({
|
||||
products.map((p) => {
|
||||
const media = mapProductMedia(p, resourceMap.get(p.id.toString()) ?? []);
|
||||
return {
|
||||
...p,
|
||||
benefitAmount: p.benefitAmount ?? p.price,
|
||||
price: Number(p.price),
|
||||
benefitDisplay: Number(p.benefitAmount ?? p.price),
|
||||
mainImageUrl: p.coverResource?.url ?? null,
|
||||
})),
|
||||
...media,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,11 +64,23 @@ export class CatalogService {
|
||||
include: { coverResource: true },
|
||||
});
|
||||
if (!product) return null;
|
||||
|
||||
const resources = await this.prisma.commonResource.findMany({
|
||||
where: {
|
||||
ownerType: 'PRODUCT',
|
||||
ownerId: id,
|
||||
status: 'ACTIVE',
|
||||
bizType: { in: ['CAROUSEL', 'DETAIL'] },
|
||||
},
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
|
||||
const media = mapProductMedia(product, resources);
|
||||
return serializeBigInt({
|
||||
...product,
|
||||
benefitAmount: product.benefitAmount ?? product.price,
|
||||
price: Number(product.price),
|
||||
mainImageUrl: product.coverResource?.url ?? null,
|
||||
...media,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,7 +316,7 @@ export class AuthService {
|
||||
? await this.wechatProvider.code2Session(code)
|
||||
: await this.wechatProvider.oauth2AccessToken(code);
|
||||
|
||||
let user = await this.prisma.user.findFirst({
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { wxOpenId: session.openId, status: 1, mergedIntoUserId: null },
|
||||
include: { avatar: true },
|
||||
});
|
||||
|
||||
@@ -20,6 +20,9 @@ export class AdminDashboardService {
|
||||
partnersTotal,
|
||||
redeemToday,
|
||||
deliveriesTotal,
|
||||
pendingPayouts,
|
||||
pendingBills,
|
||||
openTickets,
|
||||
] = await Promise.all([
|
||||
this.prisma.user.count({ where: { status: 1, mergedIntoUserId: null } }),
|
||||
this.prisma.user.count({
|
||||
@@ -38,6 +41,9 @@ export class AdminDashboardService {
|
||||
this.prisma.partner.count(),
|
||||
this.prisma.redeemRecord.count({ where: { createdAt: { gte: todayStart } } }),
|
||||
this.prisma.orderDelivery.count(),
|
||||
this.prisma.storePayout.count({ where: { status: 'PENDING' } }),
|
||||
this.prisma.partnerBill.count({ where: { status: { in: ['DRAFT', 'CONFIRMED'] } } }),
|
||||
this.prisma.commonTicket.count({ where: { status: { in: ['PENDING', 'OPEN'] } } }),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -50,6 +56,9 @@ export class AdminDashboardService {
|
||||
partnersTotal,
|
||||
redeemToday,
|
||||
deliveriesTotal,
|
||||
pendingPayouts,
|
||||
pendingBills,
|
||||
openTickets,
|
||||
ordersByStatus: ordersByStatus.map((row) => ({
|
||||
status: row.status,
|
||||
count: row._count.status,
|
||||
|
||||
@@ -45,6 +45,11 @@ export class AdminStoresController {
|
||||
updateStatus(@Param('id') id: string, @Body() dto: UpdateStoreStatusDto) {
|
||||
return this.service.updateStoreStatus(BigInt(id), dto);
|
||||
}
|
||||
|
||||
@Put(':id/audit')
|
||||
audit(@Param('id') id: string, @Body() body: { approved: boolean; remark?: string }) {
|
||||
return this.service.auditStore(BigInt(id), body);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('admin/store-accounts')
|
||||
|
||||
@@ -93,6 +93,27 @@ export class AdminStoresService {
|
||||
return serializeBigInt(store);
|
||||
}
|
||||
|
||||
async auditStore(id: bigint, dto: { approved: boolean; remark?: string }) {
|
||||
const store = await this.prisma.store.findUnique({ where: { id } });
|
||||
if (!store) throw new NotFoundException('门店不存在');
|
||||
const status = dto.approved ? 'OPEN' : 'PAUSED';
|
||||
const updated = await this.prisma.store.update({
|
||||
where: { id },
|
||||
data: { status },
|
||||
});
|
||||
await this.prisma.commonEvent.create({
|
||||
data: {
|
||||
eventType: 'STORE_AUDIT',
|
||||
refType: 'STORE',
|
||||
refId: id,
|
||||
actorType: 'HQ',
|
||||
status: dto.approved ? 'APPROVED' : 'REJECTED',
|
||||
remark: dto.remark ?? (dto.approved ? '审核通过' : '审核驳回'),
|
||||
},
|
||||
});
|
||||
return serializeBigInt(updated);
|
||||
}
|
||||
|
||||
async updateStore(id: bigint, dto: UpdateStoreDto) {
|
||||
const store = await this.prisma.store.update({
|
||||
where: { id },
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import { AdminTicketsService } from './admin-tickets.service';
|
||||
import { TicketListQueryDto } from '../common/dto/common-query.dto';
|
||||
|
||||
@Controller('admin/tickets')
|
||||
@UseGuards(HqAuthGuard)
|
||||
export class AdminTicketsController {
|
||||
constructor(private readonly service: AdminTicketsService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query() query: TicketListQueryDto) {
|
||||
return this.service.list(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@Param('id') id: string) {
|
||||
return this.service.detail(BigInt(id));
|
||||
}
|
||||
|
||||
@Post(':id/approve')
|
||||
approve(@Param('id') id: string, @Body() body: { remark?: string }) {
|
||||
return this.service.approve(BigInt(id), body.remark);
|
||||
}
|
||||
|
||||
@Post(':id/reject')
|
||||
reject(@Param('id') id: string, @Body() body: { remark?: string }) {
|
||||
return this.service.reject(BigInt(id), body.remark);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { BenefitService } from '../benefit/benefit.service';
|
||||
import { TradeService } from '../trade/trade.service';
|
||||
import { TicketService } from '../common/ticket.service';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import type { TicketListQueryDto } from '../common/dto/common-query.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AdminTicketsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly ticketService: TicketService,
|
||||
private readonly tradeService: TradeService,
|
||||
private readonly benefitService: BenefitService,
|
||||
) {}
|
||||
|
||||
list(query: TicketListQueryDto) {
|
||||
return this.ticketService.list(query);
|
||||
}
|
||||
|
||||
detail(id: bigint) {
|
||||
return this.ticketService.detail(id);
|
||||
}
|
||||
|
||||
async approve(id: bigint, remark?: string) {
|
||||
const ticket = await this.prisma.commonTicket.findUnique({ where: { id } });
|
||||
if (!ticket) throw new NotFoundException('工单不存在');
|
||||
if (ticket.status !== 'PENDING' && ticket.status !== 'OPEN') {
|
||||
throw new BadRequestException('工单状态不可审批');
|
||||
}
|
||||
|
||||
if (ticket.ticketType === 'REFUND' && ticket.refType === 'ORDER') {
|
||||
const orderId = ticket.refId;
|
||||
await this.prisma.order.update({
|
||||
where: { id: orderId },
|
||||
data: { status: 'REFUNDED', payStatus: 'REFUNDED' },
|
||||
});
|
||||
await this.benefitService.voidCouponsOnRefund(orderId);
|
||||
await this.prisma.logThirdParty.create({
|
||||
data: {
|
||||
provider: 'WECHAT_REFUND',
|
||||
scene: 'ORDER_REFUND',
|
||||
refType: 'ORDER',
|
||||
refId: orderId,
|
||||
status: 'SUCCESS',
|
||||
amount: 0,
|
||||
},
|
||||
});
|
||||
await this.prisma.commonEvent.create({
|
||||
data: {
|
||||
eventType: 'ORDER_STATUS',
|
||||
refType: 'ORDER',
|
||||
refId: orderId,
|
||||
actorType: 'HQ',
|
||||
status: 'REFUNDED',
|
||||
remark: remark ?? '退款工单审批通过',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (ticket.ticketType === 'RESHIPMENT' && ticket.refType === 'ORDER') {
|
||||
await this.tradeService.applyStatusTransition(
|
||||
ticket.refId,
|
||||
'PENDING_SHIP',
|
||||
'PENDING_SHIP',
|
||||
'HQ',
|
||||
);
|
||||
}
|
||||
|
||||
return this.ticketService.updateStatus(id, {
|
||||
status: 'RESOLVED',
|
||||
remark: remark ?? '审批通过',
|
||||
});
|
||||
}
|
||||
|
||||
reject(id: bigint, remark?: string) {
|
||||
return this.ticketService.updateStatus(id, {
|
||||
status: 'REJECTED',
|
||||
remark: remark ?? '审批驳回',
|
||||
});
|
||||
}
|
||||
|
||||
async listPartnerReshipments(partnerAccountId: bigint) {
|
||||
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
|
||||
where: { id: partnerAccountId },
|
||||
});
|
||||
const cities = await this.prisma.commonCity.findMany({
|
||||
where: { partnerId: account.partnerId },
|
||||
select: { id: true },
|
||||
});
|
||||
const orders = await this.prisma.order.findMany({
|
||||
where: { cityId: { in: cities.map((c) => c.id) } },
|
||||
select: { id: true },
|
||||
});
|
||||
const orderIds = orders.map((o) => o.id);
|
||||
const tickets = await this.prisma.commonTicket.findMany({
|
||||
where: {
|
||||
ticketType: 'RESHIPMENT',
|
||||
refType: 'ORDER',
|
||||
refId: { in: orderIds },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return serializeBigInt(tickets);
|
||||
}
|
||||
}
|
||||
@@ -21,10 +21,14 @@ import { AdminHqAccountsController } from './admin-hq-accounts.controller';
|
||||
import { AdminHqAccountsService } from './admin-hq-accounts.service';
|
||||
import { AdminProductsController } from './admin-products.controller';
|
||||
import { AdminProductsService } from './admin-products.service';
|
||||
import { AdminTicketsController } from './admin-tickets.controller';
|
||||
import { AdminTicketsService } from './admin-tickets.service';
|
||||
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
|
||||
import { BenefitModule } from '../benefit/benefit.module';
|
||||
import { CommonModule } from '../common/common.module';
|
||||
|
||||
@Module({
|
||||
imports: [IamModule, TradeModule],
|
||||
imports: [IamModule, TradeModule, BenefitModule, CommonModule],
|
||||
controllers: [
|
||||
AdminDashboardController,
|
||||
AdminUsersController,
|
||||
@@ -41,6 +45,7 @@ import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
|
||||
AdminDeliveriesController,
|
||||
AdminHqAccountsController,
|
||||
AdminProductsController,
|
||||
AdminTicketsController,
|
||||
],
|
||||
providers: [
|
||||
AdminDashboardService,
|
||||
@@ -54,6 +59,7 @@ import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
|
||||
AdminDeliveriesService,
|
||||
AdminHqAccountsService,
|
||||
AdminProductsService,
|
||||
AdminTicketsService,
|
||||
SuperAdminGuard,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -29,6 +29,11 @@ export class UserRedeemController {
|
||||
export class ShopRedeemController {
|
||||
constructor(private readonly redeemService: RedeemService) {}
|
||||
|
||||
@Post('preview')
|
||||
preview(@CurrentUser() user: AuthUser, @Body() body: { token: string }) {
|
||||
return this.redeemService.previewRedeem(user.actorId, body.token);
|
||||
}
|
||||
|
||||
@Post('confirm')
|
||||
confirm(@CurrentUser() user: AuthUser, @Body() body: { token: string }) {
|
||||
return this.redeemService.confirmRedeem(user.actorId, body);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { IamModule } from '../iam/iam.module';
|
||||
import { BenefitModule } from '../benefit/benefit.module';
|
||||
import { SettlementModule } from '../settlement/settlement.module';
|
||||
import { RedeemService } from './redeem.service';
|
||||
import { ShopRedeemController, UserRedeemController } from './redeem.controller';
|
||||
|
||||
@Module({
|
||||
imports: [IamModule, forwardRef(() => SettlementModule)],
|
||||
imports: [IamModule, BenefitModule, forwardRef(() => SettlementModule)],
|
||||
controllers: [UserRedeemController, ShopRedeemController],
|
||||
providers: [RedeemService],
|
||||
exports: [RedeemService],
|
||||
|
||||
@@ -15,7 +15,15 @@ import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { RedisService } from '../../common/redis/redis.service';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { SettlementService } from '../settlement/settlement.service';
|
||||
import { buildBenefitLedgerEvent } from '../../common/event/event.helpers';
|
||||
import { BenefitService } from '../benefit/benefit.service';
|
||||
|
||||
type TokenPayload = {
|
||||
userId: string;
|
||||
couponId?: string;
|
||||
amount: number;
|
||||
storeId?: string | null;
|
||||
allocations?: Array<{ couponId: string; amount: number }>;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class RedeemService {
|
||||
@@ -23,6 +31,7 @@ export class RedeemService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly redis: RedisService,
|
||||
private readonly settlementService: SettlementService,
|
||||
private readonly benefitService: BenefitService,
|
||||
) {}
|
||||
|
||||
async createToken(userId: bigint, body: { couponId?: string; amount: number; storeId?: string }) {
|
||||
@@ -42,6 +51,7 @@ export class RedeemService {
|
||||
where: { userId, status: 'ACTIVE' },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
const totalBalance = coupons.reduce((s, c) => s + Number(c.balance), 0);
|
||||
const result = allocateBenefitCoupons(
|
||||
coupons.map((c) => ({
|
||||
id: c.id.toString(),
|
||||
@@ -51,6 +61,8 @@ export class RedeemService {
|
||||
body.amount,
|
||||
);
|
||||
if (!result.ok) throw new BadRequestException(result.message);
|
||||
const check = validateRedeemAmount(totalBalance, body.amount);
|
||||
if (!check.ok) throw new BadRequestException(check.message);
|
||||
allocations = result.allocations;
|
||||
}
|
||||
|
||||
@@ -70,7 +82,7 @@ export class RedeemService {
|
||||
REDEEM_TOKEN_TTL_SECONDS,
|
||||
);
|
||||
|
||||
return { token, expireAt, amount: body.amount };
|
||||
return { token, expireAt, amount: body.amount, boundStoreId: body.storeId ?? null };
|
||||
}
|
||||
|
||||
async getToken(token: string) {
|
||||
@@ -79,6 +91,40 @@ export class RedeemService {
|
||||
return cached;
|
||||
}
|
||||
|
||||
async previewRedeem(storeAccountId: bigint, token: string) {
|
||||
const account = await this.prisma.storeAccount.findUniqueOrThrow({
|
||||
where: { id: storeAccountId },
|
||||
include: { store: true },
|
||||
});
|
||||
if (account.store.status !== 'OPEN') {
|
||||
throw new BadRequestException('门店未营业');
|
||||
}
|
||||
|
||||
const cached = await this.redis.getJson<TokenPayload>(`redeem:token:${token}`);
|
||||
if (!cached) throw new BadRequestException('核销码无效或已过期');
|
||||
|
||||
if (cached.storeId && cached.storeId !== account.storeId.toString()) {
|
||||
throw new BadRequestException('该核销码仅限指定门店使用');
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: BigInt(cached.userId) },
|
||||
select: { id: true, userNo: true, phone: true, nickname: true },
|
||||
});
|
||||
|
||||
const ttl = await this.redis.ttl(`redeem:token:${token}`);
|
||||
|
||||
return serializeBigInt({
|
||||
token,
|
||||
amount: cached.amount,
|
||||
user,
|
||||
boundStoreId: cached.storeId,
|
||||
redeemType: cached.allocations && cached.allocations.length > 1 ? 'DIRECT' : cached.couponId ? 'COUPON' : 'DIRECT',
|
||||
expireInSeconds: ttl > 0 ? ttl : 0,
|
||||
storeMatch: !cached.storeId || cached.storeId === account.storeId.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
async confirmRedeem(storeAccountId: bigint, body: { token: string }) {
|
||||
const account = await this.prisma.storeAccount.findUniqueOrThrow({
|
||||
where: { id: storeAccountId },
|
||||
@@ -88,19 +134,16 @@ export class RedeemService {
|
||||
throw new BadRequestException('门店未营业');
|
||||
}
|
||||
|
||||
const cached = await this.redis.getJson<{
|
||||
userId: string;
|
||||
couponId?: string;
|
||||
amount: number;
|
||||
allocations?: Array<{ couponId: string; amount: number }>;
|
||||
}>(`redeem:token:${body.token}`);
|
||||
const cached = await this.redis.getJson<TokenPayload>(`redeem:token:${body.token}`);
|
||||
if (!cached) throw new BadRequestException('核销码无效或已过期');
|
||||
|
||||
if (cached.storeId && cached.storeId !== account.storeId.toString()) {
|
||||
throw new BadRequestException('该核销码仅限指定门店使用');
|
||||
}
|
||||
|
||||
const allocations =
|
||||
cached.allocations ??
|
||||
(cached.couponId
|
||||
? [{ couponId: cached.couponId, amount: cached.amount }]
|
||||
: []);
|
||||
(cached.couponId ? [{ couponId: cached.couponId, amount: cached.amount }] : []);
|
||||
if (allocations.length === 0) {
|
||||
throw new BadRequestException('核销码数据异常');
|
||||
}
|
||||
@@ -126,36 +169,10 @@ export class RedeemService {
|
||||
const settlementRate = cityRule ? Number(cityRule.storeSettlementRate) : 0.6;
|
||||
const settleAmount = calcRedeemSettleAmount(amount, settlementRate);
|
||||
|
||||
const record = await this.prisma.$transaction(async (tx) => {
|
||||
for (const alloc of allocations) {
|
||||
const coupon = await tx.benefitCoupon.findUniqueOrThrow({
|
||||
where: { id: BigInt(alloc.couponId) },
|
||||
});
|
||||
const allocAmount = alloc.amount;
|
||||
const updated = await tx.benefitCoupon.updateMany({
|
||||
where: { id: coupon.id, version: coupon.version, balance: { gte: allocAmount } },
|
||||
data: {
|
||||
usedAmount: { increment: allocAmount },
|
||||
balance: { decrement: allocAmount },
|
||||
version: { increment: 1 },
|
||||
status: Number(coupon.balance) - allocAmount <= 0 ? 'USED_UP' : 'ACTIVE',
|
||||
},
|
||||
});
|
||||
if (updated.count === 0) throw new BadRequestException('核销失败,请重试');
|
||||
|
||||
const newBalance = Number(coupon.balance) - allocAmount;
|
||||
await tx.commonEvent.create({
|
||||
data: buildBenefitLedgerEvent({
|
||||
userId: coupon.userId,
|
||||
couponId: coupon.id,
|
||||
type: 'REDEEM',
|
||||
amount: -allocAmount,
|
||||
balanceAfter: newBalance,
|
||||
refType: 'STORE',
|
||||
refId: account.storeId,
|
||||
}),
|
||||
});
|
||||
}
|
||||
let record;
|
||||
try {
|
||||
record = await this.prisma.$transaction(async (tx) => {
|
||||
await this.benefitService.deductCoupons(tx, allocations, 'STORE', account.storeId);
|
||||
|
||||
const redeemRecord = await tx.redeemRecord.create({
|
||||
data: {
|
||||
@@ -170,6 +187,12 @@ export class RedeemService {
|
||||
|
||||
return redeemRecord;
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message === 'BENEFIT_DEDUCT_CONFLICT') {
|
||||
throw new BadRequestException('核销失败,请重试');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
await this.settlementService.createStorePayout(record.id, account.storeId, amount, settleAmount, settlementRate);
|
||||
await this.redis.del(`redeem:token:${body.token}`);
|
||||
@@ -187,6 +210,7 @@ export class RedeemService {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: { payout: true },
|
||||
}),
|
||||
this.prisma.redeemRecord.count({ where: { storeId: account.storeId } }),
|
||||
]);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { SettlementService } from './settlement.service';
|
||||
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
|
||||
@Controller('partner/settlement')
|
||||
@@ -15,6 +16,101 @@ export class SettlementController {
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('shop/payouts')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ShopPayoutController {
|
||||
constructor(private readonly settlementService: SettlementService) {}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Query('page') page = '1',
|
||||
@Query('pageSize') pageSize = '20',
|
||||
) {
|
||||
return this.settlementService.listShopPayouts(user.actorId, Number(page), Number(pageSize));
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('admin/store-payouts')
|
||||
@UseGuards(HqAuthGuard)
|
||||
export class AdminStorePayoutController {
|
||||
constructor(private readonly settlementService: SettlementService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query() query: Record<string, string>) {
|
||||
return this.settlementService.listAdminStorePayouts({
|
||||
page: query.page ? Number(query.page) : 1,
|
||||
pageSize: query.pageSize ? Number(query.pageSize) : 20,
|
||||
status: query.status,
|
||||
storeId: query.storeId,
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@Param('id') id: string) {
|
||||
return this.settlementService.getAdminStorePayout(BigInt(id));
|
||||
}
|
||||
|
||||
@Post(':id/confirm')
|
||||
confirm(@Param('id') id: string, @Body() body: { paymentRef?: string; batchNo?: string; remark?: string }) {
|
||||
return this.settlementService.confirmStorePayout(BigInt(id), body);
|
||||
}
|
||||
|
||||
@Post('batch-confirm')
|
||||
batchConfirm(@Body() body: { ids: string[]; batchNo?: string }) {
|
||||
return this.settlementService.batchConfirmStorePayouts(body.ids ?? [], body);
|
||||
}
|
||||
|
||||
@Post('scan-due')
|
||||
scanDue() {
|
||||
return this.settlementService.scanDueStorePayouts();
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('admin/partner-bills')
|
||||
@UseGuards(HqAuthGuard)
|
||||
export class AdminPartnerBillController {
|
||||
constructor(private readonly settlementService: SettlementService) {}
|
||||
|
||||
@Post('generate')
|
||||
generate(@Body() body: { partnerId: string; year: number; month: number }) {
|
||||
return this.settlementService.generatePartnerBill(body);
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(@Query() query: Record<string, string>) {
|
||||
return this.settlementService.listAdminPartnerBills({
|
||||
page: query.page ? Number(query.page) : 1,
|
||||
pageSize: query.pageSize ? Number(query.pageSize) : 20,
|
||||
status: query.status,
|
||||
partnerId: query.partnerId,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('export')
|
||||
export(@Query() query: Record<string, string>) {
|
||||
return this.settlementService.exportPartnerBills({
|
||||
partnerId: query.partnerId,
|
||||
status: query.status,
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@Param('id') id: string) {
|
||||
return this.settlementService.getAdminPartnerBill(BigInt(id));
|
||||
}
|
||||
|
||||
@Post(':id/confirm')
|
||||
confirm(@Param('id') id: string) {
|
||||
return this.settlementService.confirmPartnerBill(BigInt(id));
|
||||
}
|
||||
|
||||
@Post(':id/mark-paid')
|
||||
markPaid(@Param('id') id: string, @Body() body: { paymentRef?: string }) {
|
||||
return this.settlementService.markPartnerBillPaid(BigInt(id), body);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('partner/me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PartnerMeController {
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { IamModule } from '../iam/iam.module';
|
||||
import { SettlementService } from './settlement.service';
|
||||
import { PartnerMeController, SettlementController } from './settlement.controller';
|
||||
import {
|
||||
AdminPartnerBillController,
|
||||
AdminStorePayoutController,
|
||||
PartnerMeController,
|
||||
SettlementController,
|
||||
ShopPayoutController,
|
||||
} from './settlement.controller';
|
||||
|
||||
@Module({
|
||||
imports: [IamModule],
|
||||
controllers: [SettlementController, PartnerMeController],
|
||||
controllers: [
|
||||
SettlementController,
|
||||
PartnerMeController,
|
||||
ShopPayoutController,
|
||||
AdminStorePayoutController,
|
||||
AdminPartnerBillController,
|
||||
],
|
||||
providers: [SettlementService],
|
||||
exports: [SettlementService],
|
||||
})
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
function generateBillNo() {
|
||||
return `PB${Date.now()}${Math.floor(Math.random() * 900 + 100)}`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SettlementService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
@@ -29,6 +34,106 @@ export class SettlementService {
|
||||
return serializeBigInt(payout);
|
||||
}
|
||||
|
||||
async listShopPayouts(storeAccountId: bigint, page = 1, pageSize = 20) {
|
||||
const account = await this.prisma.storeAccount.findUniqueOrThrow({
|
||||
where: { id: storeAccountId },
|
||||
});
|
||||
const where = { storeId: account.storeId };
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.storePayout.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: { redeemRecord: { select: { redeemNo: true, amount: true } } },
|
||||
}),
|
||||
this.prisma.storePayout.count({ where }),
|
||||
]);
|
||||
return serializeBigInt({ items, total, page, pageSize });
|
||||
}
|
||||
|
||||
async listAdminStorePayouts(query: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
status?: string;
|
||||
storeId?: string;
|
||||
}) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const where: Prisma.StorePayoutWhereInput = {};
|
||||
if (query.status) where.status = query.status as Prisma.EnumStorePayoutStatusFilter['equals'];
|
||||
if (query.storeId) where.storeId = BigInt(query.storeId);
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.storePayout.findMany({
|
||||
where,
|
||||
orderBy: { expectedPayAt: 'asc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: {
|
||||
store: { select: { id: true, name: true, cityName: true } },
|
||||
redeemRecord: { select: { redeemNo: true, amount: true, userId: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.storePayout.count({ where }),
|
||||
]);
|
||||
return serializeBigInt({ items, total, page, pageSize });
|
||||
}
|
||||
|
||||
async getAdminStorePayout(id: bigint) {
|
||||
const payout = await this.prisma.storePayout.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
store: true,
|
||||
redeemRecord: { include: { user: { select: { userNo: true, phone: true } } } },
|
||||
},
|
||||
});
|
||||
if (!payout) throw new NotFoundException('打款记录不存在');
|
||||
return serializeBigInt(payout);
|
||||
}
|
||||
|
||||
async confirmStorePayout(id: bigint, dto: { paymentRef?: string; batchNo?: string; remark?: string }) {
|
||||
const payout = await this.prisma.storePayout.findUnique({ where: { id } });
|
||||
if (!payout) throw new NotFoundException('打款记录不存在');
|
||||
if (payout.status === 'PAID') throw new BadRequestException('已打款');
|
||||
|
||||
const updated = await this.prisma.storePayout.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'PAID',
|
||||
paidAt: new Date(),
|
||||
batchNo: dto.batchNo ?? payout.batchNo,
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.commonEvent.create({
|
||||
data: {
|
||||
eventType: 'HQ_OPERATION',
|
||||
refType: 'STORE_PAYOUT',
|
||||
refId: id,
|
||||
actorType: 'HQ',
|
||||
status: 'PAID',
|
||||
param1: dto.paymentRef ?? '',
|
||||
remark: dto.remark ?? '门店 T+1 打款确认',
|
||||
},
|
||||
});
|
||||
|
||||
return serializeBigInt(updated);
|
||||
}
|
||||
|
||||
async batchConfirmStorePayouts(ids: string[], dto: { batchNo?: string }) {
|
||||
const results: Array<{ id: string; ok: boolean; message?: string }> = [];
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await this.confirmStorePayout(BigInt(id), { batchNo: dto.batchNo });
|
||||
results.push({ id, ok: true });
|
||||
} catch (e) {
|
||||
results.push({ id, ok: false, message: e instanceof Error ? e.message : '失败' });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async listPartnerBills(partnerAccountId: bigint) {
|
||||
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
|
||||
where: { id: partnerAccountId },
|
||||
@@ -39,4 +144,203 @@ export class SettlementService {
|
||||
});
|
||||
return serializeBigInt(bills);
|
||||
}
|
||||
|
||||
async listAdminPartnerBills(query: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
status?: string;
|
||||
partnerId?: string;
|
||||
}) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const where: Prisma.PartnerBillWhereInput = {};
|
||||
if (query.status) where.status = query.status as Prisma.EnumPartnerBillStatusFilter['equals'];
|
||||
if (query.partnerId) where.partnerId = BigInt(query.partnerId);
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.partnerBill.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: { partner: { select: { companyName: true } } },
|
||||
}),
|
||||
this.prisma.partnerBill.count({ where }),
|
||||
]);
|
||||
return serializeBigInt({ items, total, page, pageSize });
|
||||
}
|
||||
|
||||
async getAdminPartnerBill(id: bigint) {
|
||||
const bill = await this.prisma.partnerBill.findUnique({
|
||||
where: { id },
|
||||
include: { partner: true },
|
||||
});
|
||||
if (!bill) throw new NotFoundException('账单不存在');
|
||||
return serializeBigInt(bill);
|
||||
}
|
||||
|
||||
async generatePartnerBill(body: { partnerId: string; year: number; month: number }) {
|
||||
const partnerId = BigInt(body.partnerId);
|
||||
const periodStart = new Date(body.year, body.month - 1, 1);
|
||||
const periodEnd = new Date(body.year, body.month, 0, 23, 59, 59, 999);
|
||||
|
||||
const existing = await this.prisma.partnerBill.findFirst({
|
||||
where: {
|
||||
partnerId,
|
||||
periodStart,
|
||||
status: { not: 'DRAFT' },
|
||||
},
|
||||
});
|
||||
if (existing && existing.status !== 'DRAFT') {
|
||||
throw new BadRequestException('该月账单已确认,不可重复生成');
|
||||
}
|
||||
|
||||
const cities = await this.prisma.commonCity.findMany({
|
||||
where: { partnerId },
|
||||
include: { commissionRule: true },
|
||||
});
|
||||
const cityIds = cities.map((c) => c.id);
|
||||
const defaultOrderRate = cities[0]?.commissionRule?.orderCommissionRate
|
||||
? Number(cities[0].commissionRule.orderCommissionRate)
|
||||
: 0.05;
|
||||
const defaultRedeemRate = cities[0]?.commissionRule?.redeemCommissionRate
|
||||
? Number(cities[0].commissionRule.redeemCommissionRate)
|
||||
: 0.03;
|
||||
|
||||
const orders = await this.prisma.order.findMany({
|
||||
where: {
|
||||
cityId: { in: cityIds },
|
||||
payStatus: 'PAID',
|
||||
paidAt: { gte: periodStart, lte: periodEnd },
|
||||
},
|
||||
});
|
||||
const orderCommission = orders.reduce(
|
||||
(sum, o) => sum + Number(o.payAmount) * defaultOrderRate,
|
||||
0,
|
||||
);
|
||||
|
||||
const stores = await this.prisma.store.findMany({ where: { partnerId }, select: { id: true } });
|
||||
const storeIds = stores.map((s) => s.id);
|
||||
const redeems = await this.prisma.redeemRecord.findMany({
|
||||
where: {
|
||||
storeId: { in: storeIds },
|
||||
createdAt: { gte: periodStart, lte: periodEnd },
|
||||
},
|
||||
});
|
||||
const redeemCommission = redeems.reduce(
|
||||
(sum, r) => sum + Number(r.amount) * defaultRedeemRate,
|
||||
0,
|
||||
);
|
||||
|
||||
const totalAmount = Math.round((orderCommission + redeemCommission) * 100) / 100;
|
||||
|
||||
const draft = await this.prisma.partnerBill.findFirst({
|
||||
where: { partnerId, periodStart, status: 'DRAFT' },
|
||||
});
|
||||
|
||||
const bill = draft
|
||||
? await this.prisma.partnerBill.update({
|
||||
where: { id: draft.id },
|
||||
data: { orderCommission, redeemCommission, totalAmount, periodEnd },
|
||||
})
|
||||
: await this.prisma.partnerBill.create({
|
||||
data: {
|
||||
billNo: generateBillNo(),
|
||||
partnerId,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
orderCommission,
|
||||
redeemCommission,
|
||||
totalAmount,
|
||||
status: 'DRAFT',
|
||||
},
|
||||
});
|
||||
|
||||
return serializeBigInt(bill);
|
||||
}
|
||||
|
||||
async confirmPartnerBill(id: bigint) {
|
||||
const bill = await this.prisma.partnerBill.findUnique({ where: { id } });
|
||||
if (!bill) throw new NotFoundException('账单不存在');
|
||||
if (bill.status !== 'DRAFT') throw new BadRequestException('仅草稿可确认');
|
||||
|
||||
const updated = await this.prisma.partnerBill.update({
|
||||
where: { id },
|
||||
data: { status: 'CONFIRMED', confirmedAt: new Date() },
|
||||
});
|
||||
|
||||
await this.prisma.commonEvent.create({
|
||||
data: {
|
||||
eventType: 'HQ_OPERATION',
|
||||
refType: 'PARTNER_BILL',
|
||||
refId: id,
|
||||
actorType: 'HQ',
|
||||
status: 'CONFIRMED',
|
||||
amount1: Number(updated.totalAmount),
|
||||
},
|
||||
});
|
||||
|
||||
return serializeBigInt(updated);
|
||||
}
|
||||
|
||||
async markPartnerBillPaid(id: bigint, dto: { paymentRef?: string }) {
|
||||
const bill = await this.prisma.partnerBill.findUnique({ where: { id } });
|
||||
if (!bill) throw new NotFoundException('账单不存在');
|
||||
if (bill.status !== 'CONFIRMED') throw new BadRequestException('仅已确认账单可标记打款');
|
||||
|
||||
const updated = await this.prisma.partnerBill.update({
|
||||
where: { id },
|
||||
data: { status: 'PAID', paidAt: new Date() },
|
||||
});
|
||||
|
||||
await this.prisma.commonEvent.create({
|
||||
data: {
|
||||
eventType: 'HQ_OPERATION',
|
||||
refType: 'PARTNER_BILL',
|
||||
refId: id,
|
||||
actorType: 'HQ',
|
||||
status: 'PAID',
|
||||
param1: dto.paymentRef ?? '',
|
||||
amount1: Number(updated.totalAmount),
|
||||
},
|
||||
});
|
||||
|
||||
return serializeBigInt(updated);
|
||||
}
|
||||
|
||||
async exportPartnerBills(query: { partnerId?: string; status?: string }) {
|
||||
const where: Prisma.PartnerBillWhereInput = {};
|
||||
if (query.partnerId) where.partnerId = BigInt(query.partnerId);
|
||||
if (query.status) where.status = query.status as Prisma.EnumPartnerBillStatusFilter['equals'];
|
||||
|
||||
const bills = await this.prisma.partnerBill.findMany({
|
||||
where,
|
||||
include: { partner: { select: { companyName: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const header = 'billNo,partner,periodStart,periodEnd,orderCommission,redeemCommission,totalAmount,status';
|
||||
const rows = bills.map((b) =>
|
||||
[
|
||||
b.billNo,
|
||||
b.partner.companyName,
|
||||
b.periodStart.toISOString().slice(0, 10),
|
||||
b.periodEnd.toISOString().slice(0, 10),
|
||||
Number(b.orderCommission),
|
||||
Number(b.redeemCommission),
|
||||
Number(b.totalAmount),
|
||||
b.status,
|
||||
].join(','),
|
||||
);
|
||||
return { csv: [header, ...rows].join('\n'), count: bills.length };
|
||||
}
|
||||
|
||||
async scanDueStorePayouts() {
|
||||
const now = new Date();
|
||||
const due = await this.prisma.storePayout.findMany({
|
||||
where: { status: 'PENDING', expectedPayAt: { lte: now } },
|
||||
take: 100,
|
||||
});
|
||||
return serializeBigInt({ dueCount: due.length, items: due });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,15 @@ export class TradeController {
|
||||
confirmReceive(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.tradeService.confirmReceive(user.actorId, BigInt(id));
|
||||
}
|
||||
|
||||
@Post(':id/refund-requests')
|
||||
refundRequest(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { remark?: string },
|
||||
) {
|
||||
return this.tradeService.createRefundRequest(user.actorId, BigInt(id), body.remark);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('partner/orders')
|
||||
@@ -85,3 +94,14 @@ export class PartnerOrderController {
|
||||
return this.tradeService.advanceDelivery(user.actorId, BigInt(id), body.targetStatus);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('partner/reshipments')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PartnerReshipmentController {
|
||||
constructor(private readonly tradeService: TradeService) {}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: AuthUser) {
|
||||
return this.tradeService.listPartnerReshipments(user.actorId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@ import { Module, forwardRef } from '@nestjs/common';
|
||||
import { IntegrationsModule } from '../../integrations/integrations.module';
|
||||
import { IamModule } from '../iam/iam.module';
|
||||
import { BenefitModule } from '../benefit/benefit.module';
|
||||
import { TradeController, PartnerOrderController } from './trade.controller';
|
||||
import { CatalogModule } from '../catalog/catalog.module';
|
||||
import { CommonModule } from '../common/common.module';
|
||||
import { TradeController, PartnerOrderController, PartnerReshipmentController } from './trade.controller';
|
||||
import { TradeService } from './trade.service';
|
||||
|
||||
@Module({
|
||||
imports: [IntegrationsModule, IamModule, forwardRef(() => BenefitModule)],
|
||||
controllers: [TradeController, PartnerOrderController],
|
||||
imports: [IntegrationsModule, IamModule, CatalogModule, forwardRef(() => BenefitModule), CommonModule],
|
||||
controllers: [TradeController, PartnerOrderController, PartnerReshipmentController],
|
||||
providers: [TradeService],
|
||||
exports: [TradeService],
|
||||
})
|
||||
|
||||
@@ -14,7 +14,9 @@ import {
|
||||
import { loadAppConfig, WECHAT_AUTH_REQUIRED } from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { CatalogService } from '../catalog/catalog.service';
|
||||
import { BenefitService } from '../benefit/benefit.service';
|
||||
import { TicketService } from '../common/ticket.service';
|
||||
import { PAY_PROVIDER, DELIVERY_PROVIDER } from '../../integrations/integrations.constants';
|
||||
import { IPayProvider } from '../../integrations/pay/pay.interface';
|
||||
import { IDeliveryProvider } from '../../integrations/delivery/delivery.interface';
|
||||
@@ -29,16 +31,16 @@ import type { Request } from 'express';
|
||||
export class TradeService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly catalogService: CatalogService,
|
||||
private readonly benefitService: BenefitService,
|
||||
private readonly ticketService: TicketService,
|
||||
private readonly ipGeoService: IpGeoService,
|
||||
@Inject(PAY_PROVIDER) private readonly payProvider: IPayProvider,
|
||||
@Inject(DELIVERY_PROVIDER) private readonly deliveryProvider: IDeliveryProvider,
|
||||
) {}
|
||||
|
||||
async preview(userId: bigint, body: { productId: string; quantity: number; addressId?: string }) {
|
||||
const product = await this.prisma.commonProductItem.findUnique({
|
||||
where: { id: BigInt(body.productId) },
|
||||
});
|
||||
const product = await this.catalogService.getProduct(BigInt(body.productId));
|
||||
if (!product || product.status !== 'ON_SALE') {
|
||||
throw new BadRequestException('商品不可购买');
|
||||
}
|
||||
@@ -73,7 +75,7 @@ export class TradeService {
|
||||
const freightPayType: FreightPayType | null = deliveryType === 'CROSS_CITY' ? 'COD' : null;
|
||||
|
||||
return {
|
||||
product: serializeBigInt(product),
|
||||
product,
|
||||
quantity: body.quantity,
|
||||
deliveryType,
|
||||
productAmount,
|
||||
@@ -384,6 +386,47 @@ export class TradeService {
|
||||
return this.getOrder(userId, orderId);
|
||||
}
|
||||
|
||||
async createRefundRequest(userId: bigint, orderId: bigint, remark?: string) {
|
||||
const order = await this.prisma.order.findFirst({ where: { id: orderId, userId } });
|
||||
if (!order) throw new NotFoundException('订单不存在');
|
||||
if (!['PENDING_SHIP', 'PENDING_RECEIVE', 'COMPLETED', 'OUT_WAREHOUSE', 'SHIPPING'].includes(order.status)) {
|
||||
throw new BadRequestException('当前订单不可申请退款');
|
||||
}
|
||||
await this.prisma.order.update({
|
||||
where: { id: orderId },
|
||||
data: { status: 'REFUNDING', payStatus: 'REFUNDING' },
|
||||
});
|
||||
return this.ticketService.create({
|
||||
ticketType: 'REFUND',
|
||||
refType: 'ORDER',
|
||||
refId: orderId.toString(),
|
||||
remark: remark ?? '用户申请退款',
|
||||
});
|
||||
}
|
||||
|
||||
async listPartnerReshipments(partnerAccountId: bigint) {
|
||||
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
|
||||
where: { id: partnerAccountId },
|
||||
});
|
||||
const cities = await this.prisma.commonCity.findMany({
|
||||
where: { partnerId: account.partnerId },
|
||||
select: { id: true },
|
||||
});
|
||||
const orders = await this.prisma.order.findMany({
|
||||
where: { cityId: { in: cities.map((c) => c.id) } },
|
||||
select: { id: true },
|
||||
});
|
||||
const tickets = await this.prisma.commonTicket.findMany({
|
||||
where: {
|
||||
ticketType: 'RESHIPMENT',
|
||||
refType: 'ORDER',
|
||||
refId: { in: orders.map((o) => o.id) },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return serializeBigInt(tickets);
|
||||
}
|
||||
|
||||
async listPartnerOrders(partnerAccountId: bigint, page = 1, pageSize = 20) {
|
||||
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
|
||||
where: { id: partnerAccountId },
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# 杜康好客 · V2 编码手册(完整规格 · 唯一事实源)
|
||||
|
||||
> **V3 交付提示**:当前交付验收以 [`杜康好客-v3编码手册.md`](./杜康好客-v3编码手册.md) 为准。本手册中「核销单次上限 ¥500」等规则已被 V3 替代(直接核销可达总余额 / 单据 cap)。
|
||||
|
||||
> **版本**:**V2**(完整四端 + 微信生态 + 真实第三方)
|
||||
> **联调裁剪版**:见 [`杜康好客-preV1编码手册.md`](./杜康好客-preV1编码手册.md)(三端 H5 + Mock,同库同 API 契约)
|
||||
> **用途**:V2 正式编码与 preV1 预留对齐的完整规格
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# 杜康好客 · preV1 编码手册(Mock 联调版)
|
||||
|
||||
> **V3 交付提示**:preV1 不再作为交付验收标准。核销规则以 [`杜康好客-v3编码手册.md`](./杜康好客-v3编码手册.md) §3 为准(无 ¥500 上限)。
|
||||
|
||||
> **版本**:preV1(在 V2 完整规格之上的**裁剪实现阶段**)
|
||||
> **完整规格(V2)**:[`杜康好客-V2编码手册.md`](./杜康好客-V2编码手册.md) — PRD、DB v3.1、API、任务卡均以 V2 为准
|
||||
> **原则**:**不删表、不删 V2 API 路径、不改 V2 字段语义**;preV1 用 Mock / Feature Flag / Seed 跳过外部依赖,开关关闭即切回 V2 真实流程。
|
||||
|
||||
Reference in New Issue
Block a user