diff --git a/furniture-3d-system/prisma/migrations/20260828170000_add_supplier_recommended_products/migration.sql b/furniture-3d-system/prisma/migrations/20260828170000_add_supplier_recommended_products/migration.sql new file mode 100644 index 00000000..a6bdd964 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260828170000_add_supplier_recommended_products/migration.sql @@ -0,0 +1,48 @@ +CREATE TABLE "SupplierRecommendedProduct" ( + "id" TEXT NOT NULL, + "recommendationDate" DATE, + "supplier" TEXT NOT NULL DEFAULT '', + "purchaser" TEXT NOT NULL DEFAULT '', + "productCode" TEXT NOT NULL DEFAULT '', + "imageUrl" TEXT, + "sampleRequest" TEXT NOT NULL DEFAULT '', + "dimensions" TEXT NOT NULL DEFAULT '', + "price" TEXT NOT NULL DEFAULT '', + "material" TEXT NOT NULL DEFAULT '', + "boardGrade" TEXT NOT NULL DEFAULT '', + "weight" TEXT NOT NULL DEFAULT '', + "orderQuantity" INTEGER NOT NULL DEFAULT 0, + "productionCycle" TEXT NOT NULL DEFAULT '', + "shippingMode" TEXT NOT NULL DEFAULT '', + "dropShippingFee" TEXT NOT NULL DEFAULT '', + "createdById" TEXT, + "createdByName" TEXT NOT NULL DEFAULT '', + "updatedById" TEXT, + "updatedByName" TEXT NOT NULL DEFAULT '', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "SupplierRecommendedProduct_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "SupplierRecommendedProductChangeLog" ( + "id" TEXT NOT NULL, + "itemId" TEXT NOT NULL, + "operation" TEXT NOT NULL, + "actorId" TEXT, + "actorName" TEXT NOT NULL, + "changes" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "SupplierRecommendedProductChangeLog_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "SupplierRecommendedProduct_recommendationDate_idx" ON "SupplierRecommendedProduct"("recommendationDate"); +CREATE INDEX "SupplierRecommendedProduct_supplier_idx" ON "SupplierRecommendedProduct"("supplier"); +CREATE INDEX "SupplierRecommendedProduct_purchaser_idx" ON "SupplierRecommendedProduct"("purchaser"); +CREATE INDEX "SupplierRecommendedProduct_productCode_idx" ON "SupplierRecommendedProduct"("productCode"); +CREATE INDEX "SupplierRecommendedProduct_shippingMode_idx" ON "SupplierRecommendedProduct"("shippingMode"); +CREATE INDEX "SupplierRecommendedProductChangeLog_itemId_createdAt_idx" ON "SupplierRecommendedProductChangeLog"("itemId", "createdAt"); +CREATE INDEX "SupplierRecommendedProductChangeLog_createdAt_idx" ON "SupplierRecommendedProductChangeLog"("createdAt"); + +ALTER TABLE "SupplierRecommendedProductChangeLog" ADD CONSTRAINT "SupplierRecommendedProductChangeLog_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "SupplierRecommendedProduct"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/furniture-3d-system/prisma/schema.prisma b/furniture-3d-system/prisma/schema.prisma index 44b028d9..68829ab5 100644 --- a/furniture-3d-system/prisma/schema.prisma +++ b/furniture-3d-system/prisma/schema.prisma @@ -164,6 +164,53 @@ model FinishedProductDeliveryRecord { @@index([itemId, createdAt]) } +model SupplierRecommendedProduct { + id String @id @default(cuid()) + recommendationDate DateTime? @db.Date + supplier String @default("") + purchaser String @default("") + productCode String @default("") + imageUrl String? @db.Text + sampleRequest String @default("") + dimensions String @default("") + price String @default("") + material String @default("") + boardGrade String @default("") + weight String @default("") + orderQuantity Int @default(0) + productionCycle String @default("") + shippingMode String @default("") + dropShippingFee String @default("") + createdById String? + createdByName String @default("") + updatedById String? + updatedByName String @default("") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + changeLogs SupplierRecommendedProductChangeLog[] + + @@index([recommendationDate]) + @@index([supplier]) + @@index([purchaser]) + @@index([productCode]) + @@index([shippingMode]) +} + +model SupplierRecommendedProductChangeLog { + id String @id @default(cuid()) + itemId String + item SupplierRecommendedProduct @relation(fields: [itemId], references: [id], onDelete: Cascade) + operation String + actorId String? + actorName String + changes Json + createdAt DateTime @default(now()) + + @@index([itemId, createdAt]) + @@index([createdAt]) +} + + // ============================================ // 产品分类 // ============================================ diff --git a/furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/[id]/history/route.ts b/furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/[id]/history/route.ts new file mode 100644 index 00000000..2375a243 --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/[id]/history/route.ts @@ -0,0 +1,12 @@ +import { NextRequest } from 'next/server' +import { handleRequest, jsonSuccess } from '@/server/core/response' +import { supplierRecommendedProductsService } from '@/server/services/supplier-recommended-products.service' + +type Context = { params: Promise<{ id: string }> } + +export async function GET(request: NextRequest, context: Context) { + return handleRequest(request, async (ctx) => { + const { id } = await context.params + return jsonSuccess(await supplierRecommendedProductsService.listChangeLogs(id, ctx.user), 200, ctx.requestId) + }, { requireAuth: true }) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/[id]/route.ts b/furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/[id]/route.ts new file mode 100644 index 00000000..89d0ff8c --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/[id]/route.ts @@ -0,0 +1,13 @@ +import { NextRequest } from 'next/server' +import { handleRequest, jsonSuccess } from '@/server/core/response' +import { supplierRecommendedProductsService } from '@/server/services/supplier-recommended-products.service' + +type Context = { params: Promise<{ id: string }> } + +export async function PATCH(request: NextRequest, context: Context) { + return handleRequest(request, async (ctx) => { + const { id } = await context.params + const body = await request.json().catch(() => null) + return jsonSuccess(await supplierRecommendedProductsService.update(id, body, ctx.user), 200, ctx.requestId) + }, { requireAuth: true }) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/import/route.ts b/furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/import/route.ts new file mode 100644 index 00000000..f058eb9d --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/import/route.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from 'next/server' +import * as XLSX from 'xlsx' +import { SUPPLIER_RECOMMENDED_PRODUCT_IMPORT_HEADERS, type SupplierRecommendedProductImportProgress, type SupplierRecommendedProductImportStreamEvent } from '@/lib/supplier-recommended-products' +import { ErrorCode, isAppError } from '@/server/core/errors' +import { handleRequest, jsonError } from '@/server/core/response' +import { supplierRecommendedProductsService } from '@/server/services/supplier-recommended-products.service' + +export async function POST(request: NextRequest) { + return handleRequest(request, async (ctx) => { + const form = await request.formData() + const file = form.get('file') + if (!(file instanceof File)) return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请选择 Excel 文件', 400, ctx.requestId) + if (file.size > 10 * 1024 * 1024) return jsonError(ErrorCode.VALIDATION_OUT_OF_RANGE, 'Excel 文件不能超过10MB', 400, ctx.requestId) + const workbook = XLSX.read(Buffer.from(await file.arrayBuffer()), { type: 'buffer', cellDates: true }) + const sheet = workbook.Sheets[workbook.SheetNames[0]] + if (!sheet) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, 'Excel 文件没有工作表', 400, ctx.requestId) + const rawRows = XLSX.utils.sheet_to_json>(sheet, { defval: '' }) + const rows = rawRows.map((raw) => Object.fromEntries(Object.entries(raw).flatMap(([header, value]) => { + const key = SUPPLIER_RECOMMENDED_PRODUCT_IMPORT_HEADERS[header.trim()] + return key ? [[key, value]] : [] + }))) + if (!rows.length) return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '导入文件没有可用数据', 400, ctx.requestId) + if (rows.length > 5_000) return jsonError(ErrorCode.VALIDATION_OUT_OF_RANGE, '单次最多导入5000行', 400, ctx.requestId) + const encoder = new TextEncoder() + const stream = new ReadableStream({ + start(controller) { + let closed = false + let progress: SupplierRecommendedProductImportProgress = { processed: 0, total: rows.length, created: 0, updated: 0 } + const send = (event: SupplierRecommendedProductImportStreamEvent) => { if (!closed) { try { controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`)) } catch { closed = true } } } + const close = () => { if (!closed) { closed = true; controller.close() } } + send({ type: 'progress', progress }) + void supplierRecommendedProductsService.importRows(rows, ctx.user, (next) => { progress = next; send({ type: 'progress', progress }) }).then((result) => { progress = { processed: rows.length, total: rows.length, ...result }; send({ type: 'complete', progress }); close() }).catch((error: unknown) => { console.error(`[${ctx.requestId}] Supplier recommended products import failed:`, error); send({ type: 'error', progress, message: isAppError(error) ? error.message : '导入过程中发生错误' }); close() }) + }, + }) + return new NextResponse(stream, { status: 200, headers: { 'Content-Type': 'application/x-ndjson; charset=utf-8', 'Cache-Control': 'no-cache, no-transform', 'X-Accel-Buffering': 'no' } }) + }, { requireAuth: true }) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/route.ts b/furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/route.ts new file mode 100644 index 00000000..5c9166bd --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/route.ts @@ -0,0 +1,33 @@ +import { NextRequest } from 'next/server' +import { z } from 'zod' +import { EMPTY_SUPPLIER_RECOMMENDED_PRODUCT_FILTERS } from '@/lib/supplier-recommended-products' +import { ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { supplierRecommendedProductsService } from '@/server/services/supplier-recommended-products.service' + +const listSchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + pageSize: z.coerce.number().int().min(1).max(200).default(50), + search: z.string().trim().max(200).default(''), + supplier: z.string().trim().max(200).default(''), + purchaser: z.string().trim().max(200).default(''), + shippingMode: z.string().trim().max(200).default(''), + dateFrom: z.string().trim().max(20).default(''), + dateTo: z.string().trim().max(20).default(''), +}) + +export async function GET(request: NextRequest) { + return handleRequest(request, async (ctx) => { + const parsed = listSchema.safeParse({ ...EMPTY_SUPPLIER_RECOMMENDED_PRODUCT_FILTERS, ...Object.fromEntries(request.nextUrl.searchParams.entries()) }) + if (!parsed.success) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '供应商新品推荐筛选条件不正确', 400, ctx.requestId) + const { page, pageSize, ...filters } = parsed.data + return jsonSuccess(await supplierRecommendedProductsService.listPage({ page, pageSize, filters, user: ctx.user }), 200, ctx.requestId) + }, { requireAuth: true }) +} + +export async function POST(request: NextRequest) { + return handleRequest(request, async (ctx) => { + const body = await request.json().catch(() => null) + return jsonSuccess(await supplierRecommendedProductsService.create(body, ctx.user), 201, ctx.requestId) + }, { requireAuth: true }) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/template/route.ts b/furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/template/route.ts new file mode 100644 index 00000000..3704f0d3 --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/supplier-recommended-products/template/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from 'next/server' +import * as XLSX from 'xlsx' +import { SUPPLIER_RECOMMENDED_PRODUCT_FIELDS } from '@/lib/supplier-recommended-products' +import { handleRequest } from '@/server/core/response' +import { supplierRecommendedProductsService } from '@/server/services/supplier-recommended-products.service' + +export async function GET(request: NextRequest) { + return handleRequest(request, async (ctx) => { + await supplierRecommendedProductsService.requireTemplateAccess(ctx.user) + const headers = SUPPLIER_RECOMMENDED_PRODUCT_FIELDS.map((field) => field.label) + const sheet = XLSX.utils.aoa_to_sheet([headers]) + sheet['!cols'] = headers.map((header) => ({ wch: Math.max(14, header.length * 2 + 4) })) + const workbook = XLSX.utils.book_new() + XLSX.utils.book_append_sheet(workbook, sheet, '供应商新品推荐导入') + const content = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }) as Buffer + return new NextResponse(new Uint8Array(content), { status: 200, headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'Content-Disposition': "attachment; filename*=UTF-8''%E4%BE%9B%E5%BA%94%E5%95%86%E6%96%B0%E5%93%81%E6%8E%A8%E8%8D%90%E5%AF%BC%E5%85%A5%E6%A8%A1%E6%9D%BF.xlsx", 'Cache-Control': 'no-store' } }) + }, { requireAuth: true }) +} diff --git a/furniture-3d-system/src/app/supply-chain/components/supply-chain-tabs.tsx b/furniture-3d-system/src/app/supply-chain/components/supply-chain-tabs.tsx index c3996548..261d688a 100644 --- a/furniture-3d-system/src/app/supply-chain/components/supply-chain-tabs.tsx +++ b/furniture-3d-system/src/app/supply-chain/components/supply-chain-tabs.tsx @@ -101,6 +101,7 @@ export const SUPPLY_CHAIN_ROUTE_TITLES: Record = { '/supply-chain/new-products/unified-table': '新品开发进度总表', '/supply-chain/new-products/sample-progress': '打样进度明细', '/supply-chain/purchase/finished-product-progress': '成品进度明细', + '/supply-chain/purchase/supplier-recommended-products': '供应商新品推荐', '/supply-chain/purchase/supplier-information': '供应商信息', '/supply-chain/purchase/daily-affairs': '日常事项', '/supply-chain/purchase/price-change': '产品变价', diff --git a/furniture-3d-system/src/app/supply-chain/config/supply-chain-menu.ts b/furniture-3d-system/src/app/supply-chain/config/supply-chain-menu.ts index ae80c93a..aeace97e 100644 --- a/furniture-3d-system/src/app/supply-chain/config/supply-chain-menu.ts +++ b/furniture-3d-system/src/app/supply-chain/config/supply-chain-menu.ts @@ -69,6 +69,7 @@ export const SUPPLY_CHAIN_MENU: SupplyChainMenuModule[] = [ items: [ { label: '打样进度明细', route: '/supply-chain/new-products/sample-progress', targetKey: 'newProductSampleProgress' }, { label: '成品进度明细', route: '/supply-chain/purchase/finished-product-progress', targetKey: 'newProductSampleProgress' }, + { label: '供应商新品推荐', route: '/supply-chain/purchase/supplier-recommended-products', targetKey: 'purchaseSupplierRecommendedProducts' }, { label: '供应商信息', route: '/supply-chain/purchase/supplier-information', targetKey: 'purchaseSupplierInformation' }, { label: '日常事项', route: '/supply-chain/purchase/daily-affairs', targetKey: 'purchaseDailyAffairs' }, { label: '产品变价', route: '/supply-chain/purchase/price-change', targetKey: 'purchasePriceChange' }, diff --git a/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/SupplierRecommendedProductsClient.tsx b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/SupplierRecommendedProductsClient.tsx new file mode 100644 index 00000000..49e81859 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/SupplierRecommendedProductsClient.tsx @@ -0,0 +1,27 @@ +'use client' + +import Image from 'next/image' +import { FileDown, Plus, RotateCcw, Search, Upload, X } from 'lucide-react' +import { useCallback, useRef, useState } from 'react' +import { EMPTY_SUPPLIER_RECOMMENDED_PRODUCT_FILTERS, type SupplierRecommendedProductChangeLogEntry, type SupplierRecommendedProductFilters, type SupplierRecommendedProductPage, type SupplierRecommendedProductRow } from '@/lib/supplier-recommended-products' +import { SupplierRecommendedProductColumnSettings } from './components/SupplierRecommendedProductColumnSettings' +import { SupplierRecommendedProductEditor } from './components/SupplierRecommendedProductEditor' +import { SupplierRecommendedProductHistoryDialog } from './components/SupplierRecommendedProductHistoryDialog' +import { SupplierRecommendedProductImportProgress } from './components/SupplierRecommendedProductImportProgress' +import { SupplierRecommendedProductPagination } from './components/SupplierRecommendedProductPagination' +import { SupplierRecommendedProductTable } from './components/SupplierRecommendedProductTable' +import { supplierRecommendedProductsStyles } from './supplierRecommendedProductsStyles' +import { useSupplierRecommendedProductColumns } from './useSupplierRecommendedProductColumns' +import { useSupplierRecommendedProductImport } from './useSupplierRecommendedProductImport' + +async function readResponse(response: Response) { const payload = await response.json().catch(() => null); if (!response.ok || !payload?.success) throw new Error(payload?.error?.message || '请求失败'); return payload.data } + +export function SupplierRecommendedProductsClient({ initialData, canEdit, canDownload }: { initialData: SupplierRecommendedProductPage; canEdit: boolean; canDownload: boolean }) { + const [data, setData] = useState(initialData); const [filters, setFilters] = useState({ ...EMPTY_SUPPLIER_RECOMMENDED_PRODUCT_FILTERS }); const [editor, setEditor] = useState(null); const [logs, setLogs] = useState(null); const [preview, setPreview] = useState(null); const [loading, setLoading] = useState(false); const [pageInput, setPageInput] = useState(String(initialData.pageInfo.page)); const fileRef = useRef(null); const columns = useSupplierRecommendedProductColumns() + const load = useCallback(async (nextFilters: SupplierRecommendedProductFilters, page: number, pageSize: number) => { setLoading(true); try { const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) }); Object.entries(nextFilters).forEach(([key, value]) => { if (value) params.set(key, value) }); const next = await readResponse(await fetch(`/api/supply-chain/supplier-recommended-products?${params}`, { cache: 'no-store' })) as SupplierRecommendedProductPage; setData(next); setFilters(nextFilters); setPageInput(String(next.pageInfo.page)) } catch (error) { window.alert(error instanceof Error ? error.message : '加载失败') } finally { setLoading(false) } }, []) + const afterImport = useCallback(() => load(filters, 1, data.pageInfo.pageSize), [data.pageInfo.pageSize, filters, load]); const importTask = useSupplierRecommendedProductImport(afterImport) + const reset = () => { const next = { ...EMPTY_SUPPLIER_RECOMMENDED_PRODUCT_FILTERS }; setFilters(next); void load(next, 1, data.pageInfo.pageSize) } + const openHistory = async (row: SupplierRecommendedProductRow) => { try { setLogs(await readResponse(await fetch(`/api/supply-chain/supplier-recommended-products/${row.id}/history`, { cache: 'no-store' }))) } catch (error) { window.alert(error instanceof Error ? error.message : '日志加载失败') } } + const commitPage = () => { const parsed = Number(pageInput); if (!Number.isFinite(parsed)) return setPageInput(String(data.pageInfo.page)); const page = Math.min(Math.max(1, Math.trunc(parsed)), data.pageInfo.totalPages); setPageInput(String(page)); if (page !== data.pageInfo.page) void load(filters, page, data.pageInfo.pageSize) } + return

供应商新品推荐

维护供应商自荐产品资料、寄样和发货信息

{canDownload && }{canEdit && <> { const file = event.target.files?.[0]; if (file) void importTask.importFile(file); event.currentTarget.value = '' }} />}
{ event.preventDefault(); void load(filters, 1, data.pageInfo.pageSize) }}> setFilters({ ...filters, search: event.target.value })} placeholder="搜索供应商、采购、产品代码、材质或发货模式" /> setFilters({ ...filters, dateFrom: event.target.value })} /> setFilters({ ...filters, dateTo: event.target.value })} />
void openHistory(row)} onPreview={setPreview} /> void load(filters, page, data.pageInfo.pageSize)} onPageSize={(pageSize) => void load(filters, 1, pageSize)} />
{editor && setEditor(null)} onSaved={() => { setEditor(null); void load(filters, data.pageInfo.page, data.pageInfo.pageSize) }} />}{logs && setLogs(null)} />}{importTask.state.status !== 'idle' && }{preview?.imageUrl &&
{ if (event.target === event.currentTarget) setPreview(null) }}>
{preview.productCode
}
+} diff --git a/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductColumnSettings.tsx b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductColumnSettings.tsx new file mode 100644 index 00000000..c72c778c --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductColumnSettings.tsx @@ -0,0 +1,8 @@ +'use client' + +import { Columns3 } from 'lucide-react' +import { SUPPLIER_RECOMMENDED_PRODUCT_COLUMNS, type SupplierRecommendedProductColumnKey } from '../supplierRecommendedProductColumns' + +export function SupplierRecommendedProductColumnSettings({ visibleKeys, hiddenCount, onToggle, onShowAll }: { visibleKeys: ReadonlySet; hiddenCount: number; onToggle: (key: SupplierRecommendedProductColumnKey) => void; onShowAll: () => void }) { + return
列设置{hiddenCount > 0 && {hiddenCount}}
显示字段
{SUPPLIER_RECOMMENDED_PRODUCT_COLUMNS.map((column) => )}
+} diff --git a/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductEditor.tsx b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductEditor.tsx new file mode 100644 index 00000000..74a76535 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductEditor.tsx @@ -0,0 +1,16 @@ +'use client' + +import { useState } from 'react' +import { Save, X } from 'lucide-react' +import type { SupplierRecommendedProductInput, SupplierRecommendedProductRow } from '@/lib/supplier-recommended-products' + +const EMPTY: SupplierRecommendedProductInput = { recommendationDate: null, supplier: '', purchaser: '', productCode: '', imageUrl: '', sampleRequest: '', dimensions: '', price: '', material: '', boardGrade: '', weight: '', orderQuantity: 0, productionCycle: '', shippingMode: '', dropShippingFee: '' } +function toDraft(row: SupplierRecommendedProductRow | null): SupplierRecommendedProductInput { if (!row) return { ...EMPTY }; const { id: _id, createdAt: _createdAt, updatedAt: _updatedAt, updatedByName: _updatedByName, ...input } = row; return input } + +export function SupplierRecommendedProductEditor({ item, onClose, onSaved }: { item: SupplierRecommendedProductRow | null; onClose: () => void; onSaved: () => void }) { + const [draft, setDraft] = useState(() => toDraft(item)); const [saving, setSaving] = useState(false) + const set = (key: K, value: SupplierRecommendedProductInput[K]) => setDraft((current) => ({ ...current, [key]: value })) + const save = async () => { setSaving(true); try { const response = await fetch(item ? `/api/supply-chain/supplier-recommended-products/${item.id}` : '/api/supply-chain/supplier-recommended-products', { method: item ? 'PATCH' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(draft) }); const payload = await response.json().catch(() => null); if (!response.ok || !payload?.success) throw new Error(payload?.error?.message || '保存失败'); onSaved() } catch (error) { window.alert(error instanceof Error ? error.message : '保存失败') } finally { setSaving(false) } } + return
{ if (event.target === event.currentTarget && !saving) onClose() }}>

