Merge remote-tracking branch 'origin/codex/xuxiake-mainline' into codex/wuyongjie-bom-category-skills-v7

This commit is contained in:
WuYongJie 2026-08-11 18:33:33 +08:00
commit 944b5b0a58
57 changed files with 1940 additions and 356 deletions

View File

@ -79,9 +79,7 @@ DINGTALK_CLIENT_ID=
DINGTALK_CLIENT_SECRET=
DINGTALK_CALLBACK_ORIGIN=
# 钉钉待办(预留)
# 注意:当前仓库尚未内置待办发送服务,以下变量仅作为接入预留,
# 需要配合后端待办服务与投递表一起开发后才会实际生效。
# 钉钉待办:OA 阶段超时预警,以及预计出货日前 15 天的品牌策划主管提醒。
DINGTALK_TODO_ENABLED=false
DINGTALK_TODO_DETAIL_BASE_URL=
DINGTALK_TODO_SOURCE_APP=oa-board

View File

@ -52,6 +52,7 @@
| `storageType` | 入库/代发 | | | `SupplyChainProductArchiveWarehouseConfig.storageType` | 按出入库仓库名称匹配配置表 |
| `isSeasonal` | 是否季节性产品 | 暂无来源 | 暂无来源 | | 保持空值 |
| `replaceCount` | 替换款数量 | 暂无来源 | 暂无来源 | | 保持空值 |
| `region` | 区域 | | | `rpt.js_supplier.region` | 按供应商名称匹配后写主表 |
| `supplierName` | 供应商 | `rpt.jst_sku.supplier_name` | `archive_supplier_name` | | 原样保留 |
| `supplierType` | 供应商类型 | | | `rpt.js_supplier.scmtype` | 按供应商名称匹配后写主表 |
| `supplierStatus` | 供应商状态 | | | `rpt.js_supplier.coopersgn` | `1=合作`、`2=不合作` |

View File

