Merge remote-tracking branch 'origin/codex/xuxiake-mainline' into codex/wuyongjie-bom-category-skills-v7
This commit is contained in:
commit
944b5b0a58
@ -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
|
||||
|
||||
@ -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=不合作` |
|
||||
|
||||
@ -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;
|
||||
@ -0,0 +1,2 @@
|
||||
ALTER TABLE "SampleProgressItem"
|
||||
ADD COLUMN "productDevelopmentFlowUrl" TEXT;
|
||||
@ -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;
|
||||
@ -0,0 +1,2 @@
|
||||
ALTER TABLE "SupplyChainProductArchive"
|
||||
ADD COLUMN "region" TEXT NOT NULL DEFAULT '';
|
||||
@ -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("")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -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),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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() !==
|
||||
|
||||
@ -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">
|
||||
预留
|
||||
|
||||
@ -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 ?? ''}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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> }
|
||||
10
furniture-3d-system/src/app/intake/sample-progress/page.tsx
Normal file
10
furniture-3d-system/src/app/intake/sample-progress/page.tsx
Normal 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} />
|
||||
}
|
||||
11
furniture-3d-system/src/app/sample-progress-preview/page.tsx
Normal file
11
furniture-3d-system/src/app/sample-progress-preview/page.tsx
Normal 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="荆川" /> }
|
||||
@ -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': '产品变价',
|
||||
}
|
||||
|
||||
@ -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)
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 ?? ''}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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}/`)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
})
|
||||
}
|
||||
27
furniture-3d-system/src/lib/sample-progress.test.ts
Normal file
27
furniture-3d-system/src/lib/sample-progress.test.ts
Normal 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))
|
||||
})
|
||||
59
furniture-3d-system/src/lib/sample-progress.ts
Normal file
59
furniture-3d-system/src/lib/sample-progress.ts
Normal 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'
|
||||
}
|
||||
@ -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: '供应商状态' },
|
||||
|
||||
@ -33,6 +33,7 @@ export type SupplyChainProductArchiveRow = {
|
||||
storageType: string
|
||||
isSeasonal: string
|
||||
replaceCount: string
|
||||
region: string
|
||||
supplier: string
|
||||
supplierType: string
|
||||
supplierStatus: string
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 } }) },
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -36,6 +36,7 @@ const productArchiveSelect = {
|
||||
storageType: true,
|
||||
isSeasonal: true,
|
||||
replaceCount: true,
|
||||
region: true,
|
||||
supplierName: true,
|
||||
supplierType: true,
|
||||
supplierStatus: true,
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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(
|
||||
|
||||
@ -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 })
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
} = {},
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
@ -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)
|
||||
},
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user