diff --git a/furniture-3d-system/src/app/api/supply-chain/product-archive/dashboard/route.ts b/furniture-3d-system/src/app/api/supply-chain/product-archive/dashboard/route.ts new file mode 100644 index 00000000..c6d45e6a --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/product-archive/dashboard/route.ts @@ -0,0 +1,28 @@ +import { NextRequest } from 'next/server' +import { z } from 'zod' +import { PRODUCT_ARCHIVE_SALES_STATUSES } from '@/lib/supply-chain-product-archive' +import { supplyChainProductArchiveDashboardService } from '@/server/services/supply-chain-product-archive-dashboard.service' +import { ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' + +const querySchema = z.object({ + keyword: z.string().trim().max(160).optional(), + categoryL1: z.string().trim().max(80).optional(), + categoryL2: z.string().trim().max(160).optional(), + productStatus: z.enum(PRODUCT_ARCHIVE_SALES_STATUSES).optional(), + supplier: z.string().trim().max(120).optional(), + updateTimeFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), +}) + +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 data = await supplyChainProductArchiveDashboardService.getDashboard(parsed.data) + return jsonSuccess(data, 200, ctx.requestId) + }, + { requireAuth: true, allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'] }, + ) +} diff --git a/furniture-3d-system/src/app/supply-chain/dashboard/DashboardDomainPanel.tsx b/furniture-3d-system/src/app/supply-chain/dashboard/DashboardDomainPanel.tsx new file mode 100644 index 00000000..04cd3a17 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/dashboard/DashboardDomainPanel.tsx @@ -0,0 +1,53 @@ +import { ArrowUpRight, Boxes, BriefcaseBusiness, Database, Package, ShoppingCart, type LucideIcon } from 'lucide-react' +import type { DashboardDomain } from './dashboardConfig' + +const ICONS: Record = { + product: Package, + supply: Boxes, + operation: BriefcaseBusiness, + warehouse: Database, + data: ShoppingCart, +} + +const HIGHLIGHTS: Record = { + product: ['商品状态', '一级类目', '二级类目', '品牌'], + supply: ['供应商', '采购', '结算方式', '产品起订量', '首单生产周期', '稳定生产周期'], + operation: ['运营总负责人', '运营部门负责人', '运营负责人', '运营负责人限价', '铺货ID数量和明细'], + warehouse: ['仓库属性', '出入库仓库', '仓库负责人'], + data: ['库存', '销售与发货', '价格与利润', '售后与退货'], +} + +export function DashboardDomainPanel({ domain, large = false }: { domain: DashboardDomain; large?: boolean }) { + const Icon = ICONS[domain.key] + const highlights = HIGHLIGHTS[domain.key] + + return ( +
+
+
+ +
+

{domain.label}

+

{domain.fieldCount} 个字段

+
+
+ +
+
+
+ 当前数据 + -- + 待接入 +
+
+ {highlights.map((label) => ( +
+ {label} + -- +
+ ))} +
+
+
+ ) +} diff --git a/furniture-3d-system/src/app/supply-chain/dashboard/DashboardFilterBar.tsx b/furniture-3d-system/src/app/supply-chain/dashboard/DashboardFilterBar.tsx new file mode 100644 index 00000000..be6c65d8 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/dashboard/DashboardFilterBar.tsx @@ -0,0 +1,73 @@ +'use client' + +import { Filter, RotateCcw, Search } from 'lucide-react' +import { useState } from 'react' + +export type DashboardFilterValues = { + keyword: string + categoryL1: string + categoryL2: string + status: string + supplier: string + dateRange: string +} +const EMPTY_FILTERS: DashboardFilterValues = { + keyword: '', + categoryL1: '', + categoryL2: '', + status: '', + supplier: '', + dateRange: 'all', +} + +export function DashboardFilterBar({ onApply }: { onApply: (filters: DashboardFilterValues) => void }) { + const [draft, setDraft] = useState(EMPTY_FILTERS) + + function update(key: keyof DashboardFilterValues, value: string) { + setDraft((current) => ({ ...current, [key]: value })) + } + + return ( +
{ + event.preventDefault() + onApply(draft) + }} + > +
+ + 档案筛选 +
+ + update('categoryL1', event.target.value)} placeholder="一级类目" aria-label="一级类目" /> + update('categoryL2', event.target.value)} placeholder="二级类目" aria-label="二级类目" /> + + update('supplier', event.target.value)} placeholder="供应商" aria-label="供应商" /> + + + +
+ ) +} diff --git a/furniture-3d-system/src/app/supply-chain/dashboard/DashboardKpiStrip.tsx b/furniture-3d-system/src/app/supply-chain/dashboard/DashboardKpiStrip.tsx new file mode 100644 index 00000000..482e9274 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/dashboard/DashboardKpiStrip.tsx @@ -0,0 +1,49 @@ +import { Barcode, Package, Tags, Users, type LucideIcon } from 'lucide-react' +import type { ProductArchiveDashboardData } from '@/lib/supply-chain-product-archive-dashboard' +import { DASHBOARD_KPIS } from './dashboardConfig' + +type Kpi = (typeof DASHBOARD_KPIS)[number] + +const ICONS: Record = { + barcode: Barcode, + product: Package, + status: Tags, + supplier: Users, +} + +export function DashboardKpiStrip({ data, loading }: { data: ProductArchiveDashboardData | null; loading: boolean }) { + const values: Record = { + barcode: data ? String(data.overview.barcodeCount) : '--', + spu: data ? String(data.overview.spuCount) : '--', + sales: data ? String(data.overview.salesCount) : '--', + stopped: data ? String(data.overview.stoppedCount) : '--', + retired: data ? String(data.overview.retiredCount) : '--', + supplier: data ? String(data.overview.supplierCount) : '--', + } + + const notes: Record = { + barcode: '条码维度', + spu: 'SPU 去重', + sales: '销售状态', + stopped: '停产状态', + retired: '退市状态', + supplier: data ? `${data.overview.purchaserCount} 位采购负责人` : '采购负责人', + } + + return ( +
+ {DASHBOARD_KPIS.map((kpi) => { + const Icon = ICONS[kpi.icon] + return ( +
+
+
+
{kpi.label}
+ {loading ? '...' : values[kpi.key]} +
{notes[kpi.key]}
+
+ ) + })} +
+ ) +} diff --git a/furniture-3d-system/src/app/supply-chain/dashboard/DashboardProductInfoPanel.tsx b/furniture-3d-system/src/app/supply-chain/dashboard/DashboardProductInfoPanel.tsx new file mode 100644 index 00000000..9c148fa2 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/dashboard/DashboardProductInfoPanel.tsx @@ -0,0 +1,83 @@ +import { ArrowUpRight, BarChart3, Layers3, Package, Tags, type LucideIcon } from 'lucide-react' +import type { ProductArchiveDashboardBreakdown, ProductArchiveDashboardData } from '@/lib/supply-chain-product-archive-dashboard' + +const STATUS_COLORS: Record = { 销售: '#1a9b72', 停产: '#c88924', 退市: '#c65779', 状态未录入: '#9aa3af' } + +function maxCount(items: ProductArchiveDashboardBreakdown[]) { + return Math.max(1, ...items.map((item) => item.count)) +} +function BreakdownList({ items, color, compact = false }: { items: ProductArchiveDashboardBreakdown[]; color: string; compact?: boolean }) { + const max = maxCount(items) + if (items.length === 0) return
暂无数据
+ return ( +
+ {items.map((item) => ( +
+
{item.label}
+
+ {item.count} +
+ ))} +
+ ) +} + +export function DashboardProductInfoPanel({ data }: { data: ProductArchiveDashboardData | null }) { + const statuses = data?.productInfo.statuses ?? [] + const categoriesL1 = data?.productInfo.categoriesL1 ?? [] + const categoriesL2 = data?.productInfo.categoriesL2 ?? [] + const brands = data?.productInfo.brands ?? [] + const recent = data?.productInfo.recent ?? [] + + return ( +
+
+
+ +

商品信息

来自产品全量档案的真实商品数据

+
+
实时汇总
+
+ +
+
+
商品状态条码数
+
+ {statuses.map((item) => ( +
+ + {item.label} + {item.count} +
+ ))} + {statuses.length === 0 &&
暂无数据
} +
+
+
+
一级类目Top 10
+ +
+
+
品牌Top 10
+ +
+
+ +
+
+
二级类目排行条码数
+ {categoriesL2.length === 0 ?
暂无数据
: categoriesL2.map((item, index) => ( +
{index + 1}{item.label}{item.count}
+ ))} +
+
+
最近更新商品按更新时间
+
条码商品名称状态更新时间
+ {recent.length === 0 ?
暂无数据
: recent.slice(0, 6).map((row) => ( +
{row.barcode}{row.productName || '--'}{row.productStatus || '未录入'}{new Date(row.updatedAt).toLocaleDateString('zh-CN')}
+ ))} +
+
+
+ ) +} diff --git a/furniture-3d-system/src/app/supply-chain/dashboard/DashboardStyles.tsx b/furniture-3d-system/src/app/supply-chain/dashboard/DashboardStyles.tsx new file mode 100644 index 00000000..fafc3e19 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/dashboard/DashboardStyles.tsx @@ -0,0 +1,135 @@ +export function DashboardStyles() { + return ( + + ) +} diff --git a/furniture-3d-system/src/app/supply-chain/dashboard/DashboardSupplyChainPanel.tsx b/furniture-3d-system/src/app/supply-chain/dashboard/DashboardSupplyChainPanel.tsx new file mode 100644 index 00000000..aec928ef --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/dashboard/DashboardSupplyChainPanel.tsx @@ -0,0 +1,32 @@ +import { ArrowUpRight, Boxes } from 'lucide-react' +import type { ProductArchiveDashboardBreakdown, ProductArchiveDashboardData } from '@/lib/supply-chain-product-archive-dashboard' + +function Ranking({ items, title }: { items: ProductArchiveDashboardBreakdown[]; title: string }) { + const max = Math.max(1, ...items.map((item) => item.count)) + return ( +
+
{title}条码数
+ {items.length === 0 ?
暂无数据
: items.slice(0, 6).map((item, index) => ( +
+ {index + 1}{item.label}
{item.count} +
+ ))} +
+ ) +} + +export function DashboardSupplyChainPanel({ data }: { data: ProductArchiveDashboardData | null }) { + return ( +
+
+

供应链信息

供应商与采购负责人真实分布

+
{data?.overview.supplierCount ?? '--'} 家供应商
+
+
+ + + +
+
+ ) +} diff --git a/furniture-3d-system/src/app/supply-chain/dashboard/DashboardView.tsx b/furniture-3d-system/src/app/supply-chain/dashboard/DashboardView.tsx index 42140719..742ae5fa 100644 --- a/furniture-3d-system/src/app/supply-chain/dashboard/DashboardView.tsx +++ b/furniture-3d-system/src/app/supply-chain/dashboard/DashboardView.tsx @@ -1,6 +1,16 @@ 'use client' -import { Calendar } from 'lucide-react' +import { CalendarDays, ExternalLink, LayoutDashboard, RefreshCw } from 'lucide-react' +import { useMemo, useState } from 'react' +import { DashboardDomainPanel } from './DashboardDomainPanel' +import { DashboardFilterBar, type DashboardFilterValues } from './DashboardFilterBar' +import { DashboardKpiStrip } from './DashboardKpiStrip' +import { DashboardProductInfoPanel } from './DashboardProductInfoPanel' +import { DashboardStyles } from './DashboardStyles' +import { DashboardSupplyChainPanel } from './DashboardSupplyChainPanel' +import { useProductArchiveDashboardData } from './useProductArchiveDashboardData' +import { DASHBOARD_DATA_SECTIONS, DASHBOARD_DOMAINS, TOTAL_ARCHIVE_FIELD_COUNT } from './dashboardConfig' +import type { ProductArchiveDashboardFilters } from '@/lib/supply-chain-product-archive-dashboard' function getGreeting(): string { const hour = new Date().getHours() @@ -11,59 +21,79 @@ function getGreeting(): string { if (hour < 18) return '下午好' return '晚上好' } - function getToday(): string { - const d = new Date() - const week = ['日', '一', '二', '三', '四', '五', '六'][d.getDay()] - return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日 星期${week}` + const date = new Date() + const week = ['日', '一', '二', '三', '四', '五', '六'][date.getDay()] + return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日 星期${week}` +} + +function dateFromRange(range: string) { + if (range === 'all') return undefined + const date = new Date() + date.setDate(date.getDate() - Number(range)) + return date.toISOString().slice(0, 10) } export function DashboardView({ username }: { username?: string | null }) { + const [activeFilters, setActiveFilters] = useState({ + keyword: '', categoryL1: '', categoryL2: '', status: '', supplier: '', dateRange: 'all', + }) + const queryFilters = useMemo(() => ({ + keyword: activeFilters.keyword || undefined, + categoryL1: activeFilters.categoryL1 || undefined, + categoryL2: activeFilters.categoryL2 || undefined, + productStatus: (activeFilters.status || undefined) as ProductArchiveDashboardFilters['productStatus'], + supplier: activeFilters.supplier || undefined, + updateTimeFrom: dateFromRange(activeFilters.dateRange), + }), [activeFilters]) + const { data, loading, error } = useProductArchiveDashboardData(queryFilters) + const emptyDomains = DASHBOARD_DOMAINS.filter((domain) => ['operation', 'warehouse', 'data'].includes(domain.key)) + return ( -
-
-

{getGreeting()},{username || '你好'}

-

欢迎回到供应链中心

-
- - {getToday()} +
+
+
+
供应链中心 / 工作台
+

{getGreeting()},{username || '你好'}

+

产品全量档案驾驶舱

-
- +
+
{getToday()}
+ + 查看全量档案 +
+ + + + + {error &&
{error}
} + + + + +
+ {emptyDomains.filter((domain) => domain.key !== 'data').map((domain) => )} +
+ +
+
+

数据经营

库存、销售、价格利润和售后退货

+ 暂无数据 +
+
+ {DASHBOARD_DATA_SECTIONS.map((section) => ( +
+

{section.label}

+
+ {section.fields.map((field) =>
{field}--
)} +
+
+ ))} +
+
当前展示口径:档案表当前快照{TOTAL_ARCHIVE_FIELD_COUNT} 个字段 / 5 个分组
+
+ +
) } diff --git a/furniture-3d-system/src/app/supply-chain/dashboard/dashboardConfig.ts b/furniture-3d-system/src/app/supply-chain/dashboard/dashboardConfig.ts new file mode 100644 index 00000000..eff42958 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/dashboard/dashboardConfig.ts @@ -0,0 +1,50 @@ +import { ARCHIVE_COLUMNS } from '../database/product-archive/columns' + +export type DashboardDomainKey = 'product' | 'supply' | 'operation' | 'warehouse' | 'data' + +export type DashboardDomain = { + key: DashboardDomainKey + label: string + fieldCount: number + color: string + fields: string[] +} + +const DOMAIN_META: Record> = { + product: { color: '#2f6fed' }, + supply: { color: '#0f9f7f' }, + operation: { color: '#c88924' }, + warehouse: { color: '#7558c9' }, + data: { color: '#c65779' }, +} + +const DOMAIN_KEYS: DashboardDomainKey[] = ['product', 'supply', 'operation', 'warehouse', 'data'] + +export const DASHBOARD_DOMAINS: DashboardDomain[] = ARCHIVE_COLUMNS.map((group, index) => ({ + key: DOMAIN_KEYS[index] ?? 'data', + label: group.group, + fieldCount: group.columns.length, + fields: group.columns.map((column) => column.label), + ...DOMAIN_META[DOMAIN_KEYS[index] ?? 'data'], +})) + +export const DASHBOARD_KPIS = [ + { key: 'barcode', label: '条码档案', color: '#2f6fed', icon: 'barcode' as const }, + { key: 'spu', label: 'SPU 产品', color: '#0f9f7f', icon: 'product' as const }, + { key: 'sales', label: '销售中', color: '#1a9b72', icon: 'status' as const }, + { key: 'stopped', label: '停产', color: '#c88924', icon: 'status' as const }, + { key: 'retired', label: '退市', color: '#c65779', icon: 'status' as const }, + { key: 'supplier', label: '供应商', color: '#7558c9', icon: 'supplier' as const }, +] as const + +export const DASHBOARD_DATA_SECTIONS = [ + { key: 'inventory', label: '库存', fields: ['库存货值', '库存可使用天数', '简尚库存', '清货库存', '工厂库存', '云仓库存', '在途数量'] }, + { key: 'sales', label: '销售与发货', fields: ['采购日销', '3日 / 7日日销', '15日 / 30日日销', '发货金额', '发货件数'] }, + { key: 'profit', label: '价格与利润', fields: ['采购价', '成本价', '毛利额', '毛利率', '费比', '营业利润额'] }, + { key: 'afterSales', label: '售后与退货', fields: ['售后次数', '售后率', '售后成本', '退货件数', '退货率', '配件补发次数'] }, +] as const + +export const TOTAL_ARCHIVE_FIELD_COUNT = DASHBOARD_DOMAINS.reduce( + (total, domain) => total + domain.fieldCount, + 0, +) diff --git a/furniture-3d-system/src/app/supply-chain/dashboard/useProductArchiveDashboardData.ts b/furniture-3d-system/src/app/supply-chain/dashboard/useProductArchiveDashboardData.ts new file mode 100644 index 00000000..31a4fb4f --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/dashboard/useProductArchiveDashboardData.ts @@ -0,0 +1,37 @@ +'use client' + +import { useEffect, useState } from 'react' +import type { ApiResponse } from '@/server/core/types' +import type { ProductArchiveDashboardData, ProductArchiveDashboardFilters } from '@/lib/supply-chain-product-archive-dashboard' + +export function useProductArchiveDashboardData(filters: ProductArchiveDashboardFilters) { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + + useEffect(() => { + const controller = new AbortController() + const params = new URLSearchParams() + Object.entries(filters).forEach(([key, value]) => { if (value) params.set(key, value) }) + + async function load() { + setLoading(true) + setError('') + try { + const response = await fetch(`/api/supply-chain/product-archive/dashboard?${params.toString()}`, { signal: controller.signal }) + const payload = (await response.json()) as ApiResponse + if (!response.ok || !payload.success || !payload.data) throw new Error(payload.error?.message || '看板数据加载失败') + setData(payload.data) + } catch (loadError) { + if (controller.signal.aborted) return + setError(loadError instanceof Error ? loadError.message : '看板数据加载失败') + } finally { + if (!controller.signal.aborted) setLoading(false) + } + } + void load() + return () => controller.abort() + }, [filters]) + + return { data, loading, error } +} diff --git a/furniture-3d-system/src/lib/supply-chain-product-archive-dashboard.ts b/furniture-3d-system/src/lib/supply-chain-product-archive-dashboard.ts new file mode 100644 index 00000000..793ed6bb --- /dev/null +++ b/furniture-3d-system/src/lib/supply-chain-product-archive-dashboard.ts @@ -0,0 +1,55 @@ +import type { ProductArchiveSalesStatus } from './supply-chain-product-archive' + +export type ProductArchiveDashboardFilters = { + keyword?: string + categoryL1?: string + categoryL2?: string + productStatus?: ProductArchiveSalesStatus + supplier?: string + updateTimeFrom?: string +} + +export type ProductArchiveDashboardBreakdown = { + label: string + count: number +} + +export type ProductArchiveDashboardRecentRow = { + barcode: string + spu: string + productName: string + categoryL1: string + categoryL2: string + brand: string + productStatus: string + supplier: string + purchaser: string + updatedAt: string +} + +export type ProductArchiveDashboardData = { + overview: { + barcodeCount: number + spuCount: number + salesCount: number + stoppedCount: number + retiredCount: number + statusMissingCount: number + brandCount: number + supplierCount: number + purchaserCount: number + } + productInfo: { + statuses: ProductArchiveDashboardBreakdown[] + categoriesL1: ProductArchiveDashboardBreakdown[] + categoriesL2: ProductArchiveDashboardBreakdown[] + brands: ProductArchiveDashboardBreakdown[] + recent: ProductArchiveDashboardRecentRow[] + } + supplyChain: { + suppliers: ProductArchiveDashboardBreakdown[] + purchasers: ProductArchiveDashboardBreakdown[] + minimumOrderQuantities: ProductArchiveDashboardBreakdown[] + } + generatedAt: string +} diff --git a/furniture-3d-system/src/server/repositories/supply-chain-product-archive-dashboard.repository.ts b/furniture-3d-system/src/server/repositories/supply-chain-product-archive-dashboard.repository.ts new file mode 100644 index 00000000..e07323d5 --- /dev/null +++ b/furniture-3d-system/src/server/repositories/supply-chain-product-archive-dashboard.repository.ts @@ -0,0 +1,137 @@ +import { Prisma } from '@prisma/client' +import { prisma } from '@/lib/prisma' +import type { ProductArchiveDashboardFilters } from '@/lib/supply-chain-product-archive-dashboard' + +const recentSelect = { + barcode: true, + spu: true, + productName: true, + categoryL1: true, + categoryL2: true, + brand: true, + productStatus: true, + supplierName: true, + purchaseOwner: true, + updatedAt: true, +} satisfies Prisma.SupplyChainProductArchiveSelect + +function parseDateBoundary(value: string) { + return new Date(`${value}T00:00:00.000`) +} + +export function buildProductArchiveDashboardWhere( + filters: ProductArchiveDashboardFilters = {}, +): Prisma.SupplyChainProductArchiveWhereInput { + const clauses: Prisma.SupplyChainProductArchiveWhereInput[] = [] + const keyword = filters.keyword?.trim() + + if (keyword) { + clauses.push({ + OR: [ + { barcode: { contains: keyword, mode: 'insensitive' } }, + { spu: { contains: keyword, mode: 'insensitive' } }, + { sku: { contains: keyword, mode: 'insensitive' } }, + { productName: { contains: keyword, mode: 'insensitive' } }, + ], + }) + } + if (filters.categoryL1?.trim()) clauses.push({ categoryL1: { equals: filters.categoryL1.trim() } }) + if (filters.categoryL2?.trim()) clauses.push({ categoryL2: { contains: filters.categoryL2.trim(), mode: 'insensitive' } }) + if (filters.productStatus) clauses.push({ productStatus: filters.productStatus }) + if (filters.supplier?.trim()) clauses.push({ supplierName: { contains: filters.supplier.trim(), mode: 'insensitive' } }) + if (filters.updateTimeFrom) clauses.push({ updatedAt: { gte: parseDateBoundary(filters.updateTimeFrom) } }) + + return clauses.length ? { AND: clauses } : {} +} + +export class SupplyChainProductArchiveDashboardRepository { + async getDashboard(filters: ProductArchiveDashboardFilters = {}) { + const where = buildProductArchiveDashboardWhere(filters) + const nonEmpty = (field: 'barcode' | 'spu' | 'brand' | 'supplierName' | 'purchaseOwner') => ({ + AND: [where, { [field]: { not: '' } }], + }) as Prisma.SupplyChainProductArchiveWhereInput + + const [ + total, + barcodes, + spus, + brands, + suppliers, + purchasers, + statusRows, + categoryL1Rows, + categoryL2Rows, + brandRows, + supplierRows, + purchaserRows, + minimumOrderQuantityRows, + recent, + ] = await Promise.all([ + prisma.supplyChainProductArchive.count({ where }), + prisma.supplyChainProductArchive.findMany({ where: nonEmpty('barcode'), distinct: ['barcode'], select: { barcode: true } }), + prisma.supplyChainProductArchive.findMany({ where: nonEmpty('spu'), distinct: ['spu'], select: { spu: true } }), + prisma.supplyChainProductArchive.findMany({ where: nonEmpty('brand'), distinct: ['brand'], select: { brand: true } }), + prisma.supplyChainProductArchive.findMany({ where: nonEmpty('supplierName'), distinct: ['supplierName'], select: { supplierName: true } }), + prisma.supplyChainProductArchive.findMany({ where: nonEmpty('purchaseOwner'), distinct: ['purchaseOwner'], select: { purchaseOwner: true } }), + prisma.supplyChainProductArchive.groupBy({ where, by: ['productStatus'], _count: { _all: true } }), + prisma.supplyChainProductArchive.groupBy({ where: { AND: [where, { categoryL1: { not: '' } }] }, by: ['categoryL1'], _count: { _all: true }, orderBy: { _count: { categoryL1: 'desc' } }, take: 10 }), + prisma.supplyChainProductArchive.groupBy({ where: { AND: [where, { categoryL2: { not: '' } }] }, by: ['categoryL2'], _count: { _all: true }, orderBy: { _count: { categoryL2: 'desc' } }, take: 10 }), + prisma.supplyChainProductArchive.groupBy({ where: nonEmpty('brand'), by: ['brand'], _count: { _all: true }, orderBy: { _count: { brand: 'desc' } }, take: 10 }), + prisma.supplyChainProductArchive.groupBy({ where: nonEmpty('supplierName'), by: ['supplierName'], _count: { _all: true }, orderBy: { _count: { supplierName: 'desc' } }, take: 10 }), + prisma.supplyChainProductArchive.groupBy({ where: nonEmpty('purchaseOwner'), by: ['purchaseOwner'], _count: { _all: true }, orderBy: { _count: { purchaseOwner: 'desc' } }, take: 10 }), + prisma.supplyChainProductArchive.groupBy({ where: { AND: [where, { minimumOrderQuantity: { not: '' } }] }, by: ['minimumOrderQuantity'], _count: { _all: true }, orderBy: { _count: { minimumOrderQuantity: 'desc' } }, take: 10 }), + prisma.supplyChainProductArchive.findMany({ where, select: recentSelect, orderBy: [{ updatedAt: 'desc' }, { barcode: 'asc' }], take: 8 }), + ]) + + const countByStatus = new Map(statusRows.map((row) => [row.productStatus, row._count._all])) + const toBreakdown = (rows: Array<{ _count: { _all: number }} & Record>, key: T) => + rows.map((row) => ({ label: row[key], count: row._count._all })) + + return { + overview: { + barcodeCount: barcodes.length, + spuCount: spus.length, + salesCount: countByStatus.get('销售') ?? 0, + stoppedCount: countByStatus.get('停产') ?? 0, + retiredCount: countByStatus.get('退市') ?? 0, + statusMissingCount: countByStatus.get('') ?? 0, + brandCount: brands.length, + supplierCount: suppliers.length, + purchaserCount: purchasers.length, + }, + productInfo: { + statuses: [ + { label: '销售', count: countByStatus.get('销售') ?? 0 }, + { label: '停产', count: countByStatus.get('停产') ?? 0 }, + { label: '退市', count: countByStatus.get('退市') ?? 0 }, + { label: '状态未录入', count: countByStatus.get('') ?? 0 }, + ], + categoriesL1: toBreakdown(categoryL1Rows, 'categoryL1'), + categoriesL2: toBreakdown(categoryL2Rows, 'categoryL2'), + brands: toBreakdown(brandRows, 'brand'), + recent: recent.map((row) => ({ + barcode: row.barcode, + spu: row.spu, + productName: row.productName, + categoryL1: row.categoryL1, + categoryL2: row.categoryL2, + brand: row.brand, + productStatus: row.productStatus, + supplier: row.supplierName, + purchaser: row.purchaseOwner, + updatedAt: row.updatedAt.toISOString(), + })), + }, + supplyChain: { + suppliers: toBreakdown(supplierRows, 'supplierName'), + purchasers: toBreakdown(purchaserRows, 'purchaseOwner'), + minimumOrderQuantities: toBreakdown(minimumOrderQuantityRows, 'minimumOrderQuantity'), + }, + total, + generatedAt: new Date().toISOString(), + } + } +} + +export const supplyChainProductArchiveDashboardRepository = + new SupplyChainProductArchiveDashboardRepository() diff --git a/furniture-3d-system/src/server/services/supply-chain-product-archive-dashboard.service.ts b/furniture-3d-system/src/server/services/supply-chain-product-archive-dashboard.service.ts new file mode 100644 index 00000000..862730aa --- /dev/null +++ b/furniture-3d-system/src/server/services/supply-chain-product-archive-dashboard.service.ts @@ -0,0 +1,11 @@ +import type { ProductArchiveDashboardFilters } from '@/lib/supply-chain-product-archive-dashboard' +import { supplyChainProductArchiveDashboardRepository } from '@/server/repositories/supply-chain-product-archive-dashboard.repository' + +export class SupplyChainProductArchiveDashboardService { + async getDashboard(filters: ProductArchiveDashboardFilters = {}) { + return supplyChainProductArchiveDashboardRepository.getDashboard(filters) + } +} + +export const supplyChainProductArchiveDashboardService = + new SupplyChainProductArchiveDashboardService()