feat(supply-chain): 限价管理的查看/导出/申诉权限统一由管理员配置

- 查看:页面与数据仍走「限价管理 · 可见」;导出改按「限价管理 · 下载」,申诉/审核/生成/导入改按「限价管理 · 操作」(原来是经营管理 edit)
- 服务端:下载中心 limit-export 任务与产品档案导出接口的 columnSet=limit 都按「限价管理 · 下载」鉴权
- 前端:无下载权限不展示导出入口(限价明细两个导出、破价订单导出当前筛选/乐捐明细);规则配置仍单独走 breachRuleConfig 节点
- 申诉保留兜底:破价单负责人本人即使无「操作」权限也可申诉本人订单,审核必须「操作」
- 测试:CustomTableView 补导出入口权限用例并修正过期的表头断言;文档补充权限章节
This commit is contained in:
居居 2026-10-16 17:41:37 +08:00 committed by WuYongJie
parent f276164532
commit eca1d63842
9 changed files with 98 additions and 33 deletions

View File

@ -39,7 +39,23 @@
5. **乐捐**:驳回计 ¥10/单(离职不计);配套审计链(数据快照 + 申诉材料 + 审核记录),扣款执行交财务确认,保留异议通道。
6. **举证期限**:待与运营确认后设置(避免事后无法追溯凭证)。
## 三、生成与运行
## 三、权限(由管理员统一管理)
路径:用户管理后台 → 权限配置 → 经营管理。限价管理相关的查看 / 导出 / 申诉都由管理员在这里开通。
| 能力 | 权限节点 | 动作 |
| --- | --- | --- |
| 页面与数据查看(限价明细、破价订单审核) | 限价管理 | 可见 |
| 导出(导出明细 / 导出明细(含图片)/ 破价订单导出 / 乐捐明细) | 限价管理 | 下载 |
| 申诉 · 审核 · 生成 · 导入 | 限价管理 | 操作 |
| 规则配置(页面可见 + 保存参数) | 限价管理 · 规则配置 | 可见 / 操作 |
- 没有「下载」权限时页面不展示导出入口,服务端导出接口同样鉴权(下载中心任务按「限价管理 · 下载」校验)。
- 没有「操作」权限时:不显示申诉/审核按钮,服务端也会拒绝申诉、审核、生成、导入。
- 申诉保留一条兜底:破价单的负责人本人,即使没有「操作」权限,也可以对本人负责的订单发起申诉(审核仍需「操作」)。
- 没有「规则配置」权限时,规则配置页签不显示、接口不可调用。
## 四、生成与运行
1. **按天增量**:限价逻辑上线后,破价订单**按天增量生成**(每天只生成最新一天的破价单并入库),**历史数据不再重复回算**;破价订单审核页每天展示最新破价单。
2. **数据来源**:聚水潭报表库(`jst_Orders` + `jst_OrderItem` / 天猫奇门 `jst_qmcust_orderlist` + `_item`),取 `seller_income_amount`、`buyer_paid_amount`、`qty`、`shop_i_id`、`shop_sku_id`、`pay_date`、退款状态,以及换货识别字段 `item_status`、`item_ext_data`(`item_source`、`sku_uuid`)。报表库查询较慢,逐日增量任务建议按天分片拉取。
@ -69,7 +85,7 @@
写入规则:同货号多流程取**审核通过时间最新**的一条;限价为 0 或空的明细剔除;「申请与SPU负责人同价 / 活动限价」不覆盖负责人限价,只作为价格授权;流程「货号」列若填的是商品编码,用商品编码匹配产品档案换算为货号;无货号时同样用商品编码匹配。页面限价的时间按**北京时间墙钟**存(与流程完成时间一致)。
4. **覆盖率**:暂不做未匹配清单与告警(后续优化)。
## 四、待办
## 五、待办
- [ ] 限价流程数据接入 + 筛选逻辑(生效时间版本、货号维度)
- [ ] 非负责人限价 10-07 规则、负责人触发规则(前两个月同价 / 两个月后按销量)落到判定引擎

View File