@ -0,0 +1,57 @@
CREATE TABLE "SampleProgressItem" (
"id" TEXT NOT NULL,
"orderDate" DATE NOT NULL,
"imageUrl" TEXT,
"openingCode" TEXT NOT NULL,
"productSize" TEXT NOT NULL,
"color" TEXT NOT NULL,
"material" TEXT NOT NULL,
"sampleQuantity" INTEGER NOT NULL,
"brand" TEXT NOT NULL,
"sampleDueDate" DATE NOT NULL,
"oaReachedAt" TIMESTAMP(3),
"supplier" TEXT NOT NULL,
"finalSampleFactory" TEXT,
"status" TEXT NOT NULL,
"designer" TEXT NOT NULL,
"purchaseOwner" TEXT,
"merchandiserOwner" TEXT,
"progressDescription" TEXT,
"requestedCost" DECIMAL(12,2),
"actualCost" DECIMAL(12,2),
"followUpOwner" TEXT,
"createdBy" TEXT,
"updatedBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SampleProgressItem_pkey" PRIMARY KEY ("id"),
CONSTRAINT "SampleProgressItem_quantity_positive" CHECK ("sampleQuantity" > 0),
CONSTRAINT "SampleProgressItem_status_allowed" CHECK ("status" IN ('BARE_WOOD', 'BARE_WOOD_REVIEW', 'PAINT', 'VIDEO_REVIEW', 'SAMPLE_SHIPPING', 'PHYSICAL_REVIEW', 'COMPLETED')),
CONSTRAINT "SampleProgressItem_cost_non_negative" CHECK (
("requestedCost" IS NULL OR "requestedCost" >= 0) AND
("actualCost" IS NULL OR "actualCost" >= 0)
)
);
CREATE UNIQUE INDEX "SampleProgressItem_openingCode_key" ON "SampleProgressItem"("openingCode");
CREATE INDEX "SampleProgressItem_status_idx" ON "SampleProgressItem"("status");
CREATE INDEX "SampleProgressItem_brand_idx" ON "SampleProgressItem"("brand");
CREATE INDEX "SampleProgressItem_sampleDueDate_idx" ON "SampleProgressItem"("sampleDueDate");
CREATE INDEX "SampleProgressItem_oaReachedAt_idx" ON "SampleProgressItem"("oaReachedAt");
CREATE INDEX "SampleProgressItem_designer_idx" ON "SampleProgressItem"("designer");
CREATE TABLE "SampleProgressChangeLog" (
"id" TEXT NOT NULL,
"itemId" TEXT NOT NULL,
"changedFields" JSONB NOT NULL,
"beforeSnapshot" JSONB NOT NULL,
"afterSnapshot" JSONB NOT NULL,
"changeReason" TEXT,
"changedById" TEXT,
"changedByName" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SampleProgressChangeLog_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "SampleProgressChangeLog_itemId_createdAt_idx" ON "SampleProgressChangeLog"("itemId", "createdAt");
ALTER TABLE "SampleProgressChangeLog" ADD CONSTRAINT "SampleProgressChangeLog_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "SampleProgressItem"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,2 @@
ALTER TABLE "SampleProgressItem"
ADD COLUMN "productDevelopmentFlowUrl" TEXT;

View File

@ -0,0 +1,7 @@
ALTER TABLE "SampleProgressItem"
ADD COLUMN "autoGenerated" BOOLEAN NOT NULL DEFAULT false;
UPDATE "SampleProgressItem"
SET "autoGenerated" = true
WHERE "progressDescription" = 'OA 自动生成,待补充打样信息'
AND "createdBy" IS NULL;

View File

@ -0,0 +1,2 @@
ALTER TABLE "SupplyChainProductArchive"
ADD COLUMN "region" TEXT NOT NULL DEFAULT '';

View File

@ -9,6 +9,58 @@ datasource db {
provider = "postgresql"
}
model SampleProgressItem {
id String @id @default(cuid())
orderDate DateTime @db.Date
imageUrl String?
openingCode String @unique
productDevelopmentFlowUrl String? @db.Text
productSize String
color String
material String
sampleQuantity Int
brand String
sampleDueDate DateTime @db.Date
oaReachedAt DateTime?
supplier String
finalSampleFactory String?
status String
designer String
purchaseOwner String?
merchandiserOwner String?
progressDescription String?
autoGenerated Boolean @default(false)
requestedCost Decimal? @db.Decimal(12, 2)
actualCost Decimal? @db.Decimal(12, 2)
followUpOwner String?
createdBy String?
updatedBy String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
changeLogs SampleProgressChangeLog[]
@@index([status])
@@index([brand])
@@index([sampleDueDate])
@@index([oaReachedAt])
@@index([designer])
}
model SampleProgressChangeLog {
id String @id @default(cuid())
itemId String
item SampleProgressItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
changedFields Json
beforeSnapshot Json
afterSnapshot Json
changeReason String?
changedById String?
changedByName String
createdAt DateTime @default(now())
@@index([itemId, createdAt])
}
// ============================================
// 产品分类
// ============================================
@ -480,6 +532,7 @@ model SupplyChainProductArchive {
storageType String @default("")
isSeasonal String @default("")
replaceCount String @default("")
region String @default("")
supplierName String @default("")
purchaseOwner String @default("")
supplierType String @default("")

View File

@ -43,7 +43,7 @@ EnvironmentFile=-$HR_SYNC_ENV
# Keep dependent snapshots and the todo scan after the OA refresh so each
# consumer uses the newest workflow state.
# The global switch remains off by default and skips the todo scan until it is explicitly enabled.
ExecStart=/bin/bash -lc 'set -euo pipefail; cd "$APP_ROOT"; ./node_modules/.bin/tsx scripts/sync-oa-board.ts --apply; ./node_modules/.bin/tsx scripts/sync-oa-brand-planning-board.ts --apply; case "\${DINGTALK_TODO_ENABLED:-false}" in 1|true|TRUE|yes|YES|on|ON) ./node_modules/.bin/tsx scripts/sync-oa-board-todos.ts ;; *) echo "DingTalk OA overtime todo dispatch is disabled; skipping." ;; esac'
ExecStart=/bin/bash -lc 'set -euo pipefail; cd "$APP_ROOT"; ./node_modules/.bin/tsx scripts/sync-oa-board.ts --apply; ./node_modules/.bin/tsx scripts/sync-oa-brand-planning-board.ts --apply; case "\${DINGTALK_TODO_ENABLED:-false}" in 1|true|TRUE|yes|YES|on|ON) ./node_modules/.bin/tsx scripts/sync-oa-board-todos.ts ;; *) echo "DingTalk OA todo dispatch is disabled; skipping." ;; esac'
StandardOutput=journal
StandardError=journal
EOF

View File

@ -2,6 +2,7 @@
import { prisma } from '@/lib/prisma'
import { oaBoardDingtalkTodoDispatchService } from '@/server/services/oa-board-dingtalk-todo-dispatch.service'
import { oaBrandPlanningShipmentTodoService } from '@/server/services/oa-brand-planning-shipment-todo.service'
function readArg(name: string) {
const index = process.argv.findIndex((arg) => arg === `--${name}`)
@ -14,17 +15,20 @@ async function main() {
const owner = readArg('owner')?.trim() || undefined
const approvalStage = readArg('approval-stage')?.trim() || undefined
const summary = await oaBoardDingtalkTodoDispatchService.syncDashboard({
approvalNumber: openingCode,
owner,
approvalStage,
})
const [overtime, shipmentReminder] = await Promise.all([
oaBoardDingtalkTodoDispatchService.syncDashboard({
approvalNumber: openingCode,
owner,
approvalStage,
}),
oaBrandPlanningShipmentTodoService.sync(),
])
console.log(
JSON.stringify(
{
ok: true,
summary,
summary: { overtime, shipmentReminder },
},
null,
2,

View File

@ -8,6 +8,7 @@ import {
oaBoardService,
type OaBoardProjectListFilter,
} from '@/server/services/oa-board.service'
import { OA_BOARD_DEFAULT_TRACKED_STATUSES } from '@/server/services/oa-board.constants'
import {
filterOaBoardLocalPreviewDashboard,
readOaBoardLocalPreviewDashboard,
@ -20,7 +21,6 @@ import {
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
const ALL_FLOW_STATUSES = ['RUNNING', 'COMPLETED', 'REFUSED', 'TERMINATED']
const EXPORT_FILTER_QUERY_KEYS = [
'keyword',
'approvalNumber',
@ -298,7 +298,7 @@ function buildExportFilter(searchParams: URLSearchParams): OaBoardProjectListFil
? selectedStatuses
: hasExplicitFilter
? undefined
: [...ALL_FLOW_STATUSES],
: [...OA_BOARD_DEFAULT_TRACKED_STATUSES],
createdDateStart: searchParams.get('createdDateStart') || undefined,
createdDateEnd: searchParams.get('createdDateEnd') || undefined,
productType: searchParams.get('productType') || undefined,

View File

@ -0,0 +1,8 @@
import { NextRequest } from 'next/server'
import { handleRequest, jsonSuccess } from '@/server/core/response'
import { sampleProgressService } from '@/server/services/sample-progress.service'
type Context = { params: Promise<{ id: string }> }
export async function GET(request: NextRequest, context: Context) {
return handleRequest(request, async (ctx) => jsonSuccess(await sampleProgressService.listChangeLogs((await context.params).id), 200, ctx.requestId), { requireAuth: true })
}

View File

@ -0,0 +1,17 @@
import { NextRequest } from 'next/server'
import { sampleProgressUpdateSchema } from '@/lib/sample-progress'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { sampleProgressService } from '@/server/services/sample-progress.service'
type Context = { params: Promise<{ id: string }> }
export async function PUT(request: NextRequest, context: Context) {
return handleRequest(request, async (ctx) => {
const parsed = sampleProgressUpdateSchema.safeParse(await request.json().catch(() => null))
if (!parsed.success) return jsonError(ErrorCode.VALIDATION_FAILED, parsed.error.issues[0]?.message ?? '数据不完整', 400, ctx.requestId)
return jsonSuccess(await sampleProgressService.update((await context.params).id, parsed.data, { id: ctx.user?.id, name: ctx.user?.name }), 200, ctx.requestId)
}, { requireAuth: true })
}
export async function DELETE(request: NextRequest, context: Context) {
return handleRequest(request, async (ctx) => { await sampleProgressService.delete((await context.params).id, { id: ctx.user?.id, name: ctx.user?.name }); return jsonSuccess({ deleted: true }, 200, ctx.requestId) }, { requireAuth: true })
}

View File

@ -0,0 +1,14 @@
import { NextRequest } from 'next/server'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { sampleProgressService } from '@/server/services/sample-progress.service'
export async function GET(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const openingCode = request.nextUrl.searchParams.get('openingCode')?.trim() || ''
if (!openingCode) return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请先输入开款编号', 400, ctx.requestId)
const prefill = await sampleProgressService.getOaPrefill(openingCode)
if (!prefill) return jsonError(ErrorCode.VALIDATION_FAILED, '未在 OA 新品流程中找到该开款编号', 404, ctx.requestId)
return jsonSuccess(prefill, 200, ctx.requestId)
}, { requireAuth: true })
}

View File

@ -0,0 +1,17 @@
import { NextRequest } from 'next/server'
import { sampleProgressInputSchema } from '@/lib/sample-progress'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { sampleProgressService } from '@/server/services/sample-progress.service'
export async function GET(request: NextRequest) {
return handleRequest(request, async (ctx) => jsonSuccess(await sampleProgressService.list(), 200, ctx.requestId), { requireAuth: true })
}
export async function POST(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const parsed = sampleProgressInputSchema.safeParse(await request.json().catch(() => null))
if (!parsed.success) return jsonError(ErrorCode.VALIDATION_FAILED, parsed.error.issues[0]?.message ?? '数据不完整', 400, ctx.requestId)
return jsonSuccess(await sampleProgressService.create(parsed.data, { id: ctx.user?.id, name: ctx.user?.name }), 201, ctx.requestId)
}, { requireAuth: true })
}

View File

@ -4,7 +4,7 @@ import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { supplyChainProductArchiveWarehouseConfigService } from '@/server/services/supply-chain-product-archive-warehouse-config.service'
const statusSchema = z.enum(['ACTIVE', 'INACTIVE'])
const statusSchema = z.enum(['ACTIVE', 'INACTIVE', 'UNCONFIGURED'])
const storageTypeSchema = z.enum(['INBOUND', 'DROPSHIP'])
const listQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),

View File

@ -1,4 +1,5 @@
import { normalizeAssetVariantUvConfig } from "@/lib/asset-variant-render-config";
import { normalizeNonIntegratedPackageSetCounts } from "@/lib/non-integrated-package-set-count";
import { QUALITY_INSPECTION_EDITABLE_FIELD_KEYS, QUALITY_INSPECTION_IMAGE_FIELD_KEYS, type NonIntegratedQualityInspectionRow } from "@/lib/non-integrated-quality-inspection-schema";
import { BRAND_OPTIONS } from "./basicInfoConfig";
import { type FormValues } from "./productFormValueHelpers";
@ -86,7 +87,7 @@ export function buildProductPayload(
metadata?: Record<string, unknown>,
): NonIntegratedProductRecord {
const now = new Date().toISOString();
const derivedSkuItemsForPayload = skuItems.map((item, index) => {
const derivedSkuItems = skuItems.map((item, index) => {
const derivedItem = {
...deriveSkuItem(item, index, formValues.spu, formValues),
model3dFileName: item.model3dFileName?.trim() ?? "",
@ -110,6 +111,13 @@ export function buildProductPayload(
? cleanLegacySeriesSkuComponentFields(derivedItem)
: derivedItem;
});
const {
skuItems: derivedSkuItemsForPayload,
factoryRows: factoryRowsForPayload,
} = normalizeNonIntegratedPackageSetCounts(
derivedSkuItems,
normalizeFactoryRowsForPayload(factoryRows),
);
const normalizedStyleDetails = Object.entries(styleDetails)
.filter(([styleKey]) => Boolean(styleKey))
.map<NonIntegratedStyleDetail>(([styleKey, detail]) => ({
@ -444,7 +452,7 @@ export function buildProductPayload(
skuBatchFlowNos,
skuBatchChangeTypes,
skuBatchConfirmedAt,
factoryRows: normalizeFactoryRowsForPayload(factoryRows),
factoryRows: factoryRowsForPayload,
pricingRows: normalizePricingRowsForPayload(pricingRows),
planningLayoutRows: normalizePlanningLayoutRowsForPayload(
planningLayoutRows,

View File

@ -3,6 +3,7 @@ import {
useRef,
} from "react";
import { hasConsistentNonIntegratedPackageSetCounts } from "@/lib/non-integrated-package-set-count";
import {
NON_INTEGRATED_PRODUCTS_UPDATED,
prepareNonIntegratedProductForFormalSpuAssignment,
@ -240,6 +241,15 @@ export function useNonIntegratedSaveController({
})
: saveNonIntegratedProduct(productForSave);
if (
!hasConsistentNonIntegratedPackageSetCounts(
savedProduct.skuItems,
savedProduct.factoryRows,
)
) {
throw new Error("????????????????");
}
if (
fixedSeriesProductSpu &&
state.formValuesRef.current.spu.trim().toUpperCase() !==

View File

@ -40,6 +40,8 @@ import type {
OaBrandPlanningBoardDashboardDTO,
OaBrandPlanningBoardItemDTO,
} from '@/server/services/oa-brand-planning-board.service'
import SampleProgressClient from '@/app/intake/sample-progress/SampleProgressClient'
import type { SampleProgressDTO } from '@/server/services/sample-progress.service'
type FilterState = {
keyword: string
@ -72,6 +74,7 @@ type OaBoardViewTab =
| 'timeout-analysis'
| 'shipment'
| 'listing'
| 'sample-progress'
type BrandPlanningFilterState = {
keyword: string
@ -189,6 +192,7 @@ function parseOaBoardViewTab(value: string | null): OaBoardViewTab {
value === 'timeout-analysis' ||
value === 'shipment' ||
value === 'listing'
|| value === 'sample-progress'
) {
return value
}
@ -374,7 +378,7 @@ const WORKBENCH_FILTER_CONTROL_CLASS =
const WORKBENCH_SIDE_SECTION_CLASS =
'rounded-[1.4rem] border border-apple-separator bg-white/92 shadow-apple-xs'
const WORKBENCH_SIDE_BUTTON_BASE_CLASS =
'flex w-full items-center justify-between rounded-apple-lg border px-4 py-3 text-sm font-apple-medium transition'
'flex h-[54px] w-full min-w-0 items-center justify-between rounded-apple-lg border px-4 py-2 text-sm font-apple-medium transition'
const WORKBENCH_SIDE_BUTTON_ACTIVE_CLASS =
'border-zinc-950 bg-zinc-950 text-white shadow-none'
const WORKBENCH_SIDE_BUTTON_DEFAULT_CLASS =
@ -3303,9 +3307,13 @@ function MonthlyReportPanel({
export default function OaBoardClient({
dashboard,
loadErrorMessage,
sampleProgressItems = [],
currentUserName = '',
}: {
dashboard: OaBoardDashboardDTO
loadErrorMessage?: string | null
sampleProgressItems?: SampleProgressDTO[]
currentUserName?: string
}) {
const { items: allItems, stageGraph, dailyReport, monthlyReport } = dashboard
const canViewDailyReport = Boolean(dailyReport)
@ -3376,84 +3384,84 @@ export default function OaBoardClient({
const showingMonthlyReport = canViewMonthlyReport && activeTab === 'monthly'
const activeTabLabel =
activeTab === 'summary'
? '新品进度汇总'
? '新品-进度汇总'
: showingDailyReport
? '新品流程日报'
? '新品-日报'
: showingMonthlyReport
? '新品流程月度分析'
: activeTab === 'detail'
? '新品流程明细'
? '新品-流程明细'
: activeTab === 'brand-planning'
? '新品品牌策划看板'
? '新品-出图进度(品牌策划)'
: activeTab === 'sample-progress'
? '新品-打样进度(供应链中心)'
: activeTab === 'timeout-analysis'
? '当前超时分析'
? '新品-超时分析'
: activeTab === 'shipment'
? '新品出货进度表'
: '新品上架数据'
? '新品-出货进度(供应链中心)'
: '新品-上架数据'
const sideNavSections = [
{
title: '产品开发',
items: [
{
key: 'summary',
label: '新品进度汇总',
label: '新品-进度汇总',
active: activeTab === 'summary',
disabled: false,
onClick: () => switchTab('summary'),
},
...(canViewDailyReport
? [{
key: 'daily' as const,
label: '新品流程日报',
active: showingDailyReport,
disabled: false,
onClick: () => switchTab('daily'),
}]
: []),
...(canViewMonthlyReport
? [{
key: 'monthly' as const,
label: '新品流程月度分析',
active: showingMonthlyReport,
disabled: false,
onClick: () => switchTab('monthly'),
}]
: []),
{
key: 'detail',
label: '新品流程明细',
label: '新品-流程明细',
active: activeTab === 'detail',
disabled: false,
onClick: () => switchTab('detail'),
},
{
key: 'brand-planning',
label: '新品品牌策划看板',
active: activeTab === 'brand-planning',
key: 'sample-progress',
label: '新品-打样进度(供应链中心)',
active: activeTab === 'sample-progress',
disabled: false,
onClick: () => switchTab('brand-planning'),
onClick: () => switchTab('sample-progress'),
},
{
key: 'shipment',
label: '新品出货进度表',
label: '新品-出货进度(供应链中心)',
active: activeTab === 'shipment',
disabled: false,
onClick: () => switchTab('shipment'),
},
{
key: 'brand-planning',
label: '新品-出图进度(品牌策划)',
active: activeTab === 'brand-planning',
disabled: false,
onClick: () => switchTab('brand-planning'),
},
{
key: 'listing',
label: '新品上架数据',
label: '新品-上架数据',
active: activeTab === 'listing',
disabled: false,
onClick: () => switchTab('listing'),
},
{
key: 'timeout-analysis',
label: '当前超时分析',
label: '新品-超时分析',
active: activeTab === 'timeout-analysis',
disabled: false,
onClick: () => switchTab('timeout-analysis'),
},
...(canViewDailyReport
? [{
key: 'daily' as const,
label: '新品-日报',
active: showingDailyReport,
disabled: false,
onClick: () => switchTab('daily'),
}]
: []),
],
},
] as const
@ -4464,6 +4472,8 @@ export default function OaBoardClient({
</>
) : activeTab === 'brand-planning' ? (
<BrandPlanningBoardPanel />
) : activeTab === 'sample-progress' ? (
<SampleProgressClient initialItems={sampleProgressItems} currentUserName={currentUserName} embedded />
) : activeTab === 'timeout-analysis' ? (
<TimeoutAnalysis
items={items}
@ -4531,7 +4541,7 @@ export default function OaBoardClient({
: WORKBENCH_SIDE_BUTTON_DEFAULT_CLASS,
].join(' ')}
>
<span>{item.label}</span>
<span className="min-w-0 flex-1 text-left leading-5">{item.label}</span>
{item.key === 'shipment' || item.key === 'listing' ? (
<span className="rounded-full bg-black/6 px-2 py-0.5 text-[10px] font-medium">
预留

View File

@ -8,6 +8,7 @@ import { OA_BOARD_DEFAULT_TRACKED_STATUSES } from '@/server/services/oa-board.co
import { canViewOaBoardDailyReport } from '@/server/services/oa-board-daily-report-access'
import { readOaBoardLocalPreviewDashboard } from '@/server/services/oa-board-local-preview.service'
import OaBoardClient from './OaBoardClient'
import { sampleProgressService } from '@/server/services/sample-progress.service'
export const dynamic = 'force-dynamic'
@ -127,12 +128,16 @@ export default async function OaBoardPage({
const dashboard = canViewDailyReport
? result.dashboard
: { ...result.dashboard, dailyReport: undefined }
await sampleProgressService.syncFromOaItems(dashboard.items)
const sampleProgressItems = await sampleProgressService.listStored()
return (
<OaBoardClient
key={pageStateKey}
dashboard={dashboard}
loadErrorMessage={result.loadErrorMessage}
sampleProgressItems={sampleProgressItems}
currentUserName={session?.user.name ?? ''}
/>
)
}

View File

@ -1065,9 +1065,9 @@ export function NonIntegratedPendingDetail({
totalFilterControlCount === 10
? hasCreateAction
? hasMultipleCreateActions
? "md:grid-cols-[116px_112px_236px_repeat(8,minmax(0,1fr))_136px_216px]"
: "md:grid-cols-[116px_112px_236px_repeat(8,minmax(0,1fr))_136px_132px]"
: "md:grid-cols-[116px_112px_236px_repeat(8,minmax(0,1fr))_136px]"
? "md:grid-cols-[116px_168px_236px_repeat(8,minmax(104px,1fr))_136px_216px]"
: "md:grid-cols-[116px_168px_236px_repeat(8,minmax(104px,1fr))_136px_132px]"
: "md:grid-cols-[116px_168px_236px_repeat(8,minmax(104px,1fr))_136px]"
: showCreatedDateFilter && totalFilterControlCount === 10
? hasCreateAction
? hasMultipleCreateActions
@ -1113,7 +1113,7 @@ export function NonIntegratedPendingDetail({
<div className="flex h-full min-w-0 flex-col overflow-hidden rounded-apple-xl border border-apple-separator bg-apple-bg-primary shadow-apple-xs">
<div className="min-w-0 border-b border-apple-separator px-4 py-2.5">
<div
className={`flex min-w-0 max-w-full items-end gap-2 overflow-x-auto pb-1 md:grid md:overflow-visible md:pb-0 ${filterGridColumnClass}`}
className={`flex min-w-0 max-w-full items-end gap-2 overflow-x-auto pb-1 md:grid ${filterGridColumnClass}`}
>
<label className="min-w-[104px] space-y-1.5 text-xs text-apple-label-secondary max-md:space-y-0 md:min-w-0">
<span className="max-md:hidden">范围</span>
@ -1136,7 +1136,7 @@ export function NonIntegratedPendingDetail({
</select>
</label>
{showProductTagFilter ? (
<div className="min-w-[104px] space-y-1.5 text-xs text-apple-label-secondary max-md:space-y-0 md:min-w-0">
<div className="min-w-[168px] space-y-1.5 text-xs text-apple-label-secondary max-md:space-y-0 md:min-w-[168px]">
<span className="max-md:hidden">产品类型</span>
<div
className="grid h-8 grid-cols-3 overflow-hidden rounded-apple-md border border-apple-separator bg-apple-bg-secondary p-0.5 max-md:h-7"
@ -1155,7 +1155,7 @@ export function NonIntegratedPendingDetail({
key={option.value}
type="button"
aria-pressed={selected}
className={`rounded-apple-sm px-2 text-xs font-apple-medium transition-colors ${
className={`whitespace-nowrap rounded-apple-sm px-2 text-xs font-apple-medium transition-colors ${
selected
? "bg-zinc-950 text-white"
: "text-apple-label-secondary hover:bg-white hover:text-apple-label-primary"

View File

@ -0,0 +1,166 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import Image from 'next/image'
import { AlertTriangle, CalendarClock, CheckCircle2, ChevronLeft, ChevronRight, CircleDollarSign, Edit3, ExternalLink, History, ImageIcon, Link2, Plus, Search, Trash2, X } from 'lucide-react'
import { canEditSampleProgress, SAMPLE_PROGRESS_ANOMALY_LABELS, SAMPLE_PROGRESS_STATUS_LABELS, SAMPLE_PROGRESS_STATUSES, type SampleProgressInput, type SampleProgressUpdateInput } from '@/lib/sample-progress'
import type { SampleProgressDTO } from '@/server/services/sample-progress.service'
const PAGE_SIZE = 12
const emptyDraft: SampleProgressInput = { orderDate: new Date(), imageUrl: '', openingCode: '', productDevelopmentFlowUrl: '', productSize: '', color: '', material: '', sampleQuantity: 1, brand: '', sampleDueDate: new Date(), supplier: '', finalSampleFactory: '', status: 'BARE_WOOD', designer: '', purchaseOwner: '', merchandiserOwner: '', progressDescription: '', requestedCost: null, actualCost: null, followUpOwner: '' }
const dateText = (value: string | Date) => new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit' }).format(new Date(value))
const money = (value: number | null) => value === null ? '—' : `¥${value.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`
const isOverdue = (item: SampleProgressDTO) => !item.autoGenerated && item.status !== 'COMPLETED' && new Date(item.sampleDueDate).getTime() < new Date(new Date().toDateString()).getTime()
const inputDate = (value: string | Date) => {
const date = new Date(value)
return Number.isNaN(date.getTime()) ? '' : date.toISOString().slice(0, 10)
}
export default function SampleProgressClient({ initialItems, currentUserName, embedded = false }: { initialItems: SampleProgressDTO[]; currentUserName: string; embedded?: boolean }) {
const [items, setItems] = useState(initialItems)
const [keyword, setKeyword] = useState('')
const [status, setStatus] = useState('ALL')
const [page, setPage] = useState(1)
const [editing, setEditing] = useState<SampleProgressDTO | null | 'new'>(null)
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState('')
const [historyItem, setHistoryItem] = useState<SampleProgressDTO | null>(null)
const canEdit = (item: SampleProgressDTO) => canEditSampleProgress(currentUserName, item)
const filtered = useMemo(() => items.filter((item) => {
const haystack = [item.openingCode, item.brand, item.supplier, item.designer, item.color, item.material].join(' ').toLowerCase()
return (!keyword || haystack.includes(keyword.toLowerCase())) && (status === 'ALL' || item.status === status)
}), [items, keyword, status])
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
const rows = filtered.slice((Math.min(page, totalPages) - 1) * PAGE_SIZE, Math.min(page, totalPages) * PAGE_SIZE)
const overdueCount = items.filter(isOverdue).length
const approvedCount = items.filter((item) => item.status === 'COMPLETED').length
const requestedTotal = items.reduce((sum, item) => sum + (item.requestedCost ?? 0), 0)
const actualTotal = items.reduce((sum, item) => sum + (item.actualCost ?? 0), 0)
async function remove(item: SampleProgressDTO) {
if (!window.confirm(`确认删除 ${item.openingCode} 的打样记录?`)) return
const response = await fetch(`/api/intake/sample-progress/${item.id}`, { method: 'DELETE' })
if (response.ok) setItems((current) => current.filter((row) => row.id !== item.id))
}
return <main className={`${embedded ? 'h-full min-h-0 overflow-auto rounded-apple-xl border border-apple-separator' : 'min-h-full'} bg-[#f5f6f6] text-[#222829]`}>
<header className="border-b border-black/[0.06] bg-white px-4 py-5 sm:px-8">
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div><p className="mb-1 text-xs font-bold text-[#788083]">产品开发 · 业务协同</p><h1 className="text-[24px] font-black">新品打样进度</h1></div>
<button onClick={() => setEditing('new')} className="inline-flex h-10 items-center justify-center gap-2 rounded-[6px] bg-[#222829] px-4 text-sm font-bold text-white hover:bg-black"><Plus size={16}/>新增打样</button>
</div>
</header>
<section className="grid border-b border-black/[0.06] bg-white sm:grid-cols-2 xl:grid-cols-4">
<Metric icon={<CalendarClock size={18}/>} label="在管打样" value={`${items.length} 款`} tone="plain" />
<Metric icon={<AlertTriangle size={18}/>} label="交期已超期" value={`${overdueCount} 款`} tone={overdueCount ? 'danger' : 'plain'} />
<Metric icon={<CheckCircle2 size={18}/>} label="已完结" value={`${approvedCount} 款`} tone="success" />
<Metric icon={<CircleDollarSign size={18}/>} label="成本差异" value={money(actualTotal - requestedTotal)} tone={actualTotal > requestedTotal ? 'danger' : 'plain'} />
</section>
<section className="p-4 sm:p-6">
<div className="mb-3 flex flex-col gap-2 md:flex-row">
<label className="relative min-w-0 flex-1 md:max-w-[420px]"><Search className="absolute left-3 top-2.5 text-[#8c9496]" size={16}/><input value={keyword} onChange={(e) => { setKeyword(e.target.value); setPage(1) }} placeholder="搜索开款编号、品牌、供应商或设计师" className="h-10 w-full rounded-[6px] border border-black/[0.1] bg-white pl-9 pr-3 text-sm outline-none focus:border-[#26756b]"/></label>
<select value={status} onChange={(e) => { setStatus(e.target.value); setPage(1) }} className="h-10 rounded-[6px] border border-black/[0.1] bg-white px-3 text-sm"><option value="ALL">全部状态</option>{SAMPLE_PROGRESS_STATUSES.map((value) => <option key={value} value={value}>{SAMPLE_PROGRESS_STATUS_LABELS[value]}</option>)}</select>
</div>
<div className="overflow-hidden rounded-[6px] border border-black/[0.08] bg-white shadow-[0_8px_24px_rgba(21,31,33,0.05)]">
<div className="overflow-x-auto"><table className="w-full min-w-[1900px] border-collapse text-left text-[13px]">
<thead className="bg-[#eef0ef] text-[11px] font-bold text-[#657074]"><tr>{['图片','开款编号','结构设计师','流程链接','异常项','OA流程到达时间','下单时间','打样交期','产品尺寸','颜色 / 材质','数量','计划售卖品牌','采购负责人','跟单负责人','供应商','最终出样厂家','打样状态','进度说明','需求成本','实际成本','操作'].map((label) => <th key={label} className="whitespace-nowrap border-b border-black/[0.07] px-3 py-3">{label}</th>)}</tr></thead>
<tbody>{rows.map((item) => <tr key={item.id} className="group border-b border-black/[0.05] align-middle hover:bg-[#f8faf9]">
<td className="sticky left-0 z-10 bg-white p-2 group-hover:bg-[#f8faf9]">{item.imageUrl ? <Image unoptimized src={item.imageUrl} alt={item.openingCode} width={64} height={56} className="h-14 w-16 rounded-[4px] border border-black/[0.06] object-cover"/> : <div className="flex h-14 w-16 items-center justify-center rounded-[4px] bg-[#eef0ef] text-[#9ca3a5]"><ImageIcon size={18}/></div>}</td>
<td className="sticky left-[80px] z-10 bg-white px-3 font-black text-[#1e5d56] group-hover:bg-[#f8faf9]">{item.openingCode}</td><td className="px-3">{item.designer}</td>
<td className="px-3">{item.productDevelopmentFlowUrl ? <a href={item.productDevelopmentFlowUrl} target="_blank" rel="noreferrer" title={item.productDevelopmentFlowUrl} className="inline-flex items-center gap-1 whitespace-nowrap font-bold text-[#26756b] hover:underline">打开流程<ExternalLink size={13}/></a> : '—'}</td>
<td className="px-3"><Anomaly value={item.anomaly}/></td><td className="whitespace-nowrap px-3 text-[#697174]">{item.oaReachedAt ? new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }).format(new Date(item.oaReachedAt)) : '—'}</td><td className="px-3 text-[#697174]">{item.autoGenerated ? '待填写' : dateText(item.orderDate)}</td>
<td className={`px-3 font-bold ${isOverdue(item) ? 'text-[#b8423b]' : ''}`}>{item.autoGenerated ? '待填写' : dateText(item.sampleDueDate)}{isOverdue(item) && <span className="ml-1 text-[10px]">超期</span>}</td>
<td className="px-3">{item.productSize}</td><td className="px-3"><div>{item.color}</div><div className="text-xs text-[#7d8588]">{item.material}</div></td><td className="px-3 font-bold">{item.sampleQuantity}</td><td className="px-3">{item.brand}</td><td className="px-3">{item.purchaseOwner || '—'}</td><td className="px-3">{item.merchandiserOwner || '—'}</td><td className="px-3">{item.supplier}</td><td className="px-3">{item.finalSampleFactory || '—'}</td><td className="px-3"><Status status={item.status}/></td>
<td className="max-w-[260px] px-3"><div className="line-clamp-2" title={item.progressDescription || ''}>{item.progressDescription || '—'}</div>{item.followUpOwner && <div className="mt-1 text-xs text-[#7d8588]">跟进:{item.followUpOwner}</div>}</td>
<td className="px-3">{money(item.requestedCost)}</td><td className={`px-3 font-bold ${item.actualCost !== null && item.requestedCost !== null && item.actualCost > item.requestedCost ? 'text-[#b8423b]' : ''}`}>{money(item.actualCost)}</td>
<td className="px-3"><div className="flex gap-1"><button title="变更记录" onClick={() => setHistoryItem(item)} className="rounded-[4px] p-2 hover:bg-black/[0.05]"><History size={15}/></button>{canEdit(item) && <><button title="编辑" onClick={() => setEditing(item)} className="rounded-[4px] p-2 hover:bg-black/[0.05]"><Edit3 size={15}/></button><button title="删除" onClick={() => void remove(item)} className="rounded-[4px] p-2 text-[#b8423b] hover:bg-[#fff0ef]"><Trash2 size={15}/></button></>}</div></td>
</tr>)}</tbody>
</table>{rows.length === 0 && <div className="py-16 text-center text-sm text-[#7d8588]">暂无匹配的打样记录</div>}</div>
<footer className="flex items-center justify-between border-t border-black/[0.06] px-4 py-3 text-xs text-[#697174]"><span>共 {filtered.length} 条</span><div className="flex items-center gap-2"><button aria-label="上一页" disabled={page <= 1} onClick={() => setPage((p) => p - 1)} className="rounded-[4px] border p-1.5 disabled:opacity-30"><ChevronLeft size={14}/></button><span>{Math.min(page, totalPages)} / {totalPages}</span><button aria-label="下一页" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)} className="rounded-[4px] border p-1.5 disabled:opacity-30"><ChevronRight size={14}/></button></div></footer>
</div>
</section>
{editing && <Editor item={editing === 'new' ? null : editing} saving={saving} message={message} onClose={() => { setEditing(null); setMessage('') }} onSave={async (draft) => {
setSaving(true); setMessage('')
const url = editing === 'new' ? '/api/intake/sample-progress' : `/api/intake/sample-progress/${editing.id}`
const response = await fetch(url, { method: editing === 'new' ? 'POST' : 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(draft) })
const result = await response.json()
if (!response.ok) { setMessage(result?.error?.message || '保存失败'); setSaving(false); return }
const saved = result.data as SampleProgressDTO
setItems((current) => editing === 'new' ? [saved, ...current] : current.map((row) => row.id === saved.id ? saved : row)); setSaving(false); setEditing(null)
}}/>}
{historyItem && <ChangeHistory item={historyItem} onClose={() => setHistoryItem(null)}/>}
</main>
}
function Metric({ icon, label, value, tone }: { icon: React.ReactNode; label: string; value: string; tone: 'plain'|'danger'|'success' }) { return <div className="flex items-center gap-3 border-b border-black/[0.05] px-5 py-4 sm:border-r"><span className={tone === 'danger' ? 'text-[#b8423b]' : tone === 'success' ? 'text-[#26756b]' : 'text-[#697174]'}>{icon}</span><div><div className="text-xs text-[#788083]">{label}</div><div className="mt-0.5 text-lg font-black">{value}</div></div></div> }
function Status({ status }: { status: string }) { const styles: Record<string,string> = { COMPLETED:'bg-[#e6f2ee] text-[#21665e]', PHYSICAL_REVIEW:'bg-[#fff6dc] text-[#826411]', SAMPLE_SHIPPING:'bg-[#e9f0f6] text-[#35647c]', VIDEO_REVIEW:'bg-[#eeeaf7] text-[#655184]', PAINT:'bg-[#f7eee4] text-[#815b31]', BARE_WOOD_REVIEW:'bg-[#fff6dc] text-[#826411]', BARE_WOOD:'bg-[#f0f1f1] text-[#626b6e]' }; return <span className={`inline-flex rounded-full px-2 py-1 text-[11px] font-bold ${styles[status] || styles.BARE_WOOD}`}>{SAMPLE_PROGRESS_STATUS_LABELS[status as keyof typeof SAMPLE_PROGRESS_STATUS_LABELS] || status}</span> }
function Anomaly({ value }: { value: keyof typeof SAMPLE_PROGRESS_ANOMALY_LABELS }) { const abnormal = value !== 'NONE'; return <span className={`inline-flex whitespace-nowrap rounded-full px-2 py-1 text-[11px] font-bold ${abnormal ? 'bg-[#fff0ef] text-[#ad3c36]' : 'bg-[#edf4f1] text-[#26756b]'}`}>{SAMPLE_PROGRESS_ANOMALY_LABELS[value]}</span> }
function Editor({ item, saving, message, onClose, onSave }: { item: SampleProgressDTO | null; saving: boolean; message: string; onClose: () => void; onSave: (draft: SampleProgressUpdateInput) => Promise<void> }) {
const [draft, setDraft] = useState<SampleProgressInput>(item ? { ...item, orderDate: new Date(item.orderDate), sampleDueDate: new Date(item.sampleDueDate), status: item.status as SampleProgressInput['status'] } : emptyDraft)
const [oaPrefill, setOaPrefill] = useState<{ loading: boolean; message: string; reachedAt: string | null }>({ loading: false, message: item?.oaReachedAt ? 'OA 数据已关联' : '', reachedAt: item?.oaReachedAt ?? null })
const [deliveryDateChangeReason, setDeliveryDateChangeReason] = useState('')
const dueDateChanged = Boolean(item && inputDate(draft.sampleDueDate) !== inputDate(item.sampleDueDate))
const set = (key: keyof SampleProgressInput, value: unknown) => setDraft((current) => ({ ...current, [key]: value }))
async function loadOaPrefill() {
if (!draft.openingCode.trim()) return
setOaPrefill((current) => ({ ...current, loading: true, message: '正在读取 OA 数据…' }))
const response = await fetch(`/api/intake/sample-progress/oa-prefill?openingCode=${encodeURIComponent(draft.openingCode)}`)
const result = await response.json()
if (!response.ok) { setOaPrefill({ loading: false, message: result?.error?.message || 'OA 数据读取失败', reachedAt: null }); return }
const data = result.data as { brand: string; designer: string; imageUrl: string | null; oaReachedAt: string | null; purchaseOwner: string; merchandiserOwner: string }
setDraft((current) => ({ ...current, brand: data.brand || current.brand, designer: data.designer || current.designer, imageUrl: data.imageUrl || current.imageUrl, purchaseOwner: data.purchaseOwner || current.purchaseOwner, merchandiserOwner: data.merchandiserOwner || current.merchandiserOwner }))
setOaPrefill({ loading: false, message: 'OA 数据已自动带入', reachedAt: data.oaReachedAt })
}
return <div className="fixed inset-0 z-[80] flex justify-end bg-black/25" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose() }}><form onSubmit={(e) => { e.preventDefault(); void onSave({ ...draft, deliveryDateChangeReason }) }} className="h-full w-full max-w-[680px] overflow-y-auto bg-white shadow-2xl">
<header className="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-5 py-4"><div><h2 className="text-lg font-black">{item ? '编辑打样记录' : '新增打样记录'}</h2><p className="mt-1 text-xs text-[#788083]">带 * 的字段必填</p></div><button type="button" title="关闭" onClick={onClose} className="p-2"><X size={19}/></button></header>
<div className="grid gap-4 p-5 sm:grid-cols-2">
<Field label="开款编号 *"><div className="flex gap-2"><input required readOnly={Boolean(item)} value={draft.openingCode} onChange={(e)=>set('openingCode',e.target.value)} onBlur={() => void loadOaPrefill()} className="h-10 min-w-0 flex-1 rounded-[5px] border px-3 outline-none read-only:bg-[#f1f3f2] focus:border-[#26756b]"/>{!item && <button type="button" title="从 OA 带入" disabled={oaPrefill.loading} onClick={() => void loadOaPrefill()} className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[5px] border bg-white disabled:opacity-50"><Link2 size={16}/></button>}</div></Field><Field label="图片地址(OA自动带入)"><input readOnly value={draft.imageUrl || ''}/></Field>
<div className="sm:col-span-2 -mt-2 flex min-h-5 items-center justify-between text-xs text-[#657074]"><span>{oaPrefill.message}</span><span>{oaPrefill.reachedAt ? `OA流程到达:${new Date(oaPrefill.reachedAt).toLocaleString('zh-CN')}` : ''}</span></div>
<Field label="由壹点产品开发流程链接地址" wide><input type="url" maxLength={8000} placeholder="https://" value={draft.productDevelopmentFlowUrl || ''} onChange={(e)=>set('productDevelopmentFlowUrl',e.target.value)}/></Field>
<Field label="下单时间 *"><input required type="date" value={inputDate(draft.orderDate)} onChange={(e)=>{ if (e.target.value) set('orderDate',new Date(`${e.target.value}T00:00:00`)) }}/></Field><Field label="打样交期 *"><input required type="date" value={inputDate(draft.sampleDueDate)} onChange={(e)=>{ if (e.target.value) set('sampleDueDate',new Date(`${e.target.value}T00:00:00`)) }}/></Field>
<Field label="产品尺寸 *"><input required value={draft.productSize} onChange={(e)=>set('productSize',e.target.value)}/></Field><Field label="打样数量 *"><input required min="1" type="number" value={draft.sampleQuantity} onChange={(e)=>set('sampleQuantity',Number(e.target.value))}/></Field>
<Field label="颜色 *"><input required value={draft.color} onChange={(e)=>set('color',e.target.value)}/></Field><Field label="材质 *"><input required value={draft.material} onChange={(e)=>set('material',e.target.value)}/></Field>
<Field label="计划售卖品牌(OA自动带入)"><input readOnly value={draft.brand}/></Field><Field label="结构设计师(OA自动带入)"><input readOnly value={draft.designer}/></Field>
<Field label="采购负责人"><input value={draft.purchaseOwner || ''} onChange={(e)=>set('purchaseOwner',e.target.value)}/></Field><Field label="跟单负责人"><input value={draft.merchandiserOwner || ''} onChange={(e)=>set('merchandiserOwner',e.target.value)}/></Field>
<Field label="供应商 *"><input required value={draft.supplier} onChange={(e)=>set('supplier',e.target.value)}/></Field><Field label="最终出样厂家"><input value={draft.finalSampleFactory || ''} onChange={(e)=>set('finalSampleFactory',e.target.value)}/></Field>
<Field label="打样状态 *"><select value={draft.status} onChange={(e)=>set('status',e.target.value)}>{SAMPLE_PROGRESS_STATUSES.map((value)=><option key={value} value={value}>{SAMPLE_PROGRESS_STATUS_LABELS[value]}</option>)}</select></Field><Field label="进度说明"><textarea rows={3} value={draft.progressDescription || ''} onChange={(e)=>set('progressDescription',e.target.value)}/></Field>
<Field label="需求成本"><input min="0" step="0.01" type="number" value={draft.requestedCost ?? ''} onChange={(e)=>set('requestedCost',e.target.value ? Number(e.target.value) : null)}/></Field><Field label="实际成本"><input min="0" step="0.01" type="number" value={draft.actualCost ?? ''} onChange={(e)=>set('actualCost',e.target.value ? Number(e.target.value) : null)}/></Field>
<Field label="跟进人"><input value={draft.followUpOwner || ''} onChange={(e)=>set('followUpOwner',e.target.value)}/></Field>
{dueDateChanged && <Field label="交期变更原因 *" wide><textarea required rows={3} value={deliveryDateChangeReason} onChange={(e)=>setDeliveryDateChangeReason(e.target.value)}/></Field>}
</div>{message && <p className="mx-5 rounded-[4px] bg-[#fff0ef] px-3 py-2 text-sm text-[#ad3c36]">{message}</p>}
<footer className="sticky bottom-0 mt-4 flex justify-end gap-2 border-t bg-white px-5 py-4"><button type="button" onClick={onClose} className="h-10 rounded-[6px] border px-4 text-sm font-bold">取消</button><button disabled={saving} className="h-10 rounded-[6px] bg-[#222829] px-5 text-sm font-bold text-white disabled:opacity-50">{saving ? '保存中…' : '保存记录'}</button></footer>
</form></div>
}
type ChangeLogRow = { id: string; changedFields: Array<{ field: string; label: string; before: unknown; after: unknown }>; changeReason: string | null; changedByName: string; createdAt: string }
function displayChangeValue(value: unknown) {
if (value === null || value === undefined || value === '') return '—'
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value)) return new Date(value).toLocaleString('zh-CN')
return String(value)
}
function ChangeHistory({ item, onClose }: { item: SampleProgressDTO; onClose: () => void }) {
const [logs, setLogs] = useState<ChangeLogRow[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
let active = true
void fetch(`/api/intake/sample-progress/${item.id}/history`).then(async (response) => {
const result = await response.json()
if (!active) return
if (!response.ok) setError(result?.error?.message || '变更记录读取失败')
else setLogs(result.data as ChangeLogRow[])
setLoading(false)
})
return () => { active = false }
}, [item.id])
return <div className="fixed inset-0 z-[90] flex justify-end bg-black/25" onMouseDown={(event) => { if (event.target === event.currentTarget) onClose() }}><aside className="h-full w-full max-w-[560px] overflow-y-auto bg-white shadow-2xl">
<header className="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-5 py-4"><div><h2 className="text-lg font-black">变更记录</h2><p className="mt-1 text-xs text-[#788083]">{item.openingCode}</p></div><button type="button" title="关闭" onClick={onClose} className="p-2"><X size={19}/></button></header>
<div className="space-y-3 p-5">{loading && <p className="py-10 text-center text-sm text-[#788083]">读取中…</p>}{error && <p className="rounded-[5px] bg-[#fff0ef] p-3 text-sm text-[#ad3c36]">{error}</p>}{!loading && !error && logs.length === 0 && <p className="py-10 text-center text-sm text-[#788083]">暂无变更记录</p>}{logs.map((log) => <section key={log.id} className="rounded-[6px] border border-black/[0.08] p-4"><div className="mb-3 flex items-center justify-between text-xs"><strong>{log.changedByName}</strong><span className="text-[#788083]">{new Date(log.createdAt).toLocaleString('zh-CN')}</span></div><div className="space-y-2">{log.changedFields.map((change) => <div key={change.field} className="grid grid-cols-[100px_1fr] gap-2 text-xs"><span className="font-bold text-[#657074]">{change.label}</span><span className="break-words"><del className="mr-2 text-[#a35c57]">{displayChangeValue(change.before)}</del><span className="text-[#26756b]">→ {displayChangeValue(change.after)}</span></span></div>)}</div>{log.changeReason && <p className="mt-3 rounded-[4px] bg-[#fff6dc] px-3 py-2 text-xs text-[#70570e]">交期变更原因:{log.changeReason}</p>}</section>)}</div>
</aside></div>
}
function Field({ label, wide, children }: { label: string; wide?: boolean; children: React.ReactElement }) { return <label className={wide ? 'sm:col-span-2' : ''}><span className="mb-1.5 block text-xs font-bold text-[#657074]">{label}</span><div className="[&>input]:h-10 [&>input]:w-full [&>input]:rounded-[5px] [&>input]:border [&>input]:px-3 [&>select]:h-10 [&>select]:w-full [&>select]:rounded-[5px] [&>select]:border [&>select]:px-3 [&>textarea]:w-full [&>textarea]:rounded-[5px] [&>textarea]:border [&>textarea]:p-3 [&>*]:outline-none focus-within:[&>*]:border-[#26756b]">{children}</div></label> }

View File

@ -0,0 +1,10 @@
import { requireSession } from '@/lib/server-session'
import { sampleProgressService } from '@/server/services/sample-progress.service'
import SampleProgressClient from './SampleProgressClient'
export const dynamic = 'force-dynamic'
export default async function SampleProgressPage() {
const { user } = await requireSession('/login')
const items = await sampleProgressService.list()
return <SampleProgressClient initialItems={items} currentUserName={user.name} />
}

View File

@ -0,0 +1,11 @@
import SampleProgressClient from '@/app/intake/sample-progress/SampleProgressClient'
import type { SampleProgressDTO } from '@/server/services/sample-progress.service'
const now = new Date().toISOString()
const samples: SampleProgressDTO[] = [
{ id: 'preview-1', openingCode: 'XP202608100021', productDevelopmentFlowUrl: 'https://oa.example.com/product-development/process/XP202608100021?source=sample-progress-board', orderDate: '2026-08-01T00:00:00.000Z', sampleDueDate: '2026-08-18T00:00:00.000Z', oaReachedAt: '2026-08-08T02:30:00.000Z', anomaly: 'NONE', imageUrl: null, productSize: '1200 x 600 x 750 mm', color: '胡桃木色', material: 'E0 级颗粒板 + 黑色钢架', sampleQuantity: 2, brand: '朴美', supplier: '佳盛家居', finalSampleFactory: '佳盛一厂', status: 'BARE_WOOD', designer: '陈屿', purchaseOwner: '荆川', merchandiserOwner: '王霞', progressDescription: '白茬已完成,等待尺寸复核', requestedCost: 1800, actualCost: 1650, followUpOwner: '王霞', createdBy: null, updatedBy: null, createdAt: now, updatedAt: now },
{ id: 'preview-2', openingCode: 'XP202607280017', productDevelopmentFlowUrl: null, orderDate: '2026-07-28T00:00:00.000Z', sampleDueDate: '2026-08-08T00:00:00.000Z', oaReachedAt: '2026-07-28T03:10:00.000Z', anomaly: 'FINISHED_PRODUCT', imageUrl: null, productSize: '860 x 920 x 780 mm', color: '米白', material: '科技布 + 高弹海绵', sampleQuantity: 1, brand: '顾家', supplier: '安吉智造', finalSampleFactory: null, status: 'VIDEO_REVIEW', designer: '李悦', purchaseOwner: '刘强', merchandiserOwner: '周林', progressDescription: '成品视频已提交,等待审核', requestedCost: 2600, actualCost: 3150, followUpOwner: '刘强', createdBy: null, updatedBy: null, createdAt: now, updatedAt: now },
{ id: 'preview-3', openingCode: 'XP202607220009', productDevelopmentFlowUrl: 'https://oa.example.com/product-development/process/XP202607220009', orderDate: '2026-07-22T00:00:00.000Z', sampleDueDate: '2026-08-06T00:00:00.000Z', oaReachedAt: '2026-07-23T01:00:00.000Z', anomaly: 'NONE', imageUrl: null, productSize: '1800 x 400 x 450 mm', color: '深灰', material: '实木多层板 + 岩板', sampleQuantity: 1, brand: '索菲亚', supplier: '嘉兴木业', finalSampleFactory: '嘉兴木业', status: 'COMPLETED', designer: '张凯', purchaseOwner: '孙丽', merchandiserOwner: '方敏', progressDescription: '实物审样通过,打样已完结', requestedCost: 2200, actualCost: 2180, followUpOwner: '孙丽', createdBy: null, updatedBy: null, createdAt: now, updatedAt: now },
]
export default function SampleProgressPreviewPage() { return <SampleProgressClient initialItems={samples} currentUserName="荆川" /> }

View File

@ -83,6 +83,7 @@ export const SUPPLY_CHAIN_ROUTE_TITLES: Record<string, string> = {
'/supply-chain/dashboard': '工作台',
'/supply-chain/database/product-archive': '产品全量档案',
'/supply-chain/database/category-config': '类目配置',
'/supply-chain/database/warehouse-config': '仓库配置',
'/supply-chain/purchase/daily-affairs': '日常事项',
'/supply-chain/purchase/price-change': '产品变价',
}

View File

@ -0,0 +1,140 @@
'use client'
import { memo, useRef } from 'react'
import { Check, GripVertical, Pencil, Plus } from 'lucide-react'
import type { ColumnSetting } from './useColumnSettings'
type ColumnSettingsGroupProps = {
setting: ColumnSetting
groupIndex: number
onToggleColumn: (group: string, prop: string) => void
onSetGroupAll: (group: string, visible: boolean) => void
onAddColumn: (group: string) => void
onRenameColumn: (group: string, prop: string, label: string) => void
onDragStartGroup: (index: number) => void
onDropGroup: (targetIndex: number) => void
onDragStartColumn: (group: string, index: number) => void
onDropColumn: (group: string, targetIndex: number) => void
}
function ColumnSettingsGroupInner({
setting,
groupIndex,
onToggleColumn,
onSetGroupAll,
onAddColumn,
onRenameColumn,
onDragStartGroup,
onDropGroup,
onDragStartColumn,
onDropColumn,
}: ColumnSettingsGroupProps) {
const allVisible = setting.columns.every((column) => column.visible)
const visibleCount = setting.columns.filter((column) => column.visible).length
const groupRef = useRef<HTMLDivElement>(null)
const groupCheckboxRef = useRef<HTMLInputElement>(null)
const countRef = useRef<HTMLSpanElement>(null)
const allButtonRef = useRef<HTMLButtonElement>(null)
const getColumnCheckboxes = () => [
...groupRef.current?.querySelectorAll<HTMLInputElement>('.csp-col input[type="checkbox"]') ?? [],
]
const updateGroupSummary = (columnCheckboxes: HTMLInputElement[]) => {
const checkedCount = columnCheckboxes.filter((checkbox) => checkbox.checked).length
const nextAllVisible = checkedCount === columnCheckboxes.length
if (groupCheckboxRef.current) groupCheckboxRef.current.checked = nextAllVisible
if (countRef.current) countRef.current.textContent = `${checkedCount}/${columnCheckboxes.length}`
if (allButtonRef.current) allButtonRef.current.textContent = nextAllVisible ? '全不选' : '全选'
}
const setAllCheckboxes = (visible: boolean) => {
const columnCheckboxes = getColumnCheckboxes()
for (const checkbox of columnCheckboxes) checkbox.checked = visible
updateGroupSummary(columnCheckboxes)
onSetGroupAll(setting.group, visible)
}
return (
<div ref={groupRef} className="csp-group">
<div
className="csp-group-head"
draggable
onDragStart={() => onDragStartGroup(groupIndex)}
onDragOver={(event) => event.preventDefault()}
onDrop={() => onDropGroup(groupIndex)}
>
<span className="csp-drag"><GripVertical size={14} /></span>
<label className="csp-check">
<input
ref={groupCheckboxRef}
type="checkbox"
defaultChecked={allVisible}
onChange={(event) => setAllCheckboxes(event.currentTarget.checked)}
/>
<Check size={13} className="csp-check-icon" />
<span>{setting.group}</span>
</label>
<span ref={countRef} className="csp-count">{visibleCount}/{setting.columns.length}</span>
<button
type="button"
className="csp-icon-btn"
title={`在${setting.group}新增字段`}
aria-label={`在${setting.group}新增字段`}
onClick={() => onAddColumn(setting.group)}
>
<Plus size={14} strokeWidth={1.8} />
</button>
<button
ref={allButtonRef}
type="button"
className="csp-all-btn"
onClick={() => {
const columnCheckboxes = getColumnCheckboxes()
setAllCheckboxes(!columnCheckboxes.every((checkbox) => checkbox.checked))
}}
>
{allVisible ? '全不选' : '全选'}
</button>
</div>
<div className="csp-cols">
{setting.columns.map((column, columnIndex) => (
<div
key={column.prop}
className={`csp-col ${!column.visible ? 'hidden' : ''}`}
draggable
onDragStart={() => onDragStartColumn(setting.group, columnIndex)}
onDragOver={(event) => event.preventDefault()}
onDrop={() => onDropColumn(setting.group, columnIndex)}
>
<span className="csp-drag"><GripVertical size={13} /></span>
<label className="csp-check">
<input
type="checkbox"
defaultChecked={column.visible}
onChange={() => {
updateGroupSummary(getColumnCheckboxes())
onToggleColumn(setting.group, column.prop)
}}
/>
<Check size={13} className="csp-check-icon" />
<span>{column.label}</span>
</label>
<button
type="button"
className="csp-col-edit"
title={`修改${column.label}名称`}
aria-label={`修改${column.label}名称`}
onClick={() => onRenameColumn(setting.group, column.prop, column.label)}
>
<Pencil size={13} strokeWidth={1.8} />
</button>
</div>
))}
</div>
</div>
)
}
export const ColumnSettingsGroup = memo(ColumnSettingsGroupInner)

View File

@ -1,19 +1,20 @@
'use client'
import { useState, type SetStateAction } from 'react'
import { GripVertical, RotateCcw, X, Check, Plus, Pencil } from 'lucide-react'
import type { ColumnSetting } from './useColumnSettings'
import { memo, startTransition, useCallback, useRef, useState, type SetStateAction } from 'react'
import { RotateCcw, X } from 'lucide-react'
import { ColumnSettingsGroup } from './ColumnSettingsGroup'
import {
persistColumnSettings,
resetColumnSettings,
type ColumnSetting,
} from './useColumnSettings'
const TABLE_SETTINGS_SYNC_DELAY_MS = 250
type ColumnSettingsPanelProps = {
open: boolean
onClose: () => void
settings: ColumnSetting[]
toggleColumn: (group: string, prop: string) => void
toggleGroup: (group: string) => void
setGroupAll: (group: string, visible: boolean) => void
reset: () => void
addCustomColumn: (group: string, label: string) => void
renameColumn: (group: string, prop: string, label: string) => void
setSettings: (value: SetStateAction<ColumnSetting[]>) => void
}
@ -21,23 +22,110 @@ type FieldEditor =
| { mode: 'add'; group: string; label: string }
| { mode: 'rename'; group: string; prop: string; label: string }
export function ColumnSettingsPanel({
type DragType = { kind: 'group' | 'col'; group?: string; index: number }
function ColumnSettingsPanelInner({
open,
onClose,
settings,
toggleColumn,
toggleGroup,
setGroupAll,
reset,
addCustomColumn,
renameColumn,
settings: initialSettings,
setSettings,
}: ColumnSettingsPanelProps) {
const [settings, setDraftSettings] = useState(() => initialSettings)
const settingsRef = useRef(settings)
const pendingTableSettingsRef = useRef(settings)
const tableSyncFrameRef = useRef<number | null>(null)
const tableSyncTimerRef = useRef<number | null>(null)
const [groupRenderRevision, setGroupRenderRevision] = useState(0)
const [msg, setMsg] = useState('')
const [fieldEditor, setFieldEditor] = useState<FieldEditor | null>(null)
const scheduleTableSettingsSync = useCallback((next: ColumnSetting[]) => {
pendingTableSettingsRef.current = next
if (tableSyncTimerRef.current !== null) {
window.clearTimeout(tableSyncTimerRef.current)
tableSyncTimerRef.current = null
}
if (tableSyncFrameRef.current !== null) return
tableSyncFrameRef.current = window.requestAnimationFrame(() => {
tableSyncFrameRef.current = null
tableSyncTimerRef.current = window.setTimeout(() => {
tableSyncTimerRef.current = null
startTransition(() => setSettings(pendingTableSettingsRef.current))
}, TABLE_SETTINGS_SYNC_DELAY_MS)
})
}, [setSettings])
const closePanel = useCallback(() => {
if (tableSyncFrameRef.current !== null) {
window.cancelAnimationFrame(tableSyncFrameRef.current)
tableSyncFrameRef.current = null
}
if (tableSyncTimerRef.current !== null) {
window.clearTimeout(tableSyncTimerRef.current)
tableSyncTimerRef.current = null
}
setSettings(settingsRef.current)
onClose()
}, [onClose, setSettings])
const publishSettings = useCallback((next: ColumnSetting[]) => {
settingsRef.current = next
persistColumnSettings(next)
scheduleTableSettingsSync(next)
}, [scheduleTableSettingsSync])
const updateSettings = useCallback((value: SetStateAction<ColumnSetting[]>) => {
const next = typeof value === 'function' ? value(settingsRef.current) : value
setDraftSettings(next)
publishSettings(next)
}, [publishSettings])
const updateVisibilitySettings = useCallback((value: SetStateAction<ColumnSetting[]>) => {
const next = typeof value === 'function' ? value(settingsRef.current) : value
publishSettings(next)
}, [publishSettings])
const toggleColumn = useCallback((group: string, prop: string) => {
updateVisibilitySettings((current) =>
current.map((entry) =>
entry.group === group
? {
...entry,
columns: entry.columns.map((column) =>
column.prop === prop ? { ...column, visible: !column.visible } : column,
),
}
: entry,
),
)
}, [updateVisibilitySettings])
const setGroupAll = useCallback((group: string, visible: boolean) => {
updateVisibilitySettings((current) =>
current.map((entry) =>
entry.group === group
? {
...entry,
columns: entry.columns.map((column) => ({ ...column, visible })),
}
: entry,
),
)
}, [updateVisibilitySettings])
const openAddColumn = useCallback((group: string) => {
setFieldEditor({ mode: 'add', group, label: '' })
}, [])
const openRenameColumn = useCallback((group: string, prop: string, label: string) => {
setFieldEditor({ mode: 'rename', group, prop, label })
}, [])
const onReset = () => {
reset()
updateSettings(resetColumnSettings)
setGroupRenderRevision((current) => current + 1)
setMsg('已恢复系统字段默认设置,自定义字段已保留')
setTimeout(() => setMsg(''), 2000)
}
@ -52,37 +140,82 @@ export function ColumnSettingsPanel({
}
if (fieldEditor.mode === 'add') {
addCustomColumn(fieldEditor.group, label)
updateSettings((current) =>
current.map((entry) =>
entry.group === fieldEditor.group
? {
...entry,
columns: [
...entry.columns,
{
prop: `custom_${Date.now().toString(36)}`,
label,
width: 120,
visible: true,
isCustom: true,
},
],
}
: entry,
),
)
setMsg(`已在${fieldEditor.group}新增字段`)
} else {
renameColumn(fieldEditor.group, fieldEditor.prop, label)
updateSettings((current) =>
current.map((entry) =>
entry.group === fieldEditor.group
? {
...entry,
columns: entry.columns.map((column) =>
column.prop === fieldEditor.prop ? { ...column, label } : column,
),
}
: entry,
),
)
setMsg('字段名称已更新')
}
setFieldEditor(null)
setTimeout(() => setMsg(''), 2000)
}
const isGroupAll = (g: ColumnSetting) => g.columns.every((c) => c.visible)
const dragTypeRef = useRef<DragType | null>(null)
// 原生拖拽:分组排序
const [dragType, setDragType] = useState<{ kind: 'group' | 'col'; group?: string; index: number } | null>(null)
const onDragStartGroup = useCallback((index: number) => {
dragTypeRef.current = { kind: 'group', index }
}, [])
const onDragStartGroup = (index: number) => setDragType({ kind: 'group', index })
const onDropGroup = (targetIndex: number) => {
if (!dragType || dragType.kind !== 'group' || dragType.index === targetIndex) { setDragType(null); return }
setSettings((cur) => {
const onDropGroup = useCallback((targetIndex: number) => {
const dragType = dragTypeRef.current
if (!dragType || dragType.kind !== 'group' || dragType.index === targetIndex) {
dragTypeRef.current = null
return
}
updateSettings((cur) => {
const next = [...cur]
const [moved] = next.splice(dragType.index, 1)
next.splice(targetIndex, 0, moved)
return next
})
setDragType(null)
}
dragTypeRef.current = null
}, [updateSettings])
const onDragStartCol = (group: string, index: number) => setDragType({ kind: 'col', group, index })
const onDropCol = (group: string, targetIndex: number) => {
if (!dragType || dragType.kind !== 'col' || dragType.group !== group || dragType.index === targetIndex) { setDragType(null); return }
setSettings((cur) =>
const onDragStartColumn = useCallback((group: string, index: number) => {
dragTypeRef.current = { kind: 'col', group, index }
}, [])
const onDropColumn = useCallback((group: string, targetIndex: number) => {
const dragType = dragTypeRef.current
if (
!dragType
|| dragType.kind !== 'col'
|| dragType.group !== group
|| dragType.index === targetIndex
) {
dragTypeRef.current = null
return
}
updateSettings((cur) =>
cur.map((g) => {
if (g.group !== group) return g
const cols = [...g.columns]
@ -91,15 +224,15 @@ export function ColumnSettingsPanel({
return { ...g, columns: cols }
}),
)
setDragType(null)
}
dragTypeRef.current = null
}, [updateSettings])
if (!open) return null
return (
<>
{/* 遮罩 */}
<div className="csp-overlay" onClick={onClose} />
<div className="csp-overlay" onClick={closePanel} />
{/* 抽屉 */}
<div className="csp-drawer">
<div className="csp-head">
@ -107,7 +240,7 @@ export function ColumnSettingsPanel({
<div className="csp-title">列设置</div>
<div className="csp-sub">勾选显示,拖拽排序</div>
</div>
<button className="csp-close" onClick={onClose} aria-label="关闭"><X size={18} strokeWidth={1.8} /></button>
<button className="csp-close" onClick={closePanel} aria-label="关闭"><X size={18} strokeWidth={1.8} /></button>
</div>
<div className="csp-actions">
@ -116,70 +249,19 @@ export function ColumnSettingsPanel({
<div className="csp-list">
{settings.map((g, gi) => (
<div key={g.group} className={`csp-group ${!g.groupVisible ? 'hidden' : ''}`}>
<div className="csp-group-head"
draggable
onDragStart={() => onDragStartGroup(gi)}
onDragOver={(e) => e.preventDefault()}
onDrop={() => onDropGroup(gi)}
>
<span className="csp-drag"><GripVertical size={14} /></span>
<label className="csp-check">
<input type="checkbox" checked={g.groupVisible} onChange={() => toggleGroup(g.group)} />
<Check size={13} className="csp-check-icon" />
<span>{g.group}</span>
</label>
<span className="csp-count">{g.columns.filter((c) => c.visible).length}/{g.columns.length}</span>
<button
type="button"
className="csp-icon-btn"
title={`在${g.group}新增字段`}
aria-label={`在${g.group}新增字段`}
onClick={() => setFieldEditor({ mode: 'add', group: g.group, label: '' })}
>
<Plus size={14} strokeWidth={1.8} />
</button>
<button className="csp-all-btn" onClick={() => setGroupAll(g.group, !isGroupAll(g))}>
{isGroupAll(g) ? '全不选' : '全选'}
</button>
</div>
{g.groupVisible && (
<div className="csp-cols">
{g.columns.map((c, ci) => (
<div key={c.prop} className={`csp-col ${!c.visible ? 'hidden' : ''}`}
draggable
onDragStart={() => onDragStartCol(g.group, ci)}
onDragOver={(e) => e.preventDefault()}
onDrop={() => onDropCol(g.group, ci)}
>
<span className="csp-drag"><GripVertical size={13} /></span>
<label className="csp-check">
<input type="checkbox" checked={c.visible} onChange={() => toggleColumn(g.group, c.prop)} />
<Check size={13} className="csp-check-icon" />
<span>{c.label}</span>
</label>
<button
type="button"
className="csp-col-edit"
title={`修改${c.label}名称`}
aria-label={`修改${c.label}名称`}
onClick={() =>
setFieldEditor({
mode: 'rename',
group: g.group,
prop: c.prop,
label: c.label,
})
}
>
<Pencil size={13} strokeWidth={1.8} />
</button>
</div>
))}
</div>
)}
</div>
<ColumnSettingsGroup
key={`${g.group}-${groupRenderRevision}`}
setting={g}
groupIndex={gi}
onToggleColumn={toggleColumn}
onSetGroupAll={setGroupAll}
onAddColumn={openAddColumn}
onRenameColumn={openRenameColumn}
onDragStartGroup={onDragStartGroup}
onDropGroup={onDropGroup}
onDragStartColumn={onDragStartColumn}
onDropColumn={onDropColumn}
/>
))}
</div>
@ -224,7 +306,7 @@ export function ColumnSettingsPanel({
</div>
)}
<style jsx>{`
<style jsx global>{`
.csp-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.25);
z-index: 200;
@ -271,7 +353,6 @@ export function ColumnSettingsPanel({
border: 1px solid #f4f4f5; border-radius: 6px; overflow: hidden;
flex: 0 0 auto;
}
.csp-group.hidden { opacity: 0.5; }
.csp-group-head {
display: flex; align-items: center; gap: 6px;
padding: 8px 10px; background: #f4f4f5;
@ -351,3 +432,11 @@ export function ColumnSettingsPanel({
</>
)
}
export const ColumnSettingsPanel = memo(
ColumnSettingsPanelInner,
(previous, next) =>
previous.open === next.open
&& previous.onClose === next.onClose
&& previous.setSettings === next.setSettings,
)

View File

@ -1,3 +1,4 @@
import { memo } from 'react'
import type { SupplyChainProductArchiveRow } from '@/lib/supply-chain-product-archive'
import type { ArchiveColumnGroup } from './columns'
import { ProductArchiveActions } from './ProductArchiveActions'
@ -9,6 +10,60 @@ const IDLE_IMAGE_STATE: ProductArchivePageImageState = {
image: null,
}
type ProductArchiveTableBodyProps = {
rows: SupplyChainProductArchiveRow[]
columns: ArchiveColumnGroup[]
startIndex: number
imageStates: Record<string, ProductArchivePageImageState>
onImageVisible: (archiveId: string) => void
onPreviewImage: (image: string, title: string) => void
onViewRow: (row: SupplyChainProductArchiveRow) => void
onOpenChangeLog: (barcode: string) => void
}
function hasSameColumnLayout(previous: ArchiveColumnGroup[], next: ArchiveColumnGroup[]) {
if (previous.length !== next.length) return false
return previous.every((previousGroup, groupIndex) => {
const nextGroup = next[groupIndex]
if (
!nextGroup
|| previousGroup.group !== nextGroup.group
|| previousGroup.columns.length !== nextGroup.columns.length
) {
return false
}
return previousGroup.columns.every((previousColumn, columnIndex) => {
const nextColumn = nextGroup.columns[columnIndex]
return Boolean(
nextColumn
&& previousColumn.prop === nextColumn.prop
&& previousColumn.label === nextColumn.label
&& previousColumn.width === nextColumn.width,
)
})
})
}
function areTableBodyPropsEqual(
previous: ProductArchiveTableBodyProps,
next: ProductArchiveTableBodyProps,
) {
const imageVisible = next.columns.some((group) =>
group.columns.some((column) => column.prop === 'image'),
)
return previous.rows === next.rows
&& previous.startIndex === next.startIndex
&& previous.onImageVisible === next.onImageVisible
&& previous.onPreviewImage === next.onPreviewImage
&& previous.onViewRow === next.onViewRow
&& previous.onOpenChangeLog === next.onOpenChangeLog
&& (!imageVisible || previous.imageStates === next.imageStates)
&& hasSameColumnLayout(previous.columns, next.columns)
}
function formatDateTime(value: string) {
if (!value) return ''
const date = new Date(value)
@ -49,7 +104,7 @@ function renderCell(
return row[prop as keyof SupplyChainProductArchiveRow]
}
export function ProductArchiveTableBody({
export const ProductArchiveTableBody = memo(function ProductArchiveTableBody({
rows,
columns,
startIndex,
@ -58,16 +113,7 @@ export function ProductArchiveTableBody({
onPreviewImage,
onViewRow,
onOpenChangeLog,
}: {
rows: SupplyChainProductArchiveRow[]
columns: ArchiveColumnGroup[]
startIndex: number
imageStates: Record<string, ProductArchivePageImageState>
onImageVisible: (archiveId: string) => void
onPreviewImage: (image: string, title: string) => void
onViewRow: (row: SupplyChainProductArchiveRow) => void
onOpenChangeLog: (barcode: string) => void
}) {
}: ProductArchiveTableBodyProps) {
return (
<tbody>
{rows.map((row, rowIndex) => (
@ -89,6 +135,7 @@ export function ProductArchiveTableBody({
return (
<td
key={`${group.group}-${column.prop}`}
data-archive-column={column.prop}
className="archive-td-field"
title={title}
>
@ -150,4 +197,4 @@ export function ProductArchiveTableBody({
`}</style>
</tbody>
)
}
}, areTableBodyPropsEqual)

View File

@ -99,6 +99,14 @@ export function ProductArchiveView() {
setCurrentPage(1)
}, [setCurrentPage, setFilters])
const previewImage = useCallback((image: string, title: string) => {
setImagePreview({ image, title })
}, [])
const closeColumnSettings = useCallback(() => {
setShowColumnSettings(false)
}, [])
const commitPageInput = useCallback(() => {
const parsed = Number(pageInput)
if (!Number.isFinite(parsed)) {
@ -144,9 +152,15 @@ export function ProductArchiveView() {
<table className="archive-table">
<colgroup>
<col style={{ width: 60 }} />
{visibleColumns.map(({ group, column }) => (
<col key={`${group}-${column.prop}`} style={{ width: column.width }} />
))}
{effectiveColumns.flatMap((group) =>
group.columns.map((column) => (
<col
key={`${group.group}-${column.prop}`}
data-archive-column={column.prop}
style={{ width: column.width }}
/>
)),
)}
<col style={{ width: 138 }} />
</colgroup>
<thead>
@ -166,6 +180,7 @@ export function ProductArchiveView() {
g.columns.map((c) => (
<th
key={c.prop}
data-archive-column={c.prop}
className="archive-th-field"
style={{ background: GROUP_COLORS[g.group] || '#fff' }}
>
@ -198,7 +213,7 @@ export function ProductArchiveView() {
startIndex={(currentPage - 1) * pageSize}
imageStates={imageStates}
onImageVisible={markImageVisible}
onPreviewImage={(image, title) => setImagePreview({ image, title })}
onPreviewImage={previewImage}
onViewRow={setDetailRow}
onOpenChangeLog={setChangeLogBarcode}
/>
@ -259,11 +274,14 @@ export function ProductArchiveView() {
</span>
</div>
<ColumnSettingsPanel
{...columnSettings}
open={showColumnSettings}
onClose={() => setShowColumnSettings(false)}
/>
{showColumnSettings && (
<ColumnSettingsPanel
open
settings={columnSettings.settings}
setSettings={columnSettings.setSettings}
onClose={closeColumnSettings}
/>
)}
<ProductArchiveImagePreview
image={imagePreview?.image ?? null}
title={imagePreview?.title ?? ''}

View File

@ -8,7 +8,6 @@ import { ARCHIVE_COLUMNS, type ArchiveColumnGroup } from './columns'
export type ColumnSetting = {
group: string
groupVisible: boolean
columns: {
prop: string
label: string
@ -20,15 +19,31 @@ export type ColumnSetting = {
const STORAGE_KEY = 'caigou_archive_columns'
export function persistColumnSettings(settings: ColumnSetting[]) {
if (typeof window === 'undefined') return
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings))
}
// 从默认配置生成初始设置
function buildDefault(): ColumnSetting[] {
return ARCHIVE_COLUMNS.map((g) => ({
group: g.group,
groupVisible: true,
columns: g.columns.map((c) => ({ ...c, visible: true })),
}))
}
export function resetColumnSettings(current: ColumnSetting[]): ColumnSetting[] {
return buildDefault().map((defaultGroup) => {
const currentGroup = current.find((group) => group.group === defaultGroup.group)
const customColumns = currentGroup?.columns.filter((column) => column.isCustom) ?? []
return {
...defaultGroup,
columns: [...defaultGroup.columns, ...customColumns],
}
})
}
// 从 localStorage 加载(与默认配置合并:保留新增字段、删多余字段)
function loadSettings(): ColumnSetting[] {
if (typeof window === 'undefined') return buildDefault()
@ -77,7 +92,6 @@ function loadSettings(): ColumnSetting[] {
return {
group: dg.group,
groupVisible: sg.groupVisible !== false,
columns: [...orderedCols, ...customColumns],
}
})
@ -99,101 +113,20 @@ export function useColumnSettings() {
// 持久化
useEffect(() => {
if (hydrated && typeof window !== 'undefined') {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings))
persistColumnSettings(settings)
}
}, [hydrated, settings])
// 生效的列(groupVisible 且至少有一列 visible 的分组,只含 visible 列)
// 生效的列(至少有一列 visible 的分组,只含 visible 列)
const effectiveColumns = useMemo(() => {
return settings
.filter((g) => g.groupVisible && g.columns.some((c) => c.visible))
.filter((g) => g.columns.some((c) => c.visible))
.map((g) => ({
group: g.group,
columns: g.columns.filter((c) => c.visible),
})) as ArchiveColumnGroup[]
}, [settings])
const toggleColumn = useCallback((group: string, prop: string) => {
setSettings((cur) =>
cur.map((g) =>
g.group === group
? { ...g, columns: g.columns.map((c) => (c.prop === prop ? { ...c, visible: !c.visible } : c)) }
: g,
),
)
}, [])
const toggleGroup = useCallback((group: string) => {
setSettings((cur) =>
cur.map((g) => (g.group === group ? { ...g, groupVisible: !g.groupVisible } : g)),
)
}, [])
const setGroupAll = useCallback((group: string, visible: boolean) => {
setSettings((cur) =>
cur.map((g) =>
g.group === group ? { ...g, columns: g.columns.map((c) => ({ ...c, visible })) } : g,
),
)
}, [])
const reset = useCallback(() => {
setSettings((current) =>
buildDefault().map((defaultGroup) => {
const currentGroup = current.find((group) => group.group === defaultGroup.group)
const customColumns = currentGroup?.columns.filter((column) => column.isCustom) ?? []
return {
...defaultGroup,
columns: [...defaultGroup.columns, ...customColumns],
}
}),
)
}, [])
const addCustomColumn = useCallback((group: string, label: string) => {
const normalizedLabel = label.trim()
if (!normalizedLabel) return
setSettings((current) =>
current.map((entry) =>
entry.group === group
? {
...entry,
columns: [
...entry.columns,
{
prop: `custom_${Date.now().toString(36)}`,
label: normalizedLabel,
width: 120,
visible: true,
isCustom: true,
},
],
}
: entry,
),
)
}, [])
const renameColumn = useCallback((group: string, prop: string, label: string) => {
const normalizedLabel = label.trim()
if (!normalizedLabel) return
setSettings((current) =>
current.map((entry) =>
entry.group === group
? {
...entry,
columns: entry.columns.map((column) =>
column.prop === prop ? { ...column, label: normalizedLabel } : column,
),
}
: entry,
),
)
}, [])
const resizeColumn = useCallback((group: string, prop: string, width: number) => {
const normalizedWidth = Math.round(Math.min(720, Math.max(70, width)))
@ -214,12 +147,6 @@ export function useColumnSettings() {
return {
settings,
effectiveColumns,
toggleColumn,
toggleGroup,
setGroupAll,
reset,
addCustomColumn,
renameColumn,
resizeColumn,
setSettings,
}

View File

@ -6,19 +6,20 @@ import { ArrowLeft, ArrowRight, Edit3, Plus, RefreshCw, Search, ToggleLeft, Togg
type Status = 'ACTIVE' | 'INACTIVE'
type StorageType = 'INBOUND' | 'DROPSHIP'
type Item = {
id: string
id: string | null
warehouseName: string
warehouseHead: string
storageType: StorageType
status: Status
updatedAt: string
storageType: StorageType | null
status: Status | null
configured: boolean
updatedAt: string | null
}
type PageInfo = { page: number; pageSize: number; total: number; totalPages: number }
type Draft = {
id?: string
warehouseName: string
warehouseHead: string
storageType: StorageType
storageType: StorageType | ''
status: Status
}
@ -32,7 +33,8 @@ async function readResponse(response: Response) {
return payload.data
}
function formatTime(value: string) {
function formatTime(value: string | null) {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '-'
return new Intl.DateTimeFormat('zh-CN', {
@ -46,7 +48,7 @@ export function WarehouseConfigView() {
const [page, setPage] = useState(1)
const [searchInput, setSearchInput] = useState('')
const [search, setSearch] = useState('')
const [status, setStatus] = useState<'ALL' | Status>('ALL')
const [status, setStatus] = useState<'ALL' | Status | 'UNCONFIGURED'>('ALL')
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [draft, setDraft] = useState<Draft | null>(null)
@ -110,6 +112,7 @@ export function WarehouseConfigView() {
}
const toggleStatus = async (item: Item) => {
if (!item.id || !item.storageType || !item.status) return
try {
await readResponse(await fetch(`/api/supply-chain/warehouse-config/${encodeURIComponent(item.id)}`, {
method: 'PATCH',
@ -133,7 +136,7 @@ export function WarehouseConfigView() {
<div><h1>产品全量档案 - 仓库配置</h1><p>按出入库仓库匹配入库或代发</p></div>
<div className="warehouse-config-toolbar-meta">
<span>共 {pageInfo.total} 条</span>
<button type="button" className="warehouse-config-button primary" onClick={() => setDraft({ warehouseName: '', warehouseHead: '', storageType: 'INBOUND', status: 'ACTIVE' })}>
<button type="button" className="warehouse-config-button primary" onClick={() => setDraft({ warehouseName: '', warehouseHead: '', storageType: '', status: 'ACTIVE' })}>
<Plus size={15} /> 新增配置
</button>
</div>
@ -141,7 +144,7 @@ export function WarehouseConfigView() {
<form className="warehouse-config-filters" onSubmit={submitSearch}>
<label className="warehouse-config-search"><Search size={15} /><input value={searchInput} onChange={(event) => setSearchInput(event.target.value)} placeholder="搜索仓库名称" /></label>
<select value={status} onChange={(event) => { setPage(1); setStatus(event.target.value as 'ALL' | Status) }}><option value="ALL">全部状态</option><option value="ACTIVE">启用</option><option value="INACTIVE">停用</option></select>
<select value={status} onChange={(event) => { setPage(1); setStatus(event.target.value as 'ALL' | Status | 'UNCONFIGURED') }}><option value="ALL">全部状态</option><option value="UNCONFIGURED">未配置</option><option value="ACTIVE">启用</option><option value="INACTIVE">停用</option></select>
<button type="submit" className="warehouse-config-button">搜索</button>
<button type="button" className="warehouse-config-icon-button" title="刷新" aria-label="刷新" onClick={() => void load()}><RefreshCw size={15} /></button>
</form>
@ -153,15 +156,15 @@ export function WarehouseConfigView() {
{loading && <tr><td colSpan={6} className="warehouse-config-empty">正在加载...</td></tr>}
{!loading && error && <tr><td colSpan={6} className="warehouse-config-empty error">{error}</td></tr>}
{!loading && !error && items.length === 0 && <tr><td colSpan={6} className="warehouse-config-empty">暂无配置</td></tr>}
{!loading && !error && items.map((item) => <tr key={item.id}>
{!loading && !error && items.map((item) => <tr key={item.id ?? `warehouse:${item.warehouseName}`}>
<td className="warehouse-config-name">{item.warehouseName}</td>
<td>{item.warehouseHead || '-'}</td>
<td>{item.storageType === 'INBOUND' ? '入库' : '代发'}</td>
<td><span className={`warehouse-config-status ${item.status.toLowerCase()}`}>{item.status === 'ACTIVE' ? '启用' : '停用'}</span></td>
<td>{item.storageType === 'INBOUND' ? '入库' : item.storageType === 'DROPSHIP' ? '代发' : '-'}</td>
<td><span className={`warehouse-config-status ${item.status?.toLowerCase() ?? 'unconfigured'}`}>{item.status === 'ACTIVE' ? '启用' : item.status === 'INACTIVE' ? '停用' : '未配置'}</span></td>
<td className="warehouse-config-time">{formatTime(item.updatedAt)}</td>
<td className="warehouse-config-actions">
<button type="button" onClick={() => setDraft({ ...item })}><Edit3 size={14} /> 编辑</button>
<button type="button" onClick={() => void toggleStatus(item)}>{item.status === 'ACTIVE' ? <ToggleRight size={16} /> : <ToggleLeft size={16} />}{item.status === 'ACTIVE' ? '停用' : '启用'}</button>
<button type="button" onClick={() => setDraft({ id: item.id ?? undefined, warehouseName: item.warehouseName, warehouseHead: item.warehouseHead, storageType: item.storageType ?? '', status: item.status ?? 'ACTIVE' })}><Edit3 size={14} /> {item.configured ? '编辑' : '配置'}</button>
{item.configured && <button type="button" onClick={() => void toggleStatus(item)}>{item.status === 'ACTIVE' ? <ToggleRight size={16} /> : <ToggleLeft size={16} />}{item.status === 'ACTIVE' ? '停用' : '启用'}</button>}
</td>
</tr>)}
</tbody>
@ -179,14 +182,14 @@ export function WarehouseConfigView() {
<div className="warehouse-config-modal-header"><h2>{draft.id ? '编辑仓库配置' : '新增仓库配置'}</h2><button type="button" title="关闭" aria-label="关闭" onClick={() => setDraft(null)}><X size={17} /></button></div>
<label>仓库名称<input autoFocus required maxLength={160} value={draft.warehouseName} onChange={(event) => setDraft({ ...draft, warehouseName: event.target.value })} /></label>
<label>仓库负责人<input maxLength={80} value={draft.warehouseHead} onChange={(event) => setDraft({ ...draft, warehouseHead: event.target.value })} /></label>
<label>入库/代发<select value={draft.storageType} onChange={(event) => setDraft({ ...draft, storageType: event.target.value as StorageType })}><option value="INBOUND">入库</option><option value="DROPSHIP">代发</option></select></label>
<label>入库/代发<select required value={draft.storageType} onChange={(event) => setDraft({ ...draft, storageType: event.target.value as StorageType | '' })}><option value="" disabled>请选择</option><option value="INBOUND">入库</option><option value="DROPSHIP">代发</option></select></label>
{draft.id && <label>状态<select value={draft.status} onChange={(event) => setDraft({ ...draft, status: event.target.value as Status })}><option value="ACTIVE">启用</option><option value="INACTIVE">停用</option></select></label>}
<div className="warehouse-config-modal-actions"><button type="button" onClick={() => setDraft(null)}>取消</button><button type="submit" className="primary" disabled={saving}>{saving ? '保存中...' : '保存'}</button></div>
</form>
</div>}
<style jsx>{`
.warehouse-config-page{display:flex;flex:1;min-height:0;flex-direction:column;background:#fafafa;color:#27272a}.warehouse-config-toolbar{display:flex;align-items:flex-end;justify-content:space-between;gap:20px;padding:22px 28px 18px;border-bottom:1px solid #e4e4e7;background:#fff}.warehouse-config-toolbar h1{margin:0;font-size:20px;font-weight:650}.warehouse-config-toolbar p{margin:7px 0 0;color:#71717a;font-size:12px}.warehouse-config-toolbar-meta{display:flex;align-items:center;gap:14px;color:#71717a;font-size:12px}.warehouse-config-filters{display:flex;align-items:center;gap:8px;padding:14px 28px;border-bottom:1px solid #e4e4e7;background:#fff}.warehouse-config-search{display:flex;height:34px;width:min(380px,42vw);align-items:center;gap:8px;padding:0 10px;border:1px solid #d4d4d8;border-radius:5px;color:#71717a}.warehouse-config-search input{width:100%;border:0;outline:0;background:transparent;font-size:13px}.warehouse-config-filters select,.warehouse-config-modal select{height:34px;padding:0 28px 0 10px;border:1px solid #d4d4d8;border-radius:5px;background:#fff;color:#3f3f46;font-size:13px}.warehouse-config-button,.warehouse-config-icon-button{display:inline-flex;height:34px;align-items:center;justify-content:center;gap:6px;padding:0 12px;border:1px solid #d4d4d8;border-radius:5px;background:#fff;color:#3f3f46;font-size:13px;cursor:pointer}.warehouse-config-button.primary{border-color:#27272a;background:#27272a;color:#fff}.warehouse-config-icon-button{width:34px;padding:0}.warehouse-config-table-wrap{flex:1;min-height:0;overflow:auto;padding:0 28px}.warehouse-config-table{width:100%;border-collapse:collapse;background:#fff;font-size:13px}.warehouse-config-table th{position:sticky;top:0;height:42px;padding:0 14px;border-bottom:1px solid #d4d4d8;background:#f4f4f5;color:#52525b;font-size:12px;text-align:left}.warehouse-config-table td{height:48px;padding:0 14px;border-bottom:1px solid #f0f0f1;color:#3f3f46}.warehouse-config-name{font-weight:500;color:#18181b!important}.warehouse-config-time{color:#71717a!important}.warehouse-config-status{display:inline-flex;align-items:center;height:24px;padding:0 8px;border-radius:4px;font-size:12px}.warehouse-config-status.active{background:#ecfdf3;color:#15803d}.warehouse-config-status.inactive{background:#f4f4f5;color:#71717a}.warehouse-config-actions{display:flex;gap:12px}.warehouse-config-actions button{display:inline-flex;align-items:center;gap:4px;border:0;background:transparent;color:#52525b;font-size:12px;cursor:pointer}.warehouse-config-empty{height:160px!important;text-align:center;color:#a1a1aa!important}.warehouse-config-empty.error{color:#b91c1c!important}.warehouse-config-pagination{display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:12px 28px;border-top:1px solid #e4e4e7;background:#fff;color:#71717a;font-size:12px}.warehouse-config-pagination button{display:inline-flex;align-items:center;gap:4px;height:30px;padding:0 9px;border:1px solid #d4d4d8;border-radius:4px;background:#fff;color:#52525b;font-size:12px;cursor:pointer}.warehouse-config-pagination button:disabled{opacity:.45}.warehouse-config-modal-backdrop{position:fixed;inset:0;z-index:100;display:flex;align-items:center;justify-content:center;background:rgba(24,24,27,.26)}.warehouse-config-modal{width:min(480px,calc(100vw - 32px));padding:20px;border:1px solid #e4e4e7;border-radius:8px;background:#fff;box-shadow:0 18px 50px rgba(24,24,27,.18)}.warehouse-config-modal-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:18px}.warehouse-config-modal-header h2{margin:0;font-size:16px}.warehouse-config-modal-header button{border:0;background:transparent;color:#71717a;cursor:pointer}.warehouse-config-modal label{display:flex;flex-direction:column;gap:6px;margin-top:14px;color:#52525b;font-size:12px}.warehouse-config-modal input{height:36px;width:100%;box-sizing:border-box;padding:0 10px;border:1px solid #d4d4d8;border-radius:5px;outline:0;font-size:13px}.warehouse-config-modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:22px}.warehouse-config-modal-actions button{height:34px;padding:0 13px;border:1px solid #d4d4d8;border-radius:5px;background:#fff;cursor:pointer}.warehouse-config-modal-actions .primary{border-color:#27272a;background:#27272a;color:#fff}@media(max-width:760px){.warehouse-config-toolbar{align-items:flex-start;flex-direction:column;padding:18px}.warehouse-config-filters{padding:12px 18px;flex-wrap:wrap}.warehouse-config-search{width:100%}.warehouse-config-table-wrap{padding:0 18px}}
.warehouse-config-page{display:flex;flex:1;min-height:0;flex-direction:column;background:#fafafa;color:#27272a}.warehouse-config-toolbar{display:flex;align-items:flex-end;justify-content:space-between;gap:20px;padding:22px 28px 18px;border-bottom:1px solid #e4e4e7;background:#fff}.warehouse-config-toolbar h1{margin:0;font-size:20px;font-weight:650}.warehouse-config-toolbar p{margin:7px 0 0;color:#71717a;font-size:12px}.warehouse-config-toolbar-meta{display:flex;align-items:center;gap:14px;color:#71717a;font-size:12px}.warehouse-config-filters{display:flex;align-items:center;gap:8px;padding:14px 28px;border-bottom:1px solid #e4e4e7;background:#fff}.warehouse-config-search{display:flex;height:34px;width:min(380px,42vw);align-items:center;gap:8px;padding:0 10px;border:1px solid #d4d4d8;border-radius:5px;color:#71717a}.warehouse-config-search input{width:100%;border:0;outline:0;background:transparent;font-size:13px}.warehouse-config-filters select,.warehouse-config-modal select{height:34px;padding:0 28px 0 10px;border:1px solid #d4d4d8;border-radius:5px;background:#fff;color:#3f3f46;font-size:13px}.warehouse-config-button,.warehouse-config-icon-button{display:inline-flex;height:34px;align-items:center;justify-content:center;gap:6px;padding:0 12px;border:1px solid #d4d4d8;border-radius:5px;background:#fff;color:#3f3f46;font-size:13px;cursor:pointer}.warehouse-config-button.primary{border-color:#27272a;background:#27272a;color:#fff}.warehouse-config-icon-button{width:34px;padding:0}.warehouse-config-table-wrap{flex:1;min-height:0;overflow:auto;padding:0 28px}.warehouse-config-table{width:100%;border-collapse:collapse;background:#fff;font-size:13px}.warehouse-config-table th{position:sticky;top:0;height:42px;padding:0 14px;border-bottom:1px solid #d4d4d8;background:#f4f4f5;color:#52525b;font-size:12px;text-align:left}.warehouse-config-table td{height:48px;padding:0 14px;border-bottom:1px solid #f0f0f1;color:#3f3f46}.warehouse-config-name{font-weight:500;color:#18181b!important}.warehouse-config-time{color:#71717a!important}.warehouse-config-status{display:inline-flex;align-items:center;height:24px;padding:0 8px;border-radius:4px;font-size:12px}.warehouse-config-status.active{background:#ecfdf3;color:#15803d}.warehouse-config-status.inactive{background:#f4f4f5;color:#71717a}.warehouse-config-status.unconfigured{background:#fff7ed;color:#c2410c}.warehouse-config-actions{display:flex;gap:12px}.warehouse-config-actions button{display:inline-flex;align-items:center;gap:4px;border:0;background:transparent;color:#52525b;font-size:12px;cursor:pointer}.warehouse-config-empty{height:160px!important;text-align:center;color:#a1a1aa!important}.warehouse-config-empty.error{color:#b91c1c!important}.warehouse-config-pagination{display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:12px 28px;border-top:1px solid #e4e4e7;background:#fff;color:#71717a;font-size:12px}.warehouse-config-pagination button{display:inline-flex;align-items:center;gap:4px;height:30px;padding:0 9px;border:1px solid #d4d4d8;border-radius:4px;background:#fff;color:#52525b;font-size:12px;cursor:pointer}.warehouse-config-pagination button:disabled{opacity:.45}.warehouse-config-modal-backdrop{position:fixed;inset:0;z-index:100;display:flex;align-items:center;justify-content:center;background:rgba(24,24,27,.26)}.warehouse-config-modal{width:min(480px,calc(100vw - 32px));padding:20px;border:1px solid #e4e4e7;border-radius:8px;background:#fff;box-shadow:0 18px 50px rgba(24,24,27,.18)}.warehouse-config-modal-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:18px}.warehouse-config-modal-header h2{margin:0;font-size:16px}.warehouse-config-modal-header button{border:0;background:transparent;color:#71717a;cursor:pointer}.warehouse-config-modal label{display:flex;flex-direction:column;gap:6px;margin-top:14px;color:#52525b;font-size:12px}.warehouse-config-modal input{height:36px;width:100%;box-sizing:border-box;padding:0 10px;border:1px solid #d4d4d8;border-radius:5px;outline:0;font-size:13px}.warehouse-config-modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:22px}.warehouse-config-modal-actions button{height:34px;padding:0 13px;border:1px solid #d4d4d8;border-radius:5px;background:#fff;cursor:pointer}.warehouse-config-modal-actions .primary{border-color:#27272a;background:#27272a;color:#fff}@media(max-width:760px){.warehouse-config-toolbar{align-items:flex-start;flex-direction:column;padding:18px}.warehouse-config-filters{padding:12px 18px;flex-wrap:wrap}.warehouse-config-search{width:100%}.warehouse-config-table-wrap{padding:0 18px}}
`}</style>
</div>
)

View File

@ -96,6 +96,7 @@ function AppShellContent({ children, user, localOaBoardPreview = false }: AppShe
const isBomCostingRoute =
pathname === '/bom-costing' || pathname.startsWith('/bom-costing/')
const isOaBoardRoute = pathname === '/intake/oa-board' || pathname.startsWith('/intake/oa-board/')
const isSampleProgressRoute = pathname === '/intake/sample-progress' || pathname.startsWith('/intake/sample-progress/')
const isLocalOaBoardPreview = localOaBoardPreview && isOaBoardRoute
const isWarehouseRoute = pathname === '/warehouse' || pathname.startsWith('/warehouse/')
const isWarehouseQualityRoute =
@ -131,6 +132,7 @@ function AppShellContent({ children, user, localOaBoardPreview = false }: AppShe
const shouldLockMainViewport =
isWorkbenchShellRoute &&
!isOaBoardRoute &&
!isSampleProgressRoute &&
!isMobileProductDetailLinkView
useEffect(() => {

View File

@ -46,6 +46,12 @@ import { SupplyChainSearch } from '@/app/supply-chain/components/SupplyChainSear
function matchesWorkbenchHref(pathname: string, href: string) {
if (href === '/') return pathname === '/'
if (href === '/intake') {
return pathname === '/intake' || (pathname.startsWith('/intake/') && !pathname.startsWith('/intake/oa-board') && !pathname.startsWith('/intake/sample-progress'))
}
if (href === '/intake/oa-board') {
return pathname === href || pathname.startsWith(`${href}/`) || pathname.startsWith('/intake/sample-progress')
}
return pathname === href || pathname.startsWith(`${href}/`)
}

View File

@ -0,0 +1,72 @@
import {
hasConsistentNonIntegratedPackageSetCounts,
normalizeNonIntegratedPackageSetCounts,
} from './non-integrated-package-set-count'
describe('non-integrated package set count', () => {
it('uses the package group size for every SKU and factory row in a two-package group', () => {
const normalized = normalizeNonIntegratedPackageSetCounts(
[
{ id: 'sku-a', packageGroupId: 'package-1', setCount: '1' },
{ id: 'sku-b', packageGroupId: 'package-1', setCount: '2' },
],
[
{ skuId: 'sku-a', setCount: '1' },
{ skuId: 'sku-b', setCount: '2' },
],
)
expect(normalized.skuItems.map((item) => item.setCount)).toEqual(['2', '2'])
expect(normalized.factoryRows.map((row) => row.setCount)).toEqual(['2', '2'])
expect(
hasConsistentNonIntegratedPackageSetCounts(
normalized.skuItems,
normalized.factoryRows,
),
).toBe(true)
})
it('uses the actual package count for groups larger than two', () => {
const normalized = normalizeNonIntegratedPackageSetCounts(
[
{ id: 'sku-a', packageGroupId: 'package-1', setCount: '1' },
{ id: 'sku-b', packageGroupId: 'package-1', setCount: '2' },
{ id: 'sku-c', packageGroupId: 'package-1', setCount: '9' },
],
[],
)
expect(normalized.skuItems.map((item) => item.setCount)).toEqual(['3', '3', '3'])
})
it('does not change rows without a multi-package group', () => {
const normalized = normalizeNonIntegratedPackageSetCounts(
[
{ id: 'sku-a', packageGroupId: '', setCount: '1' },
{ id: 'sku-b', packageGroupId: 'single-package', setCount: '4' },
],
[
{ skuId: 'sku-a', setCount: '7' },
{ skuId: 'sku-b', setCount: '8' },
],
)
expect(normalized.skuItems.map((item) => item.setCount)).toEqual(['1', '4'])
expect(normalized.factoryRows.map((row) => row.setCount)).toEqual(['7', '8'])
})
it('detects a stale factory-row value after save', () => {
expect(
hasConsistentNonIntegratedPackageSetCounts(
[
{ id: 'sku-a', packageGroupId: 'package-1', setCount: '2' },
{ id: 'sku-b', packageGroupId: 'package-1', setCount: '2' },
],
[
{ skuId: 'sku-a', setCount: '1' },
{ skuId: 'sku-b', setCount: '2' },
],
),
).toBe(false)
})
})

View File

@ -0,0 +1,91 @@
type PackageSkuRow = {
id: string
packageGroupId: string
setCount: string
}
type PackageFactoryRow = {
skuId: string
setCount: string
}
type PackageSetCountExpectations = {
bySkuId: Map<string, string>
bySkuIndex: Map<number, string>
}
function normalizeText(value: string) {
return value.trim()
}
function buildPackageSetCountExpectations<TSku extends PackageSkuRow>(
skuItems: readonly TSku[],
): PackageSetCountExpectations {
const skuIndexesByPackageGroupId = new Map<string, number[]>()
skuItems.forEach((item, index) => {
const packageGroupId = normalizeText(item.packageGroupId)
if (!packageGroupId) return
const indexes = skuIndexesByPackageGroupId.get(packageGroupId) ?? []
indexes.push(index)
skuIndexesByPackageGroupId.set(packageGroupId, indexes)
})
const bySkuId = new Map<string, string>()
const bySkuIndex = new Map<number, string>()
skuIndexesByPackageGroupId.forEach((indexes) => {
if (indexes.length <= 1) return
const expectedSetCount = String(indexes.length)
indexes.forEach((index) => {
bySkuIndex.set(index, expectedSetCount)
const skuId = normalizeText(skuItems[index]?.id ?? '')
if (skuId) {
bySkuId.set(skuId, expectedSetCount)
}
})
})
return { bySkuId, bySkuIndex }
}
export function normalizeNonIntegratedPackageSetCounts<
TSku extends PackageSkuRow,
TFactory extends PackageFactoryRow,
>(skuItems: readonly TSku[], factoryRows: readonly TFactory[]) {
const expectations = buildPackageSetCountExpectations(skuItems)
return {
skuItems: skuItems.map((item, index) => {
const expectedSetCount = expectations.bySkuIndex.get(index)
return expectedSetCount && normalizeText(item.setCount) !== expectedSetCount
? { ...item, setCount: expectedSetCount }
: item
}),
factoryRows: factoryRows.map((row) => {
const expectedSetCount = expectations.bySkuId.get(normalizeText(row.skuId))
return expectedSetCount && normalizeText(row.setCount) !== expectedSetCount
? { ...row, setCount: expectedSetCount }
: row
}),
}
}
export function hasConsistentNonIntegratedPackageSetCounts<
TSku extends PackageSkuRow,
TFactory extends PackageFactoryRow,
>(skuItems: readonly TSku[], factoryRows: readonly TFactory[]) {
const expectations = buildPackageSetCountExpectations(skuItems)
const skuItemsAreConsistent = skuItems.every((item, index) => {
const expectedSetCount = expectations.bySkuIndex.get(index)
return !expectedSetCount || normalizeText(item.setCount) === expectedSetCount
})
if (!skuItemsAreConsistent) return false
return factoryRows.every((row) => {
const expectedSetCount = expectations.bySkuId.get(normalizeText(row.skuId))
return !expectedSetCount || normalizeText(row.setCount) === expectedSetCount
})
}

View File

@ -0,0 +1,27 @@
import { calculateSampleProgressAnomaly, canEditSampleProgress } from './sample-progress'
const now = new Date('2026-08-11T12:00:00.000Z')
describe('calculateSampleProgressAnomaly', () => {
it('白茬停留超过 5 天时标记白茬异常', () => {
expect(calculateSampleProgressAnomaly({ status: 'BARE_WOOD', oaReachedAt: '2026-08-05T00:00:00.000Z', sampleDueDate: '2026-08-20', now })).toBe('BARE_WOOD')
})
it('进入油漆前超过 10 天时标记油漆异常', () => {
expect(calculateSampleProgressAnomaly({ status: 'BARE_WOOD_REVIEW', oaReachedAt: '2026-07-31T00:00:00.000Z', sampleDueDate: '2026-08-20', now })).toBe('PAINT')
})
it('超过打样交期时成品异常优先', () => {
expect(calculateSampleProgressAnomaly({ status: 'PAINT', oaReachedAt: '2026-08-09T00:00:00.000Z', sampleDueDate: '2026-08-10', now })).toBe('FINISHED_PRODUCT')
})
it('已完结记录不再显示异常', () => {
expect(calculateSampleProgressAnomaly({ status: 'COMPLETED', oaReachedAt: '2026-07-01T00:00:00.000Z', sampleDueDate: '2026-07-10', now })).toBe('NONE')
})
})
describe('canEditSampleProgress', () => {
const owners = { purchaseOwner: '王霞、刘强', merchandiserOwner: '周林' }
it.each(['王霞', '刘强', '周林', '荆川'])('%s 可以编辑', (name) => expect(canEditSampleProgress(name, owners)).toBe(true))
it('非负责人不可编辑', () => expect(canEditSampleProgress('其他用户', owners)).toBe(false))
})

View File

@ -0,0 +1,59 @@
import { z } from 'zod'
export const SAMPLE_PROGRESS_STATUSES = ['BARE_WOOD', 'BARE_WOOD_REVIEW', 'PAINT', 'VIDEO_REVIEW', 'SAMPLE_SHIPPING', 'PHYSICAL_REVIEW', 'COMPLETED'] as const
export const SAMPLE_PROGRESS_STATUS_LABELS: Record<(typeof SAMPLE_PROGRESS_STATUSES)[number], string> = {
BARE_WOOD: '白茬', BARE_WOOD_REVIEW: '白茬审样', PAINT: '油漆', VIDEO_REVIEW: '视频审核成品', SAMPLE_SHIPPING: '寄样', PHYSICAL_REVIEW: '实物审样', COMPLETED: '已完结',
}
const optionalText = z.string().trim().max(1000).optional().nullable()
const optionalHttpUrl = z.string().trim().max(8000, '流程链接地址不能超过 8000 个字符').optional().nullable().refine((value) => {
if (!value) return true
try {
const url = new URL(value)
return url.protocol === 'http:' || url.protocol === 'https:'
} catch {
return false
}
}, '请输入有效的 http 或 https 链接')
export const sampleProgressInputSchema = z.object({
orderDate: z.coerce.date(), imageUrl: optionalText,
openingCode: z.string().trim().min(1, '开款编号不能为空').max(100),
productDevelopmentFlowUrl: optionalHttpUrl,
productSize: z.string().trim().min(1).max(200), color: z.string().trim().min(1).max(100), material: z.string().trim().min(1).max(200),
sampleQuantity: z.coerce.number().int().positive().max(100000), brand: z.string().trim().max(100),
sampleDueDate: z.coerce.date(), supplier: z.string().trim().min(1).max(200), finalSampleFactory: optionalText,
status: z.enum(SAMPLE_PROGRESS_STATUSES), designer: z.string().trim().max(100),
purchaseOwner: optionalText, merchandiserOwner: optionalText,
progressDescription: optionalText,
requestedCost: z.coerce.number().min(0).max(9999999999).optional().nullable(), actualCost: z.coerce.number().min(0).max(9999999999).optional().nullable(),
followUpOwner: optionalText,
})
export type SampleProgressInput = z.infer<typeof sampleProgressInputSchema>
export const sampleProgressUpdateSchema = sampleProgressInputSchema.extend({ deliveryDateChangeReason: z.string().trim().max(1000).optional().nullable() })
export type SampleProgressUpdateInput = z.infer<typeof sampleProgressUpdateSchema>
function ownerNames(value: string | null | undefined) {
return String(value || '').split(/[,,、;/\s]+/).map((name) => name.trim()).filter(Boolean)
}
export function canEditSampleProgress(userName: string, owners: { purchaseOwner?: string | null; merchandiserOwner?: string | null }) {
const normalized = userName.trim()
return normalized === '荆川' || [...ownerNames(owners.purchaseOwner), ...ownerNames(owners.merchandiserOwner)].includes(normalized)
}
export const SAMPLE_PROGRESS_ANOMALY_LABELS = {
NONE: '无异常', BARE_WOOD: '白茬异常', PAINT: '油漆异常', FINISHED_PRODUCT: '成品异常',
} as const
export type SampleProgressAnomaly = keyof typeof SAMPLE_PROGRESS_ANOMALY_LABELS
const DAY_MS = 24 * 60 * 60 * 1000
export function calculateSampleProgressAnomaly(input: { status: string; oaReachedAt: Date | string | null; sampleDueDate: Date | string; now?: Date }): SampleProgressAnomaly {
if (input.status === 'COMPLETED') return 'NONE'
const now = input.now ?? new Date()
const dueAt = new Date(input.sampleDueDate)
if (now.getTime() > dueAt.getTime() + DAY_MS - 1) return 'FINISHED_PRODUCT'
if (!input.oaReachedAt) return 'NONE'
const elapsedDays = (now.getTime() - new Date(input.oaReachedAt).getTime()) / DAY_MS
const beforePaint = input.status === 'BARE_WOOD' || input.status === 'BARE_WOOD_REVIEW'
if (elapsedDays > 10 && beforePaint) return 'PAINT'
if (elapsedDays > 5 && input.status === 'BARE_WOOD') return 'BARE_WOOD'
return 'NONE'
}

View File

@ -28,6 +28,7 @@ export const PRODUCT_ARCHIVE_CHANGE_FIELDS = [
{ key: 'storageType', label: '入库/代发' },
{ key: 'isSeasonal', label: '是否季节性产品' },
{ key: 'replaceCount', label: '替换款数量' },
{ key: 'region', label: '区域' },
{ key: 'supplierName', label: '供应商' },
{ key: 'supplierType', label: '供应商类型' },
{ key: 'supplierStatus', label: '供应商状态' },

View File

@ -33,6 +33,7 @@ export type SupplyChainProductArchiveRow = {
storageType: string
isSeasonal: string
replaceCount: string
region: string
supplier: string
supplierType: string
supplierStatus: string

View File

@ -23,6 +23,7 @@ export const publicPaths = [
'/register',
'/first-login',
'/oa-board-preview',
'/sample-progress-preview',
'/share',
'/installation-demo-share',
'/internal/asset-thumbnail',
@ -88,6 +89,15 @@ export async function proxy(request: NextRequest) {
return NextResponse.rewrite(previewUrl)
}
if (
OA_BOARD_LOCAL_PREVIEW_ENABLED &&
(pathname === '/intake/sample-progress' || pathname.startsWith('/intake/sample-progress/'))
) {
const previewUrl = request.nextUrl.clone()
previewUrl.pathname = '/sample-progress-preview'
return NextResponse.rewrite(previewUrl)
}
if (isPublicPath(pathname)) {
return NextResponse.next()
}

View File

@ -2,6 +2,7 @@ import sql from 'mssql'
export type JstSupplierProfile = {
supplierName: string
region: string
supplierType: string
supplierStatusCode: string
purchaser: string
@ -85,6 +86,7 @@ class JstSupplierProfileRepositoryClass implements JstSupplierProfileRepository
WITH ranked_suppliers AS (
SELECT
LTRIM(RTRIM(CAST(scmname AS nvarchar(255)))) AS supplierName,
LTRIM(RTRIM(COALESCE(CAST(region AS nvarchar(255)), N''))) AS region,
LTRIM(RTRIM(COALESCE(CAST(scmtype AS nvarchar(255)), N''))) AS supplierType,
LTRIM(RTRIM(COALESCE(CAST(coopersgn AS nvarchar(20)), N''))) AS supplierStatusCode,
LTRIM(RTRIM(COALESCE(CAST(buyer AS nvarchar(255)), N''))) AS purchaser,
@ -100,6 +102,7 @@ class JstSupplierProfileRepositoryClass implements JstSupplierProfileRepository
)
SELECT
supplierName,
region,
supplierType,
supplierStatusCode,
purchaser,

View File

@ -0,0 +1,39 @@
import { Prisma } from '@prisma/client'
import { prisma } from '@/lib/prisma'
export const sampleProgressRepository = {
list() { return prisma.sampleProgressItem.findMany({ orderBy: [{ sampleDueDate: 'asc' }, { updatedAt: 'desc' }] }) },
createMany(data: Prisma.SampleProgressItemCreateManyInput[]) {
if (data.length === 0) return Promise.resolve({ count: 0 })
return prisma.sampleProgressItem.createMany({ data, skipDuplicates: true })
},
findById(id: string) { return prisma.sampleProgressItem.findUnique({ where: { id } }) },
create(data: Prisma.SampleProgressItemCreateInput) { return prisma.sampleProgressItem.create({ data }) },
update(id: string, data: Prisma.SampleProgressItemUpdateInput) { return prisma.sampleProgressItem.update({ where: { id }, data }) },
updateWithChangeLog(input: {
id: string
data: Prisma.SampleProgressItemUpdateInput
changedFields: Prisma.InputJsonValue
beforeSnapshot: Prisma.InputJsonValue
afterSnapshot: Prisma.InputJsonValue
changeReason?: string | null
changedById?: string
changedByName: string
}) {
return prisma.$transaction(async (tx) => {
const updated = await tx.sampleProgressItem.update({ where: { id: input.id }, data: input.data })
await tx.sampleProgressChangeLog.create({ data: {
itemId: input.id,
changedFields: input.changedFields,
beforeSnapshot: input.beforeSnapshot,
afterSnapshot: input.afterSnapshot,
changeReason: input.changeReason || null,
changedById: input.changedById,
changedByName: input.changedByName,
} })
return updated
})
},
listChangeLogs(itemId: string) { return prisma.sampleProgressChangeLog.findMany({ where: { itemId }, orderBy: { createdAt: 'desc' } }) },
delete(id: string) { return prisma.sampleProgressItem.delete({ where: { id } }) },
}

View File

@ -12,6 +12,7 @@ import {
} from '@/lib/supply-chain-product-archive-field-source'
export const PRODUCT_ARCHIVE_ENRICHMENT_FIELDS = [
'region',
'supplierType',
'supplierStatus',
'purchaseOwner',
@ -67,6 +68,7 @@ const currentArchiveSelect = {
barcode: true,
spu: true,
fieldSourceMap: true,
region: true,
supplierType: true,
supplierStatus: true,
purchaseOwner: true,

View File

@ -20,12 +20,12 @@ export type ProductArchiveWarehouseConfigRecord = Prisma.SupplyChainProductArchi
}>
export interface SupplyChainProductArchiveWarehouseConfigRepository {
listPage(input: {
page: number
pageSize: number
listCandidates(input: {
search?: string
status?: ProductArchiveWarehouseConfigStatus
}): Promise<{ records: ProductArchiveWarehouseConfigRecord[]; total: number }>
}): Promise<{
warehouseNames: string[]
configurations: ProductArchiveWarehouseConfigRecord[]
}>
listActive(): Promise<ProductArchiveWarehouseConfigRecord[]>
create(input: {
warehouseName: string
@ -45,30 +45,36 @@ export interface SupplyChainProductArchiveWarehouseConfigRepository {
class SupplyChainProductArchiveWarehouseConfigRepositoryClass
implements SupplyChainProductArchiveWarehouseConfigRepository
{
async listPage(input: {
page: number
pageSize: number
search?: string
status?: ProductArchiveWarehouseConfigStatus
}) {
const where: Prisma.SupplyChainProductArchiveWarehouseConfigWhereInput = {
...(input.search
? { warehouseName: { contains: input.search, mode: 'insensitive' } }
: {}),
...(input.status ? { status: input.status } : {}),
}
const skip = (input.page - 1) * input.pageSize
const [records, total] = await Promise.all([
async listCandidates(input: { search?: string }) {
const searchFilter = input.search
? { contains: input.search, mode: Prisma.QueryMode.insensitive }
: undefined
const [archiveWarehouses, configurations] = await Promise.all([
prisma.supplyChainProductArchive.findMany({
where: {
inOutWarehouse: {
not: '',
...searchFilter,
},
},
select: { inOutWarehouse: true },
distinct: ['inOutWarehouse'],
orderBy: { inOutWarehouse: 'asc' },
}),
prisma.supplyChainProductArchiveWarehouseConfig.findMany({
where,
where: input.search
? { warehouseName: searchFilter }
: undefined,
select: warehouseConfigSelect,
orderBy: [{ warehouseName: 'asc' }, { id: 'asc' }],
skip,
take: input.pageSize,
}),
prisma.supplyChainProductArchiveWarehouseConfig.count({ where }),
])
return { records, total }
return {
warehouseNames: archiveWarehouses
.map((record) => record.inOutWarehouse.trim())
.filter(Boolean),
configurations,
}
}
async listActive() {

View File

@ -36,6 +36,7 @@ const productArchiveSelect = {
storageType: true,
isSeasonal: true,
replaceCount: true,
region: true,
supplierName: true,
supplierType: true,
supplierStatus: true,

View File

@ -2023,7 +2023,7 @@ describe('intakeNonIntegratedProductService', () => {
)
})
it('分配正式 SPU 时用产品名和 SPU 补齐单 SKU 主数据', async () => {
it('does not copy the opening name into a single SKU spec name', async () => {
const payload = createPayload({
id: 'draft-single-sku-master-data-autofill',
spu: '',
@ -2073,7 +2073,7 @@ describe('intakeNonIntegratedProductService', () => {
}
expect(createArg.payload?.skuItems?.[0]).toEqual(
expect.objectContaining({
specName: '禾沐边几(A款)',
specName: '',
model: 'BJ0214',
skuCode: 'BJ021401',
barcode: 'BJ021401N01V01',
@ -2081,10 +2081,10 @@ describe('intakeNonIntegratedProductService', () => {
)
expect(createArg.skuItems?.createMany?.data?.[0]).toEqual(
expect.objectContaining({
specName: '禾沐边几(A款)',
specName: '',
model: 'BJ0214',
dataQualityStatus: 'READY',
missingRequiredFields: [],
dataQualityStatus: 'INCOMPLETE',
missingRequiredFields: ['spec_name'],
}),
)
})
@ -5968,6 +5968,31 @@ describe('intakeNonIntegratedProductService', () => {
})
})
it('does not let stale factory rows overwrite a multi-package group set count', () => {
const payload = createPayload({
skuItems: [
createSkuItem({
id: 'sku-package-a',
packageGroupId: 'package-group-1',
setCount: '2',
}),
createSkuItem({
id: 'sku-package-b',
packageGroupId: 'package-group-1',
setCount: '2',
}),
],
factoryRows: [
{ skuId: 'sku-package-a', setCount: '1' },
{ skuId: 'sku-package-b', setCount: '2' },
],
})
expect(
buildStructuredSkuItemInputs(payload).map((item) => item.setCount),
).toEqual(['2', '2'])
})
it('keeps each SKU flow while synchronizing purchase ownership from the workflow', () => {
const payload = createPayload({
demandFlowNo: 'FLOW-ROOT',

View File

@ -0,0 +1,28 @@
jest.mock('server-only', () => ({}))
import { evaluateShipmentReminder } from '../oa-brand-planning-shipment-todo.service'
describe('brand planning shipment reminder window', () => {
it('opens the reminder window 15 calendar days before shipment in Shanghai', () => {
expect(evaluateShipmentReminder('2026-08-26', new Date('2026-08-10T15:59:59Z'))).toEqual({
shipmentDate: '2026-08-26',
reminderDate: '2026-08-11',
eligible: false,
})
expect(evaluateShipmentReminder('2026-08-26', new Date('2026-08-10T16:00:00Z'))).toEqual({
shipmentDate: '2026-08-26',
reminderDate: '2026-08-11',
eligible: true,
})
})
it('closes the reminder window on the shipment date', () => {
expect(evaluateShipmentReminder('2026-08-26', new Date('2026-08-25T15:59:59Z')).eligible).toBe(true)
expect(evaluateShipmentReminder('2026-08-26', new Date('2026-08-25T16:00:00Z')).eligible).toBe(false)
})
it('rejects invalid or non-date shipment values', () => {
expect(evaluateShipmentReminder('2026-02-30', new Date()).eligible).toBe(false)
expect(evaluateShipmentReminder('待确认', new Date()).eligible).toBe(false)
})
})

View File

@ -57,6 +57,7 @@ describe('supply-chain product archive enrichment sync', () => {
const supplierRepository = {
listByNames: jest.fn().mockResolvedValue([{
supplierName: '源沃家具',
region: '河北',
supplierType: '家具厂',
supplierStatusCode: '1',
purchaser: '千里眼',
@ -126,10 +127,15 @@ describe('supply-chain product archive enrichment sync', () => {
expect.objectContaining({
archiveId: 'archive-1',
patch: expect.objectContaining({
region: '河北',
supplierType: '家具厂',
supplierStatus: '合作',
purchaseOwner: '千里眼',
}),
source: expect.objectContaining({
sourceObject: 'js_supplier',
sourceColumns: expect.objectContaining({ region: 'region' }),
}),
}),
)
expect(repository.applyPatch).toHaveBeenCalledWith(

View File

@ -0,0 +1,90 @@
import {
SupplyChainProductArchiveWarehouseConfigService,
} from '@/server/services/supply-chain-product-archive-warehouse-config.service'
import type {
ProductArchiveWarehouseConfigRecord,
SupplyChainProductArchiveWarehouseConfigRepository,
} from '@/server/repositories/supply-chain-product-archive-warehouse-config.repository'
function configuration(overrides: Partial<ProductArchiveWarehouseConfigRecord> = {}) {
return {
id: 'config-1',
warehouseName: '杭州仓',
warehouseHead: '负责人甲',
storageType: 'INBOUND',
status: 'ACTIVE',
createdAt: new Date('2026-08-01T00:00:00.000Z'),
updatedAt: new Date('2026-08-02T00:00:00.000Z'),
...overrides,
} satisfies ProductArchiveWarehouseConfigRecord
}
function repository(
candidates: Awaited<ReturnType<SupplyChainProductArchiveWarehouseConfigRepository['listCandidates']>>,
): SupplyChainProductArchiveWarehouseConfigRepository {
return {
listCandidates: jest.fn().mockResolvedValue(candidates),
listActive: jest.fn(),
create: jest.fn(),
update: jest.fn(),
findById: jest.fn(),
}
}
describe('SupplyChainProductArchiveWarehouseConfigService', () => {
it('combines archive warehouse names with saved configurations', async () => {
const service = new SupplyChainProductArchiveWarehouseConfigService(repository({
warehouseNames: ['杭州仓', '上海仓'],
configurations: [configuration()],
}))
const result = await service.list({})
expect(result.items).toEqual([
expect.objectContaining({
id: null,
warehouseName: '上海仓',
storageType: null,
status: null,
configured: false,
}),
expect.objectContaining({
id: 'config-1',
warehouseName: '杭州仓',
storageType: 'INBOUND',
status: 'ACTIVE',
configured: true,
}),
])
expect(result.pageInfo.total).toBe(2)
})
it('matches saved configurations to archive warehouses ignoring case and spaces', async () => {
const service = new SupplyChainProductArchiveWarehouseConfigService(repository({
warehouseNames: [' HangZhou '],
configurations: [configuration({ warehouseName: 'hangzhou' })],
}))
const result = await service.list({})
expect(result.items).toHaveLength(1)
expect(result.items[0]).toEqual(expect.objectContaining({
id: 'config-1',
warehouseName: 'hangzhou',
configured: true,
}))
})
it('can filter to unconfigured warehouse names before pagination', async () => {
const service = new SupplyChainProductArchiveWarehouseConfigService(repository({
warehouseNames: ['杭州仓', '上海仓', '北京仓'],
configurations: [configuration()],
}))
const result = await service.list({ status: 'UNCONFIGURED', page: 1, pageSize: 1 })
expect(result.items).toHaveLength(1)
expect(result.items[0].configured).toBe(false)
expect(result.pageInfo).toEqual({ page: 1, pageSize: 1, total: 2, totalPages: 2 })
})
})

View File

@ -44,6 +44,10 @@ import {
isExternalIntakeRecordOwner,
} from '@/lib/non-integrated-external-entry-permissions'
import { normalizeExternalIntakeOwnerFields } from '@/lib/non-integrated-external-owner-fields'
import {
hasConsistentNonIntegratedPackageSetCounts,
normalizeNonIntegratedPackageSetCounts,
} from '@/lib/non-integrated-package-set-count'
import { prisma } from '@/lib/prisma'
import { deleteObject } from '@/lib/storage'
import { extractObjectStorageKeys } from '@/lib/object-storage-reference'
@ -117,6 +121,37 @@ type SharedNonIntegratedProductRecord = NonIntegratedProductRecord & {
[key: string]: unknown
}
function normalizePackageSetCountsForProduct(
product: SharedNonIntegratedProductRecord,
): SharedNonIntegratedProductRecord {
const normalized = normalizeNonIntegratedPackageSetCounts(
product.skuItems,
product.factoryRows,
)
return {
...product,
skuItems: normalized.skuItems,
factoryRows: normalized.factoryRows,
}
}
function assertPersistedPackageSetCounts(product: SharedNonIntegratedProductRecord) {
if (
hasConsistentNonIntegratedPackageSetCounts(
product.skuItems,
product.factoryRows,
)
) {
return
}
throw new AppError(
ErrorCode.SYSTEM_INTERNAL,
'非集成产品保存后的套件量不一致',
)
}
async function deleteUploadFileObjectsNoLongerReferenced(
filesToDelete: SharedNonIntegratedUploadFile[],
remainingFiles: SharedNonIntegratedUploadFile[],
@ -485,7 +520,7 @@ function assignFormalSpuToSkuItems(
value: unknown,
formalSpu: string,
options: {
fallbackSingleSkuSpecName?: string
autofillSingleSkuModel?: boolean
sequenceOffset?: number
forceSequentialSkuSequence?: boolean
} = {},
@ -523,19 +558,14 @@ function assignFormalSpuToSkuItems(
? buildAutoBarcode(skuCode, item.manufacturerCode, versionNo)
: existingBarcode
: buildAutoBarcode(skuCode, item.manufacturerCode, versionNo)
const shouldAutofillSingleSkuMasterData =
value.length === 1 && Boolean(readText(options.fallbackSingleSkuSpecName))
const shouldAutofillSingleSkuModel =
value.length === 1 && options.autofillSingleSkuModel === true
return {
...item,
specName:
readText(item.specName) ||
(shouldAutofillSingleSkuMasterData
? readText(options.fallbackSingleSkuSpecName)
: item.specName),
model:
readText(item.model) ||
(shouldAutofillSingleSkuMasterData ? formalSpu.trim().toUpperCase() : item.model),
(shouldAutofillSingleSkuModel ? formalSpu.trim().toUpperCase() : item.model),
skuSequence,
skuCode,
barcode,
@ -3132,7 +3162,7 @@ function buildPersistedProductPayload(
? existingRecord.updatedBy
: undefined
return normalizeSharedProductSpuByBrand({
const mergedProduct = normalizeSharedProductSpuByBrand({
...existingRecord,
...incomingRecord,
...normalizedProduct,
@ -3149,6 +3179,8 @@ function buildPersistedProductPayload(
...(updatedBy ? { updatedBy } : {}),
createdAt: existingCreatedAt ?? normalizedProduct.createdAt,
})
return normalizePackageSetCountsForProduct(mergedProduct)
}
function sanitizeUploadCategory(value: string | undefined) {
@ -5497,8 +5529,12 @@ export function buildStructuredSkuItemInputs(
const seenSkuItemIds = new Map<string, number>()
const skuCodeCounts = new Map<string, number>()
const barcodeCounts = new Map<string, number>()
const skuItems = Array.isArray(product.skuItems) ? product.skuItems : []
const factoryRows = Array.isArray(product.factoryRows) ? product.factoryRows : []
const normalizedPackageSetCounts = normalizeNonIntegratedPackageSetCounts(
Array.isArray(product.skuItems) ? product.skuItems : [],
Array.isArray(product.factoryRows) ? product.factoryRows : [],
)
const skuItems = normalizedPackageSetCounts.skuItems
const factoryRows = normalizedPackageSetCounts.factoryRows
const sampleMeasurementRows = Array.isArray(product.sampleMeasurementRows)
? product.sampleMeasurementRows
: []
@ -6468,7 +6504,7 @@ class IntakeNonIntegratedProductService {
brand: normalizedBrand,
formalSpuAssigned: true,
skuItems: assignFormalSpuToSkuItems(productInput.skuItems, assignedSpu, {
fallbackSingleSkuSpecName: readText(openingReference.productName),
autofillSingleSkuModel: Boolean(readText(openingReference.productName)),
sequenceOffset: options.sequenceOffset,
forceSequentialSkuSequence: options.forceSequentialSkuSequence,
}),
@ -6593,6 +6629,8 @@ class IntakeNonIntegratedProductService {
throw new AppError(ErrorCode.VALIDATION_FAILED, '非集成产品数据不完整,无法保存')
}
assertNonIntegratedProductMasterDataReadyForFormalUse(normalizedProduct, {
allowedMissingFields:
normalizedProduct.skuItems.length === 1 ? ['spec_name'] : [],
usageLabel: '正式 SPU 分配',
})
@ -6666,7 +6704,7 @@ class IntakeNonIntegratedProductService {
persistedProduct,
options,
)
const permittedProduct = permittedWrite.product
const permittedProduct = normalizePackageSetCountsForProduct(permittedWrite.product)
const permittedSpu = resolveNonIntegratedPersistedSpu(permittedProduct)
const permittedSpuBase = stripNonIntegratedBrandPrefix(permittedSpu)
const preservedSpu = normalizePreservedNonIntegratedSpu(activeExistingRecord)
@ -6808,6 +6846,7 @@ class IntakeNonIntegratedProductService {
if (!savedProduct) {
throw new AppError(ErrorCode.SYSTEM_INTERNAL, '非集成产品保存后读取失败')
}
assertPersistedPackageSetCounts(savedProduct)
if (saveResult.created) {
await recordIntakeProductCreated({
@ -7004,7 +7043,7 @@ class IntakeNonIntegratedProductService {
persistedProduct,
options,
)
const permittedProduct = permittedWrite.product
const permittedProduct = normalizePackageSetCountsForProduct(permittedWrite.product)
const permittedSpu = resolveNonIntegratedPersistedSpu(permittedProduct)
const permittedSpuBase = stripNonIntegratedBrandPrefix(permittedSpu)
const preservedSpu = normalizePreservedNonIntegratedSpu(existingRecord)
@ -7153,6 +7192,7 @@ class IntakeNonIntegratedProductService {
if (!savedProduct) {
throw new AppError(ErrorCode.SYSTEM_INTERNAL, '非集成产品保存后读取失败')
}
assertPersistedPackageSetCounts(savedProduct)
await skuQualityInspectionService.syncFirstBatchFromNonIntegratedProduct(
savedProduct,

View File

@ -132,11 +132,13 @@ export function assertNonIntegratedProductProfileReadyForFormalUse(
export function assertNonIntegratedSkuItemsReadyForFormalUse(
product: Pick<NonIntegratedProductRecord, 'skuItems'>,
options: {
allowedMissingFields?: readonly string[]
batch?: number | 'all'
usageLabel?: string
} = {},
) {
const batch = options.batch ?? 'all'
const allowedMissingFields = new Set(options.allowedMissingFields ?? [])
const targetSkuItems = product.skuItems.filter((item) =>
batch === 'all' ? true : (item.batchNo || 1) === batch,
)
@ -159,7 +161,9 @@ export function assertNonIntegratedSkuItemsReadyForFormalUse(
.map((item, index) => ({
index,
item,
missingFields: resolveNonIntegratedSkuMissingRequiredFields(item),
missingFields: resolveNonIntegratedSkuMissingRequiredFields(item).filter(
(field) => !allowedMissingFields.has(field),
),
}))
.filter((entry) => entry.missingFields.length > 0)
@ -215,6 +219,7 @@ export function assertNonIntegratedProductMasterDataReadyForFormalUse(
product: Partial<NonIntegratedProductRecord> &
Pick<NonIntegratedProductRecord, 'skuItems'>,
options: {
allowedMissingFields?: readonly string[]
batch?: number | 'all'
usageLabel?: string
} = {},

View File

@ -110,6 +110,28 @@ export async function resolveOaBoardTodoOwners(
}
}
const identityResolution = await resolveDingtalkTodoOwnersByNames(ownerNames)
return {
stageNames,
ownerDisplay,
...identityResolution,
}
}
export async function resolveDingtalkTodoOwnersByNames(inputNames: string[]) {
const ownerNames = Array.from(
new Set(inputNames.flatMap(splitOwnerText).filter((name) => name !== '-')),
)
if (ownerNames.length === 0) {
return {
resolvedOwners: [] as OaBoardResolvedTodoOwner[],
unresolvedNames: [] as string[],
ambiguousNames: [] as string[],
}
}
const [users, employees] = await Promise.all([
prisma.user.findMany({
where: {
@ -191,11 +213,5 @@ export async function resolveOaBoardTodoOwners(
})
})
return {
stageNames,
ownerDisplay,
resolvedOwners,
unresolvedNames,
ambiguousNames,
}
return { resolvedOwners, unresolvedNames, ambiguousNames }
}

View File

@ -36,6 +36,7 @@ CREATE TABLE IF NOT EXISTS ${OA_BRAND_PLANNING_SNAPSHOT_TABLE} (
product_planner VARCHAR(255) NULL,
spu_owner VARCHAR(255) NULL,
purchase_owner VARCHAR(255) NULL,
brand_planning_supervisor VARCHAR(255) NULL,
estimated_shipment_date VARCHAR(255) NULL,
product_detail_link TEXT NULL,
approval_stage VARCHAR(255) NULL,
@ -84,6 +85,7 @@ type BrandPlanningSnapshotFields = {
product_planner: string | null
spu_owner: string | null
purchase_owner: string | null
brand_planning_supervisor: string | null
estimated_shipment_date: string | null
product_detail_link: string | null
approval_stage: string | null
@ -164,6 +166,7 @@ export interface OaBrandPlanningBoardItemDTO {
productPlanner: string | null
spuOwner: string | null
purchaseOwner: string | null
brandPlanningSupervisor: string | null
estimatedShipmentDate: string | null
productDetailLink: string | null
approvalStage: string | null
@ -445,6 +448,7 @@ function buildSnapshotRows(
product_planner: toText(profile?.productPlanning),
spu_owner: toText(profile?.spuOwner),
purchase_owner: toText(profile?.purchaseOwner),
brand_planning_supervisor: readFlowField(reviewFlow, ['品牌策划主管']),
estimated_shipment_date: getFactoryLeadTime(payload, sku),
product_detail_link: `/intake/non-integrated/${encodeURIComponent(product.id)}`,
approval_stage: graph?.currentApprovalStageName ?? null,
@ -483,6 +487,9 @@ async function createConnection() {
async function ensureSnapshotTable(connection: mysql.Connection) {
await connection.query(OA_BRAND_PLANNING_SNAPSHOT_TABLE_DDL)
await connection.query(
`ALTER TABLE ${OA_BRAND_PLANNING_SNAPSHOT_TABLE} ADD COLUMN IF NOT EXISTS brand_planning_supervisor VARCHAR(255) NULL AFTER purchase_owner`,
)
}
async function loadProducts(): Promise<ProductSource[]> {
@ -547,6 +554,7 @@ async function persistRows(
'product_planner',
'spu_owner',
'purchase_owner',
'brand_planning_supervisor',
'estimated_shipment_date',
'product_detail_link',
'approval_stage',
@ -619,6 +627,7 @@ function toDTO(row: BrandPlanningSnapshotRow): OaBrandPlanningBoardItemDTO {
productPlanner: row.product_planner,
spuOwner: row.spu_owner,
purchaseOwner: row.purchase_owner,
brandPlanningSupervisor: row.brand_planning_supervisor,
estimatedShipmentDate: row.estimated_shipment_date,
productDetailLink: row.product_detail_link,
approvalStage: row.approval_stage,

View File

@ -0,0 +1,218 @@
import 'server-only'
import * as mysql from 'mysql2/promise'
import type { ResultSetHeader, RowDataPacket } from 'mysql2/promise'
import { dingtalkTodoService, type DingtalkTodoCreateInput } from './dingtalk-todo.service'
import { oaBrandPlanningBoardService } from './oa-brand-planning-board.service'
import { resolveDingtalkTodoOwnersByNames } from './oa-board-owner-resolution.service'
import { createPlanningConnectionConfig } from './oa-board-sync.service'
const TABLE = 'oa_brand_planning_shipment_todo_dispatch'
const REMINDER_DAYS = 15
const DDL = `
CREATE TABLE IF NOT EXISTS ${TABLE} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
row_key VARCHAR(191) NOT NULL,
shipment_date DATE NOT NULL,
receiver_dingtalk_user_id VARCHAR(128) NOT NULL,
receiver_dingtalk_union_id VARCHAR(128) NULL,
receiver_name VARCHAR(255) NULL,
todo_id VARCHAR(128) NULL,
status VARCHAR(32) NOT NULL,
dispatch_error TEXT NULL,
payload_json LONGTEXT NULL,
sent_at DATETIME NULL,
closed_at DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_brand_shipment_todo (row_key, shipment_date, receiver_dingtalk_user_id),
KEY idx_brand_shipment_todo_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`
type DispatchRow = RowDataPacket & {
id: number
row_key: string
shipment_date: string
receiver_dingtalk_user_id: string
receiver_dingtalk_union_id: string | null
todo_id: string | null
}
export type ShipmentReminderDecision = {
shipmentDate: string | null
reminderDate: string | null
eligible: boolean
}
function dateText(value: string | null | undefined) {
const match = String(value ?? '').trim().match(/^(\d{4})-(\d{2})-(\d{2})/)
if (!match) return null
const [, year, month, day] = match
const date = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)))
if (
date.getUTCFullYear() !== Number(year) ||
date.getUTCMonth() !== Number(month) - 1 ||
date.getUTCDate() !== Number(day)
) return null
return `${year}-${month}-${day}`
}
function shiftDate(value: string, days: number) {
const [year, month, day] = value.split('-').map(Number)
const date = new Date(Date.UTC(year, month - 1, day + days))
return date.toISOString().slice(0, 10)
}
function shanghaiToday(now: Date) {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit',
}).format(now)
}
export function evaluateShipmentReminder(
estimatedShipmentDate: string | null | undefined,
now = new Date(),
): ShipmentReminderDecision {
const shipmentDate = dateText(estimatedShipmentDate)
if (!shipmentDate) return { shipmentDate: null, reminderDate: null, eligible: false }
const reminderDate = shiftDate(shipmentDate, -REMINDER_DAYS)
const today = shanghaiToday(now)
return {
shipmentDate,
reminderDate,
eligible: today >= reminderDate && today < shipmentDate,
}
}
function detailUrl(productId: string) {
const base = process.env.DINGTALK_TODO_DETAIL_BASE_URL?.trim()
|| process.env.NEXT_PUBLIC_APP_URL?.trim()
|| process.env.DINGTALK_CALLBACK_ORIGIN?.trim()
return base ? new URL(`/intake/non-integrated/${encodeURIComponent(productId)}`, base).toString() : null
}
export interface BrandPlanningShipmentTodoSummary {
scanned: number
eligible: number
created: number
closed: number
failed: number
unresolved: number
skipped: number
}
export class OaBrandPlanningShipmentTodoService {
async sync(now = new Date()): Promise<BrandPlanningShipmentTodoSummary> {
const summary = { scanned: 0, eligible: 0, created: 0, closed: 0, failed: 0, unresolved: 0, skipped: 0 }
if (!dingtalkTodoService.isEnabled()) {
summary.skipped = 1
return summary
}
const items = await oaBrandPlanningBoardService.listAll()
summary.scanned = items.length
const connectionConfig = createPlanningConnectionConfig()
const connection = typeof connectionConfig === 'string'
? await mysql.createConnection(connectionConfig)
: await mysql.createConnection(connectionConfig)
try {
await connection.query(DDL)
const [openRows] = await connection.query<DispatchRow[]>(
`SELECT id, row_key, shipment_date, receiver_dingtalk_user_id, receiver_dingtalk_union_id, todo_id FROM ${TABLE} WHERE status IN ('OPEN', 'FAILED', 'NEEDS_OWNER_MAPPING')`,
)
const desiredKeys = new Set<string>()
const ownerResolutionCache = new Map<
string,
Awaited<ReturnType<typeof resolveDingtalkTodoOwnersByNames>>
>()
for (const item of items) {
const decision = evaluateShipmentReminder(item.estimatedShipmentDate, now)
if (!decision.eligible || !decision.shipmentDate || !item.brandPlanningSupervisor) continue
summary.eligible += 1
let owners = ownerResolutionCache.get(item.brandPlanningSupervisor)
if (!owners) {
owners = await resolveDingtalkTodoOwnersByNames([item.brandPlanningSupervisor])
ownerResolutionCache.set(item.brandPlanningSupervisor, owners)
}
const unresolved = [...owners.unresolvedNames, ...owners.ambiguousNames]
if (unresolved.length > 0) {
const receiverId = `UNRESOLVED:${unresolved.join('|')}`.slice(0, 128)
const key = `${item.id}|${decision.shipmentDate}|${receiverId}`
desiredKeys.add(key)
await connection.execute(
`INSERT INTO ${TABLE} (row_key, shipment_date, receiver_dingtalk_user_id, receiver_name, status, dispatch_error)
VALUES (?, ?, ?, ?, 'NEEDS_OWNER_MAPPING', ?)
ON DUPLICATE KEY UPDATE status='NEEDS_OWNER_MAPPING', dispatch_error=VALUES(dispatch_error), closed_at=NULL`,
[item.id, decision.shipmentDate, receiverId, unresolved.join(';'), `未找到唯一钉钉身份:${unresolved.join('、')}`],
)
summary.unresolved += 1
}
for (const owner of owners.resolvedOwners) {
if (!owner.dingtalkUserId || !owner.dingtalkUnionId) continue
const key = `${item.id}|${decision.shipmentDate}|${owner.dingtalkUserId}`
desiredKeys.add(key)
const existing = openRows.find((row) => `${row.row_key}|${dateText(row.shipment_date)}|${row.receiver_dingtalk_user_id}` === key)
if (existing?.todo_id) continue
const url = detailUrl(item.productId)
if (!url) {
summary.failed += 1
continue
}
const identity = item.sku || item.spu || item.openingCode || item.id
const payload: DingtalkTodoCreateInput = {
operatorUnionId: owner.dingtalkUnionId,
creatorUnionId: owner.dingtalkUnionId,
executorUnionIds: [owner.dingtalkUnionId],
participantUnionIds: [owner.dingtalkUnionId],
title: `【出货提醒】${identity} 距预计出货不足15天`,
description: [`产品:${item.specName || item.spu || '-'}`, `SKU:${item.sku || '-'}`, `预计出货日期:${decision.shipmentDate}`, `品牌策划主管:${owner.name}`].join('\n'),
detailUrl: url,
sourceId: `brand-shipment:${item.id}:${decision.shipmentDate}:${owner.dingtalkUserId}`,
bizTag: process.env.DINGTALK_TODO_SOURCE_APP?.trim() || 'oa-board',
dueTime: new Date(`${decision.shipmentDate}T00:00:00+08:00`).getTime(),
priority: 30,
isOnlyShowExecutor: true,
}
try {
const created = await dingtalkTodoService.createTask(payload)
await connection.execute<ResultSetHeader>(
`INSERT INTO ${TABLE} (row_key, shipment_date, receiver_dingtalk_user_id, receiver_dingtalk_union_id, receiver_name, todo_id, status, payload_json, sent_at)
VALUES (?, ?, ?, ?, ?, ?, 'OPEN', ?, NOW())
ON DUPLICATE KEY UPDATE receiver_dingtalk_union_id=VALUES(receiver_dingtalk_union_id), receiver_name=VALUES(receiver_name), todo_id=VALUES(todo_id), status='OPEN', dispatch_error=NULL, payload_json=VALUES(payload_json), sent_at=NOW(), closed_at=NULL`,
[item.id, decision.shipmentDate, owner.dingtalkUserId, owner.dingtalkUnionId, owner.name, created.taskId, JSON.stringify(payload)],
)
summary.created += 1
} catch (error) {
await connection.execute(
`INSERT INTO ${TABLE} (row_key, shipment_date, receiver_dingtalk_user_id, receiver_dingtalk_union_id, receiver_name, status, dispatch_error, payload_json)
VALUES (?, ?, ?, ?, ?, 'FAILED', ?, ?)
ON DUPLICATE KEY UPDATE status='FAILED', dispatch_error=VALUES(dispatch_error), payload_json=VALUES(payload_json)`,
[item.id, decision.shipmentDate, owner.dingtalkUserId, owner.dingtalkUnionId, owner.name, error instanceof Error ? error.message : '钉钉待办发送失败', JSON.stringify(payload)],
)
summary.failed += 1
}
}
}
for (const row of openRows) {
const key = `${row.row_key}|${dateText(row.shipment_date)}|${row.receiver_dingtalk_user_id}`
if (desiredKeys.has(key)) continue
if (row.todo_id && row.receiver_dingtalk_union_id) {
await dingtalkTodoService.deleteTask(row.receiver_dingtalk_union_id, row.todo_id).catch(() => null)
}
await connection.execute(`UPDATE ${TABLE} SET status='CLOSED', closed_at=NOW(), dispatch_error=NULL WHERE id=?`, [row.id])
summary.closed += 1
}
return summary
} finally {
await connection.end()
}
}
}
export const oaBrandPlanningShipmentTodoService = new OaBrandPlanningShipmentTodoService()

View File

@ -0,0 +1,159 @@
import { AppError, ErrorCode } from '@/server/core/errors'
import { calculateSampleProgressAnomaly, canEditSampleProgress, type SampleProgressInput, type SampleProgressUpdateInput } from '@/lib/sample-progress'
import { sampleProgressRepository } from '@/server/repositories/sample-progress.repository'
import { oaBoardService, type OaBoardProjectListItemDTO } from '@/server/services/oa-board.service'
function serialize(item: Awaited<ReturnType<typeof sampleProgressRepository.findById>> extends infer T ? NonNullable<T> : never) {
return { ...item, anomaly: item.autoGenerated ? 'NONE' as const : calculateSampleProgressAnomaly(item), orderDate: item.orderDate.toISOString(), sampleDueDate: item.sampleDueDate.toISOString(), oaReachedAt: item.oaReachedAt?.toISOString() ?? null, requestedCost: item.requestedCost?.toNumber() ?? null, actualCost: item.actualCost?.toNumber() ?? null, createdAt: item.createdAt.toISOString(), updatedAt: item.updatedAt.toISOString() }
}
export type SampleProgressDTO = Omit<ReturnType<typeof serialize>, 'autoGenerated'> & { autoGenerated?: boolean }
function exactOpeningCode(item: OaBoardProjectListItemDTO, openingCode: string) {
const expected = openingCode.trim().toUpperCase()
return [item.openingCode, item.formSummary.openingCode]
.some((value) => value?.trim().toUpperCase() === expected)
}
export type SampleProgressOaPrefill = {
openingCode: string
brand: string
designer: string
imageUrl: string | null
oaReachedAt: string | null
purchaseOwner: string
merchandiserOwner: string
}
async function readOaPrefill(openingCode: string): Promise<SampleProgressOaPrefill | null> {
const normalized = openingCode.trim()
if (!normalized) return null
const dashboard = await oaBoardService.getDashboard({ keyword: normalized })
const item = dashboard.items.find((candidate) => exactOpeningCode(candidate, normalized))
if (!item) return null
const arrivalTimes = item.nodeAssessments
.filter((node) => node.nodeName === '确认打样出货时间' && node.startedAtSource)
.map((node) => node.startedAtSource as string)
.sort()
return {
openingCode: item.formSummary.openingCode || item.openingCode || normalized,
brand: item.formSummary.plannedSalesBrands || item.formSummary.salesBrands || item.brand || '',
designer: item.formSummary.structureDesigner || '',
imageUrl: item.styleImages[0]?.url || null,
oaReachedAt: arrivalTimes[0] || null,
purchaseOwner: item.formSummary.purchaseOwner || '',
merchandiserOwner: item.formSummary.merchandiserOwner || '',
}
}
async function mergeOaPrefill(input: SampleProgressInput) {
const prefill = await readOaPrefill(input.openingCode)
return {
...input,
brand: prefill?.brand || input.brand,
designer: prefill?.designer || input.designer,
imageUrl: prefill?.imageUrl || input.imageUrl,
oaReachedAt: prefill?.oaReachedAt ? new Date(prefill.oaReachedAt) : null,
purchaseOwner: input.purchaseOwner || prefill?.purchaseOwner || null,
merchandiserOwner: input.merchandiserOwner || prefill?.merchandiserOwner || null,
}
}
function sampleArrival(item: OaBoardProjectListItemDTO) {
return item.nodeAssessments
.filter((node) => node.nodeName === '确认打样出货时间' && node.startedAtSource)
.map((node) => node.startedAtSource as string)
.sort()[0] ?? null
}
async function syncFromOaItems(items: OaBoardProjectListItemDTO[]) {
const rows = items.flatMap((item) => {
const openingCode = (item.formSummary.openingCode || item.openingCode || '').trim()
const reachedAt = sampleArrival(item)
if (!openingCode || !reachedAt) return []
const orderDate = new Date(reachedAt)
if (Number.isNaN(orderDate.getTime())) return []
const sampleDueDate = new Date(orderDate)
sampleDueDate.setDate(sampleDueDate.getDate() + 10)
return [{
openingCode,
orderDate,
sampleDueDate,
oaReachedAt: orderDate,
imageUrl: item.styleImages[0]?.url || null,
productDevelopmentFlowUrl: item.formSummary.workflowLink || null,
productSize: '',
color: '',
material: '',
sampleQuantity: 1,
brand: item.formSummary.plannedSalesBrands || item.formSummary.salesBrands || item.brand || '',
supplier: '',
finalSampleFactory: null,
status: 'BARE_WOOD',
designer: item.formSummary.structureDesigner || '',
purchaseOwner: item.formSummary.purchaseOwner || null,
merchandiserOwner: item.formSummary.merchandiserOwner || null,
progressDescription: 'OA 自动生成,待补充打样信息',
autoGenerated: true,
}]
})
await sampleProgressRepository.createMany(rows)
}
type SampleProgressActor = { id?: string; name?: string }
const FIELD_LABELS: Record<string, string> = {
imageUrl: '图片', brand: '计划售卖品牌', designer: '结构设计师', oaReachedAt: 'OA流程到达时间', orderDate: '下单时间', productDevelopmentFlowUrl: '由壹点产品开发流程链接地址', productSize: '产品尺寸', color: '颜色', material: '材质', sampleQuantity: '打样数量', sampleDueDate: '打样交期', supplier: '供应商', finalSampleFactory: '最终出样厂家', status: '打样状态', progressDescription: '进度说明', requestedCost: '需求成本', actualCost: '实际成本', followUpOwner: '跟进人', purchaseOwner: '采购负责人', merchandiserOwner: '跟单负责人',
}
function assertCanEdit(actor: SampleProgressActor, owners: { purchaseOwner?: string | null; merchandiserOwner?: string | null }) {
const actorName = actor.name?.trim() || ''
if (!canEditSampleProgress(actorName, owners)) throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '仅采购负责人、跟单负责人或荆川可编辑打样记录')
}
function comparable(value: unknown) {
if (value instanceof Date) return value.toISOString()
return value === undefined ? null : value
}
export const sampleProgressService = {
async syncFromOaItems(items: OaBoardProjectListItemDTO[]) { await syncFromOaItems(items) },
async listStored() { return (await sampleProgressRepository.list()).map(serialize) },
async list() {
const dashboard = await oaBoardService.getDashboard().catch(() => null)
if (dashboard) await syncFromOaItems(dashboard.items)
return this.listStored()
},
getOaPrefill: readOaPrefill,
async create(input: SampleProgressInput, actor: SampleProgressActor) {
const merged = await mergeOaPrefill(input)
assertCanEdit(actor, merged)
return serialize(await sampleProgressRepository.create({ ...merged, createdBy: actor.id, updatedBy: actor.id }))
},
async update(id: string, input: SampleProgressUpdateInput, actor: SampleProgressActor) {
const current = await sampleProgressRepository.findById(id)
if (!current) throw new AppError(ErrorCode.VALIDATION_FAILED, '打样记录不存在')
assertCanEdit(actor, current)
const { deliveryDateChangeReason, ...editableInput } = input
const mergedFromOa = await mergeOaPrefill({ ...editableInput, openingCode: current.openingCode })
const merged = { ...mergedFromOa, purchaseOwner: editableInput.purchaseOwner || null, merchandiserOwner: editableInput.merchandiserOwner || null }
const dueDateChanged = comparable(current.sampleDueDate) !== comparable(merged.sampleDueDate)
if (dueDateChanged && !deliveryDateChangeReason?.trim()) throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '变更打样交期必须填写交期变更原因')
const changedFields = Object.keys(FIELD_LABELS).flatMap((field) => {
const before = comparable(current[field as keyof typeof current])
const after = comparable(merged[field as keyof typeof merged])
return JSON.stringify(before) === JSON.stringify(after) ? [] : [{ field, label: FIELD_LABELS[field], before, after }]
})
if (changedFields.length === 0) return serialize(current)
const beforeSnapshot = Object.fromEntries(Object.keys(FIELD_LABELS).map((field) => [field, comparable(current[field as keyof typeof current])]))
const afterSnapshot = { ...beforeSnapshot, ...Object.fromEntries(Object.keys(FIELD_LABELS).map((field) => [field, comparable(merged[field as keyof typeof merged])])) }
return serialize(await sampleProgressRepository.updateWithChangeLog({ id, data: { ...merged, openingCode: current.openingCode, autoGenerated: false, updatedBy: actor.id }, changedFields, beforeSnapshot, afterSnapshot, changeReason: dueDateChanged ? deliveryDateChangeReason?.trim() : null, changedById: actor.id, changedByName: actor.name || '未知用户' }))
},
async listChangeLogs(id: string) {
if (!(await sampleProgressRepository.findById(id))) throw new AppError(ErrorCode.VALIDATION_FAILED, '打样记录不存在')
return (await sampleProgressRepository.listChangeLogs(id)).map((log) => ({ ...log, createdAt: log.createdAt.toISOString() }))
},
async delete(id: string, actor: SampleProgressActor) {
const current = await sampleProgressRepository.findById(id)
if (!current) throw new AppError(ErrorCode.VALIDATION_FAILED, '打样记录不存在')
assertCanEdit(actor, current)
await sampleProgressRepository.delete(id)
},
}

View File

@ -113,6 +113,7 @@ export function buildProductArchiveEnrichmentUpdates(
return [
{
patch: {
region: supplierProfile?.region ?? '',
supplierType: supplierProfile?.supplierType ?? '',
supplierStatus: supplierStatusLabel(supplierProfile?.supplierStatusCode ?? ''),
...(supplierProfile?.purchaser ? { purchaseOwner: supplierProfile.purchaser } : {}),
@ -127,6 +128,7 @@ export function buildProductArchiveEnrichmentUpdates(
'js_supplier',
supplierProfile?.supplierName || target.supplierName || target.barcode,
{
region: 'region',
supplierType: 'scmtype',
supplierStatus: 'coopersgn',
purchaseOwner: 'buyer',

View File

@ -18,15 +18,18 @@ import {
const WAREHOUSE_NAME_MAX_LENGTH = 160
export type WarehouseConfigDTO = {
id: string
id: string | null
warehouseName: string
warehouseHead: string
storageType: ProductArchiveStorageType
status: ProductArchiveWarehouseConfigStatus
createdAt: string
updatedAt: string
storageType: ProductArchiveStorageType | null
status: ProductArchiveWarehouseConfigStatus | null
configured: boolean
createdAt: string | null
updatedAt: string | null
}
export type WarehouseConfigListStatus = ProductArchiveWarehouseConfigStatus | 'UNCONFIGURED'
export type WarehouseConfigListResult = {
items: WarehouseConfigDTO[]
pageInfo: { page: number; pageSize: number; total: number; totalPages: number }
@ -39,11 +42,29 @@ function mapRecord(record: ProductArchiveWarehouseConfigRecord): WarehouseConfig
warehouseHead: record.warehouseHead,
storageType: record.storageType as ProductArchiveStorageType,
status: record.status as ProductArchiveWarehouseConfigStatus,
configured: true,
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
}
}
function normalizeWarehouseName(value: string) {
return value.trim().toLocaleLowerCase('zh-CN')
}
function mapUnconfigured(warehouseName: string): WarehouseConfigDTO {
return {
id: null,
warehouseName,
warehouseHead: '',
storageType: null,
status: null,
configured: false,
createdAt: null,
updatedAt: null,
}
}
function cleanWarehouseName(value: string) {
const cleaned = value.trim()
if (!cleaned) throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '仓库名称不能为空')
@ -89,23 +110,42 @@ export class SupplyChainProductArchiveWarehouseConfigService {
page?: number
pageSize?: number
search?: string
status?: ProductArchiveWarehouseConfigStatus
status?: WarehouseConfigListStatus
}): Promise<WarehouseConfigListResult> {
const page = Math.max(1, Math.floor(input.page ?? 1))
const pageSize = Math.min(200, Math.max(1, Math.floor(input.pageSize ?? 100)))
const result = await this.repository.listPage({
page,
pageSize,
const result = await this.repository.listCandidates({
search: input.search?.trim().slice(0, WAREHOUSE_NAME_MAX_LENGTH) || undefined,
status: input.status,
})
const merged = new Map<string, WarehouseConfigDTO>()
for (const warehouseName of result.warehouseNames) {
const key = normalizeWarehouseName(warehouseName)
if (key && !merged.has(key)) merged.set(key, mapUnconfigured(warehouseName))
}
for (const configuration of result.configurations) {
merged.set(normalizeWarehouseName(configuration.warehouseName), mapRecord(configuration))
}
const items = Array.from(merged.values())
.filter((item) => {
if (!input.status) return true
if (input.status === 'UNCONFIGURED') return !item.configured
return item.status === input.status
})
.sort((left, right) => {
if (left.configured !== right.configured) return left.configured ? 1 : -1
return left.warehouseName.localeCompare(right.warehouseName, 'zh-CN')
})
const total = items.length
const totalPages = Math.max(1, Math.ceil(total / pageSize))
const normalizedPage = Math.min(page, totalPages)
const start = (normalizedPage - 1) * pageSize
return {
items: result.records.map(mapRecord),
items: items.slice(start, start + pageSize),
pageInfo: {
page,
page: normalizedPage,
pageSize,
total: result.total,
totalPages: Math.max(1, Math.ceil(result.total / pageSize)),
total,
totalPages,
},
}
}

View File

@ -66,6 +66,7 @@ export function mapProductArchiveRow({
storageType: storageTypeLabel(record.storageType),
isSeasonal: record.isSeasonal,
replaceCount: record.replaceCount,
region: record.region,
supplier: record.supplierName,
supplierType: record.supplierType,
supplierStatus: record.supplierStatus,