feat(supply-chain): add supplier recommended products
This commit is contained in:
parent
934b073eea
commit
474f245ba7
@ -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;
|
||||
@ -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])
|
||||
}
|
||||
|
||||
|
||||
// ============================================
|
||||
// 产品分类
|
||||
// ============================================
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -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<Record<string, unknown>>(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<Uint8Array>({
|
||||
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 })
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -101,6 +101,7 @@ export const SUPPLY_CHAIN_ROUTE_TITLES: Record<string, string> = {
|
||||
'/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': '产品变价',
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -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<SupplierRecommendedProductFilters>({ ...EMPTY_SUPPLIER_RECOMMENDED_PRODUCT_FILTERS }); const [editor, setEditor] = useState<SupplierRecommendedProductRow | 'new' | null>(null); const [logs, setLogs] = useState<SupplierRecommendedProductChangeLogEntry[] | null>(null); const [preview, setPreview] = useState<SupplierRecommendedProductRow | null>(null); const [loading, setLoading] = useState(false); const [pageInput, setPageInput] = useState(String(initialData.pageInfo.page)); const fileRef = useRef<HTMLInputElement>(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 <main className="recommended-products-page"><header className="recommended-products-toolbar"><div><h1>供应商新品推荐</h1><p>维护供应商自荐产品资料、寄样和发货信息</p></div><div className="recommended-products-actions"><SupplierRecommendedProductColumnSettings visibleKeys={columns.visibleKeySet} hiddenCount={columns.hiddenCount} onToggle={columns.toggle} onShowAll={columns.showAll} />{canDownload && <button type="button" className="recommended-control-button" onClick={() => { window.location.href = '/api/supply-chain/supplier-recommended-products/template' }}><FileDown size={15} />下载模板</button>}{canEdit && <><button type="button" disabled={importTask.isImporting} onClick={() => fileRef.current?.click()} className="recommended-control-button"><Upload size={15} />{importTask.isImporting ? '导入中' : '导入'}</button><input ref={fileRef} type="file" accept=".xlsx,.xls,.csv" hidden onChange={(event) => { const file = event.target.files?.[0]; if (file) void importTask.importFile(file); event.currentTarget.value = '' }} /><button type="button" onClick={() => setEditor('new')} className="recommended-primary-button"><Plus size={15} />新增</button></>}</div></header><form className="recommended-products-filters" onSubmit={(event) => { event.preventDefault(); void load(filters, 1, data.pageInfo.pageSize) }}><input value={filters.search} onChange={(event) => setFilters({ ...filters, search: event.target.value })} placeholder="搜索供应商、采购、产品代码、材质或发货模式" /><select value={filters.supplier} onChange={(event) => setFilters({ ...filters, supplier: event.target.value })}><option value="">全部供应商</option>{data.options.suppliers.map((value) => <option key={value}>{value}</option>)}</select><select value={filters.purchaser} onChange={(event) => setFilters({ ...filters, purchaser: event.target.value })}><option value="">全部采购</option>{data.options.purchasers.map((value) => <option key={value}>{value}</option>)}</select><select value={filters.shippingMode} onChange={(event) => setFilters({ ...filters, shippingMode: event.target.value })}><option value="">全部发货模式</option>{data.options.shippingModes.map((value) => <option key={value}>{value}</option>)}</select><input type="date" title="日期开始" aria-label="日期开始" value={filters.dateFrom} onChange={(event) => setFilters({ ...filters, dateFrom: event.target.value })} /><input type="date" title="日期结束" aria-label="日期结束" value={filters.dateTo} onChange={(event) => setFilters({ ...filters, dateTo: event.target.value })} /><button type="submit" className="recommended-primary-button"><Search size={15} />查询</button><button type="button" title="重置筛选" aria-label="重置筛选" onClick={reset} className="recommended-control-button"><RotateCcw size={15} /></button></form><section className="recommended-products-table-shell"><SupplierRecommendedProductTable rows={data.rows} columns={columns.visibleColumns} canEdit={canEdit} onEdit={setEditor} onHistory={(row) => void openHistory(row)} onPreview={setPreview} /><SupplierRecommendedProductPagination {...data.pageInfo} loading={loading} pageInput={pageInput} onPageInput={setPageInput} onCommitPage={commitPage} onPage={(page) => void load(filters, page, data.pageInfo.pageSize)} onPageSize={(pageSize) => void load(filters, 1, pageSize)} /></section>{editor && <SupplierRecommendedProductEditor item={editor === 'new' ? null : editor} onClose={() => setEditor(null)} onSaved={() => { setEditor(null); void load(filters, data.pageInfo.page, data.pageInfo.pageSize) }} />}{logs && <SupplierRecommendedProductHistoryDialog logs={logs} onClose={() => setLogs(null)} />}{importTask.state.status !== 'idle' && <SupplierRecommendedProductImportProgress state={importTask.state} onDismiss={importTask.dismiss} />}{preview?.imageUrl && <div className="fixed inset-0 z-[90] flex items-center justify-center bg-black/80 p-6" onMouseDown={(event) => { if (event.target === event.currentTarget) setPreview(null) }}><div className="relative h-[min(82vh,900px)] w-[min(90vw,1200px)]"><Image unoptimized src={preview.imageUrl} alt={preview.productCode || '新品图片'} fill sizes="90vw" className="object-contain" /><button type="button" title="关闭" onClick={() => setPreview(null)} className="absolute right-0 top-0 flex h-10 w-10 items-center justify-center rounded-full bg-black/70 text-white"><X size={20} /></button></div></div>}<style jsx global>{supplierRecommendedProductsStyles}</style></main>
|
||||
}
|
||||
@ -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<SupplierRecommendedProductColumnKey>; hiddenCount: number; onToggle: (key: SupplierRecommendedProductColumnKey) => void; onShowAll: () => void }) {
|
||||
return <details className="recommended-column-settings"><summary className="recommended-control-button"><Columns3 size={15} />列设置{hiddenCount > 0 && <span className="recommended-badge">{hiddenCount}</span>}</summary><div className="recommended-column-menu"><div className="recommended-column-menu-header"><span>显示字段</span><button type="button" onClick={onShowAll}>全部显示</button></div>{SUPPLIER_RECOMMENDED_PRODUCT_COLUMNS.map((column) => <label key={column.key} className="recommended-column-option"><input type="checkbox" checked={visibleKeys.has(column.key)} disabled={column.locked} onChange={() => onToggle(column.key)} /><span>{column.label}</span>{column.locked && <em>固定</em>}</label>)}</div></details>
|
||||
}
|
||||
@ -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 = <K extends keyof SupplierRecommendedProductInput>(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 <div className="fixed inset-0 z-[70] flex justify-end bg-black/30" onMouseDown={(event) => { if (event.target === event.currentTarget && !saving) onClose() }}><section className="flex h-full w-full max-w-[780px] flex-col bg-[#f7f8f8] shadow-2xl"><header className="flex h-16 shrink-0 items-center justify-between border-b border-black/10 bg-white px-6"><div><h2 className="text-base font-bold text-zinc-900">{item ? '编辑供应商新品推荐' : '新增供应商新品推荐'}</h2><p className="mt-0.5 text-xs text-zinc-500">供应商推荐信息人工维护,空白字段可保存</p></div><button type="button" title="关闭" disabled={saving} onClick={onClose} className="recommended-icon-button"><X size={18} /></button></header><div className="min-h-0 flex-1 overflow-y-auto p-6"><div className="grid gap-4 rounded-[7px] border border-black/10 bg-white p-5 sm:grid-cols-2"><label className="recommended-field"><span>日期</span><input type="date" value={draft.recommendationDate ?? ''} onChange={(event) => set('recommendationDate', event.target.value || null)} /></label><Field label="供应商" value={draft.supplier} onChange={(value) => set('supplier', value)} /><Field label="采购" value={draft.purchaser} onChange={(value) => set('purchaser', value)} /><Field label="产品代码(供应商自己的)" value={draft.productCode} onChange={(value) => set('productCode', value)} /><Field label="申请寄样" value={draft.sampleRequest} onChange={(value) => set('sampleRequest', value)} /><Field label="尺寸" value={draft.dimensions} onChange={(value) => set('dimensions', value)} /><Field label="价格(含税价+专票)" value={draft.price} onChange={(value) => set('price', value)} /><Field label="材质" value={draft.material} onChange={(value) => set('material', value)} /><Field label="板材等级" value={draft.boardGrade} onChange={(value) => set('boardGrade', value)} /><Field label="重量" value={draft.weight} onChange={(value) => set('weight', value)} /><label className="recommended-field"><span>下单量</span><input type="number" min={0} step={1} value={draft.orderQuantity} onChange={(event) => set('orderQuantity', Math.max(0, Math.trunc(Number(event.target.value) || 0)))} /></label><Field label="生产周期" value={draft.productionCycle} onChange={(value) => set('productionCycle', value)} /><Field label="发货模式" value={draft.shippingMode} onChange={(value) => set('shippingMode', value)} /><Field label="代发运费(代发情况下)" value={draft.dropShippingFee} onChange={(value) => set('dropShippingFee', value)} /><label className="recommended-field sm:col-span-2"><span>图片 URL</span><input value={draft.imageUrl} onChange={(event) => set('imageUrl', event.target.value)} placeholder="https://..." /></label></div></div><footer className="flex shrink-0 justify-end gap-3 border-t border-black/10 bg-white px-6 py-4"><button type="button" disabled={saving} onClick={onClose} className="recommended-secondary-button">取消</button><button type="button" disabled={saving} onClick={() => void save()} className="recommended-primary-button"><Save size={15} />{saving ? '保存中...' : '保存'}</button></footer></section></div>
|
||||
}
|
||||
function Field({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) { return <label className="recommended-field"><span>{label}</span><input value={value} onChange={(event) => onChange(event.target.value)} /></label> }
|
||||
@ -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 <div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/30 p-6" onMouseDown={(event) => { if (event.target === event.currentTarget) onClose() }}><section className="flex max-h-[80vh] w-full max-w-[760px] flex-col overflow-hidden rounded-[8px] bg-white shadow-2xl"><header className="flex items-center justify-between border-b border-zinc-200 px-5 py-4"><div><h2 className="font-bold text-zinc-900">修改日志</h2><p className="mt-1 text-xs text-zinc-500">记录每次新增和编辑的操作人及前后值</p></div><button type="button" title="关闭" onClick={onClose} className="recommended-icon-button"><X size={18} /></button></header><div className="min-h-0 flex-1 overflow-y-auto p-5">{logs.length === 0 ? <p className="py-10 text-center text-sm text-zinc-500">暂无修改日志</p> : <div className="space-y-4">{logs.map((log) => <article key={log.id} className="rounded-[6px] border border-zinc-200 p-4"><div className="flex justify-between text-xs text-zinc-500"><span className="font-semibold text-zinc-800">{log.operation === 'CREATE' || log.operation === 'IMPORT_CREATE' ? '新增' : '编辑'} · {log.actorName}</span><time>{new Date(log.createdAt).toLocaleString('zh-CN')}</time></div><div className="mt-3 overflow-x-auto"><table className="w-full text-left text-xs"><thead><tr className="text-zinc-500"><th className="pb-2 pr-4">字段</th><th className="pb-2 pr-4">修改前</th><th className="pb-2">修改后</th></tr></thead><tbody>{log.changes.map((change) => <tr key={change.field} className="border-t border-zinc-100"><td className="py-2 pr-4 font-medium text-zinc-700">{change.label}</td><td className="py-2 pr-4 text-zinc-500">{String(change.before ?? '空白')}</td><td className="py-2 text-zinc-800">{String(change.after ?? '空白')}</td></tr>)}</tbody></table></div></article>)}</div>}</div></section></div>
|
||||
}
|
||||
@ -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 <div className="fixed bottom-5 right-5 z-[90] w-[min(380px,calc(100vw-40px))] rounded-[8px] border border-zinc-200 bg-white p-4 shadow-xl"><div className="flex items-center justify-between"><strong className="text-sm text-zinc-900">{state.status === 'importing' ? '正在导入供应商新品推荐' : state.status === 'complete' ? '导入完成' : '导入失败'}</strong>{state.status !== 'importing' && <button type="button" onClick={onDismiss} className="text-xs text-zinc-500 hover:text-zinc-900">关闭</button>}</div><div className="mt-3 h-1.5 overflow-hidden rounded-full bg-zinc-200"><span className={`block h-full rounded-full ${state.status === 'error' ? 'bg-red-700' : state.status === 'complete' ? 'bg-emerald-700' : 'bg-zinc-800'}`} style={{ width: `${state.status === 'importing' ? Math.max(4, percent) : 100}%` }} /></div><p className="mt-2 text-xs text-zinc-500">{state.status === 'error' ? state.message : `${progress.processed}/${progress.total} 行 · 新增 ${progress.created} 条`}</p></div>
|
||||
}
|
||||
@ -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 <nav aria-label="供应商新品推荐分页" className="absolute bottom-6 left-1/2 z-30 flex max-w-[calc(100%-32px)] -translate-x-1/2 items-center gap-2.5 whitespace-nowrap rounded-full border border-zinc-200 bg-white/95 px-4 py-2 text-[13px] shadow-[0_4px_12px_rgba(0,0,0,0.06),0_2px_4px_rgba(0,0,0,0.04)] backdrop-blur-xl max-sm:gap-1 max-sm:px-2 max-sm:text-xs"><span className="mr-1 text-zinc-600">{loading ? '正在统计...' : `共 ${total} 条`}</span><button type="button" disabled={loading || page <= 1} onClick={() => onPage(page - 1)} className="recommended-page-button"><ArrowLeft size={12} />上一页</button><span className="inline-flex items-center gap-1 text-zinc-600"><input type="text" inputMode="numeric" pattern="[0-9]*" aria-label="跳转页码" value={pageInput} disabled={loading} onChange={(event) => onPageInput(event.target.value.replace(/\D/g, ''))} onBlur={onCommitPage} onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault(); onCommitPage() } }} className="h-7 w-[52px] rounded-[4px] border border-zinc-200 text-center font-semibold outline-none focus:border-zinc-500" /><span className="text-zinc-400">/</span><span>{totalPages} 页</span></span><button type="button" disabled={loading || page >= totalPages} onClick={() => onPage(page + 1)} className="recommended-page-button">下一页<ArrowRight size={12} /></button><span className="ml-1 text-zinc-500">每页<select value={pageSize} onChange={(event) => onPageSize(Number(event.target.value))} className="mx-1 h-7 rounded-[4px] border border-zinc-200 bg-white px-1"><option value={50}>50</option><option value={100}>100</option><option value={200}>200</option></select>条</span></nav>
|
||||
}
|
||||
@ -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 <div className="min-h-0 flex-1 overflow-auto overscroll-contain"><table className="w-full border-separate border-spacing-0 text-center text-xs" style={{ minWidth }}><thead><tr>{columns.map((column) => <th key={column.key} style={{ minWidth: column.minWidth }} className={`sticky top-0 z-20 whitespace-nowrap border-b border-zinc-200 bg-[#eef0ef] px-3 py-3 ${column.key === 'recommendationDate' ? 'left-0 z-40' : column.key === 'actions' ? 'right-0 z-40' : ''}`}>{column.label}</th>)}</tr></thead><tbody>{rows.map((row) => <tr key={row.id} className="group hover:bg-zinc-50">{columns.map((column) => <SupplierRecommendedProductCell key={column.key} column={column.key} row={row} canEdit={canEdit} onEdit={onEdit} onHistory={onHistory} onPreview={onPreview} />)}</tr>)}</tbody></table>{rows.length === 0 && <div className="py-20 text-center text-sm text-zinc-500">暂无匹配的供应商新品推荐</div>}</div>
|
||||
}
|
||||
|
||||
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 <td className={`${base} sticky left-0 z-10 whitespace-nowrap`}>{row.recommendationDate || '-'}</td>
|
||||
if (column === 'imageUrl') return <td className={base}>{row.imageUrl ? <button type="button" onClick={() => onPreview(row)} className="relative h-12 w-12 overflow-hidden rounded-[5px] border border-zinc-200 bg-zinc-50"><Image unoptimized src={row.imageUrl} alt={row.productCode || '新品图片'} fill sizes="48px" className="object-cover" /></button> : <span className="text-zinc-400">-</span>}</td>
|
||||
if (column === 'actions') return <td className={`${base} sticky right-0 z-10 border-l border-zinc-200`}><div className="flex flex-nowrap justify-center gap-1 whitespace-nowrap">{canEdit && <button type="button" title="编辑" onClick={() => onEdit(row)} className="recommended-row-button"><Pencil size={14} />编辑</button>}<button type="button" title="修改日志" onClick={() => onHistory(row)} className="recommended-row-button"><History size={14} />日志</button></div></td>
|
||||
if (column === 'orderQuantity') return <td className={`${base} tabular-nums`}>{row.orderQuantity.toLocaleString('zh-CN')}</td>
|
||||
return <td className={`${base} max-w-[260px] whitespace-pre-wrap break-words`} title={String(row[column] || '')}>{String(row[column] || '-')}</td>
|
||||
}
|
||||
@ -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 <SupplierRecommendedProductsClient initialData={initialData} canEdit={access.edit} canDownload={access.download} />
|
||||
}
|
||||
@ -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))
|
||||
@ -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}}
|
||||
`
|
||||
@ -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<Set<SupplierRecommendedProductColumnKey>>(() => {
|
||||
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 }
|
||||
}
|
||||
@ -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<State>({ 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' }
|
||||
}
|
||||
123
furniture-3d-system/src/lib/supplier-recommended-products.ts
Normal file
123
furniture-3d-system/src/lib/supplier-recommended-products.ts
Normal file
@ -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<typeof supplierRecommendedProductInputSchema>
|
||||
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<string, SupplierRecommendedProductFieldKey>
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
@ -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()
|
||||
@ -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>): 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<string, string | number | null>
|
||||
}
|
||||
|
||||
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<SupplierRecommendedProductFilters>; 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<Record<string, unknown>>, 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()
|
||||
@ -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',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user