v3.5.6版本迭代
CI / verify (pull_request) Waiting to run

This commit is contained in:
2026-08-23 16:41:22 +08:00
parent 608e3e5ec5
commit 7e12eb3ca6
33 changed files with 1537 additions and 278 deletions
+1
View File
@@ -25,3 +25,4 @@ server/dukang-api/assets/fonts/*.ttf
server/dukang-api/assets/fonts/*.ttc
server/dukang-api/assets/fonts/*.otf
deploy/auto-release.env
deploy/backups/
+26 -150
View File
@@ -1,133 +1,37 @@
# 杜康好客 · AGENTS.md
> AI 编码入口。人类协作者仍读 [`agent.md`](./agent.md);本文件供 Cursor / Codex / Copilot 等自动加载
> AI 编码入口。人类读 [`agent.md`](./agent.md)。启动、端口、测试账号见 [`README.md`](./README.md)
## 项目概览
杜康酒业 O2O:**购酒 → 发券 → 门店核销**。Monorepo + NestJS 单体(`/api/v1``{ code, message, data }`)。
杜康酒业 O2O 平台:**购酒 → 发券 → 门店核销**。Monorepo + 单体 NestJS(方案三:模块 OWNER)。
| 路径 | 说明 |
|------|------|
| `apps/` | `h5-user` · `h5-shop` · `h5-partner` · `admin-web` · `mini-user` |
| `packages/` | `shared-types`(枚举/DTO)· `domain`(纯规则,无 IO)· `shared-ui` |
| `server/dukang-api/` | 唯一 API |
| `pages/` | UI 原型,只读 |
| 阶段 | 手册 | 说明 |
|------|------|------|
| **V3.0 唯一需求源** | [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md) | 产品需求与三波交付(**只看这个**) |
| **现状对照** | [`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.md) | 已完成 / 冲突 / 缺口审计 |
| **V3 实现验收** | [`杜康好客-v3编码手册.md`](./杜康好客-v3编码手册.md) | 核销规则、分工、闭环 |
| ~~V2 / preV1~~ | 历史参考(**已压缩**V2 全文见 git 2026-08-06 前) | **不再作为需求依据** |
前端:[`apps/AGENTS.md`](./apps/AGENTS.md)。后端模块/表 OWNER[`server/dukang-api/AGENTS.md`](./server/dukang-api/AGENTS.md)。
**禁止**:臆造 PRD 未定义规则;依赖 `doc/` 下过时文档;跨 OWNER 直写他人 Prisma 表。
## 文档(冲突时)
## 启动命令
```bash
# 基础设施
cd deploy && docker compose up -d # dukang-v1MySQL :6016 Redis :6017
# 依赖与数据库
pnpm install
cp server/dukang-api/.env.example server/dukang-api/.env
pnpm db:generate && pnpm db:validate
cd server/dukang-api && npx prisma db push && pnpm prisma:seed
# 开发(分终端)
pnpm dev:api # http://localhost:3010/api/v1
pnpm dev:user # :5173
pnpm dev:shop # :5174
pnpm dev:partner # :5175
pnpm dev:admin # preV1 HQ 替代(内部工具,非 V2 小程序)
# 验证
node scripts/smoke-v3.mjs
node scripts/smoke-prev1.mjs
pnpm lint && pnpm test
```
Mock 验证码:`999888`。测试账号见 [`README.md`](./README.md)。
> **端口冲突**`h5-partner` 与 `admin-web` 同为 5175,勿同时 `dev:partner` + `dev:admin`。
## 文档优先级(冲突时)
1. `杜康好客-v3-PRD.md`**唯一需求事实源**
2. `杜康好客-v3-现状对照.md` — 实现进度审计
3. `杜康好客-v3编码手册.md` — 实现验收、核销、分工
4. `conventions.md` — 协作与模块边界
5. V2 手册 §四 §五 §六 — 仅作架构/DB/API **参考**(与 V3 冲突时忽略)
1. `杜康好客-v3-PRD.md`**唯一需求事实源**(编码时禁止改业务规则;需求变更只改它)
2. `杜康好客-v3-现状对照.md` — 进度
3. `杜康好客-v3编码手册.md` — 验收、核销、分工
4. `conventions.md` — 协作边界
5. V2 手册 §四–§六 — 架构/DB/API **参考**(与 V3 冲突则忽略)
6. `pages/{user,shop,partner,hq}/` — UI 参照
> **V2 / preV1 手册不再作需求依据。**
**禁止**:臆造 PRD 未定义规则;依赖 `doc/` 过时文档;`apps/*` import `server/*`;跨模块 `prisma` 写他人表。V2 / preV1 手册不再作需求依据。
## 团队与 OWNER 边界(2 人
## OWNER2026-07 起暂由 jacy 代管全部
| 负责人 | Git 账号 | 职责 |
|--------|----------|------|
| **Jacy**(管理员) | `jacy-dukang` | C 端、admin-web、后端主模块、packages、Prisma 迁移主 Review |
| **刘景尧** | `刘景尧` | 合伙人 H5、门店 H5、`store` / `redeem` 模块(**暂由 jacy-dukang 代管** |
`jacy-dukang` 可改 `apps/*``packages/*`、全部后端模块。逻辑边界仍按 A/B/C/D,便于子 Agent 隔离。Prisma 迁移由 jacy 主导。
> **临时分工(2026-07**:刘景尧任务暂由 `jacy-dukang` 全权负责;逻辑 OWNER B+D 路径可改,Prisma 迁移由 jacy 主导。
- 跨模块只 inject 对方 **exported Service**
- Mock 只在 `integrations/*`;微信/配送回调只进 `callbacks/`
逻辑模块边界仍按 A/B/C/D 划分(便于 Agent 隔离),**当前人员合并**如下:
| 逻辑 OWNER | 负责人 | 可改路径 | 后端 Module |
|------------|--------|----------|-------------|
| **A + C + Lead + B + D** | jacy-dukang | `apps/*`, `packages/*`, `callbacks/`, `jobs/`, `common/`, `integrations/` | 全部后端模块 |
| ~~B + D~~ | ~~刘景尧~~ | — | 恢复分工前由 jacy 代管 |
**Prisma 迁移**jacy-dukang 主导 Review;涉及 `store_*` / 核销表时仍建议刘景尧知会(恢复分工后共同 Review)。
### 跨模块规则(R1–R8 摘要)
- 只 inject 对方 Module **exports 的 Service**,禁止 `prisma.xxx` 写他人表
- `apps/*` 禁止 import `server/*` 源码;只走 HTTP + `packages/shared-types`
- 业务纯规则 → `packages/domain`;枚举/DTO → `packages/shared-types`
- 微信/配送回调入口 **仅** `callbacks/`Mock 实现 **仅** `integrations/*`
## Cursor 规范
| 类型 | 路径 | 说明 |
|------|------|------|
| Rules | `.cursor/rules/` | 自动加载的编码规范 |
| 编码 Skill | `.cursor/skills/dukang-coding/` | @dukang-coding |
| V3.0 需求 Skill | `.cursor/skills/dukang-v3/` | @dukang-v3PRD、Wave、REQ/ACC |
| 需求 Skill | `.cursor/skills/dukang-project/` | @dukang-project |
| preV1 Skill | `.cursor/skills/dukang-prev1/` | Mock / Feature Flag |
| 任务卡 Skill | `.cursor/skills/dukang-task-card/` | DLV-W* / P1-* / M*-* |
| 发布 Skill | `.cursor/skills/dukang-release/` | @杜康发布 / @dukang-release |
## 子 Agent(按职责选用)
在 Cursor Agent 输入 `/` 选择:
| 子 Agent | 负责人 | 适用场景 |
|----------|--------|----------|
| `v3-delivery-lead` | jacy-dukang | V3.0 三波交付统筹、DLV 拆分、跨 Owner 协调 |
| `v3-req-reviewer` | — | V3.0 需求只读审查、REQ/ACC 缺口分析 |
| `owner-a-user-trade` | jacy-dukang | C 端、订单、支付、权益、埋点 |
| `owner-c-catalog-ops` | jacy-dukang | admin-web、开城商品、结算、运营 |
| `backend-lead` | jacy-dukang | packages、callbacks、jobs、Prisma 横切 |
| `owner-b-partner-store` | 刘景尧 | 合伙人 H5、门店 CRUD/审核 |
| `owner-d-shop-redeem` | 刘景尧 | 门店 H5、核销 |
| `boundary-reviewer` | — | PR 前只读审查跨模块违规 |
## Skills(按需 @
| Skill | 何时用 |
|-------|--------|
| `dukang-v3` | V3.0 PRD、现状对照、Wave/REQ/ACC |
| `dukang-coding` | 实现功能、修 Bug(通用编码流程) |
| `dukang-project` | PRD/需求评审、原型对照、任务卡规划(**不用于日常编码**) |
| `dukang-prev1` | Mock 开关、preV1 裁剪、Feature Flag |
| `dukang-task-card` | 领取任务卡 `DLV-W*-*` / `P1-*` / `M*-*` 并按 DoD 交付 |
| `dukang-release`(杜康发布) | `dev_jacy``dev` 合并 + `deploy.sh` 发版 |
## 快速指令
- 现状审计:`@dukang-v3` + [`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.md)
- 实现 DLV 任务:`@dukang-coding 实现 DLV-W1-M2`
- 波次规划:`/v3-delivery-lead 拆分 Wave 2 任务`
- 需求缺口审查:`/v3-req-reviewer 审查门店提现`
- PR 前审查:`/boundary-reviewer`
- 原型参照:`pages/{user,shop,partner}/`(只读)
## 核心业务常量(不可偏离)
## 不可偏离
```
权益额 = benefit_amount ?? price
@@ -137,38 +41,10 @@ C 端门店仅 status=OPEN
订单 Tab:待付款 | 已付款 | 已完成
```
**iOS 微信 H5 JSSDK**:登录/OAuth 后禁止仅 SPA 跳转再扫码;见 `packages/weixin-sdk/GOTCHAS.md`、知识库「门店端 · 踩坑」
**微信小程序 open-type**`chooseAvatar` 等 Button 的祖先禁止 `stopPropagation`(会编成 catchtap);见知识库「C 端 · 踩坑」、`.cursor/rules/mini-user-weapp-opentype.mdc`
**微信小程序页面标题**weapp 只用原生 `navigationBarTitleText`,不要再画一层与导航栏重复的 `SubPageHeader` title;见 `.cursor/rules/mini-user-weapp-nav-title.mdc`
微信:iOS H5 登录后禁止仅 SPA 跳转再扫码`packages/weixin-sdk/GOTCHAS.md`)。小程序 `open-type` 按钮祖先禁止 `stopPropagation`weapp 标题只用 `navigationBarTitleText``h5-partner``admin-web` 同端口 5175,勿同时开
## 环境与发版
## 按需加载
| 环境 | 分支 | 目录 | 端口 | 域名 |
|------|------|------|------|------|
| local | `dev_jacy` | 本机 Docker 6016/6017 | API `:3010` | — |
| **staging 测试** | `dev` | `/opt/dukang-staging` | 81908194 | `*-test.dukanghaoke.com` |
| **production 生产** | `main` | `/opt/dukang` | 80908094 | `*.dukanghaoke.com` |
- 日常发布 → `deploy/deploy-staging.sh``dev`
- 生产发布 → `deploy/deploy-prod.sh``main`,验收后)
- Staging:正式微信 + 全 Mock;配置见 `.env.staging.example`
- 详情:`@dukang-release` / `.cursor/skills/dukang-release/SKILL.md`
## 提交与 PR
- Conventional Commits`feat(trade):``fix(redeem):`scope = 端或模块
- 跨模块 PR → jacy-dukang + 刘景尧 共同 Review(若涉及双方模块)
- 改 API/表 → 同步 v3-PRD + v3编码手册 + `shared-types`
- 不提交 `.env``dist/``node_modules/`
## 完成定义(DoD
- [ ] 任务卡验收项全部满足
- [ ] 未跨模块直写 Prisma 表
- [ ] 枚举/DTO 在 `shared-types`;纯规则在 `domain`
- [ ] `pnpm lint` 无新增错误;相关 domain 单测通过
## 嵌套指引
- 后端细节:[`server/dukang-api/AGENTS.md`](./server/dukang-api/AGENTS.md)
- 前端四 App[`apps/AGENTS.md`](./apps/AGENTS.md)
- 编码 `/dukang-coding` · V3 需求 `/dukang-v3` · 发版 `/dukang-release`(只说「发版」= 生产)
- 子 Agent`/owner-a-user-trade` 等(见 `.cursor/agents/`
- DoD:验收项满足;不跨模块直写表;类型在 `shared-types`、规则在 `domain`;相关 lint/单测过
@@ -23,6 +23,11 @@ export default function RedeemRecordDetailDescriptions({ detail, maskUserPhone =
cityName?: string;
address?: string;
partnerAccount?: { companyName?: string };
primaryAccount?: {
bankAccountName?: string | null;
bankAccountNo?: string | null;
bankBranch?: string | null;
} | null;
}
| undefined;
const coupon = detail.coupon as { couponNo?: string; order?: { orderNo?: string } } | undefined;
@@ -46,6 +51,15 @@ export default function RedeemRecordDetailDescriptions({ detail, maskUserPhone =
</Descriptions.Item>
<Descriptions.Item label="门店地址">{store?.address ?? '—'}</Descriptions.Item>
<Descriptions.Item label="合伙人">{store?.partnerAccount?.companyName ?? '—'}</Descriptions.Item>
<Descriptions.Item label="收款户名">
{store?.primaryAccount?.bankAccountName || '—'}
</Descriptions.Item>
<Descriptions.Item label="收款账号">
{store?.primaryAccount?.bankAccountNo || '—'}
</Descriptions.Item>
<Descriptions.Item label="开户行">
{store?.primaryAccount?.bankBranch || '—'}
</Descriptions.Item>
<Descriptions.Item label="权益券号">{coupon?.couponNo ?? '—'}</Descriptions.Item>
<Descriptions.Item label="关联订单">{coupon?.order?.orderNo ?? '—'}</Descriptions.Item>
{Array.isArray(detail.allocations) && (detail.allocations as unknown[]).length > 0 ? (
@@ -174,11 +174,13 @@ function PackageDetailCard({
pkg,
change,
changes,
imageSize = 72,
}: {
title?: string;
pkg: StorePackageItemDto | StorePackageViewDto;
change?: keyof typeof CHANGE_LABELS;
changes?: FieldChange[];
imageSize?: number;
}) {
const images = normalizeStorePackageImageUrls(pkg);
const meta = change ? CHANGE_LABELS[change] : null;
@@ -217,7 +219,7 @@ function PackageDetailCard({
<Image.PreviewGroup>
<Space wrap size={8}>
{images.map((url) => (
<Image key={url} src={url} width={72} height={72} style={{ objectFit: 'cover', borderRadius: 4 }} />
<Image key={url} src={url} width={imageSize} height={imageSize} style={{ objectFit: 'cover', borderRadius: 4 }} />
))}
</Space>
</Image.PreviewGroup>
@@ -265,6 +267,7 @@ export type StorePackageAuditPanelProps = {
requestId: string;
/** 是否在面板顶部显示通过/驳回(抽屉 extra 另有按钮时可关) */
showActions?: boolean;
fullscreen?: boolean;
onAudited?: () => void;
onDetailLoaded?: (detail: StorePackageAuditDetailDto | null) => void;
};
@@ -273,6 +276,7 @@ export type StorePackageAuditPanelProps = {
export default function StorePackageAuditPanel({
requestId,
showActions = true,
fullscreen = false,
onAudited,
onDetailLoaded,
}: StorePackageAuditPanelProps) {
@@ -389,6 +393,7 @@ export default function StorePackageAuditPanel({
title={`套餐 ${index + 1}`}
pkg={pkg}
change={changeByKey.get(packageKey(pkg, index))}
imageSize={fullscreen ? 160 : 72}
/>
))
) : (
@@ -407,6 +412,7 @@ export default function StorePackageAuditPanel({
pkg={pkg}
change={changeByKey.get(packageKey(pkg, index))}
changes={changesByKey.get(packageKey(pkg, index))}
imageSize={fullscreen ? 160 : 72}
/>
))
) : (
+2 -2
View File
@@ -196,10 +196,10 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
'/benefit/ledgers': 'benefit',
'/redeem-records': 'benefit',
'/redeem-pending': 'benefit',
'/redeem/debug': 'benefit',
'/redeem/debug': 'benefit_debug',
'deliveries-group': 'deliveries',
'/deliveries': 'deliveries',
'/deliveries/xiaofeixia': 'deliveries',
'/deliveries/xiaofeixia': 'deliveries_debug',
'/tickets': 'tickets',
'/tickets/support': 'tech_support',
'dev-plan-group': 'dev_plan',
+101 -4
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import {
Button,
Form,
Image,
Input,
Modal,
Popconfirm,
@@ -18,6 +19,7 @@ import {
DEV_PLAN_TASK_TYPE_LABELS,
SUPPORT_TICKET_STATUS_LABELS,
type DevPlanTaskDto,
type DevPlanTaskExportFormat,
type DevPlanTaskStatusDto,
type DevPlanTaskTypeDto,
type DevPlanVersionDto,
@@ -25,7 +27,9 @@ import {
} from '@dukang/shared-types';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { downloadBase64File } from '../lib/exportExcel';
import { useAdminList } from '../lib/useAdminList';
import OssUpload from '../components/OssUpload';
const TYPE_OPTIONS = (Object.keys(DEV_PLAN_TASK_TYPE_LABELS) as DevPlanTaskTypeDto[]).map((v) => ({
value: v,
@@ -56,6 +60,8 @@ export default function DevPlanTasksPage() {
const [editing, setEditing] = useState<DevPlanTaskDto | null>(null);
const [saving, setSaving] = useState(false);
const [dispatching, setDispatching] = useState(false);
const [exportFormat, setExportFormat] = useState<DevPlanTaskExportFormat>('xlsx');
const [exporting, setExporting] = useState(false);
const [form] = Form.useForm();
const [dispatchForm] = Form.useForm<{ supplement?: string }>();
const [batchForm] = Form.useForm<{ status?: DevPlanTaskStatusDto; versionId?: string }>();
@@ -88,24 +94,30 @@ export default function DevPlanTasksPage() {
function openCreate() {
setEditing(null);
form.setFieldsValue({ content: '', type: 'BUG', status: 'TODO', supportTicketId: undefined });
form.setFieldsValue({ content: '', type: 'BUG', status: 'TODO', supportTicketId: undefined, attachmentUrls: [''] });
setModalOpen(true);
}
function openEdit(row: DevPlanTaskDto) {
setEditing(row);
form.setFieldsValue({ content: row.content, type: row.type, status: row.status });
form.setFieldsValue({
content: row.content,
type: row.type,
status: row.status,
attachmentUrls: row.attachmentUrls?.length ? row.attachmentUrls : [''],
});
setModalOpen(true);
}
async function save() {
const values = await form.validateFields();
const attachmentUrls = (values.attachmentUrls ?? []).map((u: string) => u?.trim()).filter(Boolean);
setSaving(true);
try {
if (editing) {
await request(`/admin/dev-plan/tasks/${editing.id}`, {
method: 'PUT',
body: JSON.stringify(values),
body: JSON.stringify({ ...values, attachmentUrls }),
});
message.success('已更新');
} else {
@@ -115,6 +127,7 @@ export default function DevPlanTasksPage() {
content: values.content,
type: values.type,
supportTicketId: values.supportTicketId || undefined,
attachmentUrls,
}),
});
message.success('已创建');
@@ -199,6 +212,34 @@ export default function DevPlanTasksPage() {
}
}
async function exportTasks(scope: 'filter' | 'selected') {
setExporting(true);
try {
const result = await request<{
filename: string;
mimeType: string;
contentBase64: string;
count: number;
}>('/admin/dev-plan/tasks/export', {
method: 'POST',
body: JSON.stringify({
scope,
format: exportFormat,
ids: scope === 'selected' ? selectedRowKeys : undefined,
status: filters.status || undefined,
type: filters.type || undefined,
keyword: filters.keyword || undefined,
}),
});
downloadBase64File(result.contentBase64, result.filename, result.mimeType);
message.success(`已导出 ${result.count} 条任务`);
} catch (e) {
message.error(e instanceof Error ? e.message : '导出失败');
} finally {
setExporting(false);
}
}
const columns: ColumnsType<DevPlanTaskDto> = [
{ title: '任务号', dataIndex: 'taskNo', width: 160 },
{
@@ -216,6 +257,22 @@ export default function DevPlanTasksPage() {
),
},
{ title: '内容', dataIndex: 'content', ellipsis: true },
{
title: '附件',
width: 100,
render: (_, row) =>
row.attachmentUrls?.length ? (
<Image.PreviewGroup>
<Space size={4}>
{row.attachmentUrls.slice(0, 3).map((url) => (
<Image key={url} src={url} width={32} height={32} style={{ objectFit: 'cover' }} />
))}
</Space>
</Image.PreviewGroup>
) : (
'—'
),
},
{ title: '来源工单', dataIndex: 'supportTicketNo', width: 140, render: (v) => v || '—' },
{ title: '创建人', dataIndex: 'creatorName', width: 90 },
{ title: '创建时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
@@ -243,7 +300,24 @@ export default function DevPlanTasksPage() {
<Typography.Title level={4} style={{ margin: 0 }}>
·
</Typography.Title>
<Space>
<Space wrap>
<Select
value={exportFormat}
style={{ width: 120 }}
onChange={setExportFormat}
options={[
{ value: 'markdown', label: 'Markdown' },
{ value: 'docx', label: 'Word' },
{ value: 'xlsx', label: 'Excel' },
{ value: 'pdf', label: 'PDF' },
]}
/>
<Button loading={exporting} disabled={!selectedRowKeys.length} onClick={() => void exportTasks('selected')}>
</Button>
<Button loading={exporting} onClick={() => void exportTasks('filter')}>
</Button>
<Button disabled={!selectedRowKeys.length} onClick={openBatchEdit}>
</Button>
@@ -333,6 +407,29 @@ export default function DevPlanTasksPage() {
/>
</Form.Item>
)}
<Form.Item label="附件图片">
<Form.List name="attachmentUrls">
{(fields, { add, remove }) => (
<>
{fields.map((field) => (
<Space key={field.key} align="start" style={{ display: 'flex', marginBottom: 8 }}>
<Form.Item {...field} style={{ flex: 1, marginBottom: 0 }}>
<OssUpload bizType="SUPPORT_TICKET_ATTACHMENT" mediaType="IMAGE" />
</Form.Item>
{fields.length > 1 ? (
<Button type="link" danger onClick={() => remove(field.name)}>
</Button>
) : null}
</Space>
))}
<Button type="dashed" onClick={() => add('')} block>
</Button>
</>
)}
</Form.List>
</Form.Item>
</Form>
</Modal>
@@ -44,6 +44,13 @@ type BillRow = {
settlementMethod: string;
status: string;
paidAt?: string | null;
paymentRef?: string | null;
fulfillmentProvider?: {
bankAccountName?: string | null;
bankName?: string | null;
bankBranch?: string | null;
bankAccountNo?: string | null;
};
};
type BillItem = {
@@ -140,14 +147,30 @@ export default function LogisticsBillsPage() {
}, [summaryPeriod]);
function confirmPay(ids: string[], amountHint?: number) {
let paymentRef = '';
Modal.confirm({
title: '确认结算?',
content: `将确认 ${ids.length} 笔物流对账单${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。充值模式将扣减余额,挂账模式标记已打款。`,
content: (
<div>
<div>
{ids.length}
{amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}
</div>
<Input
placeholder="打款凭证号(可选)"
style={{ marginTop: 8 }}
onChange={(e) => {
paymentRef = e.target.value;
}}
/>
</div>
),
okText: '确认结算',
cancelText: '取消',
onOk: async () => {
const body = JSON.stringify({ paymentRef: paymentRef.trim() || undefined });
if (ids.length === 1) {
await request(`/admin/logistics-bills/${ids[0]}/confirm`, { method: 'POST' });
await request(`/admin/logistics-bills/${ids[0]}/confirm`, { method: 'POST', body });
} else {
await request('/admin/logistics-bills/batch-confirm', {
method: 'POST',
@@ -217,6 +240,7 @@ export default function LogisticsBillsPage() {
}
const summary = data?.summary;
const providerBank = (r: BillRow) => r.fulfillmentProvider;
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.logisticsAmount), 0);
@@ -248,6 +272,18 @@ export default function LogisticsBillsPage() {
width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '收款户名',
width: 100,
ellipsis: true,
render: (_, r) => providerBank(r)?.bankAccountName || '—',
},
{
title: '收款账号',
width: 140,
ellipsis: true,
render: (_, r) => providerBank(r)?.bankAccountNo || '—',
},
{
title: '状态',
dataIndex: 'status',
@@ -550,7 +586,31 @@ export default function LogisticsBillsPage() {
<Descriptions.Item label="结算时间">
{detail.paidAt ? fmtTime(detail.paidAt) : '—'}
</Descriptions.Item>
<Descriptions.Item label="打款凭证">
{detail.paymentRef ? String(detail.paymentRef) : '—'}
</Descriptions.Item>
</Descriptions>
{providerBank(detail) ? (
<>
<Typography.Title level={5} style={{ marginTop: 16 }}>
</Typography.Title>
<Descriptions column={1} size="small" bordered style={{ marginBottom: 16 }}>
<Descriptions.Item label="户名">
{providerBank(detail)?.bankAccountName || '—'}
</Descriptions.Item>
<Descriptions.Item label="开户银行">
{providerBank(detail)?.bankName || '—'}
</Descriptions.Item>
<Descriptions.Item label="开户支行">
{providerBank(detail)?.bankBranch || '—'}
</Descriptions.Item>
<Descriptions.Item label="账号">
{providerBank(detail)?.bankAccountNo || '—'}
</Descriptions.Item>
</Descriptions>
</>
) : null}
<Table
rowKey="id"
size="small"
+113 -7
View File
@@ -3,6 +3,8 @@ import {
Button,
Card,
DatePicker,
Descriptions,
Drawer,
Form,
Input,
Modal,
@@ -31,8 +33,22 @@ type Row = {
periodStart: string;
periodEnd: string;
rejectReason?: string | null;
partner?: { companyName?: string; phone?: string };
partnerAccount?: { companyName?: string; phone?: string };
paymentRef?: string | null;
paidAt?: string | null;
partner?: {
companyName?: string;
phone?: string;
bankAccountName?: string | null;
bankAccountNo?: string | null;
bankBranch?: string | null;
};
partnerAccount?: {
companyName?: string;
phone?: string;
bankAccountName?: string | null;
bankAccountNo?: string | null;
bankBranch?: string | null;
};
};
type PartnerOption = { id: string; companyName: string; phone?: string };
@@ -77,6 +93,8 @@ export default function PartnerBillsPage() {
const [rejectForm] = Form.useForm();
const [rejecting, setRejecting] = useState(false);
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
const [detail, setDetail] = useState<Row | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
useEffect(() => {
void request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
@@ -127,17 +145,30 @@ export default function PartnerBillsPage() {
}
function markPaid(ids: string[], amountHint?: number) {
let paymentRef = '';
Modal.confirm({
title: '确认打款?',
content: `将标记 ${ids.length} 笔账单为已打款${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}`,
content: (
<div>
<div>
{ids.length}
{amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}
</div>
<Input
placeholder="打款凭证号(可选)"
style={{ marginTop: 8 }}
onChange={(e) => {
paymentRef = e.target.value;
}}
/>
</div>
),
okText: '确认打款',
cancelText: '取消',
onOk: async () => {
const body = JSON.stringify({ paymentRef: paymentRef.trim() || undefined });
if (ids.length === 1) {
await request(`/admin/partner-bills/${ids[0]}/mark-paid`, {
method: 'POST',
body: JSON.stringify({ paymentRef: `PAY-${Date.now()}` }),
});
await request(`/admin/partner-bills/${ids[0]}/mark-paid`, { method: 'POST', body });
} else {
await request('/admin/partner-bills/batch-mark-paid', {
method: 'POST',
@@ -182,6 +213,12 @@ export default function PartnerBillsPage() {
}
}
async function openDetail(id: string) {
const d = await request<Row>(`/admin/partner-bills/${id}`);
setDetail(d);
setDrawerOpen(true);
}
async function exportExcel() {
setExporting(true);
try {
@@ -201,6 +238,7 @@ export default function PartnerBillsPage() {
const summary = data?.summary;
const partnerName = (r: Row) => r.partner?.companyName || r.partnerAccount?.companyName || '—';
const partnerBank = (r: Row) => r.partnerAccount ?? r.partner;
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
const canSend = selectedRows.filter((r) => r.status === 'PENDING_REVIEW' || r.status === 'REJECTED');
const canPay = selectedRows.filter((r) => r.status === 'UNPAID');
@@ -238,6 +276,18 @@ export default function PartnerBillsPage() {
width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '收款户名',
width: 100,
ellipsis: true,
render: (_, r) => partnerBank(r)?.bankAccountName || '—',
},
{
title: '收款账号',
width: 140,
ellipsis: true,
render: (_, r) => partnerBank(r)?.bankAccountNo || '—',
},
{
title: '状态',
dataIndex: 'status',
@@ -259,6 +309,9 @@ export default function PartnerBillsPage() {
fixed: 'right',
render: (_, row) => (
<Space size={0} wrap>
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
</Button>
{(row.status === 'PENDING_REVIEW' || row.status === 'REJECTED') && (
<Button type="link" size="small" onClick={() => sendBills([row.id])}>
@@ -465,6 +518,59 @@ export default function PartnerBillsPage() {
</Button>
</Form>
</Modal>
<Drawer
title="合伙人账单明细"
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
width={560}
>
{detail && (
<>
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="账单号">{detail.billNo}</Descriptions.Item>
<Descriptions.Item label="合伙人">{partnerName(detail)}</Descriptions.Item>
<Descriptions.Item label="账期">
{String(detail.periodStart).slice(0, 10)} ~ {String(detail.periodEnd).slice(0, 10)}
</Descriptions.Item>
<Descriptions.Item label="订单佣金">
¥{Number(detail.orderCommission).toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="核销佣金">
¥{Number(detail.redeemCommission).toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="应付合计">
¥{Number(detail.totalAmount).toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={STATUS_COLORS[detail.status] || 'default'}>
{STATUS_LABELS[detail.status] || detail.status}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="打款时间">
{detail.paidAt ? fmtTime(detail.paidAt) : '—'}
</Descriptions.Item>
<Descriptions.Item label="打款凭证">
{detail.paymentRef ? String(detail.paymentRef) : '—'}
</Descriptions.Item>
</Descriptions>
<Typography.Title level={5} style={{ marginTop: 16 }}>
</Typography.Title>
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="户名">
{partnerBank(detail)?.bankAccountName || '—'}
</Descriptions.Item>
<Descriptions.Item label="账号">
{partnerBank(detail)?.bankAccountNo || '—'}
</Descriptions.Item>
<Descriptions.Item label="开户行">
{partnerBank(detail)?.bankBranch || '—'}
</Descriptions.Item>
</Descriptions>
</>
)}
</Drawer>
</div>
);
}
+75 -5
View File
@@ -44,6 +44,11 @@ type Row = {
settlementRate?: number | null;
payoutCount?: number | null;
store?: { id: string; name: string; cityName: string; phone?: string | null };
bankAccount?: {
bankAccountName?: string | null;
bankAccountNo?: string | null;
bankBranch?: string | null;
} | null;
};
type StoreOption = { id: string; name: string; phone: string };
@@ -55,6 +60,11 @@ const STATUS_COLORS: Record<string, string> = {
REJECTED: 'red',
};
function settlementStatusColor(kind: StoreSettlementKind, status: string) {
if (status === 'PAID') return kind === 'WITHDRAW' ? 'cyan' : 'green';
return STATUS_COLORS[status] || 'default';
}
const KIND_COLORS: Record<StoreSettlementKind, string> = {
T1_BILL: 'blue',
WITHDRAW: 'purple',
@@ -118,14 +128,30 @@ export default function StoreBillsPage() {
}, []);
function confirmPay(ids: string[], amountHint?: number) {
let paymentRef = '';
Modal.confirm({
title: '确认打款?',
content: `将确认 ${ids.length} 笔 T+1 门店对账单${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。此操作不可撤销。`,
content: (
<div>
<div>
{ids.length} T+1
{amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}
</div>
<Input
placeholder="打款凭证号(可选)"
style={{ marginTop: 8 }}
onChange={(e) => {
paymentRef = e.target.value;
}}
/>
</div>
),
okText: '确认打款',
cancelText: '取消',
onOk: async () => {
const body = JSON.stringify({ paymentRef: paymentRef.trim() || undefined });
if (ids.length === 1) {
await request(`/admin/store-bills/${ids[0]}/confirm`, { method: 'POST' });
await request(`/admin/store-bills/${ids[0]}/confirm`, { method: 'POST', body });
} else {
setBatchLoading(true);
try {
@@ -247,7 +273,9 @@ export default function StoreBillsPage() {
dataIndex: 'kind',
width: 100,
render: (k: StoreSettlementKind) => (
<Tag color={KIND_COLORS[k]}>{STORE_SETTLEMENT_KIND_LABELS[k] ?? k}</Tag>
<Tag color={KIND_COLORS[k]} style={{ minWidth: 72, textAlign: 'center' }}>
{STORE_SETTLEMENT_KIND_LABELS[k] ?? k}
</Tag>
),
},
{
@@ -271,6 +299,20 @@ export default function StoreBillsPage() {
{ title: '门店', dataIndex: ['store', 'name'], width: 140, ellipsis: true },
{ title: '登录手机', dataIndex: ['store', 'phone'], width: 120, render: (v) => v || '—' },
{ title: '城市', dataIndex: ['store', 'cityName'], width: 90 },
{
title: '收款户名',
width: 100,
ellipsis: true,
render: (_, row) =>
row.kind === 'T1_BILL' ? row.bankAccount?.bankAccountName || '—' : '—',
},
{
title: '收款账号',
width: 140,
ellipsis: true,
render: (_, row) =>
row.kind === 'T1_BILL' ? row.bankAccount?.bankAccountNo || '—' : '—',
},
{
title: '笔数',
width: 80,
@@ -288,7 +330,9 @@ export default function StoreBillsPage() {
dataIndex: 'status',
width: 100,
render: (s: string, row) => (
<Tag color={STATUS_COLORS[s] || 'default'}>{storeSettlementStatusLabel(row.kind, s)}</Tag>
<Tag color={settlementStatusColor(row.kind, s)}>
{storeSettlementStatusLabel(row.kind, s)}
</Tag>
),
},
{
@@ -339,7 +383,8 @@ export default function StoreBillsPage() {
</Typography.Title>
<Typography.Text type="secondary">
T+1 沿
T+1 沿
= T+1 =
</Typography.Text>
{overdueSummary && overdueSummary.overdueCount > 0 ? (
<Typography.Text type="danger">
@@ -503,7 +548,32 @@ export default function StoreBillsPage() {
<Descriptions.Item label="打款时间">
{detail.paidAt ? fmtTime(String(detail.paidAt)) : '—'}
</Descriptions.Item>
<Descriptions.Item label="打款凭证">
{detail.paymentRef ? String(detail.paymentRef) : '—'}
</Descriptions.Item>
</Descriptions>
{(detail.storeAccount as {
bankAccountName?: string;
bankAccountNo?: string;
bankBranch?: string;
} | null) ? (
<>
<Typography.Title level={5} style={{ marginTop: 16 }}>
</Typography.Title>
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="户名">
{(detail.storeAccount as { bankAccountName?: string }).bankAccountName || '—'}
</Descriptions.Item>
<Descriptions.Item label="账号">
{(detail.storeAccount as { bankAccountNo?: string }).bankAccountNo || '—'}
</Descriptions.Item>
<Descriptions.Item label="开户行">
{(detail.storeAccount as { bankBranch?: string }).bankBranch || '—'}
</Descriptions.Item>
</Descriptions>
</>
) : null}
<Typography.Title level={5} style={{ marginTop: 16 }}>
</Typography.Title>
@@ -55,10 +55,12 @@ function InfoChangeImageDiff({
field,
live,
proposed,
imageSize = 72,
}: {
field: string;
live: unknown;
proposed: unknown;
imageSize?: number;
}) {
const liveUrls =
field === 'coverUrl'
@@ -83,7 +85,7 @@ function InfoChangeImageDiff({
<Image.PreviewGroup>
<Space wrap size={8}>
{liveUrls.map((url) => (
<Image key={`live-${url}`} src={url} width={72} height={72} style={{ objectFit: 'cover', borderRadius: 4 }} />
<Image key={`live-${url}`} src={url} width={imageSize} height={imageSize} style={{ objectFit: 'cover', borderRadius: 4 }} />
))}
</Space>
</Image.PreviewGroup>
@@ -99,7 +101,7 @@ function InfoChangeImageDiff({
<Image.PreviewGroup>
<Space wrap size={8}>
{proposedUrls.map((url) => (
<Image key={`new-${url}`} src={url} width={72} height={72} style={{ objectFit: 'cover', borderRadius: 4 }} />
<Image key={`new-${url}`} src={url} width={imageSize} height={imageSize} style={{ objectFit: 'cover', borderRadius: 4 }} />
))}
</Space>
</Image.PreviewGroup>
@@ -128,6 +130,7 @@ function InfoChangeAuditPanel({
const [rejectOpen, setRejectOpen] = useState(false);
const [rejectReason, setRejectReason] = useState('');
const [activeId, setActiveId] = useState<string | null>(null);
const [infoFullscreen, setInfoFullscreen] = useState(false);
async function reload(nextPage = page, nextStatus = status) {
setLoading(true);
@@ -286,12 +289,19 @@ function InfoChangeAuditPanel({
<Drawer
title={detail ? `${detail.storeName || detail.storeId} · 信息变更` : '信息变更详情'}
width={680}
width={infoFullscreen ? '100%' : 680}
open={detailOpen}
onClose={() => setDetailOpen(false)}
onClose={() => {
setDetailOpen(false);
setInfoFullscreen(false);
}}
extra={
detail?.status === 'PENDING' ? (
<Space>
<Button onClick={() => setInfoFullscreen((v) => !v)}>
{infoFullscreen ? '退出全屏' : '全屏查看'}
</Button>
{detail?.status === 'PENDING' ? (
<>
<Button onClick={() => void audit(detail.id, 'APPROVE')}></Button>
<Button
danger
@@ -303,8 +313,9 @@ function InfoChangeAuditPanel({
>
</Button>
</>
) : null}
</Space>
) : null
}
>
{detailLoading ? (
@@ -328,7 +339,12 @@ function InfoChangeAuditPanel({
label={STORE_INFO_CHANGEABLE_FIELD_LABELS[d.field] ?? d.field}
>
{d.field === 'coverUrl' || d.field === 'envPhotoUrls' ? (
<InfoChangeImageDiff field={d.field} live={d.live} proposed={d.proposed} />
<InfoChangeImageDiff
field={d.field}
live={d.live}
proposed={d.proposed}
imageSize={infoFullscreen ? 160 : 72}
/>
) : (
<span>
<Typography.Text delete type="secondary">
@@ -388,6 +404,7 @@ export default function StorePackageAuditsPage() {
const [detailOpen, setDetailOpen] = useState(false);
const [activeRequestId, setActiveRequestId] = useState<string | null>(null);
const [drawerTitle, setDrawerTitle] = useState('套餐变更详情');
const [packageFullscreen, setPackageFullscreen] = useState(false);
async function reload(nextPage = page, nextStatus = status) {
setLoading(true);
@@ -554,14 +571,23 @@ export default function StorePackageAuditsPage() {
<Drawer
title={drawerTitle}
width={880}
width={packageFullscreen ? '100%' : 880}
open={detailOpen}
onClose={() => setDetailOpen(false)}
onClose={() => {
setDetailOpen(false);
setPackageFullscreen(false);
}}
destroyOnClose
extra={
<Button onClick={() => setPackageFullscreen((v) => !v)}>
{packageFullscreen ? '退出全屏' : '全屏查看'}
</Button>
}
>
{activeRequestId ? (
<StorePackageAuditPanel
requestId={activeRequestId}
fullscreen={packageFullscreen}
onAudited={() => {
setDetailOpen(false);
void reload(page, status);
@@ -218,8 +218,13 @@ export default function SupportTicketsPage() {
setReviewOpen(false);
const summary = [detail!.title, detail!.content].filter(Boolean).join('\n').slice(0, 500);
const ticketUrls = (detail!.attachmentUrls ?? []).filter(Boolean);
tasksForm.setFieldsValue({
tasks: [{ content: summary || detail!.title, type: mapSupportTicketTypeToDevPlanTask(detail!.ticketType) }],
tasks: [{
content: summary || detail!.title,
type: mapSupportTicketTypeToDevPlanTask(detail!.ticketType),
attachmentUrls: ticketUrls,
}],
dispatchToWecom: localStorage.getItem(DISPATCH_WECOM_STORAGE_KEY) !== 'false',
dispatchSupplement: DEFAULT_DISPATCH_SUPPLEMENT,
});
@@ -364,7 +369,11 @@ export default function SupportTicketsPage() {
body: JSON.stringify({
decision: 'APPROVE',
note: note?.trim() || undefined,
tasks: values.tasks.map((t) => ({ content: t.content.trim(), type: t.type })),
tasks: values.tasks.map((t) => ({
content: t.content.trim(),
type: t.type,
attachmentUrls: t.attachmentUrls?.length ? t.attachmentUrls : detail!.attachmentUrls,
})),
dispatchToWecom: !!values.dispatchToWecom,
dispatchSupplement: values.dispatchSupplement?.trim() || undefined,
}),
+49 -2
View File
@@ -37,6 +37,13 @@ type Row = {
wineryAmount: number;
status: string;
paidAt?: string | null;
paymentRef?: string | null;
wineryBank?: {
bankAccountName?: string | null;
bankName?: string | null;
bankBranch?: string | null;
bankAccountNo?: string | null;
};
};
type BillItem = {
@@ -127,14 +134,30 @@ export default function WineryBillsPage() {
(profile?.permissionKeys ?? []).includes('system_settings_winery_bank');
function confirmPay(ids: string[], amountHint?: number) {
let paymentRef = '';
Modal.confirm({
title: '确认打款?',
content: `将确认 ${ids.length} 笔酒厂对账单${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。此操作不可撤销。`,
content: (
<div>
<div>
{ids.length}
{amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}
</div>
<Input
placeholder="打款凭证号(可选)"
style={{ marginTop: 8 }}
onChange={(e) => {
paymentRef = e.target.value;
}}
/>
</div>
),
okText: '确认打款',
cancelText: '取消',
onOk: async () => {
const body = JSON.stringify({ paymentRef: paymentRef.trim() || undefined });
if (ids.length === 1) {
await request(`/admin/winery-bills/${ids[0]}/confirm`, { method: 'POST' });
await request(`/admin/winery-bills/${ids[0]}/confirm`, { method: 'POST', body });
} else {
await request('/admin/winery-bills/batch-confirm', {
method: 'POST',
@@ -399,7 +422,31 @@ export default function WineryBillsPage() {
{displayWineryStatus(detail.status, detail.wineryAmount).label}
</Descriptions.Item>
<Descriptions.Item label="打款时间">{detail.paidAt ? fmtTime(detail.paidAt) : '—'}</Descriptions.Item>
<Descriptions.Item label="打款凭证">
{detail.paymentRef ? String(detail.paymentRef) : '—'}
</Descriptions.Item>
</Descriptions>
{detail.wineryBank ? (
<>
<Typography.Title level={5} style={{ marginTop: 16 }}>
</Typography.Title>
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="户名">
{detail.wineryBank.bankAccountName || '—'}
</Descriptions.Item>
<Descriptions.Item label="开户银行">
{detail.wineryBank.bankName || '—'}
</Descriptions.Item>
<Descriptions.Item label="开户支行">
{detail.wineryBank.bankBranch || '—'}
</Descriptions.Item>
<Descriptions.Item label="账号">
{detail.wineryBank.bankAccountNo || '—'}
</Descriptions.Item>
</Descriptions>
</>
) : null}
<Typography.Title level={5} style={{ marginTop: 16 }}>
</Typography.Title>
+10 -1
View File
@@ -24,6 +24,8 @@
| 3.4.16 | 08-09 | 联系电话分离;取消自动 ST;套餐 UI;合伙人列表/入驻客服门槛;HQ 门店列表表格 | [`v3.4.16`](./杜康好客-v3.4.16-门店联系电话与体验优化.md) |
| 3.5.3 | 08-20 | 门店照片/企微审核/分享;核销金额序列化与提现补建;测试流水计结算;核销浮动提示;权益金额门店图标 | [`v3.5.3`](./杜康好客-v3.5.3-开发文档.md) |
| 3.5.4 | 08-21 | 商品 SPU+SKU 规格;DK 码/独立 69 码/规格主图;下单兼容回落;发票品类;权益文案与门店详情/收银台 UI;客户端版本号 | [`v3.5.4`](./杜康好客-v3.5.4-开发文档.md) |
| 3.5.5 | 08-23 | HQ 订单监控导出 Excel/PDF + 列表页布局优化 | [`v3.5.5`](./杜康好客-v3.5.5-开发文档.md) |
| 3.5.6 | 08-23 | HQ 任务导出;核销/财务银行信息;工单图片入任务;运营去调试;门店审核全屏 | [`v3.5.6`](./杜康好客-v3.5.6-开发文档.md) |
---
@@ -88,6 +90,13 @@
**FIN-001~003**:白名单未出账提现 · 单店日限 ¥5000 · T+0 审完预警。
合伙人 **T+30** 独立月账/打款。
**HQ 财务打款信息**`admin-web` 财务四页,v3.5.6):
- 门店/合伙人/酒厂/物流账单列表、详情、导出展示收款账户(户名、账号、开户行)
- 确认打款时可填写打款凭证号(`paymentRef`),已打款后在详情与导出中展示
- 门店账单统一列表:T+1 终态「已打款」、手动提现终态「已结算」(均为 `PAID`,文案区分业务类型)
- 核销详情展示核销门店主账户收款信息
### 3.4~3.8 Wave 能力
- **3.4** 门店一号多店、主账号、店员子账号(W2)
@@ -101,7 +110,7 @@
| § | 主题 | 文档 |
|---|------|------|
| 3.9 | 门店套餐 ≤10 条、独立审核 | v3.4.10 |
| 3.10 | 开发计划/任务/版本/技术支持联动 | v3.4.11 |
| 3.10 | 开发计划/任务/版本/技术支持联动;任务导出 md/docx/xlsx/pdf;任务附件 | v3.4.11 / v3.5.6 |
| 3.11 | 企微智能机器人 + 消息推送 Webhook | v3.4.11 |
| 3.12 | **登录手机号 ≠ 对外联系电话**;体验优化 | v3.4.16 |
+2
View File
@@ -60,9 +60,11 @@
| 3.5.3 | [`门店照片/企微审核/分享 + 核销金额与提现补修`](./杜康好客-v3.5.3-开发文档.md) | ✅ 生产 |
| 3.5.4 | [`商品规格 SPU+SKU + 发票/文案 UI`](./杜康好客-v3.5.4-开发文档.md) | ✅ 生产(weapp 版本号需单独上传) |
| 3.5.5 | [`HQ 订单导出 + 订单监控页优化`](./杜康好客-v3.5.5-开发文档.md) | 🔶 开发完成,待发版 |
| 3.5.6 | [`HQ 任务导出/财务银行信息/工单图片/运营去调试/审核全屏`](./杜康好客-v3.5.6-开发文档.md) | ✅ 已交付 |
| 日期 | 说明 |
|------|------|
| 2026-08-23 | v3.5.6:开发计划任务导出;核销/财务银行信息与打款凭证;工单图片入任务;运营去调试;门店审核全屏 |
| 2026-08-23 | HQ 订单监控:筛选/勾选导出 Excel(xlsx)与 PDF`POST /admin/orders/export` |
| 2026-08-21 | v3.5.4 发生产:规格表/回填 SKU/DK 码/主图列/发票品类;H5+API 200 |
| 2026-08-20 | v3.5.3 增补:Decimal 序列化/提现补建、测试流水计结算、核销浮动提示、好客权益金额图标 |
+129
View File
@@ -0,0 +1,129 @@
# 杜康好客 · v3.5.6 开发文档
> **2026-08-23** · admin-web / API
> **主题**:HQ 运营与财务体验增强(任务导出、核销/账单银行信息、工单图片、权限、审核全屏)
---
## 1. 版本目标
1. **开发计划** 任务列表支持导出 Markdown / DOCX / Excel / PDF。
2. **好客权益 · 核销详情** 展示核销门店收款账户(户名/账号/开户行)。
3. **门店账单** 保留「已打款」「已结算」文案,用颜色与类型标签区分;页头说明两类终态含义。
4. **财务四页**(门店/合伙人/酒厂/物流)统一展示收款账户与打款凭证;确认打款可填凭证号。
5. **技术支持 → 开发任务** 审批创建任务时带入工单图片;新建任务支持上传图片。
6. **运营权限** 去掉调试模块(核销调试、小飞侠联调),仅超级管理员可见。
7. **门店审核对比** 支持全屏查看详情。
**不做**:C 端 / 合伙人 / 门店端改动;不升小程序版本号。
---
## 2. 功能清单
### 2.1 开发计划任务导出
| # | 项 | 说明 |
|---|----|------|
| 1 | API | `POST /api/v1/admin/dev-plan/tasks/export` |
| 2 | 范围 | `scope=filter` 按筛选;`scope=selected` 仅勾选 `ids` |
| 3 | 格式 | `markdown` / `docx` / `xlsx` / `pdf` |
| 4 | 列 | 任务编号、类型、状态、内容、关联工单、创建人、创建时间、完成时间、附件 |
| 5 | 权限 | `dev_plan` |
### 2.2 核销详情 · 门店收款账户
| # | 项 | 说明 |
|---|----|------|
| 6 | API | `GET /admin/redeem-records/:id` 增加 `store.primaryAccount` |
| 7 | UI | `RedeemRecordDetailDescriptions` 增加「收款账户」区块 |
### 2.3 门店账单状态区分
| 类型 | 终态 | 颜色 | 含义 |
|------|------|------|------|
| T+1 出账 | 已打款 | 绿 | HQ 确认已对公打款 |
| 手动提现 | 已结算 | 青 | HQ 审核通过并完成结算 |
两类单据终态枚举均为 `PAID`,故文案不同;本版不改文案,仅强化视觉区分。
### 2.4 财务四页 · 银行卡打款信息
| 页 | 收款账户来源 | 打款凭证 |
|----|-------------|----------|
| 门店账单 | 主 `StoreAccount` | `StoreBill.paymentRef`T+1);提现沿用 `StoreWithdrawRequest` |
| 合伙人账单 | `PartnerAccount` | `PartnerBill.paymentRef` |
| 酒厂账单 | `WINERY_BANK_*` 系统配置 | `WineryBill.paymentRef` |
| 物流对账 | `FulfillmentProvider` | `LogisticsBill.paymentRef`(挂账模式) |
列表 / 详情 / 导出均展示户名、账号、开户行(及酒厂开户银行);确认打款弹窗可选填凭证号。
### 2.5 工单 → 任务图片
| # | 项 | 说明 |
|---|----|------|
| 8 | DB | `dev_plan_task.attachment_urls` JSON |
| 9 | 审批创建 | 默认拷贝工单 `attachmentUrls` |
| 10 | 新建任务 | `DevPlanTasksPage` 支持 `OssUpload` |
### 2.6 运营去掉调试模块
| 菜单 | 路由 | 新权限键 |
|------|------|----------|
| 核销调试 | `/redeem/debug` | `benefit_debug` |
| 小飞侠联调 | `/deliveries/xiaofeixia` | `deliveries_debug` |
`SUPER_ADMIN` 默认拥有;调试 API 同步鉴权。
### 2.7 门店审核全屏
`StorePackageAuditsPage` 套餐对比 / 信息变更 Drawer 支持「全屏查看」切换 `width=100%`;全屏时图片放大。
---
## 3. Prisma 迁移
- `store_bill.payment_ref`
- `partner_bill.payment_ref`
- `winery_bill.payment_ref`
- `logistics_bill.payment_ref`
- `dev_plan_task.attachment_urls`
脚本:`prisma/migrate-finance-payment-ref-v356.sql``prisma/migrate-dev-plan-attachments-v356.sql`
---
## 4. 发版步骤
| 步骤 | 动作 |
|------|------|
| 1 | `pnpm install`(新增 `docx` |
| 2 | 执行 Prisma 迁移 |
| 3 | `pnpm build`API + admin-web |
| 4 | 发 `dukang-api` + `admin-web` |
---
## 5. 关键路径
| 域 | 路径 |
|----|------|
| 任务导出 | `server/.../dev-plan/admin-dev-plan-export.util.ts` |
| 核销详情 | `admin-redeem.service.ts` + `RedeemRecordDetailDescriptions.tsx` |
| 门店账单 | `StoreBillsPage.tsx` + `settlement.service.ts` |
| 合伙人/酒厂/物流 | `PartnerBillsPage` / `WineryBillsPage` / `LogisticsBillsPage` |
| 工单图片 | `dev-plan.service.ts` + `SupportTicketsPage.tsx` + `DevPlanTasksPage.tsx` |
| 权限 | `hq-permissions.ts` + `AdminLayout.tsx` |
| 审核全屏 | `StorePackageAuditsPage.tsx` + `StorePackageAuditPanel.tsx` |
---
## 6. 验收清单
- [ ] 开发计划:勾选/筛选导出 md/docx/xlsx/pdf,中文正常
- [ ] 核销详情:有主账户时展示户名/账号/开户行
- [ ] 门店账单:T+1「已打款」绿色、提现「已结算」青色;类型 Tag 醒目
- [ ] 财务四页:列表/详情/导出含收款账户;确认打款可填凭证;已打款后可见凭证
- [ ] 工单审批创建任务:图片带入;新建任务可上传图
- [ ] 运营账号无「核销调试」「小飞侠联调」菜单;直链 403
- [ ] 门店审核对比可全屏;图片放大可预览
+23 -1
View File
@@ -92,6 +92,8 @@ export interface DevPlanTaskDto {
completedAt?: string | null;
attachmentUrls?: string[] | null;
}
@@ -148,6 +150,8 @@ export interface CreateDevPlanTaskInput {
supportTicketId?: string;
attachmentUrls?: string[];
}
@@ -160,6 +164,8 @@ export interface UpdateDevPlanTaskInput {
status?: DevPlanTaskStatusDto;
attachmentUrls?: string[];
}
@@ -228,7 +234,23 @@ export interface DevPlanLinkedTaskSummary {
/** 技术支持工单类型 → 开发任务类型 */
export type DevPlanTaskExportFormat = 'markdown' | 'docx' | 'xlsx' | 'pdf';
export interface DevPlanTaskExportRequest {
scope: 'filter' | 'selected';
format: DevPlanTaskExportFormat;
ids?: string[];
status?: string;
type?: string;
keyword?: string;
}
export interface DevPlanTaskExportResponse {
filename: string;
mimeType: string;
contentBase64: string;
count: number;
}
export function mapSupportTicketTypeToDevPlanTask(
+15 -2
View File
@@ -10,7 +10,9 @@ export const HQ_PERMISSION_CATALOG = [
{ key: 'partners', label: '开城管理', group: '业务' },
{ key: 'finance', label: '财务', group: '业务' },
{ key: 'benefit', label: '好客权益', group: '业务' },
{ key: 'benefit_debug', label: '核销调试', group: '业务' },
{ key: 'deliveries', label: '配送单', group: '业务' },
{ key: 'deliveries_debug', label: '小飞侠联调', group: '业务' },
{ key: 'tickets', label: '工单中心', group: '业务' },
{ key: 'tech_support', label: '技术支持', group: '业务' },
{ key: 'invoices', label: '发票管理', group: '业务' },
@@ -41,6 +43,11 @@ export const HQ_PERMISSION_CATALOG = [
export type HqPermissionKey = (typeof HQ_PERMISSION_CATALOG)[number]['key'];
/** 危险操作:非超管角色默认不含,需在权限分配中显式勾选;超管默认拥有 */
export const HQ_DEBUG_PERMISSION_KEYS = [
'benefit_debug',
'deliveries_debug',
] as const satisfies readonly HqPermissionKey[];
export const HQ_DANGEROUS_PERMISSION_KEYS = [
'users_delete',
'orders_delete',
@@ -51,8 +58,14 @@ export function isHqDangerousPermission(key: string): boolean {
return (HQ_DANGEROUS_PERMISSION_KEYS as readonly string[]).includes(key);
}
export function isHqDebugPermission(key: string): boolean {
return (HQ_DEBUG_PERMISSION_KEYS as readonly string[]).includes(key);
}
export function hqBasePermissionKeys(): HqPermissionKey[] {
return HQ_PERMISSION_CATALOG.map((p) => p.key).filter((k) => !isHqDangerousPermission(k));
return HQ_PERMISSION_CATALOG.map((p) => p.key).filter(
(k) => !isHqDangerousPermission(k) && !isHqDebugPermission(k),
);
}
/** 系统配置 registry group → 权限 key */
@@ -100,7 +113,7 @@ export const HQ_ADMIN_ROLES = [
] as const;
export const HQ_ROLE_DEFAULT_PERMISSIONS: Record<string, HqPermissionKey[]> = {
SUPER_ADMIN: hqBasePermissionKeys(),
SUPER_ADMIN: [...hqBasePermissionKeys(), ...HQ_DEBUG_PERMISSION_KEYS],
OPS: [
'dashboard',
'users',
@@ -96,6 +96,7 @@ export interface RejectSupportTicketRequest {
export interface CreateDevPlanTaskFromTicketInput {
content: string;
type: 'BUG' | 'REQUIREMENT' | 'OPTIMIZATION';
attachmentUrls?: string[];
}
export interface ReviewSupportTicketRequest {
+111 -44
View File
@@ -68,13 +68,13 @@ importers:
version: 18.3.7(@types/react@18.3.31)
'@vitejs/plugin-react':
specifier: ^4.3.1
version: 4.7.0(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0))
version: 4.7.0(vite@5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0))
typescript:
specifier: ^5.4.5
version: 5.9.3
vite:
specifier: ^5.4.0
version: 5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0)
version: 5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0)
apps/h5-partner:
dependencies:
@@ -120,13 +120,13 @@ importers:
version: 18.3.7(@types/react@18.3.31)
'@vitejs/plugin-react':
specifier: ^4.3.1
version: 4.7.0(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0))
version: 4.7.0(vite@5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0))
typescript:
specifier: ^5.4.5
version: 5.9.3
vite:
specifier: ^5.4.0
version: 5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0)
version: 5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0)
apps/h5-shop:
dependencies:
@@ -160,13 +160,13 @@ importers:
version: 18.3.7(@types/react@18.3.31)
'@vitejs/plugin-react':
specifier: ^4.3.1
version: 4.7.0(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0))
version: 4.7.0(vite@5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0))
typescript:
specifier: ^5.4.5
version: 5.9.3
vite:
specifier: ^5.4.0
version: 5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0)
version: 5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0)
apps/mini-user:
dependencies:
@@ -193,7 +193,7 @@ importers:
version: 4.2.0(@swc/helpers@0.5.23)
'@tarojs/plugin-framework-react':
specifier: 4.2.0
version: 4.2.0(@tarojs/helper@4.2.0(@swc/helpers@0.5.23))(@tarojs/runtime@4.2.0)(@tarojs/shared@4.2.0)(@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0)))(react@18.3.1)(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0))(webpack@5.97.1(@swc/core@1.3.96(@swc/helpers@0.5.23))(postcss@8.5.15))
version: 4.2.0(@tarojs/helper@4.2.0(@swc/helpers@0.5.23))(@tarojs/runtime@4.2.0)(@tarojs/shared@4.2.0)(@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0)))(react@18.3.1)(vite@5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0))(webpack@5.97.1(@swc/core@1.3.96(@swc/helpers@0.5.23))(postcss@8.5.15))
'@tarojs/plugin-platform-h5':
specifier: 4.2.0
version: 4.2.0(@swc/helpers@0.5.23)(@tarojs/taro@4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0(@swc/helpers@0.5.23))(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(@swc/core@1.3.96(@swc/helpers@0.5.23))(postcss@8.5.15)))(@tarojs/helper@4.2.0(@swc/helpers@0.5.23))(@tarojs/shared@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(@swc/core@1.3.96(@swc/helpers@0.5.23))(postcss@8.5.15)))(@types/react@18.3.31)(postcss@8.5.15)(react@18.3.1)(rollup@3.30.0)(solid-js@1.9.14)(webpack@5.97.1(@swc/core@1.3.96(@swc/helpers@0.5.23))(postcss@8.5.15))
@@ -242,13 +242,13 @@ importers:
version: 4.2.0(@swc/helpers@0.5.23)
'@tarojs/vite-runner':
specifier: 4.2.0
version: 4.2.0(@swc/helpers@0.5.23)(@tarojs/runtime@4.2.0)(@types/babel__core@7.20.5)(postcss@8.5.15)(rollup@3.30.0)(terser@5.48.0)(typescript@5.9.3)(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0))
version: 4.2.0(@swc/helpers@0.5.23)(@tarojs/runtime@4.2.0)(@types/babel__core@7.20.5)(postcss@8.5.15)(rollup@3.30.0)(terser@5.48.0)(typescript@5.9.3)(vite@5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0))
'@types/react':
specifier: ^18.3.3
version: 18.3.31
'@vitejs/plugin-react':
specifier: ^4.3.1
version: 4.7.0(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0))
version: 4.7.0(vite@5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0))
babel-preset-taro:
specifier: 4.2.0
version: 4.2.0(@babel/core@7.29.7)(@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7))(@babel/preset-react@7.29.7(@babel/core@7.29.7))(@swc/helpers@0.5.23)
@@ -257,7 +257,7 @@ importers:
version: 5.9.3
vite:
specifier: ^5.4.0
version: 5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0)
version: 5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0)
packages/client-logging:
devDependencies:
@@ -272,7 +272,7 @@ importers:
version: 5.9.3
vitest:
specifier: ^1.6.0
version: 1.6.1(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0)
version: 1.6.1(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0)
packages/shared-types:
devDependencies:
@@ -358,6 +358,9 @@ importers:
class-validator:
specifier: ^0.14.1
version: 0.14.4
docx:
specifier: ^9.5.0
version: 9.7.1
dotenv:
specifier: ^17.4.2
version: 17.4.2
@@ -388,7 +391,7 @@ importers:
devDependencies:
'@nestjs/cli':
specifier: ^10.4.0
version: 10.4.9
version: 10.4.9(@swc/core@1.3.96(@swc/helpers@0.5.23))
'@nestjs/schematics':
specifier: ^10.1.0
version: 10.2.3(chokidar@3.6.0)(typescript@5.9.3)
@@ -415,7 +418,7 @@ importers:
version: 5.22.0
ts-node:
specifier: ^10.9.2
version: 10.9.2(@types/node@20.19.43)(typescript@5.9.3)
version: 10.9.2(@swc/core@1.3.96(@swc/helpers@0.5.23))(@types/node@20.19.43)(typescript@5.9.3)
typescript:
specifier: ^5.4.5
version: 5.9.3
@@ -2634,6 +2637,9 @@ packages:
'@types/node@22.20.0':
resolution: {integrity: sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==}
'@types/node@25.9.5':
resolution: {integrity: sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==}
'@types/pdfkit@0.17.6':
resolution: {integrity: sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==}
@@ -3650,6 +3656,10 @@ packages:
resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==}
engines: {node: '>=6.0.0'}
docx@9.7.1:
resolution: {integrity: sha512-ilXFf9Moz47ABjFpDiA5s1w9lpb4EFSp7+5iiJSbfyYDM+bpZdAgLlSr7fW4aXhVe/E+F6QCv0EvRVFEd5CsWg==}
engines: {node: '>=10'}
dot-case@3.0.4:
resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==}
@@ -4280,6 +4290,9 @@ packages:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'}
hash.js@1.1.7:
resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==}
hashery@1.5.1:
resolution: {integrity: sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==}
engines: {node: '>=20'}
@@ -4934,6 +4947,9 @@ packages:
resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==}
engines: {node: '>=4'}
minimalistic-assert@1.0.1:
resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==}
minimatch@10.2.5:
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
engines: {node: 18 || 20 || >=22}
@@ -5015,6 +5031,11 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
nanoid@5.1.16:
resolution: {integrity: sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==}
engines: {node: ^18 || >=20}
hasBin: true
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
@@ -6532,6 +6553,9 @@ packages:
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
undici-types@7.24.6:
resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
unescape@1.0.1:
resolution: {integrity: sha512-O0+af1Gs50lyH1nUu3ZyYS1cRh01Q/kUKatTOkSs7jukXE6/NebucDVxyiDsA9AQ4JC1V1jUH9EO8JX2nMDgGQ==}
engines: {node: '>=0.10.0'}
@@ -6813,10 +6837,17 @@ packages:
utf-8-validate:
optional: true
xml-js@1.6.11:
resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==}
hasBin: true
xml2js@0.6.2:
resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==}
engines: {node: '>=4.0.0'}
xml@1.0.1:
resolution: {integrity: sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==}
xmlbuilder@11.0.1:
resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==}
engines: {node: '>=4.0'}
@@ -8258,7 +8289,7 @@ snapshots:
bullmq: 5.79.2
tslib: 2.8.1
'@nestjs/cli@10.4.9':
'@nestjs/cli@10.4.9(@swc/core@1.3.96(@swc/helpers@0.5.23))':
dependencies:
'@angular-devkit/core': 17.3.11(chokidar@3.6.0)
'@angular-devkit/schematics': 17.3.11(chokidar@3.6.0)
@@ -8268,7 +8299,7 @@ snapshots:
chokidar: 3.6.0
cli-table3: 0.6.5
commander: 4.1.1
fork-ts-checker-webpack-plugin: 9.0.2(typescript@5.7.2)(webpack@5.97.1)
fork-ts-checker-webpack-plugin: 9.0.2(typescript@5.7.2)(webpack@5.97.1(@swc/core@1.3.96(@swc/helpers@0.5.23)))
glob: 10.4.5
inquirer: 8.2.6
node-emoji: 1.11.0
@@ -8277,8 +8308,10 @@ snapshots:
tsconfig-paths: 4.2.0
tsconfig-paths-webpack-plugin: 4.2.0
typescript: 5.7.2
webpack: 5.97.1
webpack: 5.97.1(@swc/core@1.3.96(@swc/helpers@0.5.23))
webpack-node-externals: 3.0.0
optionalDependencies:
'@swc/core': 1.3.96(@swc/helpers@0.5.23)
transitivePeerDependencies:
- '@minify-html/node'
- '@swc/css'
@@ -9084,7 +9117,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@tarojs/plugin-framework-react@4.2.0(@tarojs/helper@4.2.0(@swc/helpers@0.5.23))(@tarojs/runtime@4.2.0)(@tarojs/shared@4.2.0)(@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0)))(react@18.3.1)(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0))(webpack@5.97.1(@swc/core@1.3.96(@swc/helpers@0.5.23))(postcss@8.5.15))':
'@tarojs/plugin-framework-react@4.2.0(@tarojs/helper@4.2.0(@swc/helpers@0.5.23))(@tarojs/runtime@4.2.0)(@tarojs/shared@4.2.0)(@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0)))(react@18.3.1)(vite@5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0))(webpack@5.97.1(@swc/core@1.3.96(@swc/helpers@0.5.23))(postcss@8.5.15))':
dependencies:
'@tarojs/helper': 4.2.0(@swc/helpers@0.5.23)
'@tarojs/runtime': 4.2.0
@@ -9094,9 +9127,9 @@ snapshots:
lodash: 4.18.1
tslib: 2.8.1
optionalDependencies:
'@vitejs/plugin-react': 4.7.0(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0))
'@vitejs/plugin-react': 4.7.0(vite@5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0))
react: 18.3.1
vite: 5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0)
vite: 5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0)
webpack: 5.97.1(@swc/core@1.3.96(@swc/helpers@0.5.23))(postcss@8.5.15)
'@tarojs/plugin-platform-h5@4.2.0(@swc/helpers@0.5.23)(@tarojs/taro@4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0(@swc/helpers@0.5.23))(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(@swc/core@1.3.96(@swc/helpers@0.5.23))(postcss@8.5.15)))(@tarojs/helper@4.2.0(@swc/helpers@0.5.23))(@tarojs/shared@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(@swc/core@1.3.96(@swc/helpers@0.5.23))(postcss@8.5.15)))(@types/react@18.3.31)(postcss@8.5.15)(react@18.3.1)(rollup@3.30.0)(solid-js@1.9.14)(webpack@5.97.1(@swc/core@1.3.96(@swc/helpers@0.5.23))(postcss@8.5.15))':
@@ -9217,7 +9250,7 @@ snapshots:
rollup: 3.30.0
webpack: 5.97.1(@swc/core@1.3.96(@swc/helpers@0.5.23))(postcss@8.5.15)
'@tarojs/vite-runner@4.2.0(@swc/helpers@0.5.23)(@tarojs/runtime@4.2.0)(@types/babel__core@7.20.5)(postcss@8.5.15)(rollup@3.30.0)(terser@5.48.0)(typescript@5.9.3)(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0))':
'@tarojs/vite-runner@4.2.0(@swc/helpers@0.5.23)(@tarojs/runtime@4.2.0)(@types/babel__core@7.20.5)(postcss@8.5.15)(rollup@3.30.0)(terser@5.48.0)(typescript@5.9.3)(vite@5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0))':
dependencies:
'@ampproject/remapping': 2.3.0
'@babel/core': 7.29.7
@@ -9230,7 +9263,7 @@ snapshots:
'@tarojs/runner-utils': 4.2.0(@swc/helpers@0.5.23)
'@tarojs/runtime': 4.2.0
'@tarojs/shared': 4.2.0
'@vitejs/plugin-legacy': 4.1.1(terser@5.48.0)(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0))
'@vitejs/plugin-legacy': 4.1.1(terser@5.48.0)(vite@5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0))
acorn-walk: 8.3.5
autoprefixer: 10.5.2(postcss@8.5.15)
babel-plugin-transform-taroapi: 4.2.0(@babel/core@7.29.7)
@@ -9253,8 +9286,8 @@ snapshots:
regenerator-runtime: 0.11.1
sax: 1.2.4
stylelint: 16.26.1(typescript@5.9.3)
vite: 5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0)
vite-plugin-static-copy: 0.17.1(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0))
vite: 5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0)
vite-plugin-static-copy: 0.17.1(vite@5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0))
transitivePeerDependencies:
- '@swc/helpers'
- '@types/babel__core'
@@ -9400,6 +9433,10 @@ snapshots:
dependencies:
undici-types: 6.21.0
'@types/node@25.9.5':
dependencies:
undici-types: 7.24.6
'@types/pdfkit@0.17.6':
dependencies:
'@types/node': 20.19.43
@@ -9548,7 +9585,7 @@ snapshots:
'@typescript-eslint/types': 8.62.1
eslint-visitor-keys: 5.0.1
'@vitejs/plugin-legacy@4.1.1(terser@5.48.0)(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0))':
'@vitejs/plugin-legacy@4.1.1(terser@5.48.0)(vite@5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0))':
dependencies:
'@babel/core': 7.29.7
'@babel/preset-env': 7.29.7(@babel/core@7.29.7)
@@ -9558,11 +9595,11 @@ snapshots:
regenerator-runtime: 0.13.11
systemjs: 6.15.1
terser: 5.48.0
vite: 5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0)
vite: 5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0)
transitivePeerDependencies:
- supports-color
'@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0))':
'@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0))':
dependencies:
'@babel/core': 7.29.7
'@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7)
@@ -9570,7 +9607,7 @@ snapshots:
'@rolldown/pluginutils': 1.0.0-beta.27
'@types/babel__core': 7.20.5
react-refresh: 0.17.0
vite: 5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0)
vite: 5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0)
transitivePeerDependencies:
- supports-color
@@ -10670,6 +10707,15 @@ snapshots:
dependencies:
esutils: 2.0.3
docx@9.7.1:
dependencies:
'@types/node': 25.9.5
hash.js: 1.1.7
jszip: 3.10.1
nanoid: 5.1.16
xml: 1.0.1
xml-js: 1.6.11
dot-case@3.0.4:
dependencies:
no-case: 3.0.4
@@ -11205,7 +11251,7 @@ snapshots:
cross-spawn: 7.0.6
signal-exit: 4.1.0
fork-ts-checker-webpack-plugin@9.0.2(typescript@5.7.2)(webpack@5.97.1):
fork-ts-checker-webpack-plugin@9.0.2(typescript@5.7.2)(webpack@5.97.1(@swc/core@1.3.96(@swc/helpers@0.5.23))):
dependencies:
'@babel/code-frame': 7.29.7
chalk: 4.1.2
@@ -11220,7 +11266,7 @@ snapshots:
semver: 7.8.5
tapable: 2.3.3
typescript: 5.7.2
webpack: 5.97.1
webpack: 5.97.1(@swc/core@1.3.96(@swc/helpers@0.5.23))
form-data@4.0.6:
dependencies:
@@ -11474,6 +11520,11 @@ snapshots:
dependencies:
has-symbols: 1.1.0
hash.js@1.1.7:
dependencies:
inherits: 2.0.4
minimalistic-assert: 1.0.1
hashery@1.5.1:
dependencies:
hookified: 1.15.1
@@ -12085,6 +12136,8 @@ snapshots:
mimic-response@1.0.1: {}
minimalistic-assert@1.0.1: {}
minimatch@10.2.5:
dependencies:
brace-expansion: 5.0.7
@@ -12172,6 +12225,8 @@ snapshots:
nanoid@3.3.15: {}
nanoid@5.1.16: {}
natural-compare@1.4.0: {}
negotiator@0.6.3: {}
@@ -13589,13 +13644,15 @@ snapshots:
postcss: 8.5.15
optional: true
terser-webpack-plugin@5.6.1(webpack@5.97.1):
terser-webpack-plugin@5.6.1(@swc/core@1.3.96(@swc/helpers@0.5.23))(webpack@5.97.1(@swc/core@1.3.96(@swc/helpers@0.5.23))):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
jest-worker: 27.5.1
schema-utils: 4.3.3
terser: 5.48.0
webpack: 5.97.1
webpack: 5.97.1(@swc/core@1.3.96(@swc/helpers@0.5.23))
optionalDependencies:
'@swc/core': 1.3.96(@swc/helpers@0.5.23)
terser@5.48.0:
dependencies:
@@ -13681,7 +13738,7 @@ snapshots:
dependencies:
typescript: 5.9.3
ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3):
ts-node@10.9.2(@swc/core@1.3.96(@swc/helpers@0.5.23))(@types/node@20.19.43)(typescript@5.9.3):
dependencies:
'@cspotcode/source-map-support': 0.8.1
'@tsconfig/node10': 1.0.12
@@ -13698,6 +13755,8 @@ snapshots:
typescript: 5.9.3
v8-compile-cache-lib: 3.0.1
yn: 3.1.1
optionalDependencies:
'@swc/core': 1.3.96(@swc/helpers@0.5.23)
tsconfig-paths-webpack-plugin@4.2.0:
dependencies:
@@ -13779,6 +13838,8 @@ snapshots:
undici-types@6.21.0: {}
undici-types@7.24.6: {}
unescape@1.0.1:
dependencies:
extend-shallow: 2.0.1
@@ -13892,13 +13953,13 @@ snapshots:
vary@1.1.2: {}
vite-node@1.6.1(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0):
vite-node@1.6.1(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0):
dependencies:
cac: 6.7.14
debug: 4.4.3
pathe: 1.1.2
picocolors: 1.1.1
vite: 5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0)
vite: 5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0)
transitivePeerDependencies:
- '@types/node'
- less
@@ -13910,26 +13971,26 @@ snapshots:
- supports-color
- terser
vite-plugin-static-copy@0.17.1(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0)):
vite-plugin-static-copy@0.17.1(vite@5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0)):
dependencies:
chokidar: 3.6.0
fast-glob: 3.3.3
fs-extra: 11.3.6
picocolors: 1.1.1
vite: 5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0)
vite: 5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0)
vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0):
vite@5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0):
dependencies:
esbuild: 0.21.5
postcss: 8.5.15
rollup: 4.62.2
optionalDependencies:
'@types/node': 22.20.0
'@types/node': 25.9.5
fsevents: 2.3.3
sass: 1.101.0
terser: 5.48.0
vitest@1.6.1(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0):
vitest@1.6.1(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0):
dependencies:
'@vitest/expect': 1.6.1
'@vitest/runner': 1.6.1
@@ -13948,11 +14009,11 @@ snapshots:
strip-literal: 2.1.1
tinybench: 2.9.0
tinypool: 0.8.4
vite: 5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0)
vite-node: 1.6.1(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0)
vite: 5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0)
vite-node: 1.6.1(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 22.20.0
'@types/node': 25.9.5
transitivePeerDependencies:
- less
- lightningcss
@@ -13983,7 +14044,7 @@ snapshots:
webpack-sources@3.5.0: {}
webpack@5.97.1:
webpack@5.97.1(@swc/core@1.3.96(@swc/helpers@0.5.23)):
dependencies:
'@types/eslint-scope': 3.7.7
'@types/estree': 1.0.9
@@ -14005,7 +14066,7 @@ snapshots:
neo-async: 2.6.2
schema-utils: 3.3.0
tapable: 2.3.3
terser-webpack-plugin: 5.6.1(webpack@5.97.1)
terser-webpack-plugin: 5.6.1(@swc/core@1.3.96(@swc/helpers@0.5.23))(webpack@5.97.1(@swc/core@1.3.96(@swc/helpers@0.5.23)))
watchpack: 2.5.2
webpack-sources: 3.5.0
transitivePeerDependencies:
@@ -14129,11 +14190,17 @@ snapshots:
ws@8.21.1: {}
xml-js@1.6.11:
dependencies:
sax: 1.6.0
xml2js@0.6.2:
dependencies:
sax: 1.6.0
xmlbuilder: 11.0.1
xml@1.0.1: {}
xmlbuilder@11.0.1: {}
xmlchars@2.2.0: {}
+1
View File
@@ -43,6 +43,7 @@
"bullmq": "^5.12.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"docx": "^9.5.0",
"dotenv": "^17.4.2",
"exceljs": "^4.4.0",
"express": "^4.21.0",
@@ -0,0 +1,2 @@
-- v3.5.6: 开发计划任务附件
ALTER TABLE `dev_plan_task` ADD COLUMN `attachment_urls` JSON NULL AFTER `support_ticket_id`;
@@ -0,0 +1,5 @@
-- v3.5.6: 财务账单打款凭证
ALTER TABLE `store_bill` ADD COLUMN `payment_ref` VARCHAR(128) NULL AFTER `status`;
ALTER TABLE `partner_bill` ADD COLUMN `payment_ref` VARCHAR(128) NULL AFTER `status`;
ALTER TABLE `winery_bill` ADD COLUMN `payment_ref` VARCHAR(128) NULL AFTER `status`;
ALTER TABLE `logistics_bill` ADD COLUMN `payment_ref` VARCHAR(128) NULL AFTER `status`;
+5
View File
@@ -733,6 +733,7 @@ model DevPlanTask {
status DevPlanTaskStatus @default(TODO)
creatorHqAccountId BigInt @map("creator_hq_account_id") @db.UnsignedBigInt
supportTicketId BigInt? @map("support_ticket_id") @db.UnsignedBigInt
attachmentUrls Json? @map("attachment_urls")
lastDispatchedAt DateTime? @map("last_dispatched_at") @db.DateTime(3)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
completedAt DateTime? @map("completed_at") @db.DateTime(3)
@@ -1073,6 +1074,7 @@ model LogisticsBill {
settlementMethod LogisticsSettlementMethod @map("settlement_method")
pricingSnapshotJson String? @map("pricing_snapshot_json") @db.Text
status FinancePayStatus @default(UNPAID)
paymentRef String? @map("payment_ref") @db.VarChar(128)
paidAt DateTime? @map("paid_at") @db.DateTime(3)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
@@ -1215,6 +1217,7 @@ model PartnerBill {
redeemCommission Decimal @default(0) @map("redeem_commission") @db.Decimal(10, 2)
totalAmount Decimal @map("total_amount") @db.Decimal(10, 2)
status PartnerBillStatus @default(PENDING_REVIEW)
paymentRef String? @map("payment_ref") @db.VarChar(128)
confirmedAt DateTime? @map("confirmed_at") @db.DateTime(3)
sentAt DateTime? @map("sent_at") @db.DateTime(3)
paidAt DateTime? @map("paid_at") @db.DateTime(3)
@@ -1863,6 +1866,7 @@ model StoreBill {
settlementRate Decimal @map("settlement_rate") @db.Decimal(5, 4)
payoutAmount Decimal @map("payout_amount") @db.Decimal(10, 2)
status FinancePayStatus @default(UNPAID)
paymentRef String? @map("payment_ref") @db.VarChar(128)
paidAt DateTime? @map("paid_at") @db.DateTime(3)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
@@ -1946,6 +1950,7 @@ model WineryBill {
wineryRate Decimal @map("winery_rate") @db.Decimal(5, 4)
wineryAmount Decimal @map("winery_amount") @db.Decimal(10, 2)
status FinancePayStatus @default(UNPAID)
paymentRef String? @map("payment_ref") @db.VarChar(128)
paidAt DateTime? @map("paid_at") @db.DateTime(3)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
@@ -0,0 +1,86 @@
import type { PrismaClient } from '@prisma/client';
export type StoreBankAccountSnapshot = {
bankAccountName: string | null;
bankAccountNo: string | null;
bankBranch: string | null;
};
export async function loadStorePrimaryBank(
prisma: PrismaClient,
storeId: bigint,
): Promise<StoreBankAccountSnapshot | null> {
const binding = await prisma.storeAccountStore.findFirst({
where: { storeId, storeAccount: { isPrimary: 1 } },
include: {
storeAccount: {
select: { bankAccountName: true, bankAccountNo: true, bankBranch: true },
},
},
});
if (binding) return binding.storeAccount;
const fallback = await prisma.storeAccountStore.findFirst({
where: { storeId },
orderBy: { storeAccountId: 'asc' },
include: {
storeAccount: {
select: { bankAccountName: true, bankAccountNo: true, bankBranch: true },
},
},
});
return fallback?.storeAccount ?? null;
}
export async function loadStorePrimaryBanksMap(
prisma: PrismaClient,
storeIds: bigint[],
): Promise<Map<string, StoreBankAccountSnapshot>> {
const map = new Map<string, StoreBankAccountSnapshot>();
if (!storeIds.length) return map;
const bindings = await prisma.storeAccountStore.findMany({
where: { storeId: { in: storeIds } },
include: {
storeAccount: {
select: {
isPrimary: true,
bankAccountName: true,
bankAccountNo: true,
bankBranch: true,
},
},
},
orderBy: [{ storeAccount: { isPrimary: 'desc' } }, { storeAccountId: 'asc' }],
});
for (const binding of bindings) {
const key = String(binding.storeId);
if (map.has(key)) continue;
map.set(key, {
bankAccountName: binding.storeAccount.bankAccountName,
bankAccountNo: binding.storeAccount.bankAccountNo,
bankBranch: binding.storeAccount.bankBranch,
});
}
return map;
}
export async function loadWineryBankConfig(
prisma: PrismaClient,
): Promise<StoreBankAccountSnapshot & { bankName?: string | null }> {
const keys = [
'WINERY_BANK_ACCOUNT_NAME',
'WINERY_BANK_NAME',
'WINERY_BANK_BRANCH',
'WINERY_BANK_ACCOUNT_NO',
];
const rows = await prisma.systemConfig.findMany({
where: { configKey: { in: keys } },
select: { configKey: true, value: true },
});
const map = new Map(rows.map((r) => [r.configKey, r.value]));
return {
bankAccountName: map.get('WINERY_BANK_ACCOUNT_NAME') ?? null,
bankName: map.get('WINERY_BANK_NAME') ?? null,
bankBranch: map.get('WINERY_BANK_BRANCH') ?? null,
bankAccountNo: map.get('WINERY_BANK_ACCOUNT_NO') ?? null,
};
}
@@ -0,0 +1,270 @@
import {
DEV_PLAN_TASK_STATUS_LABELS,
DEV_PLAN_TASK_TYPE_LABELS,
type DevPlanTaskStatusDto,
type DevPlanTaskTypeDto,
} from '@dukang/shared-types';
import ExcelJS from 'exceljs';
import * as fs from 'fs';
import * as path from 'path';
import PDFDocument from 'pdfkit';
import {
Document,
HeadingLevel,
ImageRun,
Packer,
Paragraph,
Table,
TableCell,
TableRow,
TextRun,
WidthType,
} from 'docx';
const EXPORT_HEADERS = [
'任务编号',
'类型',
'状态',
'内容',
'关联工单',
'创建人',
'创建时间',
'完成时间',
'附件',
] as const;
export type DevPlanTaskExportRow = {
taskNo: string;
type: string;
status: string;
content: string;
supportTicketNo: string;
creatorName: string;
createdAt: string;
completedAt: string;
attachmentUrls: string[];
};
export type DevPlanExportFormat = 'markdown' | 'docx' | 'xlsx' | 'pdf';
export function mapTaskToExportRow(task: {
taskNo: string;
type: DevPlanTaskTypeDto;
status: DevPlanTaskStatusDto;
content: string;
supportTicketNo?: string | null;
creatorName?: string | null;
createdAt: string;
completedAt?: string | null;
attachmentUrls?: string[] | null;
}): DevPlanTaskExportRow {
return {
taskNo: task.taskNo,
type: DEV_PLAN_TASK_TYPE_LABELS[task.type] ?? task.type,
status: DEV_PLAN_TASK_STATUS_LABELS[task.status] ?? task.status,
content: task.content,
supportTicketNo: task.supportTicketNo ?? '',
creatorName: task.creatorName ?? '',
createdAt: task.createdAt.slice(0, 19).replace('T', ' '),
completedAt: task.completedAt ? task.completedAt.slice(0, 19).replace('T', ' ') : '',
attachmentUrls: task.attachmentUrls ?? [],
};
}
function rowToCells(row: DevPlanTaskExportRow): string[] {
return [
row.taskNo,
row.type,
row.status,
row.content,
row.supportTicketNo,
row.creatorName,
row.createdAt,
row.completedAt,
row.attachmentUrls.join(' '),
];
}
function resolvePdfFontPath(): string {
const candidates = [
process.env.EXPORT_PDF_FONT_PATH,
path.join(process.cwd(), 'assets', 'fonts', 'NotoSansSC-Regular.otf'),
path.join(process.cwd(), 'assets', 'fonts', 'simhei.ttf'),
path.join(process.cwd(), 'assets', 'fonts', 'msyh.ttc'),
path.join(process.cwd(), 'dist', 'assets', 'fonts', 'simhei.ttf'),
'/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc',
'C:\\Windows\\Fonts\\simhei.ttf',
'C:\\Windows\\Fonts\\msyh.ttc',
].filter(Boolean) as string[];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) return candidate;
}
throw new Error('未找到可用于 PDF 的中文字体,请将字体文件放到 server/dukang-api/assets/fonts/');
}
export function buildDevPlanMarkdown(rows: DevPlanTaskExportRow[]): Buffer {
const lines: string[] = ['# 开发计划任务导出', ''];
rows.forEach((row, index) => {
lines.push(`## ${index + 1}. ${row.taskNo} [${row.type}] ${row.status}`);
lines.push('');
lines.push(row.content);
lines.push('');
if (row.supportTicketNo) lines.push(`- 关联工单:${row.supportTicketNo}`);
if (row.creatorName) lines.push(`- 创建人:${row.creatorName}`);
lines.push(`- 创建时间:${row.createdAt}`);
if (row.completedAt) lines.push(`- 完成时间:${row.completedAt}`);
if (row.attachmentUrls.length) {
lines.push('- 附件:');
for (const url of row.attachmentUrls) {
lines.push(` ![附件](${url})`);
lines.push(` ${url}`);
}
}
lines.push('');
});
return Buffer.from(lines.join('\n'), 'utf8');
}
async function fetchImageBuffer(url: string): Promise<Buffer | null> {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
if (!res.ok) return null;
const buf = Buffer.from(await res.arrayBuffer());
return buf.length > 0 && buf.length < 5 * 1024 * 1024 ? buf : null;
} catch {
return null;
}
}
export async function buildDevPlanDocx(rows: DevPlanTaskExportRow[]): Promise<Buffer> {
const children: (Paragraph | Table)[] = [
new Paragraph({ text: '开发计划任务导出', heading: HeadingLevel.HEADING_1 }),
];
for (const row of rows) {
children.push(
new Paragraph({
text: `${row.taskNo} [${row.type}] ${row.status}`,
heading: HeadingLevel.HEADING_2,
}),
new Paragraph({ children: [new TextRun(row.content)] }),
new Paragraph({
children: [
new TextRun(`创建人:${row.creatorName || '—'} 创建:${row.createdAt}`),
],
}),
);
if (row.supportTicketNo) {
children.push(new Paragraph({ children: [new TextRun(`关联工单:${row.supportTicketNo}`)] }));
}
if (row.completedAt) {
children.push(new Paragraph({ children: [new TextRun(`完成时间:${row.completedAt}`)] }));
}
for (const url of row.attachmentUrls) {
const img = await fetchImageBuffer(url);
if (img) {
children.push(
new Paragraph({
children: [
new ImageRun({
data: img,
transformation: { width: 320, height: 240 },
type: 'png',
}),
],
}),
);
} else {
children.push(new Paragraph({ children: [new TextRun(url)] }));
}
}
children.push(new Paragraph({ text: '' }));
}
const doc = new Document({
sections: [{ children }],
});
return Packer.toBuffer(doc);
}
export async function buildDevPlanXlsx(rows: DevPlanTaskExportRow[]): Promise<Buffer> {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('开发任务');
sheet.addRow([...EXPORT_HEADERS]);
for (const row of rows) {
sheet.addRow(rowToCells(row));
}
sheet.columns.forEach((col, i) => {
col.width = i === 3 || i === 8 ? 40 : 16;
});
const buffer = await workbook.xlsx.writeBuffer();
return Buffer.from(buffer);
}
export async function buildDevPlanPdf(rows: DevPlanTaskExportRow[]): Promise<Buffer> {
const fontPath = resolvePdfFontPath();
const chunks: Buffer[] = [];
return new Promise((resolve, reject) => {
const doc = new PDFDocument({ size: 'A4', layout: 'landscape', margin: 24, bufferPages: true });
doc.on('data', (chunk) => chunks.push(chunk as Buffer));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
doc.registerFont('zh', fontPath);
doc.font('zh');
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
const colWidths = [72, 48, 48, 180, 72, 56, 78, 78, 120];
const scale = pageWidth / colWidths.reduce((s, w) => s + w, 0);
const widths = colWidths.map((w) => w * scale);
const rowHeight = 36;
let y = doc.page.margins.top;
const drawRow = (cells: string[], isHeader = false) => {
let x = doc.page.margins.left;
const height = isHeader ? 24 : rowHeight;
if (y + height > doc.page.height - doc.page.margins.bottom) {
doc.addPage({ size: 'A4', layout: 'landscape', margin: 24 });
y = doc.page.margins.top;
}
doc.fontSize(isHeader ? 8 : 7);
cells.forEach((cell, index) => {
doc.rect(x, y, widths[index], height).stroke('#dddddd');
doc.text(cell || '', x + 2, y + 4, {
width: widths[index] - 4,
height: height - 6,
lineBreak: true,
});
x += widths[index];
});
y += height;
};
drawRow([...EXPORT_HEADERS], true);
for (const row of rows) {
drawRow(rowToCells(row));
}
doc.end();
});
}
export function buildDevPlanExportFilename(format: DevPlanExportFormat, count: number): string {
const stamp = new Date().toISOString().slice(0, 10);
const ext = format === 'markdown' ? 'md' : format === 'docx' ? 'docx' : format;
return `开发任务导出_${stamp}_${count}条.${ext}`;
}
export function devPlanExportMimeType(format: DevPlanExportFormat): string {
switch (format) {
case 'markdown':
return 'text/markdown;charset=utf-8';
case 'docx':
return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
case 'xlsx':
return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
case 'pdf':
return 'application/pdf';
default:
return 'application/octet-stream';
}
}
@@ -31,6 +31,7 @@ import {
UpdateDevPlanTaskDto,
UpdateDevPlanVersionDto,
DevPlanTaskBatchUpdateDto,
DevPlanTaskExportDto,
} from './dto/dev-plan.dto';
@Controller('admin/dev-plan')
@@ -98,6 +99,11 @@ export class AdminDevPlanController {
return this.service.batchUpdateTasks(body);
}
@Post('tasks/export')
exportTasks(@Body() body: DevPlanTaskExportDto) {
return this.service.exportTasks(body);
}
@Get('versions')
listVersions(
@Query('status') status?: string,
@@ -59,6 +59,26 @@ import type {
UpdateDevPlanVersionDto,
} from './dto/dev-plan.dto';
import {
buildDevPlanDocx,
buildDevPlanExportFilename,
buildDevPlanMarkdown,
buildDevPlanPdf,
buildDevPlanXlsx,
devPlanExportMimeType,
mapTaskToExportRow,
type DevPlanExportFormat,
} from './admin-dev-plan-export.util';
function parseAttachmentUrls(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.map((u) => String(u).trim()).filter(Boolean);
}
function attachmentUrlsInput(urls?: string[]): Prisma.InputJsonValue | undefined {
const list = (urls ?? []).map((u) => u.trim()).filter(Boolean);
return list.length ? (list as unknown as Prisma.InputJsonValue) : undefined;
}
@@ -132,6 +152,8 @@ export class DevPlanService {
completedAt: Date | null;
attachmentUrls?: unknown;
},
extras?: { creatorName?: string | null; supportTicketNo?: string | null },
@@ -164,6 +186,8 @@ export class DevPlanService {
completedAt: row.completedAt?.toISOString() ?? null,
attachmentUrls: parseAttachmentUrls(row.attachmentUrls),
};
}
@@ -378,6 +402,8 @@ export class DevPlanService {
supportTicketId: dto.supportTicketId ? BigInt(dto.supportTicketId) : null,
attachmentUrls: attachmentUrlsInput(dto.attachmentUrls),
},
});
@@ -400,6 +426,12 @@ export class DevPlanService {
if (!tasks.length) throw new BadRequestException('至少创建 1 条开发任务');
const ticket = await this.prisma.commonSupportTicket.findUnique({
where: { id: ticketId },
select: { attachmentUrls: true },
});
const ticketUrls = parseAttachmentUrls(ticket?.attachmentUrls);
const created = await this.prisma.$transaction(
tasks.map((t) =>
@@ -418,6 +450,10 @@ export class DevPlanService {
supportTicketId: ticketId,
attachmentUrls: attachmentUrlsInput(
t.attachmentUrls?.length ? t.attachmentUrls : ticketUrls,
),
},
}),
@@ -464,6 +500,10 @@ export class DevPlanService {
}
if (dto.attachmentUrls !== undefined) {
data.attachmentUrls = attachmentUrlsInput(dto.attachmentUrls);
}
await this.prisma.devPlanTask.update({ where: { id }, data });
@@ -1157,6 +1197,114 @@ export class DevPlanService {
}
private buildTaskExportWhere(query: {
status?: string;
type?: string;
keyword?: string;
}): Prisma.DevPlanTaskWhereInput {
const where: Prisma.DevPlanTaskWhereInput = {};
if (query.status) where.status = query.status as DevPlanTaskStatus;
if (query.type) where.type = query.type as DevPlanTaskType;
if (query.keyword?.trim()) {
where.OR = [
{ taskNo: { contains: query.keyword.trim() } },
{ content: { contains: query.keyword.trim() } },
];
}
return where;
}
async exportTasks(dto: {
scope: 'filter' | 'selected';
format: DevPlanExportFormat;
ids?: string[];
status?: string;
type?: string;
keyword?: string;
}) {
let rows: Array<{
id: bigint;
taskNo: string;
content: string;
type: DevPlanTaskType;
status: DevPlanTaskStatus;
creatorHqAccountId: bigint;
supportTicketId: bigint | null;
attachmentUrls: unknown;
createdAt: Date;
completedAt: Date | null;
}>;
if (dto.scope === 'selected') {
if (!dto.ids?.length) throw new BadRequestException('请先勾选要导出的任务');
const ids = dto.ids.map(BigInt);
rows = await this.prisma.devPlanTask.findMany({
where: { id: { in: ids } },
orderBy: { createdAt: 'desc' },
});
if (rows.length !== ids.length) throw new BadRequestException('部分任务不存在');
} else {
rows = await this.prisma.devPlanTask.findMany({
where: this.buildTaskExportWhere(dto),
orderBy: { createdAt: 'desc' },
});
}
if (!rows.length) throw new BadRequestException('没有可导出的任务');
const creatorIds = rows.map((r) => r.creatorHqAccountId);
const ticketIds = rows.map((r) => r.supportTicketId).filter((id): id is bigint => id != null);
const [names, tickets] = await Promise.all([
this.loadHqNames(creatorIds),
ticketIds.length
? this.prisma.commonSupportTicket.findMany({
where: { id: { in: ticketIds } },
select: { id: true, ticketNo: true },
})
: Promise.resolve([]),
]);
const ticketMap = new Map(tickets.map((t) => [String(t.id), t.ticketNo] as [string, string]));
const exportRows = rows.map((r) =>
mapTaskToExportRow({
taskNo: r.taskNo,
type: r.type,
status: r.status,
content: r.content,
supportTicketNo: r.supportTicketId != null ? ticketMap.get(String(r.supportTicketId)) ?? null : null,
creatorName: names.get(String(r.creatorHqAccountId)) ?? null,
createdAt: r.createdAt.toISOString(),
completedAt: r.completedAt?.toISOString() ?? null,
attachmentUrls: parseAttachmentUrls(r.attachmentUrls),
}),
);
let buffer: Buffer;
switch (dto.format) {
case 'markdown':
buffer = buildDevPlanMarkdown(exportRows);
break;
case 'docx':
buffer = await buildDevPlanDocx(exportRows);
break;
case 'xlsx':
buffer = await buildDevPlanXlsx(exportRows);
break;
case 'pdf':
buffer = await buildDevPlanPdf(exportRows);
break;
default:
throw new BadRequestException('不支持的导出格式');
}
return {
filename: buildDevPlanExportFilename(dto.format, exportRows.length),
mimeType: devPlanExportMimeType(dto.format),
contentBase64: buffer.toString('base64'),
count: exportRows.length,
};
}
}
@@ -37,6 +37,11 @@ export class CreateDevPlanTaskDto {
@IsOptional()
@IsString()
supportTicketId?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
attachmentUrls?: string[];
}
export class UpdateDevPlanTaskDto {
@@ -52,6 +57,11 @@ export class UpdateDevPlanTaskDto {
@IsOptional()
@IsIn(DEV_PLAN_TASK_STATUSES)
status?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
attachmentUrls?: string[];
}
export class CreateDevPlanVersionDto {
@@ -131,6 +141,11 @@ export class DevPlanTaskFromTicketDto {
@IsIn(DEV_PLAN_TASK_TYPES)
type!: 'BUG' | 'REQUIREMENT' | 'OPTIMIZATION';
@IsOptional()
@IsArray()
@IsString({ each: true })
attachmentUrls?: string[];
}
export class ReviewSupportTicketDto {
@@ -177,6 +192,32 @@ export class DevPlanTaskBatchUpdateDto {
versionId?: string | null;
}
export class DevPlanTaskExportDto {
@IsIn(['filter', 'selected'])
scope!: 'filter' | 'selected';
@IsIn(['markdown', 'docx', 'xlsx', 'pdf'])
format!: 'markdown' | 'docx' | 'xlsx' | 'pdf';
@ValidateIf((o: DevPlanTaskExportDto) => o.scope === 'selected')
@IsArray()
@ArrayMinSize(1)
@IsString({ each: true })
ids?: string[];
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsString()
type?: string;
@IsOptional()
@IsString()
keyword?: string;
}
export class BatchReviewPreviewDto {
@IsArray()
@IsString({ each: true })
@@ -1,5 +1,9 @@
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import {
HqPermissionGuard,
RequireHqPermissions,
} from '../../common/guards/hq-permission.guard';
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { AdminRedeemDebugService } from './admin-redeem-debug.service';
@@ -13,7 +17,8 @@ import {
} from './dto/admin-mutate.dto';
@Controller('admin/redeem/debug')
@UseGuards(HqAuthGuard)
@UseGuards(HqAuthGuard, HqPermissionGuard)
@RequireHqPermissions('benefit_debug')
export class AdminRedeemDebugController {
constructor(private readonly service: AdminRedeemDebugService) {}
@@ -2,6 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common';
import type { DeliveryProvider } from '@prisma/client';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { loadStorePrimaryBank } from '../../common/store/store-bank.util';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import type { AdminDeliveriesQueryDto, AdminRedeemRecordsQueryDto } from './dto/admin-query.dto';
import type { UpdateDeliveryDto } from './dto/admin-mutate.dto';
@@ -113,8 +114,13 @@ export class AdminRedeemService {
},
});
if (!record) throw new NotFoundException('核销记录不存在');
const primaryAccount = await loadStorePrimaryBank(this.prisma, record.storeId);
return serializeBigInt({
...record,
store: {
...record.store,
primaryAccount,
},
allocations: record.allocations.map((a) => ({
couponId: a.couponId,
amount: Number(a.amount),
@@ -1,5 +1,9 @@
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import {
HqPermissionGuard,
RequireHqPermissions,
} from '../../common/guards/hq-permission.guard';
import { AdminXiaofeixiaService } from './admin-xiaofeixia.service';
import {
XiaofeixiaBatchShipmentQueryDto,
@@ -11,7 +15,8 @@ import {
/** HQ 小飞侠接口联调(仅管理端,勿对 C 端暴露) */
@Controller('admin/courier/xiaofeixia')
@UseGuards(HqAuthGuard)
@UseGuards(HqAuthGuard, HqPermissionGuard)
@RequireHqPermissions('deliveries_debug')
export class AdminXiaofeixiaController {
constructor(private readonly service: AdminXiaofeixiaService) {}
@@ -302,8 +302,8 @@ export class AdminStoreBillController {
refType: 'STORE_BILL',
refIdParam: 'id',
})
confirm(@Param('id') id: string) {
return this.settlementService.confirmStoreBill(BigInt(id));
confirm(@Param('id') id: string, @Body() body: { paymentRef?: string }) {
return this.settlementService.confirmStoreBill(BigInt(id), body);
}
}
@@ -485,8 +485,8 @@ export class AdminWineryBillController {
refType: 'WINERY_BILL',
refIdParam: 'id',
})
confirm(@Param('id') id: string) {
return this.settlementService.confirmWineryBill(BigInt(id));
confirm(@Param('id') id: string, @Body() body: { paymentRef?: string }) {
return this.settlementService.confirmWineryBill(BigInt(id), body);
}
}
@@ -565,8 +565,8 @@ export class AdminLogisticsBillController {
refType: 'LOGISTICS_BILL',
refIdParam: 'id',
})
confirm(@Param('id') id: string) {
return this.settlementService.confirmLogisticsBill(BigInt(id));
confirm(@Param('id') id: string, @Body() body: { paymentRef?: string }) {
return this.settlementService.confirmLogisticsBill(BigInt(id), body);
}
}
@@ -22,6 +22,11 @@ import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-
import { AnalyticsService } from '../analytics/analytics.service';
import { PartnerCityService } from '../city-scope/partner-city.service';
import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service';
import {
loadStorePrimaryBank,
loadStorePrimaryBanksMap,
loadWineryBankConfig,
} from '../../common/store/store-bank.util';
function generateBillNo(prefix: string) {
return `${prefix}${Date.now()}${Math.floor(Math.random() * 900 + 100)}`;
@@ -1043,11 +1048,20 @@ export class SettlementService {
const total = rows.length;
const slice = rows.slice((page - 1) * pageSize, page * pageSize);
const storeIds = [...new Set(rows.filter((r) => r.kind === 'T1_BILL').map((r) => r.storeId))];
const bankMap = await loadStorePrimaryBanksMap(this.prisma, storeIds);
const redeemAmount = bills.reduce((s, b) => s + Number(b.redeemAmount), 0);
const payoutAmount = rows.reduce((s, r) => s + r.amount, 0);
return serializeBigInt({
items: slice,
items: slice.map((r) => ({
...r,
bankAccount:
r.kind === 'T1_BILL'
? bankMap.get(String(r.storeId)) ?? null
: null,
})),
total,
page,
pageSize,
@@ -1112,10 +1126,11 @@ export class SettlementService {
},
});
if (!bill) throw new NotFoundException('门店对账单不存在');
return serializeBigInt(bill);
const storeAccount = await loadStorePrimaryBank(this.prisma, bill.storeId);
return serializeBigInt({ ...bill, storeAccount });
}
async confirmStoreBill(id: bigint) {
async confirmStoreBill(id: bigint, dto: { paymentRef?: string } = {}) {
const bill = await this.prisma.storeBill.findUnique({ where: { id } });
if (!bill) throw new NotFoundException('门店对账单不存在');
if (bill.status !== 'UNPAID') throw new BadRequestException('仅未打款账单可确认打款');
@@ -1124,7 +1139,11 @@ export class SettlementService {
const updated = await this.prisma.$transaction(async (tx) => {
const b = await tx.storeBill.update({
where: { id },
data: { status: 'PAID', paidAt },
data: {
status: 'PAID',
paidAt,
paymentRef: dto.paymentRef?.trim() || null,
},
});
await tx.storePayout.updateMany({
where: { storeBillId: id, status: 'PENDING' },
@@ -1157,12 +1176,32 @@ export class SettlementService {
const where = this.buildStoreBillWhere(query);
const bills = await this.prisma.storeBill.findMany({
where,
include: { store: { select: { name: true, phone: true, cityName: true } } },
include: { store: { select: { id: true, name: true, phone: true, cityName: true } } },
orderBy: { billDate: 'desc' },
});
const header = ['账单号', '账单日', '门店', '城市', '核销笔数', '核销金额', '结算比例', '应付金额', '状态', '打款时间'].join(',');
const rows = bills.map((b) =>
[
const bankMap = await loadStorePrimaryBanksMap(
this.prisma,
bills.map((b) => b.storeId),
);
const header = [
'账单号',
'账单日',
'门店',
'城市',
'核销笔数',
'核销金额',
'结算比例',
'应付金额',
'状态',
'打款时间',
'打款凭证',
'收款户名',
'收款账号',
'开户行',
].join(',');
const rows = bills.map((b) => {
const bank = bankMap.get(String(b.storeId));
return [
csvEscape(b.billNo),
b.billDate.toISOString().slice(0, 10),
csvEscape(b.store.name),
@@ -1173,8 +1212,12 @@ export class SettlementService {
Number(b.payoutAmount),
b.status,
b.paidAt ? b.paidAt.toISOString().slice(0, 19).replace('T', ' ') : '',
].join(','),
);
csvEscape(b.paymentRef ?? ''),
csvEscape(bank?.bankAccountName ?? ''),
csvEscape(bank?.bankAccountNo ?? ''),
csvEscape(bank?.bankBranch ?? ''),
].join(',');
});
return { csv: `\uFEFF${[header, ...rows].join('\n')}`, count: bills.length };
}
@@ -1262,7 +1305,15 @@ export class SettlementService {
take: pageSize,
include: {
partnerAccount: {
select: { id: true, companyName: true, name: true, phone: true },
select: {
id: true,
companyName: true,
name: true,
phone: true,
bankAccountName: true,
bankAccountNo: true,
bankBranch: true,
},
},
},
}),
@@ -1553,14 +1604,19 @@ export class SettlementService {
return serializeBigInt(updated);
}
async markPartnerBillPaid(id: bigint, _dto: { paymentRef?: string } = {}) {
async markPartnerBillPaid(id: bigint, dto: { paymentRef?: string } = {}) {
const bill = await this.prisma.partnerBill.findUnique({ where: { id } });
if (!bill) throw new NotFoundException('账单不存在');
if (bill.status !== 'UNPAID') throw new BadRequestException('仅未打款账单可标记打款');
const updated = await this.prisma.partnerBill.update({
where: { id },
data: { status: 'PAID', paidAt: new Date(), rejectReason: null },
data: {
status: 'PAID',
paidAt: new Date(),
rejectReason: null,
paymentRef: dto.paymentRef?.trim() || null,
},
});
return serializeBigInt(updated);
@@ -1588,7 +1644,17 @@ export class SettlementService {
const where = this.buildPartnerBillWhere(query);
const bills = await this.prisma.partnerBill.findMany({
where,
include: { partnerAccount: { select: { companyName: true, phone: true } } },
include: {
partnerAccount: {
select: {
companyName: true,
phone: true,
bankAccountName: true,
bankAccountNo: true,
bankBranch: true,
},
},
},
orderBy: { periodStart: 'desc' },
});
@@ -1605,6 +1671,10 @@ export class SettlementService {
'发送时间',
'确认时间',
'打款时间',
'打款凭证',
'收款户名',
'收款账号',
'开户行',
'驳回理由',
].join(',');
const rows = bills.map((b) =>
@@ -1621,6 +1691,10 @@ export class SettlementService {
b.sentAt ? b.sentAt.toISOString().slice(0, 10) : '',
b.confirmedAt ? b.confirmedAt.toISOString().slice(0, 10) : '',
b.paidAt ? b.paidAt.toISOString().slice(0, 10) : '',
csvEscape(b.paymentRef ?? ''),
csvEscape(b.partnerAccount.bankAccountName ?? ''),
csvEscape(b.partnerAccount.bankAccountNo ?? ''),
csvEscape(b.partnerAccount.bankBranch ?? ''),
csvEscape(b.rejectReason ?? ''),
].join(','),
);
@@ -1752,10 +1826,11 @@ export class SettlementService {
include: { items: { orderBy: { paidAt: 'desc' } } },
});
if (!bill) throw new NotFoundException('酒厂对账单不存在');
return serializeBigInt(bill);
const wineryBank = await loadWineryBankConfig(this.prisma);
return serializeBigInt({ ...bill, wineryBank });
}
async confirmWineryBill(id: bigint) {
async confirmWineryBill(id: bigint, dto: { paymentRef?: string } = {}) {
const bill = await this.prisma.wineryBill.findUnique({ where: { id } });
if (!bill) throw new NotFoundException('酒厂对账单不存在');
if (bill.status !== 'UNPAID') throw new BadRequestException('仅未打款账单可确认打款');
@@ -1764,7 +1839,11 @@ export class SettlementService {
}
const updated = await this.prisma.wineryBill.update({
where: { id },
data: { status: 'PAID', paidAt: new Date() },
data: {
status: 'PAID',
paidAt: new Date(),
paymentRef: dto.paymentRef?.trim() || null,
},
});
return serializeBigInt(updated);
}
@@ -1795,6 +1874,7 @@ export class SettlementService {
include: { items: true },
orderBy: { billDate: 'desc' },
});
const wineryBank = await loadWineryBankConfig(this.prisma);
const header = [
'账单号',
@@ -1806,6 +1886,11 @@ export class SettlementService {
'应付',
'支付时间',
'账单状态',
'打款凭证',
'收款户名',
'开户银行',
'开户支行',
'收款账号',
].join(',');
const rows: string[] = [];
const statusLabel = (b: { status: string; wineryAmount: Prisma.Decimal | number }) => {
@@ -1814,6 +1899,13 @@ export class SettlementService {
if (b.status === 'UNPAID') return '未打款';
return b.status;
};
const bankCols = (b: { paymentRef?: string | null }) => [
csvEscape(b.paymentRef ?? ''),
csvEscape(wineryBank.bankAccountName ?? ''),
csvEscape(wineryBank.bankName ?? ''),
csvEscape(wineryBank.bankBranch ?? ''),
csvEscape(wineryBank.bankAccountNo ?? ''),
];
for (const b of bills) {
if (b.items.length === 0) {
rows.push(
@@ -1827,6 +1919,7 @@ export class SettlementService {
Number(b.wineryAmount),
'',
statusLabel(b),
...bankCols(b),
].join(','),
);
continue;
@@ -1843,6 +1936,7 @@ export class SettlementService {
Number(item.wineryAmount),
item.paidAt.toISOString().slice(0, 19).replace('T', ' '),
statusLabel(b),
...bankCols(b),
].join(','),
);
}
@@ -2275,11 +2369,17 @@ export class SettlementService {
});
}
async confirmLogisticsBill(id: bigint) {
async confirmLogisticsBill(id: bigint, dto: { paymentRef?: string } = {}) {
const bill = await this.prisma.logisticsBill.findUnique({ where: { id } });
if (!bill) throw new NotFoundException('物流对账单不存在');
if (bill.status !== 'UNPAID') throw new BadRequestException('仅未结算账单可确认');
const paidData = {
status: 'PAID' as const,
paidAt: new Date(),
paymentRef: dto.paymentRef?.trim() || null,
};
if (bill.settlementMethod === 'PREPAID') {
const deducted = await this.prisma.$transaction(async (tx) => {
const result = await this.fulfillmentProviderService.deductPrepaid(
@@ -2295,7 +2395,7 @@ export class SettlementService {
}
return tx.logisticsBill.update({
where: { id },
data: { status: 'PAID', paidAt: new Date() },
data: paidData,
});
});
return serializeBigInt(deducted);
@@ -2303,7 +2403,7 @@ export class SettlementService {
const updated = await this.prisma.logisticsBill.update({
where: { id },
data: { status: 'PAID', paidAt: new Date() },
data: paidData,
});
return serializeBigInt(updated);
}
@@ -2331,7 +2431,16 @@ export class SettlementService {
const bills = await this.prisma.logisticsBill.findMany({
where,
include: {
fulfillmentProvider: { select: { code: true, name: true } },
fulfillmentProvider: {
select: {
code: true,
name: true,
bankAccountName: true,
bankName: true,
bankBranch: true,
bankAccountNo: true,
},
},
items: true,
},
orderBy: { periodStart: 'desc' },
@@ -2349,15 +2458,28 @@ export class SettlementService {
'物流费',
'发货时间',
'账单状态',
'打款凭证',
'收款户名',
'开户银行',
'开户支行',
'收款账号',
].join(',');
const rows: string[] = [];
for (const b of bills) {
const p = b.fulfillmentProvider;
const bankCols = [
csvEscape(b.paymentRef ?? ''),
csvEscape(p.bankAccountName ?? ''),
csvEscape(p.bankName ?? ''),
csvEscape(p.bankBranch ?? ''),
csvEscape(p.bankAccountNo ?? ''),
];
if (b.items.length === 0) {
rows.push(
[
csvEscape(b.billNo),
csvEscape(b.fulfillmentProvider.code),
csvEscape(b.fulfillmentProvider.name),
csvEscape(p.code),
csvEscape(p.name),
b.periodStart.toISOString().slice(0, 10),
b.periodEnd.toISOString().slice(0, 10),
b.settlementMethod,
@@ -2366,6 +2488,7 @@ export class SettlementService {
Number(b.logisticsAmount),
'',
b.status,
...bankCols,
].join(','),
);
continue;
@@ -2374,8 +2497,8 @@ export class SettlementService {
rows.push(
[
csvEscape(b.billNo),
csvEscape(b.fulfillmentProvider.code),
csvEscape(b.fulfillmentProvider.name),
csvEscape(p.code),
csvEscape(p.name),
b.periodStart.toISOString().slice(0, 10),
b.periodEnd.toISOString().slice(0, 10),
b.settlementMethod,
@@ -2384,6 +2507,7 @@ export class SettlementService {
Number(item.logisticsAmount),
item.shippedAt.toISOString().slice(0, 19).replace('T', ' '),
b.status,
...bankCols,
].join(','),
);
}