{item ? '编辑供应商新品推荐' : '新增供应商新品推荐'}

供应商推荐信息人工维护,空白字段可保存

set('supplier', value)} /> set('purchaser', value)} /> set('productCode', value)} /> set('sampleRequest', value)} /> set('dimensions', value)} /> set('price', value)} /> set('material', value)} /> set('boardGrade', value)} /> set('weight', value)} /> set('productionCycle', value)} /> set('shippingMode', value)} /> set('dropShippingFee', value)} />
+} +function Field({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) { return } diff --git a/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductHistoryDialog.tsx b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductHistoryDialog.tsx new file mode 100644 index 00000000..734ad112 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductHistoryDialog.tsx @@ -0,0 +1,8 @@ +'use client' + +import { X } from 'lucide-react' +import type { SupplierRecommendedProductChangeLogEntry } from '@/lib/supplier-recommended-products' + +export function SupplierRecommendedProductHistoryDialog({ logs, onClose }: { logs: SupplierRecommendedProductChangeLogEntry[]; onClose: () => void }) { + return
{ if (event.target === event.currentTarget) onClose() }}>

修改日志

记录每次新增和编辑的操作人及前后值

{logs.length === 0 ?

暂无修改日志

:
{logs.map((log) =>
{log.operation === 'CREATE' || log.operation === 'IMPORT_CREATE' ? '新增' : '编辑'} · {log.actorName}
{log.changes.map((change) => )}
字段修改前修改后
{change.label}{String(change.before ?? '空白')}{String(change.after ?? '空白')}
)}
}
+} diff --git a/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductImportProgress.tsx b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductImportProgress.tsx new file mode 100644 index 00000000..8f375eb1 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductImportProgress.tsx @@ -0,0 +1,10 @@ +'use client' + +import type { SupplierRecommendedProductImportProgress as Progress } from '@/lib/supplier-recommended-products' + +export function SupplierRecommendedProductImportProgress({ state, onDismiss }: { state: ({ status: 'idle' } | { status: 'importing' | 'complete' | 'error'; progress: Progress; message?: string }); onDismiss: () => void }) { + if (state.status === 'idle') return null + const { progress } = state + const percent = progress.total ? Math.round((progress.processed / progress.total) * 100) : 0 + return
{state.status === 'importing' ? '正在导入供应商新品推荐' : state.status === 'complete' ? '导入完成' : '导入失败'}{state.status !== 'importing' && }

{state.status === 'error' ? state.message : `${progress.processed}/${progress.total} 行 · 新增 ${progress.created} 条`}

+} diff --git a/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductPagination.tsx b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductPagination.tsx new file mode 100644 index 00000000..86a258a1 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductPagination.tsx @@ -0,0 +1,7 @@ +'use client' + +import { ArrowLeft, ArrowRight } from 'lucide-react' + +export function SupplierRecommendedProductPagination({ page, pageSize, total, totalPages, loading, pageInput, onPageInput, onCommitPage, onPage, onPageSize }: { page: number; pageSize: number; total: number; totalPages: number; loading: boolean; pageInput: string; onPageInput: (value: string) => void; onCommitPage: () => void; onPage: (page: number) => void; onPageSize: (pageSize: number) => void }) { + return +} diff --git a/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductTable.tsx b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductTable.tsx new file mode 100644 index 00000000..caac3e7c --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductTable.tsx @@ -0,0 +1,20 @@ +'use client' + +import Image from 'next/image' +import { History, Pencil } from 'lucide-react' +import type { SupplierRecommendedProductRow } from '@/lib/supplier-recommended-products' +import type { SupplierRecommendedProductColumn, SupplierRecommendedProductColumnKey } from '../supplierRecommendedProductColumns' + +export function SupplierRecommendedProductTable({ rows, columns, canEdit, onEdit, onHistory, onPreview }: { rows: SupplierRecommendedProductRow[]; columns: readonly SupplierRecommendedProductColumn[]; canEdit: boolean; onEdit: (row: SupplierRecommendedProductRow) => void; onHistory: (row: SupplierRecommendedProductRow) => void; onPreview: (row: SupplierRecommendedProductRow) => void }) { + const minWidth = columns.reduce((total, column) => total + column.minWidth, 0) + return
{columns.map((column) => )}{rows.map((row) => {columns.map((column) => )})}
{column.label}
{rows.length === 0 &&
暂无匹配的供应商新品推荐
}
+} + +function SupplierRecommendedProductCell({ column, row, canEdit, onEdit, onHistory, onPreview }: { column: SupplierRecommendedProductColumnKey; row: SupplierRecommendedProductRow; canEdit: boolean; onEdit: (row: SupplierRecommendedProductRow) => void; onHistory: (row: SupplierRecommendedProductRow) => void; onPreview: (row: SupplierRecommendedProductRow) => void }) { + const base = 'border-b border-zinc-100 bg-white px-3 py-3 text-zinc-700 group-hover:bg-zinc-50' + if (column === 'recommendationDate') return {row.recommendationDate || '-'} + if (column === 'imageUrl') return {row.imageUrl ? : -} + if (column === 'actions') return
{canEdit && }
+ if (column === 'orderQuantity') return {row.orderQuantity.toLocaleString('zh-CN')} + return {String(row[column] || '-')} +} diff --git a/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/page.tsx b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/page.tsx new file mode 100644 index 00000000..b603331f --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/page.tsx @@ -0,0 +1,15 @@ +import { redirect } from 'next/navigation' +import { requireSession } from '@/lib/server-session' +import { supplierRecommendedProductsService } from '@/server/services/supplier-recommended-products.service' +import { SupplierRecommendedProductsClient } from './SupplierRecommendedProductsClient' + +export const dynamic = 'force-dynamic' +export const metadata = { title: '供应商新品推荐 · 供应链中心' } + +export default async function SupplierRecommendedProductsPage() { + const { authUser } = await requireSession('/login') + const access = await supplierRecommendedProductsService.getAccess(authUser) + if (!access.view) redirect('/supply-chain') + const initialData = await supplierRecommendedProductsService.listPage({ page: 1, pageSize: 50, user: authUser }) + return +} diff --git a/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/supplierRecommendedProductColumns.ts b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/supplierRecommendedProductColumns.ts new file mode 100644 index 00000000..c49a9251 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/supplierRecommendedProductColumns.ts @@ -0,0 +1,25 @@ +import type { SupplierRecommendedProductFieldKey } from '@/lib/supplier-recommended-products' + +export type SupplierRecommendedProductColumnKey = SupplierRecommendedProductFieldKey | 'actions' +export type SupplierRecommendedProductColumn = { key: SupplierRecommendedProductColumnKey; label: string; minWidth: number; locked?: boolean } + +export const SUPPLIER_RECOMMENDED_PRODUCT_COLUMNS: readonly SupplierRecommendedProductColumn[] = [ + { key: 'recommendationDate', label: '日期', minWidth: 112 }, + { key: 'supplier', label: '供应商', minWidth: 170 }, + { key: 'purchaser', label: '采购', minWidth: 110 }, + { key: 'productCode', label: '产品代码(供应商自己的)', minWidth: 190 }, + { key: 'imageUrl', label: '图片', minWidth: 86 }, + { key: 'sampleRequest', label: '申请寄样', minWidth: 120 }, + { key: 'dimensions', label: '尺寸', minWidth: 150 }, + { key: 'price', label: '价格(含税价+专票)', minWidth: 170 }, + { key: 'material', label: '材质', minWidth: 140 }, + { key: 'boardGrade', label: '板材等级', minWidth: 120 }, + { key: 'weight', label: '重量', minWidth: 100 }, + { key: 'orderQuantity', label: '下单量', minWidth: 90 }, + { key: 'productionCycle', label: '生产周期', minWidth: 120 }, + { key: 'shippingMode', label: '发货模式', minWidth: 120 }, + { key: 'dropShippingFee', label: '代发运费(代发情况下)', minWidth: 190 }, + { key: 'actions', label: '操作', minWidth: 150, locked: true }, +] + +export const SUPPLIER_RECOMMENDED_PRODUCT_COLUMN_KEYS = new Set(SUPPLIER_RECOMMENDED_PRODUCT_COLUMNS.map((column) => column.key)) diff --git a/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/supplierRecommendedProductsStyles.ts b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/supplierRecommendedProductsStyles.ts new file mode 100644 index 00000000..3db5c6af --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/supplierRecommendedProductsStyles.ts @@ -0,0 +1,10 @@ +export const supplierRecommendedProductsStyles = String.raw` + .recommended-products-page{height:100%;min-height:0;display:flex;flex-direction:column;overflow:hidden;background:#f5f6f6;color:#27272a} + .recommended-products-toolbar{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:16px;padding:18px 24px;border-bottom:1px solid #e4e4e7;background:#fff} + .recommended-products-toolbar h1{margin:0;font-size:18px;font-weight:750}.recommended-products-toolbar p{margin:4px 0 0;color:#71717a;font-size:12px}.recommended-products-actions{display:flex;flex-wrap:wrap;align-items:center;justify-content:flex-end;gap:8px} + .recommended-control-button,.recommended-primary-button,.recommended-secondary-button,.recommended-page-button,.recommended-row-button,.recommended-icon-button{display:inline-flex;align-items:center;justify-content:center;gap:6px;border:1px solid #d4d4d8;border-radius:5px;background:#fff;color:#3f3f46;font-size:12px;font-weight:600;cursor:pointer;transition:background .15s,border-color .15s,color .15s}.recommended-control-button,.recommended-primary-button,.recommended-secondary-button{height:38px;padding:0 13px}.recommended-primary-button{border-color:#27272a;background:#27272a;color:#fff}.recommended-primary-button:hover{background:#18181b}.recommended-secondary-button:hover,.recommended-control-button:hover,.recommended-row-button:hover,.recommended-page-button:hover{background:#f4f4f5;border-color:#a1a1aa}.recommended-control-button:disabled,.recommended-primary-button:disabled,.recommended-secondary-button:disabled,.recommended-row-button:disabled,.recommended-page-button:disabled{cursor:not-allowed;opacity:.45}.recommended-icon-button{width:30px;height:30px;padding:0;border:0;background:transparent}.recommended-icon-button:hover{background:#f4f4f5}.recommended-row-button{height:29px;padding:0 8px;font-size:11px}.recommended-page-button{height:30px;padding:0 12px} + .recommended-products-filters{display:grid;grid-template-columns:minmax(240px,2fr) repeat(3,minmax(130px,1fr)) 150px 150px auto auto;gap:8px;padding:12px 24px;border-bottom:1px solid #e4e4e7;background:#fff}.recommended-products-filters input,.recommended-products-filters select{min-width:0;height:38px;padding:0 11px;border:1px solid #d4d4d8;border-radius:5px;background:#fff;color:#3f3f46;font-size:12px;outline:none}.recommended-products-filters input:focus,.recommended-products-filters select:focus{border-color:#71717a;box-shadow:0 0 0 2px rgba(82,82,91,.08)} + .recommended-products-table-shell{position:relative;display:flex;min-height:0;flex:1;flex-direction:column;margin:16px 20px 20px;overflow:hidden;border:1px solid #e4e4e7;border-radius:7px;background:#fff;box-shadow:0 8px 24px rgba(24,24,27,.05)}.recommended-products-table-shell>div:first-child{padding-bottom:76px}.recommended-field{display:flex;flex-direction:column;gap:6px}.recommended-field>span{font-size:12px;font-weight:650;color:#52525b}.recommended-field input{width:100%;box-sizing:border-box;height:40px;border:1px solid #d4d4d8;border-radius:5px;background:#fff;padding:9px 10px;color:#27272a;font-size:13px;outline:none}.recommended-field input:focus{border-color:#71717a;box-shadow:0 0 0 2px rgba(82,82,91,.08)} + .recommended-column-settings{position:relative}.recommended-column-settings summary{list-style:none}.recommended-column-settings summary::-webkit-details-marker{display:none}.recommended-column-menu{position:absolute;right:0;top:44px;z-index:60;width:250px;max-height:420px;overflow-y:auto;border:1px solid #d4d4d8;border-radius:7px;background:#fff;padding:10px;box-shadow:0 12px 30px rgba(24,24,27,.12)}.recommended-column-menu-header{display:flex;align-items:center;justify-content:space-between;padding:2px 4px 8px;border-bottom:1px solid #f4f4f5;font-size:12px;font-weight:700}.recommended-column-menu-header button{border:0;background:none;color:#52525b;font-size:11px;cursor:pointer}.recommended-column-option{display:flex;align-items:center;gap:8px;padding:8px 4px;font-size:12px}.recommended-column-option input{accent-color:#27272a}.recommended-column-option em{margin-left:auto;color:#a1a1aa;font-size:10px;font-style:normal}.recommended-badge{display:inline-flex;min-width:16px;height:16px;align-items:center;justify-content:center;border-radius:99px;background:#e4e4e7;color:#52525b;font-size:10px} + @media(max-width:1280px){.recommended-products-filters{grid-template-columns:repeat(4,minmax(140px,1fr))}.recommended-products-filters>input:first-child{grid-column:span 2}}@media(max-width:760px){.recommended-products-toolbar{align-items:flex-start;padding:14px}.recommended-products-actions{justify-content:flex-start}.recommended-products-filters{grid-template-columns:1fr 1fr;padding:10px 14px}.recommended-products-filters>input:first-child{grid-column:1/-1}.recommended-products-table-shell{margin:10px}.recommended-products-table-shell>div:first-child{padding-bottom:72px}.recommended-page-button{padding:0 7px}.recommended-products-toolbar p{display:none}} +` diff --git a/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/useSupplierRecommendedProductColumns.ts b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/useSupplierRecommendedProductColumns.ts new file mode 100644 index 00000000..7462a0f8 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/useSupplierRecommendedProductColumns.ts @@ -0,0 +1,21 @@ +'use client' + +import { useCallback, useMemo, useState } from 'react' +import { SUPPLIER_RECOMMENDED_PRODUCT_COLUMNS, type SupplierRecommendedProductColumnKey } from './supplierRecommendedProductColumns' + +const STORAGE_KEY = 'supply-chain:supplier-recommended-products:columns' + +export function useSupplierRecommendedProductColumns() { + const [hidden, setHidden] = useState>(() => { + try { + const values = JSON.parse(window.localStorage.getItem(STORAGE_KEY) || '[]') as string[] + return new Set(values.filter((value): value is SupplierRecommendedProductColumnKey => SUPPLIER_RECOMMENDED_PRODUCT_COLUMNS.some((column) => column.key === value && !column.locked))) + } catch { return new Set() } + }) + const toggle = useCallback((key: SupplierRecommendedProductColumnKey) => { + setHidden((current) => { const next = new Set(current); const column = SUPPLIER_RECOMMENDED_PRODUCT_COLUMNS.find((item) => item.key === key); if (column?.locked) return current; if (next.has(key)) next.delete(key); else next.add(key); window.localStorage.setItem(STORAGE_KEY, JSON.stringify([...next])); return next }) + }, []) + const showAll = useCallback(() => { setHidden(new Set()); window.localStorage.removeItem(STORAGE_KEY) }, []) + const visibleColumns = useMemo(() => SUPPLIER_RECOMMENDED_PRODUCT_COLUMNS.filter((column) => !hidden.has(column.key)), [hidden]) + return { visibleColumns, visibleKeySet: new Set(visibleColumns.map((column) => column.key)), hiddenCount: hidden.size, toggle, showAll } +} diff --git a/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/useSupplierRecommendedProductImport.ts b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/useSupplierRecommendedProductImport.ts new file mode 100644 index 00000000..8b9190dd --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/useSupplierRecommendedProductImport.ts @@ -0,0 +1,21 @@ +'use client' + +import { useCallback, useState } from 'react' +import type { SupplierRecommendedProductImportProgress, SupplierRecommendedProductImportStreamEvent } from '@/lib/supplier-recommended-products' + +type State = { status: 'idle' } | { status: 'importing' | 'complete' | 'error'; progress: SupplierRecommendedProductImportProgress; message?: string } + +export function useSupplierRecommendedProductImport(onComplete: () => void) { + const [state, setState] = useState({ status: 'idle' }) + const importFile = useCallback(async (file: File) => { + setState({ status: 'importing', progress: { processed: 0, total: 0, created: 0, updated: 0 } }) + try { + const response = await fetch('/api/supply-chain/supplier-recommended-products/import', { method: 'POST', body: (() => { const form = new FormData(); form.append('file', file); return form })() }) + if (!response.ok || !response.body) { const payload = await response.json().catch(() => null); throw new Error(payload?.error?.message || '导入失败') } + const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = '' + while (true) { const { value, done } = await reader.read(); buffer += decoder.decode(value || new Uint8Array(), { stream: !done }); const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (const line of lines) { if (!line.trim()) continue; const event = JSON.parse(line) as SupplierRecommendedProductImportStreamEvent; if (event.type === 'error') throw new Error(event.message); setState({ status: event.type === 'complete' ? 'complete' : 'importing', progress: event.progress }); if (event.type === 'complete') onComplete() } if (done) break } + } catch (error) { setState((current) => ({ status: 'error', progress: current.status === 'idle' ? { processed: 0, total: 0, created: 0, updated: 0 } : current.progress, message: error instanceof Error ? error.message : '导入失败' })) } + }, [onComplete]) + const dismiss = useCallback(() => setState({ status: 'idle' }), []) + return { state, importFile, dismiss, isImporting: state.status === 'importing' } +} diff --git a/furniture-3d-system/src/lib/supplier-recommended-products.ts b/furniture-3d-system/src/lib/supplier-recommended-products.ts new file mode 100644 index 00000000..19fc201a --- /dev/null +++ b/furniture-3d-system/src/lib/supplier-recommended-products.ts @@ -0,0 +1,123 @@ +import { z } from 'zod' + +export const SUPPLIER_RECOMMENDED_PRODUCT_FIELDS = [ + { key: 'recommendationDate', label: '日期' }, + { key: 'supplier', label: '供应商' }, + { key: 'purchaser', label: '采购' }, + { key: 'productCode', label: '产品代码(供应商自己的)' }, + { key: 'imageUrl', label: '图片' }, + { key: 'sampleRequest', label: '申请寄样' }, + { key: 'dimensions', label: '尺寸' }, + { key: 'price', label: '价格(含税价+专票)' }, + { key: 'material', label: '材质' }, + { key: 'boardGrade', label: '板材等级' }, + { key: 'weight', label: '重量' }, + { key: 'orderQuantity', label: '下单量' }, + { key: 'productionCycle', label: '生产周期' }, + { key: 'shippingMode', label: '发货模式' }, + { key: 'dropShippingFee', label: '代发运费(代发情况下)' }, +] as const + +export type SupplierRecommendedProductFieldKey = typeof SUPPLIER_RECOMMENDED_PRODUCT_FIELDS[number]['key'] + +const cleanString = (max: number) => z.preprocess( + (value) => String(value ?? '').trim(), + z.string().max(max), +) + +const nonnegativeInteger = z.preprocess((value) => { + if (value === '' || value === null || value === undefined) return 0 + return typeof value === 'number' ? value : Number(String(value).replace(/,/g, '').trim()) +}, z.number({ message: '下单量必须是数字' }).int('下单量必须是整数').min(0, '下单量不能小于0').max(2_000_000_000, '下单量超出允许范围')) + +function normalizeDate(value: unknown) { + if (value === '' || value === null || value === undefined) return null + if (value instanceof Date) return Number.isNaN(value.getTime()) ? value : value.toISOString().slice(0, 10) + if (typeof value === 'number' && Number.isFinite(value)) { + return new Date(Date.UTC(1899, 11, 30) + Math.trunc(value) * 86_400_000).toISOString().slice(0, 10) + } + return String(value).trim() +} + +const optionalDate = z.preprocess( + normalizeDate, + z.string().refine((value) => !Number.isNaN(new Date(`${value}T00:00:00Z`).getTime()), '日期格式不正确').nullable(), +) + +export const supplierRecommendedProductInputSchema = z.object({ + recommendationDate: optionalDate, + supplier: cleanString(240), + purchaser: cleanString(160), + productCode: cleanString(200), + imageUrl: cleanString(2_000), + sampleRequest: cleanString(120), + dimensions: cleanString(240), + price: cleanString(120), + material: cleanString(240), + boardGrade: cleanString(120), + weight: cleanString(120), + orderQuantity: nonnegativeInteger, + productionCycle: cleanString(120), + shippingMode: cleanString(120), + dropShippingFee: cleanString(120), +}).strict() + +export type SupplierRecommendedProductInput = z.infer +export type SupplierRecommendedProductRow = SupplierRecommendedProductInput & { + id: string + createdAt: string + updatedAt: string + updatedByName: string +} + +export type SupplierRecommendedProductPage = { + rows: SupplierRecommendedProductRow[] + pageInfo: { page: number; pageSize: number; total: number; totalPages: number } + options: { suppliers: string[]; purchasers: string[]; shippingModes: string[] } +} + +export const EMPTY_SUPPLIER_RECOMMENDED_PRODUCT_FILTERS = { + search: '', + supplier: '', + purchaser: '', + shippingMode: '', + dateFrom: '', + dateTo: '', +} as const + +export type SupplierRecommendedProductFilters = { + [K in keyof typeof EMPTY_SUPPLIER_RECOMMENDED_PRODUCT_FILTERS]: string +} + +export type SupplierRecommendedProductChange = { + field: SupplierRecommendedProductFieldKey + label: string + before: string | number | null + after: string | number | null +} + +export type SupplierRecommendedProductChangeLogEntry = { + id: string + operation: string + actorName: string + changes: SupplierRecommendedProductChange[] + createdAt: string +} + +export type SupplierRecommendedProductImportProgress = { + processed: number + total: number + created: number + updated: number +} + +export type SupplierRecommendedProductImportStreamEvent = + | { type: 'progress' | 'complete'; progress: SupplierRecommendedProductImportProgress } + | { type: 'error'; progress: SupplierRecommendedProductImportProgress; message: string } + +export const SUPPLIER_RECOMMENDED_PRODUCT_IMPORT_HEADERS = Object.fromEntries( + SUPPLIER_RECOMMENDED_PRODUCT_FIELDS.flatMap((field) => [ + [field.label, field.key], + [field.key, field.key], + ]), +) as Record diff --git a/furniture-3d-system/src/lib/supply-chain-permission-settings.ts b/furniture-3d-system/src/lib/supply-chain-permission-settings.ts index a5fad1e1..71bf36b3 100644 --- a/furniture-3d-system/src/lib/supply-chain-permission-settings.ts +++ b/furniture-3d-system/src/lib/supply-chain-permission-settings.ts @@ -30,6 +30,7 @@ export type SupplyChainPermissionTargetKey = | 'newProductUnifiedTable' | 'newProductSampleProgress' | 'purchaseSupplierInformation' + | 'purchaseSupplierRecommendedProducts' | 'purchaseDailyAffairs' | 'purchasePriceChange' | 'scriptMonitor' @@ -84,6 +85,7 @@ export const SUPPLY_CHAIN_PERMISSION_TARGETS: SupplyChainPermissionTarget[] = [ { key: 'newProductUnifiedTable', label: '新品开发进度总表', description: '新品流程公共数据源' }, { key: 'newProductSampleProgress', label: '打样进度明细', description: '新品打样进度公共数据源' }, { key: 'purchaseSupplierInformation', label: '供应商信息', description: '采购供应商信息汇总' }, + { key: 'purchaseSupplierRecommendedProducts', label: '供应商新品推荐', description: '供应商推荐新品维护' }, { key: 'purchaseDailyAffairs', label: '日常事项', description: '采购日常协作' }, { key: 'purchasePriceChange', label: '产品变价', description: '采购价格变更协作' }, { key: 'scriptMonitor', label: '脚本监控', description: '跨机器脚本任务、状态和运行结果' }, @@ -108,6 +110,7 @@ export const SUPPLY_CHAIN_PERMISSION_TARGET_ROUTES: Partial< newProductUnifiedTable: '/supply-chain/new-products/unified-table', newProductSampleProgress: '/supply-chain/new-products/sample-progress', purchaseSupplierInformation: '/supply-chain/purchase/supplier-information', + purchaseSupplierRecommendedProducts: '/supply-chain/purchase/supplier-recommended-products', purchaseDailyAffairs: '/supply-chain/purchase/daily-affairs', purchasePriceChange: '/supply-chain/purchase/price-change', scriptMonitor: '/supply-chain/script-monitor', @@ -323,6 +326,7 @@ export function getSupplyChainPermissionTargetForPath(pathname: string) { if (pathname === '/supply-chain/new-products/sample-progress') return 'newProductSampleProgress' as const if (pathname === '/supply-chain/purchase/finished-product-progress') return 'newProductSampleProgress' as const if (pathname === '/supply-chain/purchase/supplier-information') return 'purchaseSupplierInformation' as const + if (pathname === '/supply-chain/purchase/supplier-recommended-products') return 'purchaseSupplierRecommendedProducts' as const if (pathname === '/supply-chain/purchase/daily-affairs') return 'purchaseDailyAffairs' as const if (pathname === '/supply-chain/purchase/price-change') return 'purchasePriceChange' as const if (pathname === '/supply-chain/script-monitor') return 'scriptMonitor' as const @@ -364,6 +368,9 @@ export function getSupplyChainApiPermissionRequirement(pathname: string, method: if (pathname.startsWith('/api/supply-chain/supplier-information')) { return { targetKey: 'purchaseSupplierInformation' as const, action } } + if (pathname.startsWith('/api/supply-chain/supplier-recommended-products')) { + return { targetKey: 'purchaseSupplierRecommendedProducts' as const, action: pathname.endsWith('/template') ? 'download' as const : action } + } if (pathname.startsWith('/api/supply-chain/script-monitor')) { return { targetKey: 'scriptMonitor' as const, action } } diff --git a/furniture-3d-system/src/server/repositories/supplier-recommended-products.repository.ts b/furniture-3d-system/src/server/repositories/supplier-recommended-products.repository.ts new file mode 100644 index 00000000..47d60755 --- /dev/null +++ b/furniture-3d-system/src/server/repositories/supplier-recommended-products.repository.ts @@ -0,0 +1,106 @@ +import { Prisma } from '@prisma/client' +import { prisma } from '@/lib/prisma' +import type { SupplierRecommendedProductFilters, SupplierRecommendedProductInput } from '@/lib/supplier-recommended-products' + +const ITEM_SELECT = { + id: true, + recommendationDate: true, + supplier: true, + purchaser: true, + productCode: true, + imageUrl: true, + sampleRequest: true, + dimensions: true, + price: true, + material: true, + boardGrade: true, + weight: true, + orderQuantity: true, + productionCycle: true, + shippingMode: true, + dropShippingFee: true, + createdAt: true, + updatedAt: true, + updatedByName: true, +} as const + +export type SupplierRecommendedProductRecord = Prisma.SupplierRecommendedProductGetPayload<{ select: typeof ITEM_SELECT }> + +function contains(value: string) { + return { contains: value, mode: 'insensitive' as const } +} + +function buildWhere(filters: SupplierRecommendedProductFilters): Prisma.SupplierRecommendedProductWhereInput | undefined { + const and: Prisma.SupplierRecommendedProductWhereInput[] = [] + const tokens = [...new Set(filters.search.split(/[\s,,;;]+/).map((value) => value.trim()).filter(Boolean))].slice(0, 100) + tokens.forEach((token) => and.push({ OR: [ + { supplier: contains(token) }, { purchaser: contains(token) }, { productCode: contains(token) }, + { sampleRequest: contains(token) }, { dimensions: contains(token) }, { price: contains(token) }, + { material: contains(token) }, { boardGrade: contains(token) }, { weight: contains(token) }, + { productionCycle: contains(token) }, { shippingMode: contains(token) }, { dropShippingFee: contains(token) }, + ] })) + if (filters.supplier) and.push({ supplier: contains(filters.supplier) }) + if (filters.purchaser) and.push({ purchaser: contains(filters.purchaser) }) + if (filters.shippingMode) and.push({ shippingMode: contains(filters.shippingMode) }) + if (filters.dateFrom || filters.dateTo) { + and.push({ recommendationDate: { + ...(filters.dateFrom ? { gte: new Date(`${filters.dateFrom}T00:00:00Z`) } : {}), + ...(filters.dateTo ? { lte: new Date(`${filters.dateTo}T00:00:00Z`) } : {}), + } }) + } + return and.length ? { AND: and } : undefined +} + +function dataValues(input: SupplierRecommendedProductInput) { + return { + ...input, + recommendationDate: input.recommendationDate ? new Date(`${input.recommendationDate}T00:00:00Z`) : null, + imageUrl: input.imageUrl || null, + } +} + +export class SupplierRecommendedProductsRepository { + async listPage(params: { skip: number; take: number; filters: SupplierRecommendedProductFilters }) { + const where = buildWhere(params.filters) + const [rows, total] = await Promise.all([ + prisma.supplierRecommendedProduct.findMany({ where, select: ITEM_SELECT, orderBy: [{ recommendationDate: 'desc' }, { createdAt: 'desc' }], skip: params.skip, take: params.take }), + prisma.supplierRecommendedProduct.count({ where }), + ]) + return { rows, total } + } + + async listOptions() { + const [suppliers, purchasers, shippingModes] = await Promise.all([ + prisma.supplierRecommendedProduct.findMany({ select: { supplier: true }, distinct: ['supplier'], orderBy: { supplier: 'asc' } }), + prisma.supplierRecommendedProduct.findMany({ select: { purchaser: true }, distinct: ['purchaser'], orderBy: { purchaser: 'asc' } }), + prisma.supplierRecommendedProduct.findMany({ select: { shippingMode: true }, distinct: ['shippingMode'], orderBy: { shippingMode: 'asc' } }), + ]) + return { + suppliers: suppliers.map((row) => row.supplier.trim()).filter(Boolean), + purchasers: purchasers.map((row) => row.purchaser.trim()).filter(Boolean), + shippingModes: shippingModes.map((row) => row.shippingMode.trim()).filter(Boolean), + } + } + + async findById(id: string) { + return prisma.supplierRecommendedProduct.findUnique({ where: { id }, select: ITEM_SELECT }) + } + + async create(input: SupplierRecommendedProductInput, actor: { id: string; name: string }) { + return prisma.supplierRecommendedProduct.create({ data: { ...dataValues(input), createdById: actor.id, createdByName: actor.name, updatedById: actor.id, updatedByName: actor.name }, select: ITEM_SELECT }) + } + + async update(id: string, input: SupplierRecommendedProductInput, actor: { id: string; name: string }) { + return prisma.supplierRecommendedProduct.update({ where: { id }, data: { ...dataValues(input), updatedById: actor.id, updatedByName: actor.name }, select: ITEM_SELECT }) + } + + async createLog(data: { itemId: string; operation: string; actorId: string; actorName: string; changes: unknown }) { + await prisma.supplierRecommendedProductChangeLog.create({ data: { ...data, changes: data.changes as Prisma.InputJsonValue } }) + } + + async listLogs(itemId: string, take = 200) { + return prisma.supplierRecommendedProductChangeLog.findMany({ where: { itemId }, select: { id: true, operation: true, actorName: true, changes: true, createdAt: true }, orderBy: { createdAt: 'desc' }, take }) + } +} + +export const supplierRecommendedProductsRepository = new SupplierRecommendedProductsRepository() diff --git a/furniture-3d-system/src/server/services/supplier-recommended-products.service.ts b/furniture-3d-system/src/server/services/supplier-recommended-products.service.ts new file mode 100644 index 00000000..3922813a --- /dev/null +++ b/furniture-3d-system/src/server/services/supplier-recommended-products.service.ts @@ -0,0 +1,138 @@ +import { + EMPTY_SUPPLIER_RECOMMENDED_PRODUCT_FILTERS, + SUPPLIER_RECOMMENDED_PRODUCT_FIELDS, + supplierRecommendedProductInputSchema, + type SupplierRecommendedProductChange, + type SupplierRecommendedProductFilters, + type SupplierRecommendedProductImportProgress, + type SupplierRecommendedProductInput, +} from '@/lib/supplier-recommended-products' +import { AppError, ErrorCode } from '@/server/core/errors' +import type { AuthUser } from '@/server/core/types' +import { supplierRecommendedProductsRepository, type SupplierRecommendedProductRecord, type SupplierRecommendedProductsRepository } from '@/server/repositories/supplier-recommended-products.repository' +import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service' + +const MAX_PAGE_SIZE = 200 + +function dateText(value: Date | string | null | undefined) { + if (!value) return null + const date = value instanceof Date ? value : new Date(value) + return Number.isNaN(date.getTime()) ? null : date.toISOString().slice(0, 10) +} + +function cleanFilters(input?: Partial): SupplierRecommendedProductFilters { + return Object.fromEntries(Object.keys(EMPTY_SUPPLIER_RECOMMENDED_PRODUCT_FILTERS).map((key) => [key, String(input?.[key as keyof SupplierRecommendedProductFilters] ?? '').trim().slice(0, 200)])) as SupplierRecommendedProductFilters +} + +function comparable(input: SupplierRecommendedProductInput | SupplierRecommendedProductRecord) { + return { + recommendationDate: dateText(input.recommendationDate), supplier: input.supplier, purchaser: input.purchaser, + productCode: input.productCode, imageUrl: input.imageUrl ?? '', sampleRequest: input.sampleRequest, + dimensions: input.dimensions, price: input.price, material: input.material, boardGrade: input.boardGrade, + weight: input.weight, orderQuantity: input.orderQuantity, productionCycle: input.productionCycle, + shippingMode: input.shippingMode, dropShippingFee: input.dropShippingFee, + } satisfies Record +} + +function changeSet(before: SupplierRecommendedProductInput | SupplierRecommendedProductRecord | null, after: SupplierRecommendedProductInput): SupplierRecommendedProductChange[] { + const previous = before ? comparable(before) : {} + const next = comparable(after) + return SUPPLIER_RECOMMENDED_PRODUCT_FIELDS.flatMap((field) => { + const beforeValue = previous[field.key as keyof typeof previous] ?? null + const afterValue = next[field.key] + return beforeValue === afterValue ? [] : [{ field: field.key, label: field.label, before: beforeValue, after: afterValue }] + }) +} + +function toRow(record: SupplierRecommendedProductRecord) { + return { + id: record.id, + recommendationDate: dateText(record.recommendationDate), supplier: record.supplier, purchaser: record.purchaser, + productCode: record.productCode, imageUrl: record.imageUrl ?? '', sampleRequest: record.sampleRequest, + dimensions: record.dimensions, price: record.price, material: record.material, boardGrade: record.boardGrade, + weight: record.weight, orderQuantity: record.orderQuantity, productionCycle: record.productionCycle, + shippingMode: record.shippingMode, dropShippingFee: record.dropShippingFee, + createdAt: record.createdAt.toISOString(), updatedAt: record.updatedAt.toISOString(), updatedByName: record.updatedByName, + } +} + +function parseInput(input: unknown, rowNumber?: number) { + const parsed = supplierRecommendedProductInputSchema.safeParse(input) + if (parsed.success) return parsed.data + const message = parsed.error.issues[0]?.message ?? '数据格式不正确' + throw new AppError(ErrorCode.VALIDATION_FAILED, rowNumber ? `第${rowNumber}行:${message}` : message) +} + +export class SupplierRecommendedProductsService { + constructor(private readonly repository: SupplierRecommendedProductsRepository = supplierRecommendedProductsRepository) {} + + async getAccess(user: AuthUser) { + return supplyChainPermissionSettingsService.getUserAccess(user, 'purchaseSupplierRecommendedProducts') + } + + private async requireAccess(user: AuthUser, action: 'view' | 'download' | 'edit') { + const access = await this.getAccess(user) + if (!access[action]) throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有供应商新品推荐权限') + return access + } + + async listPage(params: { page?: number; pageSize?: number; filters?: Partial; user: AuthUser }) { + await this.requireAccess(params.user, 'view') + const page = Math.max(1, Math.trunc(params.page || 1)) + const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, Math.trunc(params.pageSize || 50))) + const filters = cleanFilters(params.filters) + const [result, options] = await Promise.all([ + this.repository.listPage({ skip: (page - 1) * pageSize, take: pageSize, filters }), + this.repository.listOptions(), + ]) + return { rows: result.rows.map(toRow), pageInfo: { page, pageSize, total: result.total, totalPages: Math.max(1, Math.ceil(result.total / pageSize)) }, options } + } + + async create(input: unknown, user: AuthUser) { + await this.requireAccess(user, 'edit') + const values = parseInput(input) + const record = await this.repository.create(values, user) + await this.repository.createLog({ itemId: record.id, operation: 'CREATE', actorId: user.id, actorName: user.name, changes: changeSet(null, values) }) + return toRow(record) + } + + async update(id: string, input: unknown, user: AuthUser) { + await this.requireAccess(user, 'edit') + const current = await this.repository.findById(id.trim()) + if (!current) throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, '供应商新品推荐记录不存在') + const values = parseInput(input) + const changes = changeSet(current, values) + if (!changes.length) return toRow(current) + const record = await this.repository.update(current.id, values, user) + await this.repository.createLog({ itemId: record.id, operation: 'UPDATE', actorId: user.id, actorName: user.name, changes }) + return toRow(record) + } + + async importRows(rows: Array>, user: AuthUser, onProgress?: (progress: SupplierRecommendedProductImportProgress) => void) { + await this.requireAccess(user, 'edit') + if (!rows.length) throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '导入文件没有可用数据') + if (rows.length > 5_000) throw new AppError(ErrorCode.VALIDATION_OUT_OF_RANGE, '单次最多导入5000行') + let created = 0 + for (let index = 0; index < rows.length; index += 1) { + const values = parseInput(rows[index], index + 2) + const record = await this.repository.create(values, user) + await this.repository.createLog({ itemId: record.id, operation: 'IMPORT_CREATE', actorId: user.id, actorName: user.name, changes: changeSet(null, values) }) + created += 1 + onProgress?.({ processed: index + 1, total: rows.length, created, updated: 0 }) + } + return { created, updated: 0 } + } + + async listChangeLogs(itemId: string, user: AuthUser) { + await this.requireAccess(user, 'view') + if (!await this.repository.findById(itemId.trim())) throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, '供应商新品推荐记录不存在') + const logs = await this.repository.listLogs(itemId.trim()) + return logs.map((log) => ({ id: log.id, operation: log.operation, actorName: log.actorName, changes: log.changes as unknown as SupplierRecommendedProductChange[], createdAt: log.createdAt.toISOString() })) + } + + async requireTemplateAccess(user: AuthUser) { + await this.requireAccess(user, 'download') + } +} + +export const supplierRecommendedProductsService = new SupplierRecommendedProductsService() diff --git a/furniture-3d-system/src/server/services/supply-chain-permission-settings.service.ts b/furniture-3d-system/src/server/services/supply-chain-permission-settings.service.ts index daa87def..c71313fd 100644 --- a/furniture-3d-system/src/server/services/supply-chain-permission-settings.service.ts +++ b/furniture-3d-system/src/server/services/supply-chain-permission-settings.service.ts @@ -117,6 +117,7 @@ class SupplyChainPermissionSettingsService { 'newProductUnifiedTable', 'newProductSampleProgress', 'purchaseSupplierInformation', + 'purchaseSupplierRecommendedProducts', 'purchaseDailyAffairs', 'purchasePriceChange', 'scriptMonitor', @@ -139,6 +140,7 @@ class SupplyChainPermissionSettingsService { 'newProductUnifiedTable', 'newProductSampleProgress', 'purchaseSupplierInformation', + 'purchaseSupplierRecommendedProducts', 'purchaseDailyAffairs', 'purchasePriceChange', 'scriptMonitor',