@ -22,12 +22,13 @@ export async function POST(request: NextRequest) {
}
const params = (body?.params && typeof body.params === 'object' ? body.params : {}) as Record<string, unknown>
// 任务级权限:产品档案 / 限价明细两类导出都复用产品全量档案的导出权限
// 任务级权限:产品档案导出走「产品全量档案 · 下载」,限价基础信息导出走「限价管理 · 下载」
if (type === 'archive-export' || type === 'limit-export') {
const { supplyChainPermissionSettingsService } = await import(
'@/server/services/supply-chain-permission-settings.service'
)
const access = await supplyChainPermissionSettingsService.getUserAccess(ctx.user, 'productArchive')
const target = type === 'limit-export' ? 'limitManagement' : 'productArchive'
const access = await supplyChainPermissionSettingsService.getUserAccess(ctx.user, target)
if (!access.download) {
return jsonError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有导出权限', 403, ctx.requestId)
}

View File

@ -65,7 +65,12 @@ async function createExportResponse(params: {
export async function GET(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const access = await supplyChainPermissionSettingsService.getUserAccess(ctx.user, 'productArchive')
// 限价界面的导出按「限价管理 · 下载」鉴权;产品档案导出仍按「产品全量档案 · 下载」。
const isLimitExport = request.nextUrl.searchParams.get('columnSet') === 'limit'
const access = await supplyChainPermissionSettingsService.getUserAccess(
ctx.user,
isLimitExport ? 'limitManagement' : 'productArchive',
)
if (!access.download) {
return jsonError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有导出权限', 403, ctx.requestId)
}
@ -82,7 +87,7 @@ export async function GET(request: NextRequest) {
// 默认含图片列;includeImage=0 时导出不含图片的明细(限价明细「导出明细」用)。
includeImage: request.nextUrl.searchParams.get('includeImage') !== '0',
// 限价管理页导出用「限价界面抬头」列单。
columnSet: request.nextUrl.searchParams.get('columnSet') === 'limit' ? 'limit' : 'archive',
columnSet: isLimitExport ? 'limit' : 'archive',
requestId: ctx.requestId,
signal: request.signal,
})
@ -93,12 +98,17 @@ export async function GET(request: NextRequest) {
// that would exceed the URL length limit.
export async function POST(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const access = await supplyChainPermissionSettingsService.getUserAccess(ctx.user, 'productArchive')
const body = await request.json().catch(() => null) as Record<string, unknown> | null
// 限价界面的导出按「限价管理 · 下载」鉴权;产品档案导出仍按「产品全量档案 · 下载」。
const isLimitExport = body?.columnSet === 'limit'
const access = await supplyChainPermissionSettingsService.getUserAccess(
ctx.user,
isLimitExport ? 'limitManagement' : 'productArchive',
)
if (!access.download) {
return jsonError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有导出权限', 403, ctx.requestId)
}
const body = await request.json().catch(() => null) as Record<string, unknown> | null
const filters = parseProductArchiveFiltersPayload(body?.filters ?? body ?? null)
if (!filters.success) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '筛选条件格式不正确', 400, ctx.requestId)

View File

@ -160,7 +160,7 @@ function MultiFilterChip({
)
}
export function BreachOrderTab() {
export function BreachOrderTab({ canExport = false }: { canExport?: boolean }) {
const [statusTab, setStatusTab] = useState('')
const [payload, setPayload] = useState<ListPayload | null>(null)
const [loading, setLoading] = useState(true)
@ -645,8 +645,12 @@ export function BreachOrderTab() {
<button type="button" className={styles.btn} onClick={() => void loadList()} disabled={loading}>
<RefreshCw size={14} />刷新
</button>
<button type="button" className={styles.btn} onClick={() => void exportRows('list')}>导出当前筛选</button>
<button type="button" className={styles.btn} onClick={() => void exportRows('fine')}>导出乐捐明细</button>
{canExport && (
<>
<button type="button" className={styles.btn} onClick={() => void exportRows('list')}>导出当前筛选</button>
<button type="button" className={styles.btn} onClick={() => void exportRows('fine')}>导出乐捐明细</button>
</>
)}
{hasActiveFilters && (
<button type="button" className={styles.btn} onClick={() => clearFilters()}>清空筛选条件</button>
)}

View File

@ -213,6 +213,18 @@ describe('限价管理', () => {
})
})
it('导出入口由「限价管理 · 下载」权限控制:无权限不展示导出按钮', async () => {
const withoutExport = render(<CustomTableView canEdit userId="user-a" />)
await screen.findByText('测试岩板餐桌')
expect(screen.queryByRole('button', { name: '导出明细' })).not.toBeInTheDocument()
withoutExport.unmount()
render(<CustomTableView canEdit canExport userId="user-a" />)
await screen.findByText('测试岩板餐桌')
expect(screen.getByRole('button', { name: '导出明细' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '导出明细(含图片)' })).toBeInTheDocument()
})
it('限价页按《限价抬头及逻辑》显示分组表头,并展示产品全量档案数据', async () => {
render(<CustomTableView canEdit userId="user-a" />)

View File

@ -6,7 +6,17 @@ import { LimitPriceTab } from './LimitPriceTab'
import { BreachOrderTab } from './BreachOrderTab'
import { RuleConfigTab } from './RuleConfigTab'
export function CustomTableView({ userId, canViewRuleConfig = false }: { canEdit: boolean; userId: string; canViewRuleConfig?: boolean }) {
export function CustomTableView({
userId,
canExport = false,
canViewRuleConfig = false,
}: {
canEdit: boolean
userId: string
/** 「限价管理 · 下载」:没有下载权限时不展示导出入口 */
canExport?: boolean
canViewRuleConfig?: boolean
}) {
const [primaryTab, setPrimaryTab] = useState<'limits' | 'breach' | 'rules'>('limits')
return (
@ -32,8 +42,8 @@ export function CustomTableView({ userId, canViewRuleConfig = false }: { canEdit
)}
</nav>
{primaryTab === 'limits' && <LimitPriceTab userId={userId} />}
{primaryTab === 'breach' && <BreachOrderTab />}
{primaryTab === 'limits' && <LimitPriceTab userId={userId} canExport={canExport} />}
{primaryTab === 'breach' && <BreachOrderTab canExport={canExport} />}
{primaryTab === 'rules' && canViewRuleConfig && <RuleConfigTab />}
</main>
)

View File

@ -132,7 +132,7 @@ function LimitCell({
return <span title={value}>{value}</span>
}
export function LimitPriceTab({ userId }: { userId: string }) {
export function LimitPriceTab({ userId, canExport = false }: { userId: string; canExport?: boolean }) {
const {
settings,
hydrated,
@ -440,22 +440,26 @@ export function LimitPriceTab({ userId }: { userId: string }) {
>
<RefreshCw size={15} className={loading ? 'animate-spin' : undefined} />
</button>
<button
type="button"
className={styles.exportButton}
disabled={creatingExport !== null}
onClick={() => void createLimitExportTask(false)}
>
{creatingExport === 'plain' ? '提交中…' : '导出明细'}
</button>
<button
type="button"
className={styles.exportButton}
disabled={creatingExport !== null}
onClick={() => void createLimitExportTask(true)}
>
{creatingExport === 'image' ? '提交中…' : '导出明细(含图片)'}
</button>
{canExport && (
<>
<button
type="button"
className={styles.exportButton}
disabled={creatingExport !== null}
onClick={() => void createLimitExportTask(false)}
>
{creatingExport === 'plain' ? '提交中…' : '导出明细'}
</button>
<button
type="button"
className={styles.exportButton}
disabled={creatingExport !== null}
onClick={() => void createLimitExportTask(true)}
>
{creatingExport === 'image' ? '提交中…' : '导出明细(含图片)'}
</button>
</>
)}
{exportMessage ? <span className={styles.exportHint}>{exportMessage}</span> : null}
<span className={styles.toolbarSpacer} />
<LimitTableSettingsMenu

View File

@ -12,5 +12,12 @@ export default async function CustomTablePage() {
if (!access.view) redirect('/supply-chain')
const ruleAccess = await supplyChainPermissionSettingsService.getUserAccess(authUser, 'breachRuleConfig')
return <CustomTableView canEdit={access.edit} userId={authUser.id} canViewRuleConfig={ruleAccess.view} />
return (
<CustomTableView
canEdit={access.edit}
canExport={access.download}
userId={authUser.id}
canViewRuleConfig={ruleAccess.view}
/>
)
}

View File

@ -81,7 +81,8 @@ function eachDay(dateFrom: string, dateTo: string) {
class SupplyChainBreachOrderService {
async resolveViewer(user: AuthUser): Promise<Viewer> {
const access = await supplyChainPermissionSettingsService.getUserAccess(user, 'businessManagement')
// 审核 / 申诉 / 生成 / 导入 统一由「限价管理 · 操作(edit)」控制,管理员在权限配置里开通
const access = await supplyChainPermissionSettingsService.getUserAccess(user, 'limitManagement')
return { id: user.id, name: user.name, canReview: Boolean(access.edit) }
}