feat(supply-chain): 限价管理的查看/导出/申诉权限统一由管理员配置
- 查看:页面与数据仍走「限价管理 · 可见」;导出改按「限价管理 · 下载」,申诉/审核/生成/导入改按「限价管理 · 操作」(原来是经营管理 edit) - 服务端:下载中心 limit-export 任务与产品档案导出接口的 columnSet=limit 都按「限价管理 · 下载」鉴权 - 前端:无下载权限不展示导出入口(限价明细两个导出、破价订单导出当前筛选/乐捐明细);规则配置仍单独走 breachRuleConfig 节点 - 申诉保留兜底:破价单负责人本人即使无「操作」权限也可申诉本人订单,审核必须「操作」 - 测试:CustomTableView 补导出入口权限用例并修正过期的表头断言;文档补充权限章节
This commit is contained in:
parent
f276164532
commit
eca1d63842
@ -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 规则、负责人触发规则(前两个月同价 / 两个月后按销量)落到判定引擎
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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" />)
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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) }
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user