feat(production-stop-demands): 完善档案字段、筛选、视图与通知中心联动

- 明细新增图片、产品标签、含大单近30日日销(条码/SKU 数值快照)

- 表头漏斗筛选(模糊/多选/数值比较/日期区间)、批量搜索、筛选设置

- 列设置拖动排序、快捷查询保存与套用

- 视图统计:日/周/月趋势、部门/需求人/原因/状态分布、饼图与明细下钻

- 通知中心:停产确认/待走流程/需求驳回通知、待办快照同步、钉钉全屏链接

- 新增迁移与历史数据回填脚本
This commit is contained in:
WuYongJie 2026-10-20 14:26:38 +08:00
parent 8c520de0aa
commit 6e12f0318d
24 changed files with 4831 additions and 179 deletions

View File

@ -0,0 +1,2 @@
-- 「图片」列:提单时按条码从产品全量档案库快照图片地址,列表在条码前展示缩略图。
ALTER TABLE "ProductionStopDemandItem" ADD COLUMN "image" TEXT NOT NULL DEFAULT '';

View File

@ -0,0 +1,17 @@
-- 通知中心待办同步状态(停产确认 / 待走流程两类)。
CREATE TABLE "ProductionStopNotificationSync" (
"id" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"recipientKey" TEXT NOT NULL,
"recipientName" TEXT NOT NULL DEFAULT '',
"pendingCount" INTEGER NOT NULL DEFAULT 0,
"snapshot" TEXT NOT NULL DEFAULT '',
"lastSentAt" TIMESTAMP(3),
"completedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProductionStopNotificationSync_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "ProductionStopNotificationSync_kind_recipientKey_key"
ON "ProductionStopNotificationSync"("kind", "recipientKey");

View File

@ -0,0 +1,11 @@
-- 含大单近30日日销的数值快照列:用于 >、>=、<、<= 数值筛选与数值排序。
ALTER TABLE "ProductionStopDemandItem" ADD COLUMN "sales30dWithBigValue" DECIMAL(18,4);
ALTER TABLE "ProductionStopDemandItem" ADD COLUMN "sales30dWithBigSkuValue" DECIMAL(18,4);
UPDATE "ProductionStopDemandItem"
SET "sales30dWithBigValue" = "sales30dWithBig"::numeric
WHERE "sales30dWithBig" ~ '^-?[0-9]+(\.[0-9]+)?$';
UPDATE "ProductionStopDemandItem"
SET "sales30dWithBigSkuValue" = "sales30dWithBigSku"::numeric
WHERE "sales30dWithBigSku" ~ '^-?[0-9]+(\.[0-9]+)?$';

View File

@ -1215,10 +1215,13 @@ model ProductionStopDemandItem {
brand String @default("")
productTag String @default("")
productLabel String @default("")
image String @default("")
barcodeCreatedAt DateTime?
postStopSkuStatus String @default("")
sales30dWithBig String @default("")
sales30dWithBigSku String @default("")
sales30dWithBigValue Decimal? @db.Decimal(18, 4)
sales30dWithBigSkuValue Decimal? @db.Decimal(18, 4)
opsDirector String @default("")
topSalesOpsDirector String @default("")
stopSuggestion String @default("")
@ -1302,6 +1305,23 @@ model ProductionStopFlowOwner {
@@index([isActive, sortOrder])
}
// 通知中心待办同步状态:按「待办类型 + 接收人」记录上次推送的待办快照。
// 待办集合变化时推送新的条数卡片;集合清空时推送一次「已全部处理完成」。
model ProductionStopNotificationSync {
id String @id @default(cuid())
kind String
recipientKey String
recipientName String @default("")
pendingCount Int @default(0)
snapshot String @default("")
lastSentAt DateTime?
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([kind, recipientKey])
}
// 盘停建议配置:人工批量维护「条码 → 盘停建议」,提单时按条码自动带出。
model ProductionStopSuggestionConfig {

View File

@ -0,0 +1,129 @@
/**
* 回填停产需求明细的档案快照字段:「含大单近30日日销-条码 / -SKU」「产品标签」「图片」。
* 口径:条码维度 = 产品全量档案库该条码行的「含大单近30日日销」;
* SKU 维度 = 同 SKU 全部条码求和;
* 产品标签 = SABC 标签优先,未打标按销售状态 × 考核周期真实业绩兜底;
* 图片 = 档案库该条码行的图片地址。
* 只回填当前为空的值,不覆盖已有快照。
*
* 用法:npx tsx scripts/backfill-production-stop-archive-snapshot.ts
*/
import 'dotenv/config'
import { prisma } from '../src/lib/prisma'
import { resolveProductionStopProductLabel } from '../src/lib/production-stop-demands'
import {
productionStopDemandRepository,
type ProductionStopArchiveProfile,
} from '../src/server/repositories/production-stop-demands.repository'
const ARCHIVE_BATCH_SIZE = 300
function numericValue(value: string) {
const normalized = value.trim()
return /^-?\d+(\.\d+)?$/.test(normalized) ? normalized : null
}
async function main() {
const items = await prisma.productionStopDemandItem.findMany({
where: {
OR: [
{ sales30dWithBig: '' },
{ sales30dWithBigSku: '' },
{ productLabel: '' },
{ image: '' },
],
},
select: {
id: true,
barcode: true,
sku: true,
productStatus: true,
productLabel: true,
image: true,
sales30dWithBig: true,
sales30dWithBigSku: true,
},
orderBy: { createdAt: 'asc' },
})
if (!items.length) {
console.log('[production-stop-archive-snapshot] 没有需要回填的明细')
return
}
const barcodes = [...new Set(
items.map((item) => item.barcode.trim().toUpperCase()).filter(Boolean),
)]
const skus = [...new Set(items.map((item) => item.sku.trim().toUpperCase()).filter(Boolean))]
const profilesByBarcode = new Map<string, ProductionStopArchiveProfile>()
for (let index = 0; index < barcodes.length; index += ARCHIVE_BATCH_SIZE) {
const chunk = barcodes.slice(index, index + ARCHIVE_BATCH_SIZE)
const profiles = await productionStopDemandRepository.findArchivedProfiles(chunk)
for (const [barcode, profile] of profiles) profilesByBarcode.set(barcode, profile)
}
const skuTotals = new Map<string, string>()
for (let index = 0; index < skus.length; index += ARCHIVE_BATCH_SIZE) {
const chunk = skus.slice(index, index + ARCHIVE_BATCH_SIZE)
const totals = await productionStopDemandRepository.sumSkuSales30dWithBig(chunk)
for (const [sku, total] of totals) {
if (total) skuTotals.set(sku, total)
}
}
let updated = 0
let unmatched = 0
for (const item of items) {
const profile = profilesByBarcode.get(item.barcode.trim().toUpperCase())
const barcodeValue = item.sales30dWithBig
|| (profile?.sales30dWithBig ?? '')
const skuValue = item.sales30dWithBigSku
|| skuTotals.get(item.sku.trim().toUpperCase())
|| ''
const productLabel = item.productLabel
|| (profile
? resolveProductionStopProductLabel({
sabcRating: profile.sabcRating,
productStatus: profile.productStatus,
realPerformance: profile.sabcRealPerformance,
})
: '')
const image = item.image || (profile?.image ?? '')
if (!barcodeValue && !skuValue && !productLabel && !image) {
unmatched += 1
continue
}
if (
barcodeValue === item.sales30dWithBig
&& skuValue === item.sales30dWithBigSku
&& productLabel === item.productLabel
&& image === item.image
) {
continue
}
await prisma.productionStopDemandItem.update({
where: { id: item.id },
data: {
sales30dWithBig: barcodeValue,
sales30dWithBigSku: skuValue,
sales30dWithBigValue: numericValue(barcodeValue),
sales30dWithBigSkuValue: numericValue(skuValue),
productLabel,
image,
},
})
updated += 1
}
console.log(
`[production-stop-archive-snapshot] 待回填明细 ${items.length} 条:更新 ${updated} 条,档案未匹配 ${unmatched} 条`,
)
}
main()
.catch((error) => {
console.error('[production-stop-archive-snapshot] FAILED:', error)
process.exitCode = 1
})
.finally(async () => {
await prisma.$disconnect().catch(() => null)
})

View File

@ -0,0 +1,139 @@
import { NextRequest } from 'next/server'
import { z } from 'zod'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import {
PRODUCTION_STOP_ANALYTICS_GRANULARITIES,
PRODUCTION_STOP_CONFIRMATION_STATUSES,
PRODUCTION_STOP_DEMAND_STATUSES,
isProductionStopItemMultiFilterKey,
isProductionStopItemNumericFilterKey,
isProductionStopItemTextFilterKey,
type ProductionStopItemFilters,
type ProductionStopNumericFilterOperator,
} from '@/lib/production-stop-demands'
import { productionStopDemandsService } from '@/server/services/production-stop-demands.service'
const querySchema = z.object({
granularity: z.enum(PRODUCTION_STOP_ANALYTICS_GRANULARITIES),
from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
filters: z.string().trim().max(12000).optional(),
})
type AnalyticsFilters = Omit<
ProductionStopItemFilters,
'page' | 'pageSize' | 'sortKey' | 'sortDirection' | 'dateFrom' | 'dateTo'
>
const NUMERIC_OPERATORS = new Set<ProductionStopNumericFilterOperator>(['gt', 'gte', 'lt', 'lte'])
function cleanText(value: unknown, max: number) {
return String(value ?? '').trim().slice(0, max)
}
/** 解析主表当前筛选条件(与列表共用同一套字段白名单)。 */
function parseAnalyticsFilters(raw?: string): AnalyticsFilters | undefined {
if (!raw) return undefined
try {
const parsed = JSON.parse(raw) as Record<string, unknown>
if (!parsed || typeof parsed !== 'object') return undefined
const result: AnalyticsFilters = {}
const keyword = cleanText(parsed.keyword, 2000)
if (keyword) result.keyword = keyword
const confirmationStatus = cleanText(parsed.confirmationStatus, 20)
if ((PRODUCTION_STOP_CONFIRMATION_STATUSES as readonly string[]).includes(confirmationStatus)) {
result.confirmationStatus = confirmationStatus as ProductionStopItemFilters['confirmationStatus']
}
const demandStatus = cleanText(parsed.demandStatus, 30)
if ((PRODUCTION_STOP_DEMAND_STATUSES as readonly string[]).includes(demandStatus)) {
result.demandStatus = demandStatus as ProductionStopItemFilters['demandStatus']
}
if (Array.isArray(parsed.demandStatuses)) {
const statuses = parsed.demandStatuses
.map((value) => cleanText(value, 30))
.filter((value): value is NonNullable<ProductionStopItemFilters['demandStatus']> =>
(PRODUCTION_STOP_DEMAND_STATUSES as readonly string[]).includes(value))
if (statuses.length) result.demandStatuses = statuses
}
for (const key of ['reasonCategory', 'purchaseOwner', 'requesterName'] as const) {
const value = cleanText(parsed[key], 60)
if (value) result[key] = value
}
if (parsed.fieldFilters && typeof parsed.fieldFilters === 'object') {
const fieldFilters: NonNullable<ProductionStopItemFilters['fieldFilters']> = {}
for (const [key, value] of Object.entries(parsed.fieldFilters as Record<string, unknown>)) {
if (!isProductionStopItemTextFilterKey(key)) continue
const text = cleanText(value, 120)
if (text) fieldFilters[key] = text
}
if (Object.keys(fieldFilters).length) result.fieldFilters = fieldFilters
}
if (parsed.numericFilters && typeof parsed.numericFilters === 'object') {
const numericFilters: NonNullable<ProductionStopItemFilters['numericFilters']> = {}
for (const [key, value] of Object.entries(parsed.numericFilters as Record<string, unknown>)) {
if (!isProductionStopItemNumericFilterKey(key)) continue
const entry = value as { op?: unknown; value?: unknown } | null
const op = cleanText(entry?.op, 8) as ProductionStopNumericFilterOperator
const numeric = Number(entry?.value)
if (!NUMERIC_OPERATORS.has(op) || !Number.isFinite(numeric)) continue
numericFilters[key] = { op, value: numeric }
}
if (Object.keys(numericFilters).length) result.numericFilters = numericFilters
}
if (parsed.multiFilters && typeof parsed.multiFilters === 'object') {
const multiFilters: NonNullable<ProductionStopItemFilters['multiFilters']> = {}
for (const [key, value] of Object.entries(parsed.multiFilters as Record<string, unknown>)) {
if (!isProductionStopItemMultiFilterKey(key) || !Array.isArray(value)) continue
const values = value.map((item) => cleanText(item, 120)).slice(0, 100)
if (values.length) multiFilters[key] = values
}
if (Object.keys(multiFilters).length) result.multiFilters = multiFilters
}
for (const key of ['barcodeCreatedAt', 'barcodeCreatedFrom', 'barcodeCreatedTo'] as const) {
const value = cleanText(parsed[key], 10)
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) result[key] = value
}
return Object.keys(result).length ? result : undefined
} catch {
return undefined
}
}
/** 领导视图:日/周/月需求趋势与部门、需求人、状态分布。 */
export async function GET(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
const parsed = querySchema.safeParse(
Object.fromEntries(request.nextUrl.searchParams.entries()),
)
if (!parsed.success) {
return jsonError(
ErrorCode.VALIDATION_INVALID_FORMAT,
'统计参数不正确',
400,
ctx.requestId,
)
}
const access = await productionStopDemandsService.resolveListAccess(ctx.user)
if (!access.canView) {
return jsonError(
ErrorCode.AUTH_PERMISSION_DENIED,
'当前账号没有停产需求管理的查看权限',
403,
ctx.requestId,
)
}
const result = await productionStopDemandsService.getAnalytics({
granularity: parsed.data.granularity,
from: parsed.data.from,
to: parsed.data.to,
filters: parseAnalyticsFilters(parsed.data.filters),
})
return jsonSuccess(result, 200, ctx.requestId)
},
{ requireAuth: true },
)
}

View File

@ -0,0 +1,37 @@
import { NextRequest } from 'next/server'
import { z } from 'zod'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { isProductionStopItemTextFilterKey } from '@/lib/production-stop-demands'
import { productionStopDemandRepository } from '@/server/repositories/production-stop-demands.repository'
const querySchema = z.object({
field: z.string().trim().min(1).max(64),
limit: z.coerce.number().int().min(1).max(500).default(200),
})
/** 表头漏斗筛选项:返回某字段的去重可选值。 */
export async function GET(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
const parsed = querySchema.safeParse(
Object.fromEntries(request.nextUrl.searchParams.entries()),
)
if (!parsed.success || !isProductionStopItemTextFilterKey(parsed.data.field)) {
return jsonError(
ErrorCode.VALIDATION_INVALID_FORMAT,
'筛选字段不正确',
400,
ctx.requestId,
)
}
const values = await productionStopDemandRepository.listDistinctFilterValues(
parsed.data.field,
parsed.data.limit,
)
return jsonSuccess({ values }, 200, ctx.requestId)
},
{ requireAuth: true },
)
}

View File

