feat(production-stop-demands): 完善档案字段、筛选、视图与通知中心联动
- 明细新增图片、产品标签、含大单近30日日销(条码/SKU 数值快照) - 表头漏斗筛选(模糊/多选/数值比较/日期区间)、批量搜索、筛选设置 - 列设置拖动排序、快捷查询保存与套用 - 视图统计:日/周/月趋势、部门/需求人/原因/状态分布、饼图与明细下钻 - 通知中心:停产确认/待走流程/需求驳回通知、待办快照同步、钉钉全屏链接 - 新增迁移与历史数据回填脚本
This commit is contained in:
parent
8c520de0aa
commit
6e12f0318d
@ -0,0 +1,2 @@
|
||||
-- 「图片」列:提单时按条码从产品全量档案库快照图片地址,列表在条码前展示缩略图。
|
||||
ALTER TABLE "ProductionStopDemandItem" ADD COLUMN "image" TEXT NOT NULL DEFAULT '';
|
||||
@ -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");
|
||||
@ -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]+)?$';
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
})
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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;
|
||||
|
||||
16
furniture-3d-system/src/lib/dingtalk-link.test.ts
Normal file
16
furniture-3d-system/src/lib/dingtalk-link.test.ts
Normal 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('')
|
||||
})
|
||||
})
|
||||
10
furniture-3d-system/src/lib/dingtalk-link.ts
Normal file
10
furniture-3d-system/src/lib/dingtalk-link.ts
Normal 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`
|
||||
}
|
||||
@ -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('无业绩未打标')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
70
furniture-3d-system/src/lib/production-stop-notifications.ts
Normal file
70
furniture-3d-system/src/lib/production-stop-notifications.ts
Normal 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),
|
||||
}
|
||||
}
|
||||
@ -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 } : {}),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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 = '还没有配置停产确认人,无法推送'
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
Loading…
x
Reference in New Issue
Block a user