@ -5,7 +5,13 @@ import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import {
PRODUCTION_STOP_CONFIRMATION_STATUSES,
PRODUCTION_STOP_DEMAND_STATUSES,
isProductionStopItemMultiFilterKey,
isProductionStopItemNumericFilterKey,
isProductionStopItemSortKey,
isProductionStopItemTextFilterKey,
type ProductionStopNumericFilterOperator,
type ProductionStopItemFilters,
type ProductionStopItemTextFilterKey,
type ProductionStopItemSortKey,
} from '@/lib/production-stop-demands'
import { productionStopDemandsService } from '@/server/services/production-stop-demands.service'
@ -13,7 +19,7 @@ import { productionStopDemandsService } from '@/server/services/production-stop-
const listQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(200).default(50),
keyword: z.string().trim().max(160).optional(),
keyword: z.string().trim().max(2000).optional(),
confirmationStatus: z.enum(PRODUCTION_STOP_CONFIRMATION_STATUSES).optional(),
demandStatus: z.enum(PRODUCTION_STOP_DEMAND_STATUSES).optional(),
demandStatuses: z.string().trim().max(240).optional(),
@ -22,6 +28,12 @@ const listQuerySchema = z.object({
requesterName: z.string().trim().max(60).optional(),
dateFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
dateTo: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
fieldFilters: z.string().trim().max(4000).optional(),
numericFilters: z.string().trim().max(600).optional(),
multiFilters: z.string().trim().max(6000).optional(),
barcodeCreatedAt: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
barcodeCreatedFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
barcodeCreatedTo: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
sortKey: z.string().trim().max(64).optional(),
sortDirection: z.enum(['asc', 'desc']).optional(),
})
@ -30,6 +42,65 @@ const listQuerySchema = z.object({
let lastFlowSyncAt = 0
const FLOW_SYNC_INTERVAL_MS = 5 * 60 * 1000
function parseFieldFilters(raw?: string) {
if (!raw) return undefined
try {
const parsed = JSON.parse(raw) as Record<string, unknown>
if (!parsed || typeof parsed !== 'object') return undefined
const result: Partial<Record<ProductionStopItemTextFilterKey, string>> = {}
for (const [key, value] of Object.entries(parsed)) {
if (!isProductionStopItemTextFilterKey(key)) continue
const text = String(value ?? '').trim().slice(0, 120)
if (text) result[key] = text
}
return Object.keys(result).length ? result : undefined
} catch {
return undefined
}
}
const NUMERIC_FILTER_OPERATORS = new Set<ProductionStopNumericFilterOperator>(['gt', 'gte', 'lt', 'lte'])
function parseNumericFilters(raw?: string) {
if (!raw) return undefined
try {
const parsed = JSON.parse(raw) as Record<string, unknown>
if (!parsed || typeof parsed !== 'object') return undefined
const result: NonNullable<ProductionStopItemFilters['numericFilters']> = {}
for (const [key, value] of Object.entries(parsed)) {
if (!isProductionStopItemNumericFilterKey(key)) continue
const entry = value as { op?: unknown; value?: unknown } | null
const op = String(entry?.op ?? '')
const numeric = Number(entry?.value)
if (!NUMERIC_FILTER_OPERATORS.has(op as ProductionStopNumericFilterOperator)) continue
if (!Number.isFinite(numeric)) continue
result[key] = { op: op as ProductionStopNumericFilterOperator, value: numeric }
}
return Object.keys(result).length ? result : undefined
} catch {
return undefined
}
}
function parseMultiFilters(raw?: string) {
if (!raw) return undefined
try {
const parsed = JSON.parse(raw) as Record<string, unknown>
if (!parsed || typeof parsed !== 'object') return undefined
const result: NonNullable<ProductionStopItemFilters['multiFilters']> = {}
for (const [key, value] of Object.entries(parsed)) {
if (!isProductionStopItemMultiFilterKey(key) || !Array.isArray(value)) continue
const values = value
.map((item) => String(item ?? '').slice(0, 120))
.slice(0, 100)
if (values.length) result[key] = values
}
return Object.keys(result).length ? result : undefined
} catch {
return undefined
}
}
export async function GET(request: NextRequest) {
return handleRequest(
request,
@ -79,6 +150,12 @@ export async function GET(request: NextRequest) {
requesterName: parsed.data.requesterName || undefined,
dateFrom: parsed.data.dateFrom || undefined,
dateTo: parsed.data.dateTo || undefined,
fieldFilters: parseFieldFilters(parsed.data.fieldFilters),
numericFilters: parseNumericFilters(parsed.data.numericFilters),
multiFilters: parseMultiFilters(parsed.data.multiFilters),
barcodeCreatedAt: parsed.data.barcodeCreatedAt || undefined,
barcodeCreatedFrom: parsed.data.barcodeCreatedFrom || undefined,
barcodeCreatedTo: parsed.data.barcodeCreatedTo || undefined,
sortKey: parsed.data.sortKey as ProductionStopItemSortKey | undefined,
sortDirection: parsed.data.sortDirection,
page: parsed.data.page,

View File

@ -37,6 +37,7 @@ function itemRow(overrides: Partial<ProductionStopItemRow>): ProductionStopItemR
brand: '品牌1',
productTag: '仓库-崔桥工厂仓',
productLabel: '',
image: '',
postStopSkuStatus: '非销售',
sales30dWithBig: '',
sales30dWithBigSku: '',
@ -60,6 +61,10 @@ function itemRow(overrides: Partial<ProductionStopItemRow>): ProductionStopItemR
}
describe('停产需求管理视图', () => {
beforeEach(() => {
window.localStorage.clear()
})
it('只读用户看到列表与空状态,不显示新建和配置入口', () => {
render(
<ProductionStopDemandsView initialData={emptyPage} canEdit={false} isAdmin={false} isFlowOwner={false} />,
@ -90,6 +95,384 @@ describe('停产需求管理视图', () => {
expect(screen.getByRole('separator', { name: '调整条码列宽' })).toBeInTheDocument()
})
it('设置面板支持拖动调整列顺序,列设置不再提供显隐勾选框', () => {
const { container } = render(
<ProductionStopDemandsView
initialData={{ ...emptyPage, rows: [itemRow({ id: 'item-1', barcode: 'A1' })], total: 1 }}
canEdit={false}
isAdmin={false}
isFlowOwner={false}
/>,
)
fireEvent.click(screen.getByRole('button', { name: '设置' }))
const dialog = screen.getByRole('dialog', { name: '设置' })
const columnList = within(dialog).getByRole('list', { name: '列设置' })
expect(within(columnList).getAllByRole('listitem')).toHaveLength(PRODUCTION_STOP_ITEM_COLUMNS.length)
expect(within(columnList).queryAllByRole('checkbox')).toHaveLength(0)
const dataTransfer = {
setData: jest.fn(),
getData: jest.fn(() => 'barcode'),
effectAllowed: '',
dropEffect: '',
}
const barcodeRow = within(columnList).getByText('条码').closest('li') as HTMLElement
const firstRow = within(columnList).getByText('需求时间').closest('li') as HTMLElement
fireEvent.dragStart(barcodeRow, { dataTransfer })
fireEvent.dragOver(firstRow, { dataTransfer })
fireEvent.drop(firstRow, { dataTransfer })
const fieldRow = container.querySelectorAll('thead tr')[1]
const headers = [...fieldRow.querySelectorAll('th')].map((cell) => cell.textContent?.trim())
expect(headers[1]).toBe('条码')
})
it('筛选设置支持增减快速查询框,隐藏后同步清空该筛选条件', () => {
render(
<ProductionStopDemandsView
initialData={{ ...emptyPage, rows: [itemRow({ id: 'item-1', barcode: 'A1' })], total: 1 }}
canEdit={false}
isAdmin={false}
isFlowOwner={false}
/>,
)
expect(screen.getByPlaceholderText('需求人(可输入搜索)')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '设置' }))
const dialog = screen.getByRole('dialog', { name: '设置' })
const filterList = within(dialog).getByRole('list', { name: '筛选设置' })
const requesterRow = within(filterList).getByText('需求人').closest('li') as HTMLElement
fireEvent.click(within(requesterRow).getByRole('checkbox'))
fireEvent.click(within(dialog).getByRole('button', { name: '完成' }))
expect(screen.queryByPlaceholderText('需求人(可输入搜索)')).toBeNull()
expect(screen.getByPlaceholderText('采购(可输入搜索)')).toBeInTheDocument()
})
it('筛选设置与列设置字段一致:启用列字段后可加入快速查询并按包含筛选', async () => {
const fetchMock = jest.fn(async () => ({
ok: true,
json: async () => ({ success: true, data: emptyPage }),
}))
global.fetch = fetchMock as unknown as typeof fetch
render(
<ProductionStopDemandsView
initialData={{ ...emptyPage, rows: [itemRow({ id: 'item-1', barcode: 'A1' })], total: 1 }}
canEdit={false}
isAdmin={false}
isFlowOwner={false}
/>,
)
fireEvent.click(screen.getByRole('button', { name: '设置' }))
const dialog = screen.getByRole('dialog', { name: '设置' })
const filterList = within(dialog).getByRole('list', { name: '筛选设置' })
const brandRow = within(filterList).getByText('品牌').closest('li') as HTMLElement
fireEvent.click(within(brandRow).getByRole('checkbox'))
fireEvent.click(within(dialog).getByRole('button', { name: '完成' }))
fireEvent.change(screen.getByPlaceholderText('品牌(包含)'), { target: { value: '左印' } })
fireEvent.click(screen.getByRole('button', { name: '查询' }))
await waitFor(() => expect(fetchMock).toHaveBeenCalled())
const url = decodeURIComponent(String(fetchMock.mock.calls.at(-1)?.[0]))
expect(url).toContain('"brand":"左印"')
})
it('数值字段支持 >、>=、<、<= 比较筛选', async () => {
const fetchMock = jest.fn(async () => ({
ok: true,
json: async () => ({ success: true, data: emptyPage }),
}))
global.fetch = fetchMock as unknown as typeof fetch
render(
<ProductionStopDemandsView
initialData={{ ...emptyPage, rows: [itemRow({ id: 'item-1', barcode: 'A1' })], total: 1 }}
canEdit={false}
isAdmin={false}
isFlowOwner={false}
/>,
)
fireEvent.click(screen.getByRole('button', { name: '设置' }))
const dialog = screen.getByRole('dialog', { name: '设置' })
const filterList = within(dialog).getByRole('list', { name: '筛选设置' })
const row = within(filterList).getByText('含大单近30日日销-条码').closest('li') as HTMLElement
fireEvent.click(within(row).getByRole('checkbox'))
fireEvent.click(within(dialog).getByRole('button', { name: '完成' }))
fireEvent.change(screen.getByLabelText('快速筛选含大单近30日日销-条码比较方式'), {
target: { value: 'lt' },
})
fireEvent.change(screen.getByLabelText('快速筛选含大单近30日日销-条码数值'), {
target: { value: '12.5' },
})
fireEvent.click(screen.getByRole('button', { name: '查询' }))
await waitFor(() => expect(fetchMock).toHaveBeenCalled())
const url = decodeURIComponent(String(fetchMock.mock.calls.at(-1)?.[0]))
expect(url).toContain('"sales30dWithBig":{"op":"lt","value":12.5}')
})
it('关键词搜索支持批量粘贴,多行自动转为空格分隔', () => {
render(
<ProductionStopDemandsView
initialData={{ ...emptyPage, rows: [itemRow({ id: 'item-1', barcode: 'A1' })], total: 1 }}
canEdit={false}
isAdmin={false}
isFlowOwner={false}
/>,
)
const input = screen.getByPlaceholderText(/支持批量/)
fireEvent.paste(input, {
clipboardData: { getData: () => 'A1\nA2\nA3' },
})
expect(input).toHaveValue('A1 A2 A3')
})
it('支持另存当前筛选为快捷查询并一键套用', async () => {
const fetchMock = jest.fn(async () => ({
ok: true,
json: async () => ({ success: true, data: emptyPage }),
}))
global.fetch = fetchMock as unknown as typeof fetch
render(
<ProductionStopDemandsView
initialData={{ ...emptyPage, rows: [itemRow({ id: 'item-1', barcode: 'A1' })], total: 1 }}
canEdit={false}
isAdmin={false}
isFlowOwner={false}
/>,
)
fireEvent.change(screen.getByPlaceholderText(/支持批量/), { target: { value: '27014E' } })
fireEvent.click(screen.getByRole('button', { name: '查询' }))
await waitFor(() => expect(fetchMock).toHaveBeenCalled())
fireEvent.click(screen.getByRole('button', { name: '快捷查询' }))
fireEvent.click(screen.getByRole('button', { name: '另存当前筛选为快捷查询' }))
const dialog = screen.getByRole('dialog', { name: '另存为快捷查询' })
fireEvent.change(within(dialog).getByPlaceholderText('例如:待我确认的滞销款'), {
target: { value: '我的常用查询' },
})
fireEvent.click(within(dialog).getByRole('button', { name: '保存' }))
fireEvent.click(screen.getByRole('button', { name: '重置' }))
fireEvent.click(screen.getByRole('button', { name: '快捷查询' }))
fireEvent.click(screen.getByRole('button', { name: '我的常用查询' }))
await waitFor(() => {
const url = decodeURIComponent(String(fetchMock.mock.calls.at(-1)?.[0]))
expect(url).toContain('keyword=27014E')
})
})
it('视图展示日/周/月趋势与部门、需求人、状态统计,并支持下钻明细', async () => {
const analyticsData = {
granularity: 'day',
from: '2026-10-07',
to: '2026-10-20',
total: 3,
series: [{ bucket: '2026-10-20', count: 3 }],
byDepartment: [{ key: '运营部', count: 2 }, { key: '采购部', count: 1 }],
byRequester: [{ key: '系统管理员', count: 3 }],
byReasonCategory: [{ key: '滞销款', count: 3 }],
byDemandStatus: [
{ status: 'PENDING_CONFIRMATION', count: 1 },
{ status: 'CONFIRMED_WAIT_FLOW', count: 2 },
],
byConfirmationStatus: [
{ status: 'PENDING', count: 1 },
{ status: 'AGREED', count: 2 },
],
}
const fetchMock = jest.fn(async (input: RequestInfo | URL) => {
if (String(input).includes('/analytics')) {
return { ok: true, json: async () => ({ success: true, data: analyticsData }) }
}
return { ok: true, json: async () => ({ success: true, data: emptyPage }) }
})
global.fetch = fetchMock as unknown as typeof fetch
render(
<ProductionStopDemandsView
initialData={{ ...emptyPage, rows: [itemRow({ id: 'item-1', barcode: 'A1' })], total: 1 }}
canEdit={false}
isAdmin={false}
isFlowOwner={false}
/>,
)
fireEvent.change(screen.getByPlaceholderText(/支持批量/), { target: { value: '27014E' } })
fireEvent.click(screen.getByRole('button', { name: '查询' }))
await waitFor(() => expect(fetchMock).toHaveBeenCalled())
fireEvent.click(screen.getByRole('button', { name: '视图' }))
const dialog = await screen.findByRole('dialog', { name: '停产需求分析' })
await waitFor(() => expect(within(dialog).getByText('总需求数')).toBeInTheDocument())
const analyticsCall = fetchMock.mock.calls.find(([input]) => String(input).includes('/analytics'))
expect(decodeURIComponent(String(analyticsCall?.[0]))).toContain('"keyword":"27014E"')
expect(within(dialog).getByText('需求趋势(按日)')).toBeInTheDocument()
expect(within(dialog).getByText('运营部')).toBeInTheDocument()
expect(within(dialog).getByText('系统管理员')).toBeInTheDocument()
expect(within(dialog).getAllByText('待确认').length).toBeGreaterThan(0)
fireEvent.click(within(dialog).getByRole('button', { name: '周' }))
await waitFor(() => {
expect(String(fetchMock.mock.calls.at(-1)?.[0])).toContain('granularity=week')
})
const pendingCard = await within(dialog).findByRole('button', { name: '查看待确认明细' })
fireEvent.click(pendingCard)
await waitFor(() => {
const url = decodeURIComponent(String(fetchMock.mock.calls.at(-1)?.[0]))
expect(url).toContain('demandStatus=PENDING_CONFIRMATION')
expect(url).toContain('dateFrom=2026-10-07')
expect(url).toContain('dateTo=2026-10-20')
})
expect(screen.queryByRole('dialog', { name: '停产需求分析' })).toBeNull()
})
it('表头漏斗支持模糊输入与多选值筛选', async () => {
const fetchMock = jest.fn(async (input: RequestInfo | URL) => {
if (String(input).includes('/filter-options')) {
return {
ok: true,
json: async () => ({ success: true, data: { values: ['左印', '其他品牌'] } }),
}
}
return { ok: true, json: async () => ({ success: true, data: emptyPage }) }
})
global.fetch = fetchMock as unknown as typeof fetch
render(
<ProductionStopDemandsView
initialData={{ ...emptyPage, rows: [itemRow({ id: 'item-1', barcode: 'A1' })], total: 1 }}
canEdit={false}
isAdmin={false}
isFlowOwner={false}
/>,
)
fireEvent.click(screen.getByLabelText('筛选品牌'))
fireEvent.change(screen.getByLabelText('品牌模糊筛选'), { target: { value: '左印' } })
fireEvent.click(screen.getByRole('button', { name: '确定' }))
await waitFor(() => expect(fetchMock).toHaveBeenCalled())
expect(decodeURIComponent(String(fetchMock.mock.calls.at(-1)?.[0])))
.toContain('"brand":"左印"')
// 多选值:勾选「销售」后确定。
fireEvent.click(screen.getByLabelText('筛选条码生产状态'))
fireEvent.click(screen.getByRole('checkbox', { name: '销售' }))
fireEvent.click(screen.getByRole('button', { name: '确定' }))
await waitFor(() => {
const url = decodeURIComponent(String(fetchMock.mock.calls.at(-1)?.[0]))
expect(url).toContain('"productStatus":["销售"]')
})
})
it('表头数值字段支持运算符 + 数值筛选', async () => {
const fetchMock = jest.fn(async () => ({
ok: true,
json: async () => ({ success: true, data: emptyPage }),
}))
global.fetch = fetchMock as unknown as typeof fetch
render(
<ProductionStopDemandsView
initialData={{ ...emptyPage, rows: [itemRow({ id: 'item-1', barcode: 'A1' })], total: 1 }}
canEdit={false}
isAdmin={false}
isFlowOwner={false}
/>,
)
fireEvent.click(screen.getByLabelText('筛选含大单近30日日销-条码'))
fireEvent.change(screen.getByLabelText('含大单近30日日销-条码比较方式'), {
target: { value: 'lte' },
})
fireEvent.change(screen.getByLabelText('含大单近30日日销-条码数值'), {
target: { value: '5' },
})
fireEvent.click(screen.getByRole('button', { name: '确定' }))
await waitFor(() => expect(fetchMock).toHaveBeenCalled())
expect(decodeURIComponent(String(fetchMock.mock.calls.at(-1)?.[0])))
.toContain('"sales30dWithBig":{"op":"lte","value":5}')
})
it('需求时间表头筛选支持日期区间', async () => {
const fetchMock = jest.fn(async () => ({
ok: true,
json: async () => ({ success: true, data: emptyPage }),
}))
global.fetch = fetchMock as unknown as typeof fetch
render(
<ProductionStopDemandsView
initialData={{ ...emptyPage, rows: [itemRow({ id: 'item-1', barcode: 'A1' })], total: 1 }}
canEdit={false}
isAdmin={false}
isFlowOwner={false}
/>,
)
fireEvent.click(screen.getByLabelText('筛选需求时间'))
fireEvent.change(screen.getByLabelText('需求时间起始'), {
target: { value: '2026-10-01' },
})
fireEvent.change(screen.getByLabelText('需求时间截止'), {
target: { value: '2026-10-20' },
})
fireEvent.click(screen.getByRole('button', { name: '确定' }))
await waitFor(() => expect(fetchMock).toHaveBeenCalled())
const url = decodeURIComponent(String(fetchMock.mock.calls.at(-1)?.[0]))
expect(url).toContain('dateFrom=2026-10-01')
expect(url).toContain('dateTo=2026-10-20')
})
it('图片列展示条码缩略图,点击打开大图预览且不打开明细', () => {
render(
<ProductionStopDemandsView
initialData={{
...emptyPage,
rows: [
itemRow({
id: 'item-1',
barcode: 'A1',
image: 'https://static.jiansun.vip:3333/Resource/SkuImg/A1.jpg',
}),
],
total: 1,
}}
canEdit={false}
isAdmin={false}
isFlowOwner={false}
/>,
)
const thumb = screen.getByRole('button', { name: '查看A1图片' })
expect(thumb.querySelector('img')).toHaveAttribute(
'src',
'https://static.jiansun.vip:3333/Resource/SkuImg/A1.jpg',
)
fireEvent.click(thumb)
const preview = screen.getByRole('dialog', { name: '图片预览' })
expect(within(preview).getByRole('img')).toHaveAttribute(
'src',
'https://static.jiansun.vip:3333/Resource/SkuImg/A1.jpg',
)
expect(screen.queryByRole('dialog', { name: '停产需求明细' })).toBeNull()
})
it('勾选单行或多行后出现删除入口', () => {
render(
<ProductionStopDemandsView

View File

@ -177,6 +177,41 @@
font-size: 12px;
}
.filterDateLabel {
flex: none;
color: var(--sc-muted);
font-size: 12px;
white-space: nowrap;
}
.numericFilterBox {
display: flex;
height: 34px;
align-items: center;
gap: 6px;
border: 1px solid var(--sc-border);
border-radius: 5px;
background: var(--sc-surface);
padding: 0 8px;
}
.numericFilterBox select {
border: 0;
outline: 0;
background: transparent;
color: var(--sc-ink);
font-size: 12px;
}
.numericFilterBox input {
width: 72px;
border: 0;
outline: 0;
background: transparent;
color: var(--sc-ink);
font-size: 12px;
}
.select {
height: 34px;
border: 1px solid var(--sc-border);
@ -332,6 +367,154 @@
top: 30px;
}
.headerFilterButton {
display: inline-flex;
width: 20px;
height: 20px;
flex: none;
align-items: center;
justify-content: center;
border: 0;
border-radius: 3px;
background: transparent;
color: #a1a1aa;
cursor: pointer;
}
.headerFilterButton:hover {
background: rgba(63, 63, 70, 0.08);
color: #52525b;
}
.headerFilterButtonActive {
background: rgba(37, 99, 235, 0.1);
color: #2563eb;
}
.columnFilterPopover {
position: absolute;
top: calc(100% + 4px);
left: 0;
z-index: 30;
display: grid;
width: 260px;
max-width: 320px;
gap: 8px;
border: 1px solid var(--sc-border);
border-radius: 6px;
background: #fff;
padding: 10px;
box-shadow: 0 12px 30px rgba(24, 24, 27, 0.18);
font-weight: 400;
text-align: left;
}
.columnFilterTitle {
color: var(--sc-ink);
font-size: 12px;
font-weight: 600;
}
.columnFilterSearch {
width: 100%;
height: 28px;
border: 1px solid var(--sc-border);
border-radius: 4px;
background: #fff;
padding: 0 8px;
color: var(--sc-ink);
font-size: 12px;
outline: 0;
}
.columnFilterDateRange {
display: flex;
align-items: center;
gap: 6px;
}
.columnFilterDateRange span {
flex: none;
color: var(--sc-muted);
font-size: 12px;
}
.columnFilterDateRange .columnFilterSearch {
width: auto;
min-width: 0;
flex: 1;
}
.columnFilterOptions {
display: grid;
max-height: 220px;
gap: 4px;
overflow: auto;
}
.columnFilterOptions label {
display: flex;
align-items: center;
gap: 6px;
color: var(--sc-ink);
font-size: 12px;
cursor: pointer;
}
.columnFilterOptions input[type='checkbox'] {
width: 13px;
height: 13px;
min-width: 13px;
min-height: 13px;
margin: 0;
accent-color: #2563eb;
cursor: pointer;
}
.columnFilterEmpty {
color: var(--sc-muted);
font-size: 12px;
padding: 2px 0;
}
.columnFilterNumericRow {
display: flex;
align-items: center;
gap: 6px;
}
.columnFilterNumericRow select {
width: 56px;
height: 30px;
border: 1px solid var(--sc-border);
border-radius: 4px;
background: #fff;
color: var(--sc-ink);
font-size: 12px;
outline: 0;
}
.columnFilterNumericRow input {
min-width: 0;
flex: 1;
height: 30px;
border: 1px solid var(--sc-border);
border-radius: 4px;
background: #fff;
padding: 0 8px;
color: var(--sc-ink);
font-size: 12px;
outline: 0;
}
.columnFilterFooter {
display: flex;
justify-content: flex-end;
gap: 8px;
border-top: 1px solid var(--sc-border);
padding-top: 8px;
}
.table td {
height: 46px;
border-bottom: 1px solid var(--sc-border);
@ -392,6 +575,14 @@
color: #a1a1aa;
}
.headerStaticLabel {
min-width: 0;
overflow: hidden;
padding: 2px 4px;
text-overflow: ellipsis;
white-space: nowrap;
}
.columnResizer {
position: absolute;
top: 0;
@ -414,6 +605,30 @@
font-weight: 600;
}
.imageCell {
display: inline-flex;
align-items: center;
}
.imageThumbButton {
display: block;
width: 44px;
height: 44px;
overflow: hidden;
border: 1px solid var(--sc-border);
border-radius: 4px;
background: #fafafa;
padding: 0;
cursor: zoom-in;
}
.imageThumbButton img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.badge {
display: inline-flex;
height: 22px;
@ -561,6 +776,569 @@
padding: 18px;
}
.settingsDialog {
width: min(820px, 100%);
}
.analyticsDialog {
display: flex;
width: min(1020px, calc(100vw - 40px));
min-width: min(560px, calc(100vw - 24px));
max-width: calc(100vw - 40px);
max-height: calc(100vh - 40px);
flex-direction: column;
overflow: hidden;
resize: both;
}
.analyticsDialog .dialogBody {
flex: 1;
min-height: 0;
overflow: auto;
}
.analyticsScrollBody {
display: grid;
min-width: 860px;
gap: 16px;
align-content: start;
}
.analyticsSummary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 12px;
}
.analyticsSummaryCard {
position: relative;
display: grid;
gap: 4px;
border: 1px solid #e6e9ee;
border-radius: 10px;
background: linear-gradient(180deg, #ffffff 0%, #fbfcfe 100%);
padding: 14px 16px 12px 18px;
text-align: left;
font: inherit;
cursor: pointer;
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
}
.analyticsSummaryCard::before {
content: '';
position: absolute;
left: 8px;
top: 14px;
bottom: 14px;
width: 3px;
border-radius: 3px;
background: linear-gradient(180deg, #6366f1, #a5b4fc);
}
.analyticsSummaryCard:hover {
border-color: #c7d2fe;
box-shadow: 0 8px 20px rgba(63, 63, 70, 0.08);
transform: translateY(-1px);
}
.analyticsSummaryCard span {
color: #71717a;
font-size: 12px;
}
.analyticsSummaryCard strong {
color: #18181b;
font-size: 26px;
font-weight: 700;
letter-spacing: -0.02em;
}
.analyticsSummaryCard em {
color: #a1a1aa;
font-size: 11px;
font-style: normal;
}
.analyticsSummaryCardPrimary {
border-color: #c7d2fe;
background: linear-gradient(180deg, #eef2ff 0%, #e0e7ff 100%);
}
.analyticsSummaryCardPrimary::before {
background: linear-gradient(180deg, #4f46e5, #818cf8);
}
.analyticsChartsRow {
display: grid;
grid-template-columns: minmax(0, 1.7fr) minmax(300px, 1fr);
align-items: stretch;
gap: 14px;
}
.analyticsPanel {
border: 1px solid #e8ebef;
border-radius: 10px;
background: #fff;
padding: 14px 16px 12px;
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.03);
}
.analyticsPanelHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.analyticsLegendChip {
display: inline-flex;
align-items: center;
gap: 5px;
color: #71717a;
font-size: 11px;
}
.analyticsLegendChip i {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 2px;
background: #6366f1;
}
.analyticsMuted {
color: #a1a1aa;
font-size: 11px;
}
.analyticsPieWrap {
display: grid;
grid-template-columns: minmax(150px, 180px) minmax(0, 1fr);
align-items: center;
gap: 16px;
padding: 6px 0;
}
.analyticsPie {
position: relative;
width: 172px;
height: 172px;
border-radius: 50%;
}
.analyticsPieHole {
position: absolute;
inset: 26%;
display: grid;
place-content: center;
border-radius: 50%;
background: #fff;
text-align: center;
}
.analyticsPieHole strong {
color: #18181b;
font-size: 22px;
}
.analyticsPieHole span {
color: #a1a1aa;
font-size: 11px;
}
.analyticsPieLegend {
display: grid;
min-width: 0;
gap: 4px;
}
.analyticsPieLegendRow {
display: grid;
grid-template-columns: 10px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
border: 0;
border-radius: 4px;
background: transparent;
padding: 4px 6px;
font: inherit;
text-align: left;
cursor: pointer;
}
.analyticsPieLegendRow:hover {
background: #f6f7f9;
}
.analyticsPieDot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.analyticsPieLabel {
overflow: hidden;
color: #3f3f46;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.analyticsPieValue {
color: #18181b;
font-size: 12px;
white-space: nowrap;
}
.analyticsPieValue em {
margin-left: 5px;
color: #a1a1aa;
font-size: 10px;
font-style: normal;
}
.analyticsSection {
display: grid;
min-width: 0;
gap: 8px;
}
.analyticsSection .sectionTitle::before {
content: '';
display: inline-block;
width: 6px;
height: 6px;
margin-right: 6px;
border-radius: 50%;
background: #6366f1;
vertical-align: middle;
}
.analyticsChart {
display: flex;
min-height: 170px;
align-items: flex-end;
gap: 8px;
overflow-x: auto;
border: 1px solid #f0f1f3;
border-radius: 8px;
background-image: repeating-linear-gradient(
to top,
#f1f2f4 0,
#f1f2f4 1px,
transparent 1px,
transparent 38px
);
padding: 12px 8px 6px;
}
.analyticsBarColumn {
display: flex;
width: 40px;
flex: none;
flex-direction: column;
align-items: center;
justify-content: flex-end;
gap: 4px;
border: 0;
background: transparent;
padding: 0;
font: inherit;
cursor: pointer;
}
.analyticsBarTrack {
display: flex;
width: 24px;
height: 110px;
align-items: flex-end;
border-radius: 4px;
background: rgba(99, 102, 241, 0.08);
}
.analyticsBarFill {
display: block;
width: 100%;
border-radius: 4px;
background: linear-gradient(180deg, #3f3f46, #71717a);
}
.analyticsBarColumn:hover .analyticsBarFill {
background: linear-gradient(180deg, #4f46e5, #6366f1);
}
.analyticsBarValue {
color: var(--sc-ink);
font-size: 11px;
}
.analyticsBarLabel {
color: var(--sc-muted);
font-size: 10px;
white-space: nowrap;
}
.analyticsGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 14px;
}
.analyticsList {
display: grid;
gap: 2px;
}
.analyticsRow {
display: grid;
grid-template-columns: minmax(90px, 150px) minmax(0, 1fr) 54px;
align-items: center;
gap: 8px;
border: 0;
border-radius: 4px;
background: transparent;
padding: 3px 4px;
font: inherit;
text-align: left;
cursor: pointer;
}
.analyticsRow:hover {
background: #f6f7f9;
}
.analyticsRowLabel {
overflow: hidden;
color: var(--sc-ink);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.analyticsRowTrack {
height: 10px;
overflow: hidden;
border-radius: 5px;
background: #eef1f5;
}
.analyticsRowBar {
display: block;
height: 100%;
border-radius: 5px;
background: #71717a;
}
.analyticsRowValue {
display: flex;
align-items: baseline;
justify-content: flex-end;
gap: 4px;
color: var(--sc-ink);
font-size: 12px;
}
.analyticsRowValue em {
color: #a1a1aa;
font-size: 10px;
font-style: normal;
}
.quickQueryWrap {
position: relative;
display: inline-flex;
}
.quickQueryMenu {
position: absolute;
top: calc(100% + 6px);
left: 0;
z-index: 40;
display: grid;
width: 280px;
gap: 4px;
border: 1px solid var(--sc-border);
border-radius: 6px;
background: #fff;
padding: 8px;
box-shadow: 0 12px 30px rgba(24, 24, 27, 0.18);
}
.quickQueryItem {
display: flex;
align-items: center;
gap: 4px;
border-radius: 4px;
}
.quickQueryItem:hover {
background: rgba(63, 63, 70, 0.05);
}
.quickQueryName {
min-width: 0;
flex: 1;
overflow: hidden;
border: 0;
background: transparent;
padding: 6px 8px;
color: var(--sc-ink);
font-size: 12px;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
}
.quickQueryDelete {
display: inline-flex;
width: 24px;
height: 24px;
flex: none;
align-items: center;
justify-content: center;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--sc-muted);
cursor: pointer;
}
.quickQueryDelete:hover {
background: rgba(220, 38, 38, 0.08);
color: #dc2626;
}
.quickQueryEmpty {
padding: 8px;
color: var(--sc-muted);
font-size: 12px;
}
.quickQuerySave {
width: 100%;
justify-content: center;
margin-top: 4px;
}
.quickQueryDialog {
width: min(460px, 100%);
}
.settingsBody {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
align-items: start;
gap: 24px;
}
.settingsSection {
display: grid;
min-width: 0;
gap: 8px;
}
.settingsHint {
margin: 0;
color: var(--sc-muted);
font-size: 12px;
line-height: 1.6;
}
.imagePreviewBody {
display: flex;
min-height: min(64vh, 640px);
align-items: center;
justify-content: center;
background: #fafafa;
padding: 16px;
}
.imagePreviewBody img {
display: block;
width: auto;
height: auto;
max-width: 100%;
max-height: min(64vh, 640px);
object-fit: contain;
}
.columnSettingsList {
display: grid;
max-height: min(58vh, 560px);
gap: 4px;
margin: 0;
padding: 0;
overflow: auto;
list-style: none;
}
.columnSettingsRow {
display: flex;
align-items: center;
gap: 10px;
border: 1px solid transparent;
border-radius: 6px;
background: var(--sc-surface);
padding: 7px 10px;
cursor: grab;
}
.columnSettingsRow:hover {
background: rgba(63, 63, 70, 0.05);
}
.columnSettingsRowDragging {
border-color: var(--sc-border);
opacity: 0.55;
}
.filterSettingsRow {
cursor: default;
}
.columnDragHandle {
display: inline-flex;
color: var(--sc-muted);
cursor: grab;
}
.columnSettingsLabel {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.filterSettingsRow .columnSettingsLabel {
cursor: pointer;
}
/* 移动端全局规则会把复选框撑成 44×44,这里固定为与文字接近的视觉尺寸。 */
.columnSettingsLabel input[type='checkbox'] {
appearance: none;
width: 14px;
height: 14px;
min-width: 14px;
min-height: 14px;
margin: 0;
border: 1px solid #a1a1aa;
border-radius: 3px;
background: #fff center / 10px 10px no-repeat;
box-shadow: none;
cursor: pointer;
}
.columnSettingsLabel input[type='checkbox']:hover {
border-color: #52525b;
}
.columnSettingsLabel input[type='checkbox']:checked {
border-color: #27272a;
background-color: #27272a;
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3.6 8.6l3 3 5.8-6.6' fill='none' stroke='white' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
}
.dialogFooter {
display: flex;
justify-content: flex-end;

View File

@ -0,0 +1,16 @@
import { buildDingTalkFullscreenUrl } from './dingtalk-link'
describe('钉钉全屏链接', () => {
it('把业务地址包装为 pc_slide=false 的钉钉客户端链接', () => {
expect(buildDingTalkFullscreenUrl('https://cp.jiansun.vip/a?b=1'))
.toBe(
'dingtalk://dingtalkclient/page/link?url=https%3A%2F%2Fcp.jiansun.vip%2Fa%3Fb%3D1&pc_slide=false',
)
})
it('不会重复包装或包装空地址', () => {
const wrapped = buildDingTalkFullscreenUrl('https://cp.jiansun.vip/a')
expect(buildDingTalkFullscreenUrl(wrapped)).toBe(wrapped)
expect(buildDingTalkFullscreenUrl('')).toBe('')
})
})

View File

@ -0,0 +1,10 @@
/**
* 钉钉 PC 客户端默认把机器人消息里的链接开在右侧「详情」侧栏(较窄)。
* 通过钉钉客户端路由 `pc_slide=false` 让链接以独立窗口(全屏可用)打开。
* 移动端仍由钉钉内置浏览器打开。
*/
export function buildDingTalkFullscreenUrl(url: string) {
const value = String(url || '').trim()
if (!value || value.startsWith('dingtalk://')) return value
return `dingtalk://dingtalkclient/page/link?url=${encodeURIComponent(value)}&pc_slide=false`
}

View File

@ -2,7 +2,9 @@ import {
PRODUCTION_STOP_DEFAULT_REASON_CATEGORIES,
PRODUCTION_STOP_ITEM_COLUMNS,
parseBarcodeInput,
parseProductionStopSearchTerms,
resolveProductionStopConfirmationDeadline,
resolveProductionStopProductLabel,
} from './production-stop-demands'
describe('停产需求管理口径', () => {
@ -10,6 +12,11 @@ describe('停产需求管理口径', () => {
expect(parseBarcodeInput('A1\nb2,A1; B2、C3 c4')).toEqual(['A1', 'b2', 'C3', 'c4'])
})
it('批量搜索词支持换行、逗号、空格分隔并去重', () => {
expect(parseProductionStopSearchTerms('DNZ001,DNZ002\nDNZ003 dnz001;DNZ004'))
.toEqual(['DNZ001', 'DNZ002', 'DNZ003', 'DNZ004'])
})
it('明细列覆盖需求表字段并按需求顺序排列', () => {
expect(PRODUCTION_STOP_ITEM_COLUMNS[0]).toMatchObject({ key: 'submittedAt', label: '需求时间' })
expect(PRODUCTION_STOP_ITEM_COLUMNS.map((column) => column.label)).toEqual(
@ -47,6 +54,10 @@ describe('停产需求管理口径', () => {
expect.objectContaining({ key: 'confirmationStatus', label: '停产确认' }),
]),
)
// 「图片」列插在条码前面,仅展示不参与排序。
const imageIndex = PRODUCTION_STOP_ITEM_COLUMNS.findIndex((column) => column.key === 'image')
const barcodeIndex = PRODUCTION_STOP_ITEM_COLUMNS.findIndex((column) => column.key === 'barcode')
expect(imageIndex).toBe(barcodeIndex - 1)
})
it('停产原因分类包含配置表里的初始选项', () => {
@ -95,3 +106,49 @@ describe('是否同意停产确认截止时间', () => {
).toBe('2026-10-08T08:00:00.000Z')
})
})
describe('产品标签口径', () => {
it('SABC 标签优先返回打标值', () => {
expect(resolveProductionStopProductLabel({
sabcRating: 'A',
productStatus: '销售',
realPerformance: '',
})).toBe('A')
expect(resolveProductionStopProductLabel({
sabcRating: 'C',
productStatus: '停产',
realPerformance: '0',
})).toBe('C')
})
it('未打标的非销售商品返回「非销售商品未打标」', () => {
expect(resolveProductionStopProductLabel({
sabcRating: '',
productStatus: '停产',
realPerformance: '123.45',
})).toBe('非销售商品未打标')
expect(resolveProductionStopProductLabel({
sabcRating: '',
productStatus: '',
realPerformance: '',
})).toBe('非销售商品未打标')
})
it('未打标的销售商品按考核周期真实业绩兜底', () => {
expect(resolveProductionStopProductLabel({
sabcRating: '',
productStatus: '销售',
realPerformance: '0.5000',
})).toBe('有业绩未打标')
expect(resolveProductionStopProductLabel({
sabcRating: '',
productStatus: '销售',
realPerformance: '0.0000',
})).toBe('无业绩未打标')
expect(resolveProductionStopProductLabel({
sabcRating: '',
productStatus: '销售',
realPerformance: '',
})).toBe('无业绩未打标')
})
})

View File

@ -105,8 +105,12 @@ export const PRODUCTION_STOP_DEFAULT_REASON_CATEGORIES = [
'高差评低销量产品淘汰',
] as const
/** 仅展示、不参与排序的列(如图片缩略图)。 */
export const PRODUCTION_STOP_ITEM_DISPLAY_ONLY_KEYS = ['image'] as const
export type ProductionStopItemDisplayOnlyKey = (typeof PRODUCTION_STOP_ITEM_DISPLAY_ONLY_KEYS)[number]
export type ProductionStopItemColumn = {
key: ProductionStopItemSortKey
key: ProductionStopItemSortKey | ProductionStopItemDisplayOnlyKey
label: string
width: number
group: '需求信息' | '条码档案' | '替换信息' | '状态跟踪'
@ -148,6 +152,79 @@ export function isProductionStopItemSortKey(value: string): value is ProductionS
return (PRODUCTION_STOP_ITEM_SORT_KEYS as readonly string[]).includes(value)
}
/** 明细列表支持「包含」匹配的文本字段(设置里可加为快速筛选)。 */
export const PRODUCTION_STOP_ITEM_TEXT_FILTER_KEYS = [
'barcode',
'department',
'reasonDetail',
'replaceType',
'replaceBarcode',
'imageChange',
'modificationPoint',
'categoryL2',
'spu',
'sku',
'productStatus',
'supplierName',
'brand',
'productTag',
'productLabel',
'postStopSkuStatus',
'sales30dWithBig',
'sales30dWithBigSku',
'opsDirector',
'topSalesOpsDirector',
'stopSuggestion',
] as const
export type ProductionStopItemTextFilterKey =
(typeof PRODUCTION_STOP_ITEM_TEXT_FILTER_KEYS)[number]
export function isProductionStopItemTextFilterKey(
value: string,
): value is ProductionStopItemTextFilterKey {
return (PRODUCTION_STOP_ITEM_TEXT_FILTER_KEYS as readonly string[]).includes(value)
}
/** 这些「文本筛选字段」实际存放在需求单上(不是明细表字段)。 */
export const PRODUCTION_STOP_DEMAND_TEXT_FILTER_KEYS = ['department', 'reasonDetail'] as const
export function isProductionStopDemandTextField(value: string) {
return (PRODUCTION_STOP_DEMAND_TEXT_FILTER_KEYS as readonly string[]).includes(value)
}
/** 支持多选值筛选的字段(表头漏斗弹层)。 */
export const PRODUCTION_STOP_ITEM_MULTI_FILTER_KEYS = [
...PRODUCTION_STOP_ITEM_TEXT_FILTER_KEYS,
'purchaseOwner',
'requesterName',
'reasonCategory',
'confirmationStatus',
'demandStatus',
] as const
export type ProductionStopItemMultiFilterKey =
(typeof PRODUCTION_STOP_ITEM_MULTI_FILTER_KEYS)[number]
export function isProductionStopItemMultiFilterKey(
value: string,
): value is ProductionStopItemMultiFilterKey {
return (PRODUCTION_STOP_ITEM_MULTI_FILTER_KEYS as readonly string[]).includes(value)
}
/** 数值字段筛选:支持 >、>=、<、<=。 */
export const PRODUCTION_STOP_ITEM_NUMERIC_FILTER_KEYS = [
'sales30dWithBig',
'sales30dWithBigSku',
] as const
export type ProductionStopItemNumericFilterKey =
(typeof PRODUCTION_STOP_ITEM_NUMERIC_FILTER_KEYS)[number]
export type ProductionStopNumericFilterOperator = 'gt' | 'gte' | 'lt' | 'lte'
export function isProductionStopItemNumericFilterKey(
value: string,
): value is ProductionStopItemNumericFilterKey {
return (PRODUCTION_STOP_ITEM_NUMERIC_FILTER_KEYS as readonly string[]).includes(value)
}
// 明细列表列口径,顺序与需求表的字段顺序一致,末尾追加采购反馈列。
export const PRODUCTION_STOP_ITEM_COLUMNS: readonly ProductionStopItemColumn[] = [
{ key: 'submittedAt', label: '需求时间', width: 150, group: '需求信息' },
@ -155,6 +232,7 @@ export const PRODUCTION_STOP_ITEM_COLUMNS: readonly ProductionStopItemColumn[] =
{ key: 'department', label: '需求部门', width: 120, group: '需求信息' },
{ key: 'reasonCategory', label: '停产原因分类', width: 150, group: '需求信息' },
{ key: 'reasonDetail', label: '停产原因详细说明', width: 180, group: '需求信息' },
{ key: 'image', label: '图片', width: 80, group: '需求信息' },
{ key: 'barcode', label: '条码', width: 170, group: '需求信息' },
{ key: 'replaceType', label: '替换类型', width: 100, group: '替换信息' },
{ key: 'replaceBarcode', label: '替换编码', width: 170, group: '替换信息' },
@ -180,6 +258,32 @@ export const PRODUCTION_STOP_ITEM_COLUMNS: readonly ProductionStopItemColumn[] =
{ key: 'demandStatus', label: '需求状态', width: 140, group: '状态跟踪' },
]
/**
* 「产品标签」口径与产品全量档案一致:SABC 标签(S/A/B/C/D…)优先;
* 未打标时按销售状态 × 考核周期真实业绩兜底,兜底文案与档案页保持一致。
*/
export const PRODUCTION_STOP_PRODUCT_LABELS = {
nonSales: '非销售商品未打标',
hasPerformance: '有业绩未打标',
noPerformance: '无业绩未打标',
} as const
export function resolveProductionStopProductLabel(input: {
/** 档案库「SABC 打标」值,未打标传空。 */
sabcRating: string
productStatus: string
/** 档案库同步的最新考核周期真实业绩,未匹配到传空。 */
realPerformance: string
}) {
const rating = input.sabcRating.trim()
if (rating) return rating
if (input.productStatus.trim() !== '销售') return PRODUCTION_STOP_PRODUCT_LABELS.nonSales
const performance = Number(input.realPerformance)
return Number.isFinite(performance) && performance > 0
? PRODUCTION_STOP_PRODUCT_LABELS.hasPerformance
: PRODUCTION_STOP_PRODUCT_LABELS.noPerformance
}
export type ProductionStopItemRow = {
id: string
demandId: string
@ -202,6 +306,7 @@ export type ProductionStopItemRow = {
brand: string
productTag: string
productLabel: string
image: string
postStopSkuStatus: string
sales30dWithBig: string
sales30dWithBigSku: string
@ -222,6 +327,23 @@ export type ProductionStopItemRow = {
demandStatusNote: string
}
export const PRODUCTION_STOP_ANALYTICS_GRANULARITIES = ['day', 'week', 'month'] as const
export type ProductionStopAnalyticsGranularity =
(typeof PRODUCTION_STOP_ANALYTICS_GRANULARITIES)[number]
export type ProductionStopAnalytics = {
granularity: ProductionStopAnalyticsGranularity
from: string
to: string
total: number
series: Array<{ bucket: string; count: number }>
byDepartment: Array<{ key: string; count: number }>
byRequester: Array<{ key: string; count: number }>
byReasonCategory: Array<{ key: string; count: number }>
byDemandStatus: Array<{ status: ProductionStopDemandStatus; count: number }>
byConfirmationStatus: Array<{ status: ProductionStopConfirmationStatus; count: number }>
}
export type ProductionStopItemFilters = {
keyword?: string
confirmationStatus?: ProductionStopConfirmationStatus
@ -232,6 +354,20 @@ export type ProductionStopItemFilters = {
requesterName?: string
dateFrom?: string
dateTo?: string
/** 通用字段「包含」筛选(品牌、产品标签、运营负责人等)。 */
fieldFilters?: Partial<Record<ProductionStopItemTextFilterKey, string>>
/** 条码创建时间(按天匹配)。 */
barcodeCreatedAt?: string
/** 条码创建时间区间(按天)。 */
barcodeCreatedFrom?: string
barcodeCreatedTo?: string
/** 数值字段比较筛选(含大单日销等)。 */
numericFilters?: Partial<Record<
ProductionStopItemNumericFilterKey,
{ op: ProductionStopNumericFilterOperator; value: number }
>>
/** 多选值筛选(表头漏斗弹层勾选,含空白值空字符串)。 */
multiFilters?: Partial<Record<ProductionStopItemMultiFilterKey, string[]>>
sortKey?: ProductionStopItemSortKey
sortDirection?: 'asc' | 'desc'
page: number
@ -323,3 +459,22 @@ export function parseBarcodeInput(value: string): string[] {
}
return result
}
/**
* 批量搜索词解析:支持换行、逗号、分号、顿号与空格分隔,去重后最多 100 个词。
* 用于关键词搜索框一次粘贴多个条码/SPU/SKU。
*/
export function parseProductionStopSearchTerms(value: string): string[] {
const seen = new Set<string>()
const terms: string[] = []
for (const raw of String(value ?? '').split(/[\s,,;;、]+/)) {
const term = raw.trim()
if (!term) continue
const key = term.toUpperCase()
if (seen.has(key)) continue
seen.add(key)
terms.push(term.slice(0, 120))
if (terms.length >= 100) break
}
return terms
}

View File

@ -0,0 +1,65 @@
import {
buildProductionStopConfirmationPendingMessage,
buildProductionStopFlowPendingMessage,
buildProductionStopPendingCompletedMessage,
buildProductionStopRejectedMessage,
} from './production-stop-notifications'
const linkUrl = 'https://cp.jiansun.vip/supply-chain/management/production-stop-demands'
describe('停产需求通知中心消息口径', () => {
it('停产确认通知包含待办条数与执行要求', () => {
expect(buildProductionStopConfirmationPendingMessage(3)).toEqual({
title: '是否同意停产确认',
content: [
'**待办条数:** 3 条',
'',
'**执行需求:** 当天15点之前提交的需求,当天16点前完成确认,15点之后提交的次日16点前完成确认,超时未确认默认同意停产',
].join('\n'),
})
})
it('待走流程通知只同步待走流程条数', () => {
expect(buildProductionStopFlowPendingMessage(2)).toEqual({
title: '待走停产流程',
content: '**待办条数:** 2 条',
})
})
it('需求驳回通知包含供应商、条码与驳回原因', () => {
expect(buildProductionStopRejectedMessage({
supplierName: '供应商A',
barcode: 'A1',
reason: '张三:库存仍有 300 件',
})).toEqual({
title: '需求驳回同频',
content: [
'**供应商:** 供应商A',
'**条码:** A1',
'**驳回原因:** 张三:库存仍有 300 件',
].join('\n'),
})
})
it('待办清零时生成完成消息', () => {
expect(buildProductionStopPendingCompletedMessage('CONFIRM_PENDING')).toEqual({
title: '是否同意停产确认',
content: '**待办条数:** 0 条(已全部处理完成)',
})
expect(buildProductionStopPendingCompletedMessage('FLOW_PENDING').title)
.toBe('待走停产流程')
})
it('正文追加可直接点击的处理链接(三类通知与完成消息都带)', () => {
const linkLine = `**处理链接:** [点击进入停产需求管理](${linkUrl})`
expect(buildProductionStopConfirmationPendingMessage(1, linkUrl).content).toContain(linkLine)
expect(buildProductionStopFlowPendingMessage(1, linkUrl).content).toContain(linkLine)
expect(buildProductionStopRejectedMessage({
supplierName: '供应商A',
barcode: 'A1',
reason: '库存仍有 300 件',
}, linkUrl).content).toContain(linkLine)
expect(buildProductionStopPendingCompletedMessage('FLOW_PENDING', linkUrl).content)
.toContain(linkLine)
})
})

View File

@ -0,0 +1,70 @@
// 停产需求管理 → 统一通知中心的三类消息口径。
// 通知中心会在正文顶部自动加「【项目名称】」抬头,这里只写业务标题和正文。
export const PRODUCTION_STOP_NOTIFICATION_TITLES = {
// 通知中心会自动加「【项目名称】」前缀,这里只写业务标题,避免重复。
confirmPending: '是否同意停产确认',
flowPending: '待走停产流程',
rejected: '需求驳回同频',
} as const
export const PRODUCTION_STOP_CONFIRMATION_REQUIREMENT =
'当天15点之前提交的需求,当天16点前完成确认,15点之后提交的次日16点前完成确认,超时未确认默认同意停产'
export type ProductionStopPendingNotificationKind = 'CONFIRM_PENDING' | 'FLOW_PENDING'
/** 正文末尾追加可直接点击的处理链接(钉钉 Markdown 链接)。 */
export function appendProductionStopActionLink(content: string, linkUrl?: string) {
const url = String(linkUrl ?? '').trim()
if (!url) return content
return `${content}\n\n**处理链接:** [点击进入停产需求管理](${url})`
}
export function buildProductionStopConfirmationPendingMessage(pendingCount: number, linkUrl?: string) {
return {
title: PRODUCTION_STOP_NOTIFICATION_TITLES.confirmPending,
content: appendProductionStopActionLink([
`**待办条数:** ${Math.max(0, pendingCount)} 条`,
'',
`**执行需求:** ${PRODUCTION_STOP_CONFIRMATION_REQUIREMENT}`,
].join('\n'), linkUrl),
}
}
export function buildProductionStopFlowPendingMessage(pendingCount: number, linkUrl?: string) {
return {
title: PRODUCTION_STOP_NOTIFICATION_TITLES.flowPending,
content: appendProductionStopActionLink(
`**待办条数:** ${Math.max(0, pendingCount)} 条`,
linkUrl,
),
}
}
export function buildProductionStopRejectedMessage(input: {
supplierName: string
barcode: string
reason: string
}, linkUrl?: string) {
return {
title: PRODUCTION_STOP_NOTIFICATION_TITLES.rejected,
content: appendProductionStopActionLink([
`**供应商:** ${input.supplierName.trim() || '-'}`,
`**条码:** ${input.barcode.trim() || '-'}`,
`**驳回原因:** ${input.reason.trim() || '-'}`,
].join('\n'), linkUrl),
}
}
/** 待办清零时推送一次「已全部处理完成」,用于替代通知中心无法直接“勾选完成”的待办状态。 */
export function buildProductionStopPendingCompletedMessage(
kind: ProductionStopPendingNotificationKind,
linkUrl?: string,
) {
return {
title: kind === 'CONFIRM_PENDING'
? PRODUCTION_STOP_NOTIFICATION_TITLES.confirmPending
: PRODUCTION_STOP_NOTIFICATION_TITLES.flowPending,
content: appendProductionStopActionLink('**待办条数:** 0 条(已全部处理完成)', linkUrl),
}
}

View File

@ -123,7 +123,8 @@ export class NotificationCenterClient {
send(input: NotificationCenterSendInput) {
const payload: NotificationCenterSendInput = {
idempotencyKey: input.idempotencyKey,
recipientUserId: input.recipientUserId,
...(input.recipientUserId ? { recipientUserId: input.recipientUserId } : {}),
...(input.recipientAlias ? { recipientAlias: input.recipientAlias } : {}),
title: input.title,
content: input.content,
...(input.actionTitle !== undefined ? { actionTitle: input.actionTitle } : {}),

View File

@ -28,7 +28,10 @@ export type NotificationCenterDelivery = {
export type NotificationCenterSendInput = {
idempotencyKey: string
recipientUserId: string
/** 钉钉企业内部 userId(与 recipientAlias 二选一)。 */
recipientUserId?: string
/** 由通知中心调用素材平台花名册精确解析的准确花名(与 recipientUserId 二选一)。 */
recipientAlias?: string
title: string
content: string
actionTitle?: string

View File

@ -1,11 +1,19 @@
import { Prisma } from '@prisma/client'
import type {
Prisma,
ProductionStopConfirmationApprover as PrismaConfirmationApprover,
ProductionStopDemandConfirmationVote as PrismaConfirmationVote,
ProductionStopFlowOwner as PrismaFlowOwner,
} from '@prisma/client'
import type { QueryResult, QueryResultRow } from 'pg'
import { prisma } from '@/lib/prisma'
import {
PRODUCTION_STOP_ITEM_MULTI_FILTER_KEYS,
PRODUCTION_STOP_ITEM_NUMERIC_FILTER_KEYS,
PRODUCTION_STOP_ITEM_TEXT_FILTER_KEYS,
isProductionStopDemandTextField,
parseProductionStopSearchTerms,
isProductionStopItemTextFilterKey,
} from '@/lib/production-stop-demands'
import type {
ProductionStopConfirmationApproverOption,
ProductionStopConfirmationDecision,
@ -21,6 +29,7 @@ import type {
import { getSupplyChainArchiveReadPool } from '@/server/repositories/supply-chain-archive-read-pool'
const ARCHIVE_TABLE = 'public."SupplyChainProductArchive"'
const ARCHIVE_PERFORMANCE_TABLE = 'public."SupplyChainProductArchiveSalesPerformance"'
const DEMAND_NUMBER_LOCK_KEY = 2026093001
const ARCHIVE_CONNECTION_ERROR_CODES = new Set([
@ -75,6 +84,14 @@ export type ProductionStopArchiveProfile = {
productTag: string
businessCreatedAt: Date | null
opsDirector: string
/** 含大单近30日日销(条码维度):档案库该条码行的值。 */
sales30dWithBig: string
/** SABC 打标值(S/A/B/C/D…):「产品标签」的主口径,未打标为空。 */
sabcRating: string
/** 档案库同步的考核周期真实业绩:「产品标签」未打标兜底的判定依据,未匹配传空。 */
sabcRealPerformance: string
/** 条码对应图片地址(档案库「图片」列)。 */
image: string
}
export type ProductionStopDemandCreateInput = {
@ -99,10 +116,13 @@ export type ProductionStopDemandCreateInput = {
brand: string
productTag: string
productLabel: string
image: string
barcodeCreatedAt: Date | null
postStopSkuStatus: string
sales30dWithBig: string
sales30dWithBigSku: string
sales30dWithBigValue: string | null
sales30dWithBigSkuValue: string | null
opsDirector: string
topSalesOpsDirector: string
stopSuggestion: string
@ -151,6 +171,7 @@ const ITEM_SELECT = {
brand: true,
productTag: true,
productLabel: true,
image: true,
barcodeCreatedAt: true,
postStopSkuStatus: true,
sales30dWithBig: true,
@ -216,6 +237,7 @@ function toItemRow(item: ItemWithDemand): ProductionStopItemRow {
brand: text(item.brand),
productTag: text(item.productTag),
productLabel: text(item.productLabel),
image: text(item.image),
postStopSkuStatus: text(item.postStopSkuStatus),
sales30dWithBig: text(item.sales30dWithBig),
sales30dWithBigSku: text(item.sales30dWithBigSku),
@ -340,8 +362,8 @@ const ITEM_SORT_FIELDS: Partial<Record<ProductionStopItemSortKey, keyof Prisma.P
productTag: 'productTag',
productLabel: 'productLabel',
postStopSkuStatus: 'postStopSkuStatus',
sales30dWithBig: 'sales30dWithBig',
sales30dWithBigSku: 'sales30dWithBigSku',
sales30dWithBig: 'sales30dWithBigValue',
sales30dWithBigSku: 'sales30dWithBigSkuValue',
opsDirector: 'opsDirector',
topSalesOpsDirector: 'topSalesOpsDirector',
stopSuggestion: 'stopSuggestion',
@ -360,20 +382,22 @@ function buildOrderBy(filters: ProductionStopItemFilters): Prisma.ProductionStop
function buildWhere(filters: ProductionStopItemFilters): Prisma.ProductionStopDemandItemWhereInput {
const conditions: Prisma.ProductionStopDemandItemWhereInput[] = []
const keyword = filters.keyword?.trim()
if (keyword) {
const searchTerms = parseProductionStopSearchTerms(filters.keyword ?? '')
if (searchTerms.length) {
conditions.push({
OR: searchTerms.map((term) => ({
OR: [
{ barcode: { contains: keyword, mode: 'insensitive' } },
{ spu: { contains: keyword, mode: 'insensitive' } },
{ sku: { contains: keyword, mode: 'insensitive' } },
{ productName: { contains: keyword, mode: 'insensitive' } },
{ supplierName: { contains: keyword, mode: 'insensitive' } },
{ purchaseOwner: { contains: keyword, mode: 'insensitive' } },
{ replaceBarcode: { contains: keyword, mode: 'insensitive' } },
{ demand: { is: { demandNo: { contains: keyword, mode: 'insensitive' } } } },
{ demand: { is: { requesterName: { contains: keyword, mode: 'insensitive' } } } },
{ barcode: { contains: term, mode: 'insensitive' } },
{ spu: { contains: term, mode: 'insensitive' } },
{ sku: { contains: term, mode: 'insensitive' } },
{ productName: { contains: term, mode: 'insensitive' } },
{ supplierName: { contains: term, mode: 'insensitive' } },
{ purchaseOwner: { contains: term, mode: 'insensitive' } },
{ replaceBarcode: { contains: term, mode: 'insensitive' } },
{ demand: { is: { demandNo: { contains: term, mode: 'insensitive' } } } },
{ demand: { is: { requesterName: { contains: term, mode: 'insensitive' } } } },
],
})),
})
}
if (filters.confirmationStatus) {
@ -384,7 +408,13 @@ function buildWhere(filters: ProductionStopItemFilters): Prisma.ProductionStopDe
} else if (filters.demandStatus) {
conditions.push({ demand: { is: { demandStatus: filters.demandStatus } } })
}
if (filters.reasonCategory) conditions.push({ demand: { is: { reasonCategory: filters.reasonCategory } } })
if (filters.reasonCategory) {
conditions.push({
demand: {
is: { reasonCategory: { contains: filters.reasonCategory, mode: 'insensitive' } },
},
})
}
if (filters.purchaseOwner) {
conditions.push({ purchaseOwner: { contains: filters.purchaseOwner, mode: 'insensitive' } })
}
@ -393,6 +423,60 @@ function buildWhere(filters: ProductionStopItemFilters): Prisma.ProductionStopDe
demand: { is: { requesterName: { contains: filters.requesterName, mode: 'insensitive' } } },
})
}
for (const key of PRODUCTION_STOP_ITEM_TEXT_FILTER_KEYS) {
const value = filters.fieldFilters?.[key]?.trim()
if (!value) continue
if (isProductionStopDemandTextField(key)) {
conditions.push({
demand: { is: { [key]: { contains: value, mode: 'insensitive' } } },
} as Prisma.ProductionStopDemandItemWhereInput)
} else {
conditions.push({
[key]: { contains: value, mode: 'insensitive' },
} as Prisma.ProductionStopDemandItemWhereInput)
}
}
if (filters.barcodeCreatedAt) {
const start = new Date(`${filters.barcodeCreatedAt}T00:00:00+08:00`)
const end = new Date(`${filters.barcodeCreatedAt}T23:59:59.999+08:00`)
if (!Number.isNaN(start.getTime()) && !Number.isNaN(end.getTime())) {
conditions.push({ barcodeCreatedAt: { gte: start, lte: end } })
}
}
if (filters.barcodeCreatedFrom || filters.barcodeCreatedTo) {
const range: { gte?: Date; lte?: Date } = {}
if (filters.barcodeCreatedFrom) {
range.gte = new Date(`${filters.barcodeCreatedFrom}T00:00:00+08:00`)
}
if (filters.barcodeCreatedTo) {
range.lte = new Date(`${filters.barcodeCreatedTo}T23:59:59.999+08:00`)
}
conditions.push({ barcodeCreatedAt: range })
}
for (const key of PRODUCTION_STOP_ITEM_NUMERIC_FILTER_KEYS) {
const filter = filters.numericFilters?.[key]
if (!filter || !Number.isFinite(filter.value)) continue
const numericField = `${key}Value` as const
conditions.push({
[numericField]: { [filter.op]: filter.value },
} as Prisma.ProductionStopDemandItemWhereInput)
}
for (const key of PRODUCTION_STOP_ITEM_MULTI_FILTER_KEYS) {
const values = filters.multiFilters?.[key]
if (!values?.length) continue
const clean = values.map((value) => String(value)).slice(0, 100)
if (
key === 'requesterName'
|| key === 'reasonCategory'
|| isProductionStopDemandTextField(key)
|| key === 'confirmationStatus'
|| key === 'demandStatus'
) {
conditions.push({ demand: { is: { [key]: { in: clean } } } })
} else {
conditions.push({ [key]: { in: clean } } as Prisma.ProductionStopDemandItemWhereInput)
}
}
if (filters.dateFrom || filters.dateTo) {
// 需求时间按北京时间区间换算成 UTC 再比较(存储口径为 UTC)。
const range: { gte?: Date; lte?: Date } = {}
@ -411,6 +495,23 @@ export interface ProductionStopDemandRepository {
purchaseOwners: string[]
requesterNames: string[]
}>
getDemandAnalytics(input: {
from: Date
to: Date
granularity: 'day' | 'week' | 'month'
filters?: Omit<
ProductionStopItemFilters,
'page' | 'pageSize' | 'sortKey' | 'sortDirection' | 'dateFrom' | 'dateTo'
>
}): Promise<{
total: number
series: Array<{ bucket: string; count: number }>
byDepartment: Array<{ key: string; count: number }>
byRequester: Array<{ key: string; count: number }>
byReasonCategory: Array<{ key: string; count: number }>
byDemandStatus: Array<{ status: string; count: number }>
byConfirmationStatus: Array<{ status: string; count: number }>
}>
findArchivedProfiles(barcodes: string[]): Promise<Map<string, ProductionStopArchiveProfile>>
findExistingArchiveCodes(codes: string[]): Promise<Set<string>>
findSuggestionsByBarcodes(barcodes: string[]): Promise<Map<string, string>>
@ -454,6 +555,8 @@ export interface ProductionStopDemandRepository {
}>>
deleteSuggestionConfig(id: string): Promise<boolean>
listSkuSalesBarcodes(skus: string[]): Promise<Map<string, string[]>>
sumSkuSales30dWithBig(skus: string[]): Promise<Map<string, string>>
listDistinctFilterValues(field: string, limit?: number): Promise<string[]>
listActiveProcessBarcodes(barcodes: string[]): Promise<Map<string, { demandNo: string }>>
nextDemandNo(now: Date): Promise<string>
createDemand(input: ProductionStopDemandCreateInput): Promise<{ id: string; demandNo: string }>
@ -469,6 +572,31 @@ export interface ProductionStopDemandRepository {
}): Promise<{ id: string; demandNo: string; reasonCategory: string; reasonDetail: string } | null>
findDemandById(demandId: string): Promise<ProductionStopConfirmationDemandRecord | null>
listOverduePendingDemands(now: Date, limit: number): Promise<ProductionStopConfirmationDemandRecord[]>
listPendingConfirmationDemands(): Promise<Array<{ id: string; demandNo: string }>>
listPendingFlowDemands(): Promise<Array<{ id: string; demandNo: string }>>
findRejectionNotice(demandId: string): Promise<{
id: string
demandNo: string
requesterId: string
requesterName: string
reasons: string
items: Array<{ barcode: string; supplierName: string }>
} | null>
getNotificationSync(kind: string, recipientKey: string): Promise<{
pendingCount: number
snapshot: string
completedAt: Date | null
} | null>
findUserDingtalkIds(userIds: string[]): Promise<Map<string, string>>
upsertNotificationSync(input: {
kind: string
recipientKey: string
recipientName: string
pendingCount: number
snapshot: string
lastSentAt: Date | null
completedAt: Date | null
}): Promise<void>
listConfirmationVotes(demandId: string): Promise<ProductionStopConfirmationVote[]>
upsertConfirmationVote(input: {
demandId: string
@ -585,6 +713,91 @@ class ProductionStopDemandRepositoryClass implements ProductionStopDemandReposit
}
}
/** 领导视图统计:时间趋势 + 部门/需求人/原因/状态分布。 */
async getDemandAnalytics(input: {
from: Date
to: Date
granularity: 'day' | 'week' | 'month'
filters?: Omit<
ProductionStopItemFilters,
'page' | 'pageSize' | 'sortKey' | 'sortDirection' | 'dateFrom' | 'dateTo'
>
}) {
const itemWhere = buildWhere({
...input.filters,
page: 1,
pageSize: 1,
})
const where: Prisma.ProductionStopDemandWhereInput = {
submittedAt: { gte: input.from, lte: input.to },
...(Object.keys(itemWhere).length ? { items: { some: itemWhere } } : {}),
}
const [total, departmentRows, requesterRows, reasonRows, demandStatusRows, confirmationRows, series] =
await Promise.all([
prisma.productionStopDemand.count({ where }),
prisma.productionStopDemand.groupBy({
by: ['department'],
where,
_count: { _all: true },
}),
prisma.productionStopDemand.groupBy({
by: ['requesterName'],
where,
_count: { _all: true },
}),
prisma.productionStopDemand.groupBy({
by: ['reasonCategory'],
where,
_count: { _all: true },
}),
prisma.productionStopDemand.groupBy({
by: ['demandStatus'],
where,
_count: { _all: true },
}),
prisma.productionStopDemand.groupBy({
by: ['confirmationStatus'],
where,
_count: { _all: true },
}),
prisma.$queryRaw<Array<{ bucket: string; count: number }>>(Prisma.sql`
SELECT to_char(
date_trunc(${input.granularity}, "submittedAt" + interval '8 hours'),
'YYYY-MM-DD'
) AS "bucket",
COUNT(*)::int AS "count"
FROM "ProductionStopDemand"
WHERE "submittedAt" >= ${input.from} AND "submittedAt" <= ${input.to}
GROUP BY 1
ORDER BY 1
`),
])
const toDistribution = (rows: Array<Record<string, unknown>>, key: string) =>
rows
.map((row) => ({
key: text(row[key] as string | null | undefined) || '未填写',
count: Number((row._count as { _all?: number } | undefined)?._all ?? 0),
}))
.sort((a, b) => b.count - a.count)
return {
total,
series: series.map((row) => ({ bucket: text(row.bucket), count: Number(row.count) })),
byDepartment: toDistribution(departmentRows as Array<Record<string, unknown>>, 'department'),
byRequester: toDistribution(requesterRows as Array<Record<string, unknown>>, 'requesterName'),
byReasonCategory: toDistribution(reasonRows as Array<Record<string, unknown>>, 'reasonCategory'),
byDemandStatus: demandStatusRows.map((row) => ({
status: text(row.demandStatus),
count: row._count._all,
})),
byConfirmationStatus: confirmationRows.map((row) => ({
status: text(row.confirmationStatus),
count: row._count._all,
})),
}
}
async findArchivedProfiles(barcodes: string[]) {
const result = new Map<string, ProductionStopArchiveProfile>()
const targets = [...new Set(barcodes.map((barcode) => barcode.trim().toUpperCase()).filter(Boolean))]
@ -602,13 +815,19 @@ class ProductionStopDemandRepositoryClass implements ProductionStopDemandReposit
brand: string | null
productTag: string | null
opsDirector: string | null
sales30dWithBig: string | null
sabcRating: string | null
sabcRealPerformance: string | null
image: string | null
businessCreatedAt: Date | null
}>(
`SELECT "barcode", "categoryL1", "categoryL2", "spu", "sku", "productName",
"productStatus", "supplierName", "purchaseOwner", "brand", "productTag", "businessCreatedAt"
, "opsDirector"
FROM ${ARCHIVE_TABLE}
WHERE UPPER("barcode") = ANY($1::text[])`,
`SELECT a."barcode", a."categoryL1", a."categoryL2", a."spu", a."sku", a."productName",
a."productStatus", a."supplierName", a."purchaseOwner", a."brand", a."productTag",
a."businessCreatedAt", a."opsDirector", a."sales30dWithBig", a."sabcRating", a."image",
p."realPerformance"::text AS "sabcRealPerformance"
FROM ${ARCHIVE_TABLE} AS a
LEFT JOIN ${ARCHIVE_PERFORMANCE_TABLE} AS p ON p."barcode" = a."barcode"
WHERE UPPER(a."barcode") = ANY($1::text[])`,
[targets],
)
for (const row of rows.rows) {
@ -628,6 +847,10 @@ class ProductionStopDemandRepositoryClass implements ProductionStopDemandReposit
productTag: text(row.productTag),
businessCreatedAt: row.businessCreatedAt ?? null,
opsDirector: text(row.opsDirector),
sales30dWithBig: text(row.sales30dWithBig),
sabcRating: text(row.sabcRating),
sabcRealPerformance: text(row.sabcRealPerformance),
image: text(row.image),
})
}
return result
@ -848,6 +1071,50 @@ class ProductionStopDemandRepositoryClass implements ProductionStopDemandReposit
return result
}
/** 表头漏斗筛选项:按字段取去重后的可选值(最多 500 个)。 */
async listDistinctFilterValues(field: string, limit = 200) {
const safeLimit = Math.min(Math.max(1, Number(limit) || 200), 500)
const isDemandField = field === 'requesterName'
|| field === 'reasonCategory'
|| isProductionStopDemandTextField(field)
if (!isDemandField && !isProductionStopItemTextFilterKey(field)) return []
const table = isDemandField ? 'ProductionStopDemand' : 'ProductionStopDemandItem'
const column = Prisma.raw(`"${field}"`)
const rows = await prisma.$queryRaw<Array<{ value: string | null }>>(Prisma.sql`
SELECT DISTINCT ${column} AS "value"
FROM ${Prisma.raw(`"${table}"`)}
WHERE ${column} IS NOT NULL AND ${column} <> ''
ORDER BY "value" ASC
LIMIT ${safeLimit}
`)
return rows.map((row) => text(row.value)).filter(Boolean)
}
/**
* 含大单近30日日销(SKU 维度):按货号汇总档案库中同 SKU 全部条码的「含大单近30日日销」。
* 需求口径:条码维度取该条码行自身的值,SKU 维度取同 SKU 求和。
*/
async sumSkuSales30dWithBig(skus: string[]) {
const result = new Map<string, string>()
const targets = [...new Set(skus.map((sku) => sku.trim().toUpperCase()).filter(Boolean))]
if (!targets.length) return result
const rows = await runArchiveQuery<{ sku: string | null; total: string | null }>(
`SELECT UPPER("sku") AS sku,
SUM(CASE WHEN "sales30dWithBig" ~ '^-?[0-9]+(\\.[0-9]+)?$' THEN "sales30dWithBig"::numeric ELSE 0 END) AS total
FROM ${ARCHIVE_TABLE}
WHERE UPPER("sku") = ANY($1::text[])
GROUP BY UPPER("sku")`,
[targets],
)
for (const row of rows.rows) {
const key = text(row.sku).toUpperCase()
if (!key) continue
const total = Number(row.total ?? '0')
result.set(key, Number.isFinite(total) ? total.toFixed(2) : '0.00')
}
return result
}
/** 已提交且未被驳回的条码(用于「已在停产流程中」提醒,当前仅提示不拦截)。 */
async listActiveProcessBarcodes(barcodes: string[]) {
const result = new Map<string, { demandNo: string }>()
@ -965,6 +1232,97 @@ class ProductionStopDemandRepositoryClass implements ProductionStopDemandReposit
return rows.map(toConfirmationDemandRecord)
}
/** 待确认需求(通知中心「是否同意停产确认」待办条数口径)。 */
async listPendingConfirmationDemands() {
return prisma.productionStopDemand.findMany({
where: { confirmationStatus: 'PENDING' },
select: { id: true, demandNo: true },
orderBy: { submittedAt: 'asc' },
})
}
/** 已确认待走流程需求(通知中心「待走停产流程」待办条数口径)。 */
async listPendingFlowDemands() {
return prisma.productionStopDemand.findMany({
where: { demandStatus: 'CONFIRMED_WAIT_FLOW' },
select: { id: true, demandNo: true },
orderBy: { submittedAt: 'asc' },
})
}
/** 需求驳回通知所需的供应商、条码与驳回原因。 */
async findRejectionNotice(demandId: string) {
const row = await prisma.productionStopDemand.findUnique({
where: { id: demandId },
select: {
id: true,
demandNo: true,
requesterId: true,
requesterName: true,
confirmationReasons: true,
items: { select: { barcode: true, supplierName: true }, orderBy: { createdAt: 'asc' } },
},
})
if (!row) return null
return {
id: row.id,
demandNo: row.demandNo,
requesterId: row.requesterId,
requesterName: row.requesterName,
reasons: row.confirmationReasons,
items: row.items.map((item) => ({
barcode: text(item.barcode),
supplierName: text(item.supplierName),
})),
}
}
async getNotificationSync(kind: string, recipientKey: string) {
const row = await prisma.productionStopNotificationSync.findUnique({
where: { kind_recipientKey: { kind, recipientKey } },
select: { pendingCount: true, snapshot: true, completedAt: true },
})
return row ?? null
}
async upsertNotificationSync(input: {
kind: string
recipientKey: string
recipientName: string
pendingCount: number
snapshot: string
lastSentAt: Date | null
completedAt: Date | null
}) {
await prisma.productionStopNotificationSync.upsert({
where: { kind_recipientKey: { kind: input.kind, recipientKey: input.recipientKey } },
create: input,
update: {
recipientName: input.recipientName,
pendingCount: input.pendingCount,
snapshot: input.snapshot,
lastSentAt: input.lastSentAt,
completedAt: input.completedAt,
},
})
}
/** 已绑定钉钉的账号 → dingtalkUserId(通知中心优先使用 userId 收件)。 */
async findUserDingtalkIds(userIds: string[]) {
const targets = [...new Set(userIds.map((value) => value.trim()).filter(Boolean))]
const result = new Map<string, string>()
if (!targets.length) return result
const rows = await prisma.user.findMany({
where: { id: { in: targets } },
select: { id: true, dingtalkUserId: true },
})
for (const row of rows) {
const userId = text(row.dingtalkUserId)
if (userId) result.set(row.id, userId)
}
return result
}
async listConfirmationVotes(demandId: string) {
const rows = await prisma.productionStopDemandConfirmationVote.findMany({
where: { demandId },

View File

@ -6,6 +6,7 @@ import {
} from '@/server/repositories/production-stop-demands.repository'
import { dingtalkTodoService } from '@/server/services/dingtalk-todo.service'
import { operationLogService } from '@/server/services/operation-log.service'
import { productionStopNotificationsService } from '@/server/services/production-stop-notifications.service'
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
import { productionStopDemandsService } from './production-stop-demands.service'
@ -17,6 +18,15 @@ jest.mock('@/server/services/dingtalk-todo.service', () => ({
dingtalkTodoService: { isEnabled: jest.fn(() => false), createTask: jest.fn() },
}))
jest.mock('@/server/services/production-stop-notifications.service', () => ({
productionStopNotificationsService: {
isEnabled: jest.fn(() => false),
refreshAll: jest.fn(async () => ({ sent: 0, completed: 0, failed: 0 })),
refreshAllThrottled: jest.fn(async () => undefined),
notifyRejected: jest.fn(async () => false),
},
}))
jest.mock('@/server/services/supply-chain-permission-settings.service', () => ({
supplyChainPermissionSettingsService: {
getUserAccess: jest.fn(async () => ({ view: true, download: true, edit: true })),
@ -45,6 +55,7 @@ jest.mock('@/server/repositories/production-stop-demands.repository', () => {
upsertSuggestionConfigs: jest.fn(),
deleteSuggestionConfig: jest.fn(),
listSkuSalesBarcodes: jest.fn(),
sumSkuSales30dWithBig: jest.fn(),
listActiveProcessBarcodes: jest.fn(),
nextDemandNo: jest.fn(),
createDemand: jest.fn(),
@ -83,6 +94,12 @@ const todoService = dingtalkTodoService as unknown as {
isEnabled: jest.Mock
createTask: jest.Mock
}
const notificationsService = productionStopNotificationsService as unknown as {
isEnabled: jest.Mock
refreshAll: jest.Mock
refreshAllThrottled: jest.Mock
notifyRejected: jest.Mock
}
const permissionService = supplyChainPermissionSettingsService as unknown as {
getUserAccess: jest.Mock
}
@ -93,6 +110,10 @@ function archiveProfile(overrides: Partial<{
barcode: string
sku: string
productStatus: string
sales30dWithBig: string
sabcRating: string
sabcRealPerformance: string
image: string
}> = {}) {
return {
barcode: 'A1',
@ -107,6 +128,10 @@ function archiveProfile(overrides: Partial<{
brand: '品牌1',
productTag: '仓库-崔桥工厂仓',
businessCreatedAt: new Date('2026-01-01T00:00:00.000Z'),
sales30dWithBig: '0.00',
sabcRating: '',
sabcRealPerformance: '',
image: '',
...overrides,
}
}
@ -196,6 +221,7 @@ function itemRowFixture(overrides: Partial<ProductionStopItemRow> = {}): Product
brand: '',
productTag: '',
productLabel: '',
image: '',
postStopSkuStatus: '',
sales30dWithBig: '',
sales30dWithBigSku: '',
@ -223,6 +249,7 @@ beforeEach(() => {
repo.findOwnerDepartmentByName.mockResolvedValue('')
repo.listActiveProcessBarcodes.mockResolvedValue(new Map())
repo.listSkuSalesBarcodes.mockResolvedValue(new Map())
repo.sumSkuSales30dWithBig.mockResolvedValue(new Map())
repo.nextDemandNo.mockResolvedValue('TC20260930-001')
repo.listConfirmationVotes.mockResolvedValue([])
repo.listActiveApprovers.mockResolvedValue([])
@ -230,6 +257,9 @@ beforeEach(() => {
repo.findApproverByUserId.mockResolvedValue(null)
repo.findExistingArchiveCodes.mockResolvedValue(new Set())
repo.findSuggestionsByBarcodes.mockResolvedValue(new Map())
repo.updateDemandConfirmation.mockResolvedValue(undefined)
notificationsService.isEnabled.mockReturnValue(false)
notificationsService.refreshAll.mockResolvedValue({ sent: 0, completed: 0, failed: 0 })
todoService.isEnabled.mockReturnValue(false)
permissionService.getUserAccess.mockResolvedValue({ view: true, download: true, edit: true })
})
@ -251,11 +281,16 @@ describe('停产需求:创建', () => {
it('只写入销售条码,非销售条码返回异常,并在流程中条码给出提醒', async () => {
repo.findReasonCategoryByName.mockResolvedValue({ id: 'c1', name: '滞销款', isActive: true })
repo.findArchivedProfiles.mockResolvedValue(new Map([
['A1', archiveProfile()],
['A1', archiveProfile({
sales30dWithBig: '5.00',
sabcRating: 'B',
image: 'https://static.jiansun.vip:3333/Resource/SkuImg/A1.jpg',
})],
['B2', archiveProfile({ barcode: 'B2', sku: 'SKU2', productStatus: '停产' })],
]))
repo.listActiveProcessBarcodes.mockResolvedValue(new Map([['A1', { demandNo: 'TC20260930-001' }]]))
repo.listSkuSalesBarcodes.mockResolvedValue(new Map([['SKU1', ['A1', 'A9']]]))
repo.sumSkuSales30dWithBig.mockResolvedValue(new Map([['SKU1', '12.34']]))
repo.findOwnerDepartmentByName.mockResolvedValue('品类一部')
repo.nextDemandNo.mockResolvedValue('TC20260930-002')
repo.createDemand.mockResolvedValue({ id: 'demand1', demandNo: 'TC20260930-002' })
@ -284,9 +319,60 @@ describe('停产需求:创建', () => {
productStatus: '销售',
purchaseOwner: '采购1',
postStopSkuStatus: '销售',
sales30dWithBig: '5.00',
sales30dWithBigSku: '12.34',
sales30dWithBigValue: '5.00',
sales30dWithBigSkuValue: '12.34',
productLabel: 'B',
image: 'https://static.jiansun.vip:3333/Resource/SkuImg/A1.jpg',
}),
],
}))
expect(repo.sumSkuSales30dWithBig).toHaveBeenCalledWith(['SKU1'])
})
it('产品标签按档案口径写入:SABC 标签优先,未打标兜底到业绩/无业绩', async () => {
repo.findReasonCategoryByName.mockResolvedValue({ id: 'c1', name: '滞销款', isActive: true })
repo.findArchivedProfiles.mockResolvedValue(new Map([
['A1', archiveProfile({ sabcRating: 'A' })],
['C3', archiveProfile({
barcode: 'C3',
sku: 'SKU3',
sales30dWithBig: '1.00',
sabcRealPerformance: '66.6000',
})],
['D4', archiveProfile({ barcode: 'D4', sku: 'SKU4' })],
]))
repo.createDemand.mockResolvedValue({ id: 'demand1', demandNo: 'TC20260930-001' })
await productionStopDemandsService.createDemand(
{ reasonCategory: '滞销款', barcodesText: 'A1\nC3\nD4' },
user,
)
const labels = repo.createDemand.mock.calls.map(([input]) => input.items[0].productLabel)
expect(labels).toEqual(['A', '有业绩未打标', '无业绩未打标'])
})
it('启用通知中心后提单走汇总推送,不再调用钉钉待办', async () => {
notificationsService.isEnabled.mockReturnValue(true)
notificationsService.refreshAll.mockResolvedValue({ sent: 1, completed: 0, failed: 0 })
todoService.isEnabled.mockReturnValue(true)
repo.findReasonCategoryByName.mockResolvedValue({ id: 'c1', name: '滞销款', isActive: true })
repo.findArchivedProfiles.mockResolvedValue(new Map([['A1', archiveProfile()]]))
repo.createDemand.mockResolvedValue({ id: 'demand1', demandNo: 'TC20260930-001' })
await productionStopDemandsService.createDemand(
{ reasonCategory: '滞销款', barcodesText: 'A1' },
user,
)
expect(notificationsService.refreshAll).toHaveBeenCalled()
expect(todoService.createTask).not.toHaveBeenCalled()
expect(repo.updateDemandConfirmation).toHaveBeenCalledWith(
'demand1',
expect.objectContaining({ pushMessage: '已通过通知中心推送 1 人' }),
)
})
it('全部条码都不是销售条码时不创建需求', async () => {

View File

@ -2,6 +2,7 @@ import { OperationLogAction, OperationLogStatus } from '@prisma/client'
import { AppError, ErrorCode } from '@/server/core/errors'
import type { AuthUser } from '@/server/core/types'
import {
PRODUCTION_STOP_ANALYTICS_GRANULARITIES,
PRODUCTION_STOP_CONFIRMATION_DECISIONS,
PRODUCTION_STOP_CONFIRMATION_LABELS,
PRODUCTION_STOP_DEFAULT_REASON_CATEGORIES,
@ -16,11 +17,14 @@ import {
PRODUCTION_STOP_REPLACE_TYPES,
parseBarcodeInput,
resolveProductionStopConfirmationDeadline,
resolveProductionStopProductLabel,
type ProductionStopConfirmationApproverOption,
type ProductionStopConfirmationDecision,
type ProductionStopConfirmationInfo,
type ProductionStopConfirmationStatus,
type ProductionStopConfirmationVote,
type ProductionStopAnalytics,
type ProductionStopAnalyticsGranularity,
type ProductionStopFlowOwnerOption,
type ProductionStopItemFilters,
type ProductionStopItemPage,
@ -29,6 +33,7 @@ import {
} from '@/lib/production-stop-demands'
import { operationLogService } from '@/server/services/operation-log.service'
import { dingtalkTodoService } from '@/server/services/dingtalk-todo.service'
import { productionStopNotificationsService } from '@/server/services/production-stop-notifications.service'
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
import { productionStopFlowRepository } from '@/server/repositories/production-stop-flow.repository'
import {
@ -99,6 +104,12 @@ function buildConfirmationRejectReasons(votes: ProductionStopConfirmationVote[])
.join('\n')
}
/** 数值快照:仅接受标准十进制文本,用于数值比较筛选。 */
function numericSnapshot(value: string) {
const normalized = String(value ?? '').trim()
return /^-?\d+(\.\d+)?$/.test(normalized) ? normalized : null
}
function normalizeReasonCategoryName(value: unknown) {
const name = normalizeText(value)
if (!name) throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请填写停产原因分类名称')
@ -156,6 +167,10 @@ class ProductionStopDemandsService {
async listPage(filters: ProductionStopItemFilters): Promise<ProductionStopItemPage> {
await this.ensureReasonCategoryDefaults()
// 通知中心待办兜底同步(5 分钟节流):补发/收口不依赖生产定时任务。
void productionStopNotificationsService.refreshAllThrottled().catch((error) => {
console.error('[production-stop-demands] notification sync failed', error)
})
const result = await productionStopDemandRepository.listItemsPage(filters)
return {
rows: result.rows,
@ -171,6 +186,50 @@ class ProductionStopDemandsService {
}
}
/** 领导视图:按日/周/月统计需求趋势及部门、需求人、状态分布。 */
async getAnalytics(input: {
granularity?: unknown
from?: unknown
to?: unknown
filters?: Omit<
ProductionStopItemFilters,
'page' | 'pageSize' | 'sortKey' | 'sortDirection' | 'dateFrom' | 'dateTo'
>
}): Promise<ProductionStopAnalytics> {
const granularity = normalizeText(input.granularity) as ProductionStopAnalyticsGranularity
if (!(PRODUCTION_STOP_ANALYTICS_GRANULARITIES as readonly string[]).includes(granularity)) {
throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '统计维度不正确')
}
const from = normalizeText(input.from)
const to = normalizeText(input.to)
if (!/^\d{4}-\d{2}-\d{2}$/.test(from) || !/^\d{4}-\d{2}-\d{2}$/.test(to)) {
throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '统计日期范围不正确')
}
const fromDate = new Date(`${from}T00:00:00+08:00`)
const toDate = new Date(`${to}T23:59:59.999+08:00`)
if (Number.isNaN(fromDate.getTime()) || Number.isNaN(toDate.getTime()) || fromDate > toDate) {
throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '统计日期范围不正确')
}
const result = await productionStopDemandRepository.getDemandAnalytics({
from: fromDate,
to: toDate,
granularity,
filters: input.filters,
})
return {
granularity,
from,
to,
total: result.total,
series: result.series,
byDepartment: result.byDepartment,
byRequester: result.byRequester,
byReasonCategory: result.byReasonCategory,
byDemandStatus: result.byDemandStatus as ProductionStopAnalytics['byDemandStatus'],
byConfirmationStatus: result.byConfirmationStatus as ProductionStopAnalytics['byConfirmationStatus'],
}
}
async createDemand(
input: { reasonCategory?: unknown; reasonDetail?: unknown; barcodesText?: unknown },
user: Pick<AuthUser, 'id' | 'name'>,
@ -239,6 +298,10 @@ class ProductionStopDemandsService {
const skuSalesBarcodes = await this.loadArchiveData(() =>
productionStopDemandRepository.listSkuSalesBarcodes(skuCodes),
)
// 含大单近30日日销-SKU 维度:同 SKU 全部条码求和;条码维度的值随档案逐条快照在下面写入。
const skuSales30dWithBig = await this.loadArchiveData(() =>
productionStopDemandRepository.sumSkuSales30dWithBig(skuCodes),
)
const suggestionConfigs = await productionStopDemandRepository.findSuggestionsByBarcodes(
accepted.map((entry) => entry.profile.barcode || entry.barcode),
)
@ -251,6 +314,7 @@ class ProductionStopDemandsService {
// 按条码拆单:一个条码一张需求单,各自独立确认;同一批用 batchNo 串联,便于批量确认与推送聚合。
for (const entry of accepted) {
const demandNo = await productionStopDemandRepository.nextDemandNo(now)
const skuSales30dBig = skuSales30dWithBig.get(entry.profile.sku.trim().toUpperCase()) ?? ''
const created = await productionStopDemandRepository.createDemand({
demandNo,
requesterId: user.id,
@ -272,17 +336,25 @@ class ProductionStopDemandsService {
purchaseOwner: entry.profile.purchaseOwner,
brand: entry.profile.brand,
productTag: entry.profile.productTag,
// 产品标签与含大单近30日日销:档案当前还没有对应数据列,先按空值快照,
// 待产品档案补齐后改为读取 "dataTag" / "sales30dWithBig" 即可自动带上。
productLabel: '',
// 产品标签:SABC 标签优先,未打标按销售状态 × 考核周期真实业绩兜底(提单时快照)。
productLabel: resolveProductionStopProductLabel({
sabcRating: entry.profile.sabcRating,
productStatus: entry.profile.productStatus,
realPerformance: entry.profile.sabcRealPerformance,
}),
// 图片:按条码快照档案库「图片」地址,列表在条码前展示缩略图。
image: entry.profile.image,
barcodeCreatedAt: entry.profile.businessCreatedAt,
postStopSkuStatus: resolvePostStopSkuStatus(
entry.profile.barcode || entry.barcode,
entry.profile.sku,
skuSalesBarcodes,
),
sales30dWithBig: '',
sales30dWithBigSku: '',
// 含大单近30日日销:条码维度取档案该条码行的值,SKU 维度取同 SKU 全部条码求和(提单时快照)。
sales30dWithBig: entry.profile.sales30dWithBig,
sales30dWithBigSku: skuSales30dBig,
sales30dWithBigValue: numericSnapshot(entry.profile.sales30dWithBig),
sales30dWithBigSkuValue: numericSnapshot(skuSales30dBig),
// 以下两列业务口径待补充(需求文档标注“逻辑待补充”),先留空占位。
opsDirector: entry.profile.opsDirector,
topSalesOpsDirector: '',
@ -311,9 +383,39 @@ class ProductionStopDemandsService {
}
}
/** 通知中心待办同步:失败只记录日志,不阻塞业务。 */
private refreshStopNotifications() {
void productionStopNotificationsService.refreshAll().catch((error) => {
console.error('[production-stop-demands] notification sync failed', error)
})
}
/** 需求驳回通知需求人(供应商 + 条码 + 驳回原因)。 */
private notifyRejectedDemand(demandId: string) {
void productionStopNotificationsService.notifyRejected(demandId).catch((error) => {
console.error('[production-stop-demands] rejection notification failed', error)
})
}
/** 同批需求共用一个钉钉待办(避免按条码逐条推送刷屏)。 */
private async dispatchBatchPush(demandIds: string[], deadline: Date | null, batchNo: string) {
if (!demandIds.length) return { pushed: 0, message: '没有可推送的需求' }
if (productionStopNotificationsService.isEnabled()) {
// 通知中心模式:按「待确认条数」汇总推送,避免按批次刷屏。
const result = await productionStopNotificationsService.refreshAll()
const message = result.sent
? `已通过通知中心推送 ${result.sent} 人`
: result.failed
? `通知中心推送失败 ${result.failed} 人,可在明细里重新推送`
: '通知中心待办已是最新状态'
await Promise.all(demandIds.map((demandId) =>
productionStopDemandRepository.updateDemandConfirmation(demandId, {
pushedAt: result.sent ? new Date() : null,
pushMessage: message,
}),
))
return { pushed: result.sent, message }
}
const activeApprovers = await productionStopDemandRepository.listActiveApprovers()
const approvers = activeApprovers.filter((approver) => approver.dingtalkUnionId)
let message = ''
@ -453,6 +555,7 @@ class ProductionStopDemandsService {
})
marked.push(demand.demandNo)
}
if (marked.length) this.refreshStopNotifications()
return { marked, skipped }
}
@ -544,6 +647,7 @@ class ProductionStopDemandsService {
deletedDemands: result.deletedDemands,
},
})
this.refreshStopNotifications()
return result
}
@ -565,6 +669,8 @@ class ProductionStopDemandsService {
reasons: buildConfirmationRejectReasons(rejected),
})
await this.syncDemandStatusFromConfirmation(demandId, 'REJECTED')
this.notifyRejectedDemand(demandId)
this.refreshStopNotifications()
return 'REJECTED'
}
const approvers = await productionStopDemandRepository.listActiveApprovers()
@ -576,6 +682,7 @@ class ProductionStopDemandsService {
reasons: '',
})
await this.syncDemandStatusFromConfirmation(demandId, 'AGREED')
this.refreshStopNotifications()
return 'AGREED'
}
return 'PENDING'
@ -605,8 +712,10 @@ class ProductionStopDemandsService {
reasons: rejected.length ? buildConfirmationRejectReasons(rejected) : '',
})
await this.syncDemandStatusFromConfirmation(demand.id, rejected.length ? 'REJECTED' : 'AGREED')
if (rejected.length) this.notifyRejectedDemand(demand.id)
finalized += 1
}
if (finalized) this.refreshStopNotifications()
return finalized
}
@ -627,7 +736,10 @@ class ProductionStopDemandsService {
/** 需求驳回后二次修改并重新提交确认:清空本轮确认票,重新计算截止时间并推送。 */
async resubmitDemand(demandId: string, user: AuthUser): Promise<ProductionStopConfirmationInfo> {
const access = await supplyChainPermissionSettingsService.getUserAccess(user, 'productionStopDemands')
const access = await supplyChainPermissionSettingsService.getUserAccess(
user,
'productionStopDemands',
)
if (!access.edit) {
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有该供应链功能的权限')
}
@ -649,6 +761,7 @@ class ProductionStopDemandsService {
status: 'PENDING_CONFIRMATION',
note: '驳回后重新提交确认',
})
this.refreshStopNotifications()
void this.dispatchConfirmationPush(demandId).catch((error) => {
console.error('[production-stop-demands] resubmit push failed', error)
})
@ -689,6 +802,7 @@ class ProductionStopDemandsService {
status: 'CONFIRMED_WAIT_FLOW',
note: `流程负责人强制确认停产:${note}`,
})
this.refreshStopNotifications()
return this.getConfirmationInfo(demandId, user)
}
@ -720,6 +834,7 @@ class ProductionStopDemandsService {
status: 'FLOW_IN_APPROVAL',
note: note || '流程负责人已发起停产流程,等待审批结果(次日自动校验流程状态)',
})
this.refreshStopNotifications()
return this.getConfirmationInfo(demandId, user)
}
@ -769,6 +884,7 @@ class ProductionStopDemandsService {
status: 'REJECTED',
note: `PLM 流程状态为拒绝(${hit?.title ?? '条码恢复流程'}),自动识别为需求驳回`,
})
this.notifyRejectedDemand(demand.id)
rejected += 1
continue
}
@ -800,6 +916,7 @@ class ProductionStopDemandsService {
}
}
if (rejected || approved) this.refreshStopNotifications()
return { checked: demands.length, rejected, approved, failed: false }
}
@ -1095,6 +1212,19 @@ class ProductionStopDemandsService {
if (!demand) {
throw new AppError(ErrorCode.PRODUCTION_STOP_DEMAND_NOT_FOUND, '停产需求不存在')
}
if (productionStopNotificationsService.isEnabled()) {
const result = await productionStopNotificationsService.refreshAll()
const message = result.sent
? `已通过通知中心推送 ${result.sent} 人`
: result.failed
? `通知中心推送失败 ${result.failed} 人`
: '通知中心待办已是最新状态'
await productionStopDemandRepository.updateDemandConfirmation(demandId, {
pushedAt: result.sent ? new Date() : null,
pushMessage: message,
})
return { pushed: result.sent, message }
}
const activeApprovers = await productionStopDemandRepository.listActiveApprovers()
if (!activeApprovers.length) {
const message = '还没有配置停产确认人,无法推送'

View File

@ -0,0 +1,179 @@
import { ProductionStopNotificationsService } from './production-stop-notifications.service'
const actionUrl = 'https://cp.jiansun.vip/supply-chain/management/production-stop-demands'
const wrappedActionUrl = `dingtalk://dingtalkclient/page/link?url=${encodeURIComponent(actionUrl)}&pc_slide=false`
process.env.NEXT_PUBLIC_APP_URL = 'https://cp.jiansun.vip'
type SyncRow = {
kind: string
recipientKey: string
recipientName: string
pendingCount: number
snapshot: string
lastSentAt: Date | null
completedAt: Date | null
}
type DemandRef = { id: string; demandNo: string }
function createHarness(initialPending: DemandRef[] = []) {
let pending = [...initialPending]
let flow: DemandRef[] = []
const syncRows = new Map<string, SyncRow>()
const sends: Array<Record<string, unknown>> = []
const rejectionNotice: {
value: Awaited<ReturnType<typeof fakeRepository.findRejectionNotice>>
} = { value: null }
const fakeRepository = {
listPendingConfirmationDemands: jest.fn(async () => pending),
listPendingFlowDemands: jest.fn(async () => flow),
listActiveApprovers: jest.fn(async () => [{ id: 'a1', userName: '张三' }]),
listActiveFlowOwners: jest.fn(async () => [{ id: 'f1', userName: '李四' }]),
getNotificationSync: jest.fn(async (kind: string, recipientKey: string) =>
syncRows.get(`${kind}:${recipientKey}`) ?? null),
upsertNotificationSync: jest.fn(async (row: SyncRow) => {
syncRows.set(`${row.kind}:${row.recipientKey}`, row)
}),
findUserDingtalkIds: jest.fn(async (): Promise<Map<string, string>> => new Map()),
findRejectionNotice: jest.fn(async (): Promise<{
id: string
demandNo: string
requesterId: string
requesterName: string
reasons: string
items: Array<{ barcode: string; supplierName: string }>
} | null> => rejectionNotice.value),
}
const client = {
send: jest.fn(async (input: Record<string, unknown>) => {
sends.push(input)
return { id: `notification-${sends.length}`, status: 'pending' }
}),
}
const service = new ProductionStopNotificationsService({
repository: fakeRepository as never,
createClient: () => client,
})
return {
service,
client,
repository: fakeRepository,
sends,
syncRows,
setPending: (rows: DemandRef[]) => { pending = rows },
setFlow: (rows: DemandRef[]) => { flow = rows },
setRejectionNotice: (value: typeof rejectionNotice.value) => { rejectionNotice.value = value },
}
}
describe('停产需求通知中心编排', () => {
it('按待确认条数推送,快照未变化时不重复推送', async () => {
const harness = createHarness([
{ id: 'd1', demandNo: 'TC20261020-001' },
{ id: 'd2', demandNo: 'TC20261020-002' },
])
const first = await harness.service.refreshAll()
expect(first.sent).toBe(1)
expect(harness.sends[0]).toMatchObject({
recipientAlias: '张三',
title: '是否同意停产确认',
})
expect(String(harness.sends[0].content)).toContain('**待办条数:** 2 条')
expect(String(harness.sends[0].content))
.toContain(`**处理链接:** [点击进入停产需求管理](${wrappedActionUrl})`)
expect(harness.sends[0].actionUrl).toBe(wrappedActionUrl)
expect(String(harness.sends[0].idempotencyKey)).toMatch(/^production_stop_confirm:a1:/)
const second = await harness.service.refreshAll()
expect(second.sent).toBe(0)
expect(harness.sends).toHaveLength(1)
})
it('待办清零时推送一次「已全部处理完成」', async () => {
const harness = createHarness([{ id: 'd1', demandNo: 'TC20261020-001' }])
await harness.service.refreshAll()
harness.setPending([])
const result = await harness.service.refreshAll()
expect(result.completed).toBe(1)
expect(harness.sends).toHaveLength(2)
expect(String(harness.sends[1].content)).toContain('**待办条数:** 0 条(已全部处理完成)')
expect(String(harness.sends[1].idempotencyKey)).toContain(':done:')
})
it('待走流程待办只统计已确认待走流程并推送给流程负责人', async () => {
const harness = createHarness([])
harness.setFlow([{ id: 'd3', demandNo: 'TC20261020-003' }])
const result = await harness.service.refreshAll()
expect(result.sent).toBe(1)
expect(harness.sends[0]).toMatchObject({
recipientAlias: '李四',
title: '待走停产流程',
content: [
'**待办条数:** 1 条',
'',
`**处理链接:** [点击进入停产需求管理](${wrappedActionUrl})`,
].join('\n'),
})
})
it('需求驳回通知需求人,内容包含供应商、条码与驳回原因', async () => {
const harness = createHarness([])
harness.setRejectionNotice({
id: 'd1',
demandNo: 'TC20261020-001',
requesterId: 'u-requester',
requesterName: '王五',
reasons: '张三:库存仍有 300 件',
items: [{ barcode: 'A1', supplierName: '供应商A' }],
})
const sent = await harness.service.notifyRejected('d1')
expect(sent).toBe(true)
expect(harness.sends[0]).toMatchObject({
recipientAlias: '王五',
title: '需求驳回同频',
})
expect(String(harness.sends[0].content)).toBe([
'**供应商:** 供应商A',
'**条码:** A1',
'**驳回原因:** 张三:库存仍有 300 件',
'',
`**处理链接:** [点击进入停产需求管理](${wrappedActionUrl})`,
].join('\n'))
expect(String(harness.sends[0].idempotencyKey)).toBe('production_stop_rejected:TC20261020-001:v1')
})
it('账号已绑定钉钉 userId 时优先按 userId 发送,不再依赖花名', async () => {
const harness = createHarness([{ id: 'd1', demandNo: 'TC20261020-001' }])
harness.repository.listActiveApprovers.mockResolvedValue([
{ id: 'a1', userId: 'u1', userName: '张三' },
])
harness.repository.findUserDingtalkIds.mockResolvedValue(new Map([['u1', 'ding-user-1']]))
await harness.service.refreshAll()
expect(harness.sends[0]).toMatchObject({ recipientUserId: 'ding-user-1' })
expect(harness.sends[0].recipientAlias).toBeUndefined()
})
it('发送失败时不更新待办快照,下一次同步会重试', async () => {
const harness = createHarness([{ id: 'd1', demandNo: 'TC20261020-001' }])
harness.client.send.mockRejectedValueOnce(new Error('network down'))
const failed = await harness.service.refreshAll()
expect(failed.failed).toBe(1)
expect(harness.syncRows.size).toBe(0)
const retried = await harness.service.refreshAll()
expect(retried.sent).toBe(1)
expect(harness.syncRows.size).toBe(1)
})
})

View File

@ -0,0 +1,273 @@
// 停产需求管理 → 由壹点统一通知中心的推送编排。
// - 停产确认待办 / 待走流程待办:按接收人维护「待办快照」,集合变化才推送新卡片;
// 待办清零时推送一次「已全部处理完成」(通知中心机器人消息本身不支持勾选完成)。
// - 需求驳回:按需求单推送一次,内容为供应商 + 条码 + 驳回原因。
import { createHash } from 'node:crypto'
import { buildDingTalkFullscreenUrl } from '@/lib/dingtalk-link'
import {
buildProductionStopConfirmationPendingMessage,
buildProductionStopFlowPendingMessage,
buildProductionStopPendingCompletedMessage,
buildProductionStopRejectedMessage,
type ProductionStopPendingNotificationKind,
} from '@/lib/production-stop-notifications'
import {
NotificationCenterClient,
NotificationCenterRequestError,
} from '@/server/integrations/notification-center/client'
import type { NotificationCenterSendInput } from '@/server/integrations/notification-center/types'
import { productionStopDemandRepository } from '@/server/repositories/production-stop-demands.repository'
type NotificationSender = {
send(input: NotificationCenterSendInput): Promise<unknown>
}
type NotificationRepository = Pick<
typeof productionStopDemandRepository,
| 'listPendingConfirmationDemands'
| 'listPendingFlowDemands'
| 'listActiveApprovers'
| 'listActiveFlowOwners'
| 'getNotificationSync'
| 'upsertNotificationSync'
| 'findRejectionNotice'
| 'findUserDingtalkIds'
>
const REFRESH_THROTTLE_MS = 5 * 60 * 1000
let lastRefreshStartedAt = 0
function appBaseUrl() {
return (process.env.NEXT_PUBLIC_APP_URL || 'https://cp.jiansun.vip').replace(/\/+$/, '')
}
/**
* 兼容两种变量名:接入文档使用 YYD_NOTIFICATION_PROJECT_*,
* 管理面板生成的配置使用 YYD_NOTIFICATION_CENTER_PROJECT_*。
*/
function readNotificationCenterEnv() {
return {
baseUrl: process.env.YYD_NOTIFICATION_CENTER_URL?.trim() ?? '',
projectCode: process.env.YYD_NOTIFICATION_PROJECT_CODE?.trim()
|| process.env.YYD_NOTIFICATION_CENTER_PROJECT_CODE?.trim()
|| '',
projectSecret: process.env.YYD_NOTIFICATION_PROJECT_SECRET?.trim()
|| process.env.YYD_NOTIFICATION_CENTER_PROJECT_SECRET?.trim()
|| '',
}
}
export function hasNotificationCenterConfig() {
const env = readNotificationCenterEnv()
return Boolean(env.baseUrl && env.projectCode && env.projectSecret)
}
function fingerprint(demandIds: string[]) {
return createHash('sha256').update([...demandIds].sort().join('\n')).digest('hex').slice(0, 24)
}
export class ProductionStopNotificationsService {
constructor(private readonly deps: {
repository?: NotificationRepository
createClient?: () => NotificationSender | null
} = {}) {}
private get repository(): NotificationRepository {
return this.deps.repository ?? productionStopDemandRepository
}
private createClient(): NotificationSender | null {
if (this.deps.createClient) return this.deps.createClient()
if (!hasNotificationCenterConfig()) return null
const env = readNotificationCenterEnv()
try {
return new NotificationCenterClient({
baseUrl: env.baseUrl,
projectCode: env.projectCode,
projectSecret: env.projectSecret,
})
} catch (error) {
console.error(
'[production-stop-notifications] 通知中心配置无效',
error instanceof Error ? error.message : String(error),
)
return null
}
}
isEnabled() {
return Boolean(this.createClient())
}
private actionUrl() {
return buildDingTalkFullscreenUrl(
`${appBaseUrl()}/supply-chain/management/production-stop-demands`,
)
}
private async sendTo(
client: NotificationSender,
recipient: { name: string; dingtalkUserId?: string },
idempotencyKey: string,
message: { title: string; content: string },
) {
try {
await client.send({
idempotencyKey,
...(recipient.dingtalkUserId
? { recipientUserId: recipient.dingtalkUserId }
: { recipientAlias: recipient.name }),
title: message.title,
content: message.content,
actionTitle: '查看停产需求',
actionUrl: this.actionUrl(),
})
return true
} catch (error) {
console.error('[production-stop-notifications] 通知发送失败', {
idempotencyKey,
code: error instanceof NotificationCenterRequestError ? error.code : undefined,
message: error instanceof Error ? error.message : String(error),
})
return false
}
}
private async refreshKind(kind: ProductionStopPendingNotificationKind, client: NotificationSender) {
const result = { sent: 0, completed: 0, failed: 0 }
const demands = kind === 'CONFIRM_PENDING'
? await this.repository.listPendingConfirmationDemands()
: await this.repository.listPendingFlowDemands()
const recipients = kind === 'CONFIRM_PENDING'
? (await this.repository.listActiveApprovers()).map((row) => ({
id: row.id,
userId: row.userId,
userName: row.userName,
}))
: (await this.repository.listActiveFlowOwners()).map((row) => ({
id: row.id,
userId: row.userId,
userName: row.userName,
}))
const dingtalkIds = await this.repository.findUserDingtalkIds(
recipients.map((recipient) => recipient.userId),
)
const pendingCount = demands.length
const snapshot = fingerprint(demands.map((demand) => demand.id))
const keyPrefix = kind === 'CONFIRM_PENDING' ? 'production_stop_confirm' : 'production_stop_flow'
for (const recipient of recipients) {
const recipientName = recipient.userName.trim()
if (!recipientName) continue
const recipientKey = `${kind}:${recipient.id}`
const state = await this.repository.getNotificationSync(kind, recipientKey)
if (pendingCount > 0) {
if (state?.snapshot === snapshot) continue
const message = kind === 'CONFIRM_PENDING'
? buildProductionStopConfirmationPendingMessage(pendingCount, this.actionUrl())
: buildProductionStopFlowPendingMessage(pendingCount, this.actionUrl())
const sent = await this.sendTo(
client,
{
name: recipientName,
dingtalkUserId: dingtalkIds.get(recipient.userId),
},
`${keyPrefix}:${recipient.id}:${snapshot}:v1`,
message,
)
if (!sent) {
result.failed += 1
continue
}
await this.repository.upsertNotificationSync({
kind,
recipientKey,
recipientName,
pendingCount,
snapshot,
lastSentAt: new Date(),
completedAt: null,
})
result.sent += 1
continue
}
if (state && state.pendingCount > 0) {
const sent = await this.sendTo(
client,
{
name: recipientName,
dingtalkUserId: dingtalkIds.get(recipient.userId),
},
`${keyPrefix}:${recipient.id}:done:${state.snapshot}:v1`,
buildProductionStopPendingCompletedMessage(kind, this.actionUrl()),
)
if (!sent) {
result.failed += 1
continue
}
await this.repository.upsertNotificationSync({
kind,
recipientKey,
recipientName,
pendingCount: 0,
snapshot: state.snapshot,
lastSentAt: new Date(),
completedAt: new Date(),
})
result.completed += 1
}
}
return result
}
/** 同步两类待办:仅在待办集合变化时推送新卡片,清零时推送一次完成消息。 */
async refreshAll() {
const client = this.createClient()
if (!client) return { sent: 0, completed: 0, failed: 0 }
const [confirm, flow] = await Promise.all([
this.refreshKind('CONFIRM_PENDING', client),
this.refreshKind('FLOW_PENDING', client),
])
return {
sent: confirm.sent + flow.sent,
completed: confirm.completed + flow.completed,
failed: confirm.failed + flow.failed,
}
}
/** 列表访问时的兜底同步(5 分钟节流),保证没有定时任务的本地环境也能补发/收口。 */
async refreshAllThrottled() {
if (!this.isEnabled()) return
const now = Date.now()
if (now - lastRefreshStartedAt < REFRESH_THROTTLE_MS) return
lastRefreshStartedAt = now
await this.refreshAll()
}
/** 需求驳回:通知需求人(供应商 + 条码 + 驳回原因)。 */
async notifyRejected(demandId: string) {
const client = this.createClient()
if (!client) return false
const notice = await this.repository.findRejectionNotice(demandId)
if (!notice || !notice.requesterName.trim() || !notice.items.length) return false
const message = buildProductionStopRejectedMessage({
supplierName: notice.items.map((item) => item.supplierName).filter(Boolean).join('、'),
barcode: notice.items.map((item) => item.barcode).filter(Boolean).join('、'),
reason: notice.reasons,
}, this.actionUrl())
const dingtalkIds = await this.repository.findUserDingtalkIds([notice.requesterId])
return this.sendTo(
client,
{
name: notice.requesterName.trim(),
dingtalkUserId: dingtalkIds.get(notice.requesterId),
},
`production_stop_rejected:${notice.demandNo}:v1`,
message,
)
}
}
export const productionStopNotificationsService = new ProductionStopNotificationsService()