Merge remote-tracking branch 'origin/codex/xuxiake-mainline' into codex/wuyongjie-bom-1688-realtime-cost

This commit is contained in:
WuYongJie 2026-08-17 18:48:08 +08:00
commit fa48bcc48d
60 changed files with 5712 additions and 78 deletions

View File

@ -18,6 +18,7 @@
"prisma:studio": "prisma studio",
"sync:supply-chain-product-archive": "tsx scripts/sync-supply-chain-product-archive.ts",
"sync:supply-chain-product-archive-enrichment": "tsx scripts/sync-supply-chain-product-archive-enrichment.ts",
"sync:supply-chain-product-archive-sales-performance": "tsx scripts/sync-supply-chain-product-archive-sales-performance.ts",
"sync:jst-sku-product-archive": "tsx scripts/sync-jst-sku-product-archive.ts",
"sync:bom-cost-reference": "tsx scripts/sync-bom-cost-reference.ts",
"backtest:bom-cost-reference": "tsx scripts/backtest-bom-cost-reference.ts",

View File

@ -0,0 +1,35 @@
-- CreateTable
CREATE TABLE "SupplyChainProductArchiveSalesPerformance" (
"archiveId" TEXT NOT NULL,
"barcode" TEXT NOT NULL,
"periodStart" DATE NOT NULL,
"periodEnd" DATE NOT NULL,
"shipAmount" DECIMAL(24,4) NOT NULL,
"returnAmount" DECIMAL(24,4) NOT NULL,
"realPerformance" DECIMAL(24,4) NOT NULL,
"shipQty" BIGINT NOT NULL,
"returnQty" BIGINT NOT NULL,
"grossProfit" DECIMAL(24,4) NOT NULL,
"grossMargin" DECIMAL(20,8),
"syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SupplyChainProductArchiveSalesPerformance_pkey" PRIMARY KEY ("archiveId")
);
-- CreateIndex
CREATE UNIQUE INDEX "SupplyChainProductArchiveSalesPerformance_barcode_key"
ON "SupplyChainProductArchiveSalesPerformance"("barcode");
-- CreateIndex
CREATE INDEX "SupplyChainProductArchiveSalesPerformance_periodEnd_idx"
ON "SupplyChainProductArchiveSalesPerformance"("periodEnd");
-- CreateIndex
CREATE INDEX "SupplyChainProductArchiveSalesPerformance_syncedAt_idx"
ON "SupplyChainProductArchiveSalesPerformance"("syncedAt");
-- AddForeignKey
ALTER TABLE "SupplyChainProductArchiveSalesPerformance"
ADD CONSTRAINT "SupplyChainProductArchiveSalesPerformance_archiveId_fkey"
FOREIGN KEY ("archiveId") REFERENCES "SupplyChainProductArchive"("id")
ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -563,6 +563,7 @@ model SupplyChainProductArchive {
sources SupplyChainProductArchiveSource[]
imageEmbedding ProductArchiveImageEmbedding?
imageCache SupplyChainProductArchiveImageCache?
salesPerformance SupplyChainProductArchiveSalesPerformance?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@ -576,6 +577,26 @@ model SupplyChainProductArchive {
@@index([firstSourceCode, createdAt])
}
// 销售宽表按条码汇总的每日快照;费比和营业利润额待完整费用字段开放后再接入。
model SupplyChainProductArchiveSalesPerformance {
archiveId String @id
archive SupplyChainProductArchive @relation(fields: [archiveId], references: [id], onDelete: Cascade)
barcode String @unique
periodStart DateTime @db.Date
periodEnd DateTime @db.Date
shipAmount Decimal @db.Decimal(24, 4)
returnAmount Decimal @db.Decimal(24, 4)
realPerformance Decimal @db.Decimal(24, 4)
shipQty BigInt
returnQty BigInt
grossProfit Decimal @db.Decimal(24, 4)
grossMargin Decimal? @db.Decimal(20, 8)
syncedAt DateTime @default(now())
@@index([periodEnd])
@@index([syncedAt])
}
// 图纸与视频导出来源:按条码保存当前有效的图纸/视频状态,支持月度增量覆盖。
model SupplyChainProductArchiveImageCache {
id String @id @default(cuid())

View File

@ -0,0 +1,68 @@
#!/usr/bin/env bash
set -euo pipefail
BASE="${FURNITURE_PROD_BASE:-/opt/furniture-3d-system-prod}"
SERVICE_NAME="${SUPPLY_CHAIN_ARCHIVE_SALES_SYNC_SERVICE_NAME:-furniture-supply-chain-product-archive-sales-performance-sync}"
APP_ROOT="$BASE/current/furniture-3d-system"
SHARED="$BASE/shared"
APP_ENV="$SHARED/.env"
SYNC_ENV="$SHARED/supply-chain-product-archive-sync.env"
SERVICE_FILE="/etc/systemd/system/$SERVICE_NAME.service"
TIMER_FILE="/etc/systemd/system/$SERVICE_NAME.timer"
SYNC_CALENDAR="${SUPPLY_CHAIN_ARCHIVE_SALES_SYNC_CALENDAR:-*-*-* 03:30:00}"
if [ "$(id -u)" -ne 0 ]; then
echo "This installer must run as root." >&2
exit 1
fi
if [ ! -d "$APP_ROOT" ]; then
echo "Missing app root: $APP_ROOT" >&2
exit 1
fi
if [ ! -f "$APP_ENV" ]; then
echo "Missing app env: $APP_ENV" >&2
exit 1
fi
if [ ! -f "$SYNC_ENV" ]; then
echo "Missing sync env: $SYNC_ENV" >&2
echo "Run install-supply-chain-product-archive-sync-timer.sh and fill the guard identities first." >&2
exit 1
fi
cat >"$SERVICE_FILE" <<EOF
[Unit]
Description=Sync product archive sales performance snapshot
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
WorkingDirectory=$APP_ROOT
Environment=NODE_ENV=production
EnvironmentFile=$APP_ENV
EnvironmentFile=$SYNC_ENV
ExecStart=/bin/bash -lc 'set -euo pipefail; cd "$APP_ROOT"; ./node_modules/.bin/tsx scripts/sync-supply-chain-product-archive-sales-performance.ts --apply --confirm=sync-supply-chain-product-archive-sales-performance --expected-database-identity="\${SUPPLY_CHAIN_ARCHIVE_EXPECTED_DATABASE_IDENTITY:?Set SUPPLY_CHAIN_ARCHIVE_EXPECTED_DATABASE_IDENTITY in $SYNC_ENV}" --expected-source-database-identity="\${SUPPLY_CHAIN_ARCHIVE_EXPECTED_SOURCE_DATABASE_IDENTITY:?Set SUPPLY_CHAIN_ARCHIVE_EXPECTED_SOURCE_DATABASE_IDENTITY in $SYNC_ENV}" --batch-size="\${SUPPLY_CHAIN_ARCHIVE_SALES_PERFORMANCE_BATCH_SIZE:-1000}"'
StandardOutput=journal
StandardError=journal
EOF
cat >"$TIMER_FILE" <<EOF
[Unit]
Description=Sync product archive sales performance daily
[Timer]
OnCalendar=$SYNC_CALENDAR
Persistent=true
RandomizedDelaySec=10min
Unit=$SERVICE_NAME.service
[Install]
WantedBy=timers.target
EOF
systemctl daemon-reload
systemctl enable --now "$SERVICE_NAME.timer"
systemctl list-timers --all "$SERVICE_NAME.timer" --no-pager

View File

@ -0,0 +1,97 @@
import { loadEnvConfig } from '@next/env'
loadEnvConfig(process.cwd())
const CONFIRM_TOKEN = 'sync-supply-chain-product-archive-sales-performance'
function readArgument(name: string) {
const prefix = `--${name}=`
return process.argv.slice(2).find((argument) => argument.startsWith(prefix))?.slice(prefix.length) ?? ''
}
function databaseIdentity(databaseUrl: string) {
const parsed = new URL(databaseUrl)
return [
parsed.protocol.replace(/:$/, ''),
decodeURIComponent(parsed.username || ''),
parsed.hostname,
parsed.port || '',
parsed.pathname.replace(/^\//, ''),
parsed.searchParams.get('schema') ?? '',
].join('|')
}
function sourceDatabaseIdentity() {
const host = process.env.JST_REPORT_DB_HOST?.trim() ?? ''
const database = process.env.JST_REPORT_DB_NAME?.trim() ?? ''
const user = process.env.JST_REPORT_DB_USER?.trim() ?? ''
if (!host || !database || !user) return ''
return [
'sqlserver',
user,
host,
process.env.JST_REPORT_DB_PORT?.trim() || '1433',
database,
process.env.JST_REPORT_DB_SCHEMA?.trim() || 'rpt',
].join('|')
}
function readAsOf() {
const value = readArgument('as-of')
if (!value) return undefined
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
throw new Error('--as-of must use YYYY-MM-DD.')
}
const date = new Date(`${value}T00:00:00.000Z`)
if (Number.isNaN(date.getTime())) throw new Error('--as-of is not a valid date.')
return date
}
async function main() {
if (!process.argv.includes('--apply')) {
throw new Error('This sync writes the archive. Pass --apply explicitly.')
}
if (readArgument('confirm') !== CONFIRM_TOKEN) {
throw new Error(`Pass --confirm=${CONFIRM_TOKEN}.`)
}
const databaseUrl = process.env.DATABASE_URL?.trim() ?? ''
if (!databaseUrl) throw new Error('DATABASE_URL is required.')
const actualDatabaseIdentity = databaseIdentity(databaseUrl)
if (readArgument('expected-database-identity') !== actualDatabaseIdentity) {
throw new Error(`Pass --expected-database-identity=${actualDatabaseIdentity}.`)
}
const actualSourceIdentity = sourceDatabaseIdentity()
if (!actualSourceIdentity) throw new Error('JST_REPORT_DB_* is required.')
if (readArgument('expected-source-database-identity') !== actualSourceIdentity) {
throw new Error(`Pass --expected-source-database-identity=${actualSourceIdentity}.`)
}
const batchSize = Number(readArgument('batch-size') || '1000')
if (!Number.isSafeInteger(batchSize) || batchSize < 1 || batchSize > 2_000) {
throw new Error('--batch-size must be an integer between 1 and 2000.')
}
const [
{ prisma },
{ supplyChainProductArchiveSalesPerformanceSyncService },
] = await Promise.all([
import('../src/lib/prisma'),
import('../src/server/services/supply-chain-product-archive-sales-performance-sync.service'),
])
try {
const result = await supplyChainProductArchiveSalesPerformanceSyncService.syncAll({
asOf: readAsOf(),
batchSize,
})
console.log(JSON.stringify(result, null, 2))
} finally {
await prisma.$disconnect()
}
}
void main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error))
process.exitCode = 1
})

View File

@ -0,0 +1,50 @@
import { NextRequest } from "next/server";
import { AppError } from "@/server/core/errors";
import { handleRequest, jsonError, jsonSuccess } from "@/server/core/response";
import { installationDemoDrawingTextAnnotationService } from "@/server/services/installation-demo/installation-demo-drawing-text-annotation.service";
function respondWithAppError(error: unknown, requestId: string) {
if (error instanceof AppError) {
return jsonError(error.code, error.message, error.statusCode, requestId);
}
throw error;
}
export const dynamic = "force-dynamic";
export async function GET(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
try {
const snapshot = await installationDemoDrawingTextAnnotationService.get(
ctx.user,
request.nextUrl.searchParams.get("drawingId"),
);
return jsonSuccess({ snapshot }, 200, ctx.requestId);
} catch (error) {
return respondWithAppError(error, ctx.requestId);
}
},
{ requireAuth: true },
);
}
export async function PATCH(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
try {
const snapshot =
await installationDemoDrawingTextAnnotationService.updateTarget(
ctx.user,
await request.json(),
);
return jsonSuccess({ snapshot }, 200, ctx.requestId);
} catch (error) {
return respondWithAppError(error, ctx.requestId);
}
},
{ requireAuth: true },
);
}

View File

@ -65,6 +65,9 @@ function InstallationDemoShareDrawingPanel({
imageMode={imageMode}
snapshotUrls={snapshot.numberingSnapshotUrls}
snapshotLineArtUrls={snapshotLineArtUrls}
textAnnotationsByTargetKey={
snapshot.drawingDocument?.textAnnotationsByTargetKey ?? {}
}
allowManualCapture={false}
readOnly
/>

View File

@ -41,6 +41,7 @@ function createSnapshot(): InstallationDemoShareSnapshot {
actions: [],
inventoryTransforms: {},
},
textAnnotationsByTargetKey: {},
snapshotLineArtUrls: {},
toolSnapshotLineArtUrls: {},
},

View File

@ -107,6 +107,7 @@ import { InstallationDrawingToolLineArtGenerator } from "./components/Installati
import { useInstallationDrawingCapture } from "./hooks/useInstallationDrawingCapture";
import { useInstallationDrawingAutoCapture } from "./hooks/useInstallationDrawingAutoCapture";
import { useInstallationDrawingConfirmation } from "./hooks/useInstallationDrawingConfirmation";
import { useInstallationDrawingTextAnnotations } from "./hooks/useInstallationDrawingTextAnnotations";
import { buildInstallationDrawingCaptureFingerprint } from "./lib/installationDrawingCaptureFingerprint";
import {
isInstallationDrawingActionCaptureTarget,
@ -401,6 +402,10 @@ export default function InstallationDemoWorkbenchClient({
setMode,
setActiveStepId: stepEditor.setActiveStepId,
});
const drawingTextAnnotations = useInstallationDrawingTextAnnotations(
selectedDrawingId,
catalog.access.canEdit,
);
const drawingOverviewCaptureActive =
isInstallationDrawingOverviewCaptureTarget(drawingCapture.captureTarget);
const drawingActionCaptureTarget = isInstallationDrawingActionCaptureTarget(
@ -1732,6 +1737,8 @@ export default function InstallationDemoWorkbenchClient({
templateId: "set-1",
imageMode: drawingImageMode ?? "line-art",
captures: drawingCapture.captures,
textAnnotationsByTargetKey:
drawingTextAnnotations.annotationsByTargetKey,
snapshotLineArtUrls:
shareNumberingSnapshotBatch.snapshotLineArtUrls ?? {},
toolSnapshotLineArtUrls:
@ -1763,6 +1770,7 @@ export default function InstallationDemoWorkbenchClient({
numberingModelParts.length,
numberingPlaying,
drawingCapture.captures,
drawingTextAnnotations.annotationsByTargetKey,
drawingTools.selectedTools,
numberingVisibleModelParts,
playing,
@ -2614,6 +2622,10 @@ export default function InstallationDemoWorkbenchClient({
numberingLineArtGenerating || Boolean(missingDrawingToolLineArt)
}
captures={drawingCapture.captures}
textAnnotationsByTargetKey={
drawingTextAnnotations.annotationsByTargetKey
}
textAnnotationMessage={drawingTextAnnotations.message}
initialScrollTop={
drawingSheetScrollPosition.drawingId === selectedDrawingId
? drawingSheetScrollPosition.scrollTop
@ -2638,6 +2650,9 @@ export default function InstallationDemoWorkbenchClient({
numberingMarkerCountOverrides,
);
}}
onUpdateTextAnnotations={
drawingTextAnnotations.saveTargetAnnotations
}
autoCapture={{
phase: drawingAutoCapture.phase,
error: drawingAutoCapture.error,

View File

@ -1,14 +1,22 @@
import { ListChecks, Maximize2 } from 'lucide-react'
import { ListChecks, Maximize2, Type } from 'lucide-react'
import type {
InstallationDrawingActionCapture,
InstallationDrawingCaptureTarget,
InstallationDrawingImageMode,
InstallationDrawingNumberMarker,
} from '../lib/installationDrawingCaptureTypes'
import { isInstallationDrawingImageCaptured } from '../lib/installationDrawingCaptureTypes'
import {
getInstallationDrawingCaptureTargetKey,
isInstallationDrawingImageCaptured,
} from '../lib/installationDrawingCaptureTypes'
import type { InstallationDrawingActionGroup } from '../lib/installationDrawingLayout'
import type {
InstallationDrawingTextAnnotation,
InstallationDrawingTextAnnotationsByTarget,
} from '../lib/installationDrawingTextAnnotations'
import { InstallationDrawingImageFrame } from './InstallationDrawingImageFrame'
import { InstallationDrawingNumberMarkers } from './InstallationDrawingNumberMarkers'
import { InstallationDrawingTextAnnotations } from './InstallationDrawingTextAnnotations'
interface Props {
groups: InstallationDrawingActionGroup[]
@ -23,6 +31,12 @@ interface Props {
target: InstallationDrawingCaptureTarget,
markers: InstallationDrawingNumberMarker[],
) => void
textAnnotationsByTargetKey?: InstallationDrawingTextAnnotationsByTarget
onManageTextAnnotations?: (target: InstallationDrawingCaptureTarget) => void
onCommitTextAnnotations?: (
target: InstallationDrawingCaptureTarget,
annotations: InstallationDrawingTextAnnotation[],
) => void
allowManualCapture: boolean
readOnly?: boolean
}
@ -77,6 +91,9 @@ export function InstallationDrawingActionsPage({
onAdjustImage,
onManageNumberingMarkers,
onCommitNumberingMarkers,
textAnnotationsByTargetKey = {},
onManageTextAnnotations,
onCommitTextAnnotations,
allowManualCapture,
readOnly = false,
}: Props) {
@ -100,6 +117,8 @@ export function InstallationDrawingActionsPage({
actionId: action.actionId,
sequence: action.sequence,
}
const targetKey = getInstallationDrawingCaptureTargetKey(target)
const textAnnotations = textAnnotationsByTargetKey[targetKey] ?? []
const captured = capture ? isInstallationDrawingImageCaptured(capture.image) : false
return (
<figure
@ -133,6 +152,15 @@ export function InstallationDrawingActionsPage({
: undefined}
/>
) : null}
{captured && textAnnotations.length > 0 ? (
<InstallationDrawingTextAnnotations
annotations={textAnnotations}
editable={!readOnly}
onCommit={onCommitTextAnnotations
? (annotations) => onCommitTextAnnotations(target, annotations)
: undefined}
/>
) : null}
</div>
{capture && capture.tools.length > 0 ? (
@ -155,6 +183,17 @@ export function InstallationDrawingActionsPage({
<ListChecks size={10} />
</button>
) : null}
{capture && captured ? (
<button
type="button"
onClick={() => onManageTextAnnotations?.(target)}
title="编辑文字"
aria-label={`编辑安装动作 ${action.sequence} 文字`}
className="inline-flex h-6 w-6 items-center justify-center rounded-[4px] border border-black/10 bg-white text-[#596164] shadow-sm"
>
<Type size={10} />
</button>
) : null}
{capture && captured ? (
<button
type="button"

View File

@ -0,0 +1,56 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { InstallationDrawingAutoCaptureControl } from "./InstallationDrawingAutoCaptureControl";
function renderControl(input?: {
hasActiveBatch?: boolean;
onStart?: jest.Mock;
}) {
const onStart = input?.onStart ?? jest.fn();
render(
<InstallationDrawingAutoCaptureControl
phase="idle"
hasActiveBatch={input?.hasActiveBatch ?? true}
hasDraftBatch={false}
configurationChanged={false}
completedCount={75}
totalCount={75}
error={null}
onStart={onStart}
onPause={jest.fn()}
/>,
);
return onStart;
}
describe("InstallationDrawingAutoCaptureControl", () => {
it("asks for confirmation before replacing a completed batch", () => {
const onStart = renderControl();
fireEvent.click(
screen.getByRole("button", { name: "重新批量带入 75/75" }),
);
expect(screen.getByRole("dialog", { name: "重新批量带入" })).toBeInTheDocument();
expect(onStart).not.toHaveBeenCalled();
});
it("starts replacement only after confirmation", () => {
const onStart = renderControl();
fireEvent.click(
screen.getByRole("button", { name: "重新批量带入 75/75" }),
);
fireEvent.click(screen.getByRole("button", { name: "开始重新带入" }));
expect(onStart).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("dialog", { name: "重新批量带入" })).not.toBeInTheDocument();
});
it("keeps the first automatic capture as a direct action", () => {
const onStart = renderControl({ hasActiveBatch: false });
fireEvent.click(screen.getByRole("button", { name: "自动抓取" }));
expect(onStart).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
});

View File

@ -1,6 +1,14 @@
"use client";
import { Camera, CheckCircle2, LoaderCircle, Square } from "lucide-react";
import { useState } from "react";
import {
Camera,
LoaderCircle,
RefreshCw,
Square,
TriangleAlert,
X,
} from "lucide-react";
interface InstallationDrawingAutoCaptureControlProps {
phase:
@ -32,6 +40,7 @@ export function InstallationDrawingAutoCaptureControl({
onStart,
onPause,
}: InstallationDrawingAutoCaptureControlProps) {
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
const running =
phase === "seeding" || phase === "running" || phase === "finalizing";
const loading = phase === "loading";
@ -39,60 +48,121 @@ export function InstallationDrawingAutoCaptureControl({
const startLabel = hasDraftBatch ? "继续抓取" : "自动抓取";
return (
<div className="flex min-w-0 items-center gap-2">
{settled ? (
<span className="inline-flex h-8 items-center gap-1.5 rounded-[6px] border border-[#c7cecf] bg-white px-3 text-[11px] font-black text-[#4e585a]">
<CheckCircle2 size={13} />
已带入 {completedCount}/{totalCount}
</span>
) : running ? (
<button
type="button"
onClick={onPause}
className="inline-flex h-8 items-center gap-1.5 rounded-[6px] border border-[#aeb6b8] bg-white px-3 text-[11px] font-black text-[#303638]"
>
{phase === "finalizing" ? (
<LoaderCircle size={13} className="animate-spin" />
) : (
<Square size={11} />
)}
{phase === "finalizing"
? "正在固定"
: `暂停 ${completedCount}/${totalCount}`}
</button>
) : (
<button
type="button"
onClick={onStart}
disabled={loading || totalCount === 0}
className="inline-flex h-8 items-center gap-1.5 rounded-[6px] bg-[#2d3335] px-3 text-[11px] font-black text-white disabled:cursor-not-allowed disabled:opacity-45"
>
{loading ? (
<LoaderCircle size={13} className="animate-spin" />
) : (
<Camera size={13} />
)}
{loading ? "读取中" : startLabel}
</button>
)}
{hasDraftBatch && !running ? (
<span className="text-[10px] font-bold text-[#6b7477]">
已完成 {completedCount}/{totalCount}
</span>
<>
<div className="flex min-w-0 items-center gap-2">
{settled ? (
<button
type="button"
onClick={() => setRestartDialogOpen(true)}
title="重新批量带入全部画面"
className="inline-flex h-8 items-center gap-1.5 whitespace-nowrap rounded-[6px] border border-[#aeb6b8] bg-white px-3 text-[11px] font-black text-[#303638] hover:bg-[#f4f6f6]"
>
<RefreshCw size={13} />
重新批量带入 {completedCount}/{totalCount}
</button>
) : running ? (
<button
type="button"
onClick={onPause}
className="inline-flex h-8 items-center gap-1.5 rounded-[6px] border border-[#aeb6b8] bg-white px-3 text-[11px] font-black text-[#303638]"
>
{phase === "finalizing" ? (
<LoaderCircle size={13} className="animate-spin" />
) : (
<Square size={11} />
)}
{phase === "finalizing"
? "正在固定"
: `暂停 ${completedCount}/${totalCount}`}
</button>
) : (
<button
type="button"
onClick={onStart}
disabled={loading || totalCount === 0}
className="inline-flex h-8 items-center gap-1.5 rounded-[6px] bg-[#2d3335] px-3 text-[11px] font-black text-white disabled:cursor-not-allowed disabled:opacity-45"
>
{loading ? (
<LoaderCircle size={13} className="animate-spin" />
) : (
<Camera size={13} />
)}
{loading ? "读取中" : startLabel}
</button>
)}
{hasDraftBatch && !running ? (
<span className="text-[10px] font-bold text-[#6b7477]">
已完成 {completedCount}/{totalCount}
</span>
) : null}
{configurationChanged ? (
<span className="text-[10px] font-black text-[#9a6513]">
配置已变更
</span>
) : null}
{error && !running ? (
<span
className="max-w-[220px] truncate text-[10px] font-bold text-[#b3473f]"
title={error}
>
{error}
</span>
) : null}
</div>
{restartDialogOpen ? (
<div className="fixed inset-0 z-[110] flex items-center justify-center bg-black/35 p-4 print:hidden">
<div
role="dialog"
aria-modal="true"
aria-label="重新批量带入"
className="w-full max-w-[430px] border border-black/10 bg-white shadow-[0_24px_70px_rgba(25,31,33,0.28)]"
>
<header className="flex items-center justify-between border-b border-black/10 px-5 py-3">
<div className="flex items-center gap-2">
<TriangleAlert size={16} className="text-[#9a6513]" />
<p className="text-[14px] font-black text-[#2d3335]">
重新批量带入全部画面?
</p>
</div>
<button
type="button"
onClick={() => setRestartDialogOpen(false)}
aria-label="关闭重新批量带入确认"
title="关闭"
className="inline-flex h-8 w-8 items-center justify-center rounded-[6px] border border-black/10 text-[#596164]"
>
<X size={15} />
</button>
</header>
<div className="px-5 py-4">
<p className="text-[11px] font-bold leading-5 text-[#596164]">
将重新抓取 {totalCount} 张原图和打印线稿。当前版本会保留到新批次全部完成,已有画面位置和编号设置继续沿用。
</p>
<div className="mt-5 grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setRestartDialogOpen(false)}
className="h-9 rounded-[6px] border border-black/10 text-[10px] font-black text-[#596164]"
>
取消
</button>
<button
type="button"
onClick={() => {
setRestartDialogOpen(false);
onStart();
}}
className="inline-flex h-9 items-center justify-center gap-1.5 rounded-[6px] bg-[#2d3335] text-[10px] font-black text-white"
>
<RefreshCw size={13} />
开始重新带入
</button>
</div>
</div>
</div>
</div>
) : null}
{configurationChanged ? (
<span className="text-[10px] font-black text-[#9a6513]">
配置已变更
</span>
) : null}
{error && !running ? (
<span
className="max-w-[220px] truncate text-[10px] font-bold text-[#b3473f]"
title={error}
>
{error}
</span>
) : null}
</div>
</>
);
}

View File

@ -12,6 +12,7 @@ import type {
} from "../types";
import type {
InstallationDrawingCaptures,
InstallationDrawingActionCaptureTarget,
InstallationDrawingCaptureTarget,
InstallationDrawingCapturedImage,
InstallationDrawingImageMode,
@ -20,11 +21,16 @@ import type {
} from "../lib/installationDrawingCaptureTypes";
import {
DEFAULT_INSTALLATION_DRAWING_IMAGE_TRANSFORM,
getInstallationDrawingCaptureTargetKey,
isInstallationDrawingImageAvailable,
isInstallationDrawingImageCaptured,
resolveInstallationDrawingConfirmedAtForMode,
resolveInstallationDrawingImageMode,
} from "../lib/installationDrawingCaptureTypes";
import type {
InstallationDrawingTextAnnotation,
InstallationDrawingTextAnnotationsByTarget,
} from "../lib/installationDrawingTextAnnotations";
import { buildInstallationDrawingLayout } from "../lib/installationDrawingLayout";
import { useInstallationDrawingMarkerInventory } from "../hooks/useInstallationDrawingMarkerInventory";
import {
@ -39,6 +45,7 @@ import { InstallationDrawingInventoryPage } from "./InstallationDrawingInventory
import { InstallationDrawingOverviewPage } from "./InstallationDrawingOverviewPage";
import { InstallationDrawingImageAdjustDialog } from "./InstallationDrawingImageAdjustDialog";
import { InstallationDrawingMarkerManagerDialog } from "./InstallationDrawingMarkerManagerDialog";
import { InstallationDrawingTextAnnotationDialog } from "./InstallationDrawingTextAnnotationDialog";
import {
buildInstallationDrawingInventoryItems,
type InstallationDrawingOverviewTool,
@ -57,6 +64,8 @@ interface InstallationDrawingSheetProps {
toolSnapshotLineArtUrls: Record<string, string>;
lineArtGenerating?: boolean;
captures: InstallationDrawingCaptures;
textAnnotationsByTargetKey: InstallationDrawingTextAnnotationsByTarget;
textAnnotationMessage?: string | null;
initialScrollTop: number;
onCaptureTarget: (
target: InstallationDrawingCaptureTarget,
@ -68,6 +77,10 @@ interface InstallationDrawingSheetProps {
numberingMarkers: InstallationDrawingNumberMarker[],
numberingMarkerCountOverrides: Record<string, number>,
) => void;
onUpdateTextAnnotations: (
targetKey: string,
annotations: InstallationDrawingTextAnnotation[],
) => void;
autoCapture: {
phase:
| "loading"
@ -162,9 +175,12 @@ export function InstallationDrawingSheet({
toolSnapshotLineArtUrls,
lineArtGenerating = false,
captures,
textAnnotationsByTargetKey,
textAnnotationMessage = null,
initialScrollTop,
onCaptureTarget,
onUpdateImageLayout,
onUpdateTextAnnotations,
autoCapture,
confirmation,
onClose,
@ -180,6 +196,8 @@ export function InstallationDrawingSheet({
);
const [adjustTarget, setAdjustTarget] =
useState<InstallationDrawingCaptureTarget | null>(null);
const [textAnnotationTarget, setTextAnnotationTarget] =
useState<InstallationDrawingActionCaptureTarget | null>(null);
const scrollContainerRef = useRef<HTMLElement | null>(null);
const {
markerManagerTarget,
@ -297,6 +315,23 @@ export function InstallationDrawingSheet({
image.numberingMarkerCountOverrides,
);
};
const commitTextAnnotations = (
target: InstallationDrawingActionCaptureTarget,
annotations: InstallationDrawingTextAnnotation[],
) => {
const image = resolveCapturedImage(reconciledCaptures, target);
if (!image) return;
onUpdateTextAnnotations(
getInstallationDrawingCaptureTargetKey(target),
annotations,
);
onUpdateImageLayout(
target,
image.transform,
image.numberingMarkers,
image.numberingMarkerCountOverrides,
);
};
const captureFromCurrentPosition = (
target: InstallationDrawingCaptureTarget,
) => {
@ -429,6 +464,14 @@ export function InstallationDrawingSheet({
{confirmation.message}
</p>
) : null}
{textAnnotationMessage ? (
<p
className="mt-1 max-w-[420px] truncate text-[10px] font-bold text-[#a75d29]"
title={textAnnotationMessage}
>
{textAnnotationMessage}
</p>
) : null}
</div>
<div className="flex items-center gap-2">
<InstallationDrawingAutoCaptureControl
@ -591,6 +634,15 @@ export function InstallationDrawingSheet({
onAdjustImage={setAdjustTarget}
onManageNumberingMarkers={setMarkerManagerTarget}
onCommitNumberingMarkers={commitNumberingMarkers}
textAnnotationsByTargetKey={textAnnotationsByTargetKey}
onManageTextAnnotations={(target) => {
if (target.kind === "action") setTextAnnotationTarget(target);
}}
onCommitTextAnnotations={(target, annotations) => {
if (target.kind === "action") {
commitTextAnnotations(target, annotations);
}
}}
allowManualCapture={
!autoCapture.running && !autoCapture.hasDraftBatch
}
@ -647,6 +699,21 @@ export function InstallationDrawingSheet({
onClose={() => setMarkerManagerTarget(null)}
/>
) : null}
{textAnnotationTarget ? (
<InstallationDrawingTextAnnotationDialog
title={`安装动作 ${textAnnotationTarget.sequence}`}
annotations={
textAnnotationsByTargetKey[
getInstallationDrawingCaptureTargetKey(textAnnotationTarget)
] ?? []
}
onSave={(annotations) => {
commitTextAnnotations(textAnnotationTarget, annotations);
setTextAnnotationTarget(null);
}}
onClose={() => setTextAnnotationTarget(null)}
/>
) : null}
</div>,
document.body,
);

View File

@ -80,9 +80,11 @@ function buildProps(initialScrollTop: number, onCaptureTarget: jest.Mock) {
actions: [],
inventoryTransforms: {},
},
textAnnotationsByTargetKey: {},
initialScrollTop,
onCaptureTarget,
onUpdateImageLayout: jest.fn(),
onUpdateTextAnnotations: jest.fn(),
autoCapture: {
phase: "idle" as const,
error: null,

View File

@ -0,0 +1,199 @@
"use client";
import { useState } from "react";
import { Check, Minus, Plus, Trash2, Type, X } from "lucide-react";
import {
MAX_INSTALLATION_DRAWING_TEXT_ANNOTATIONS_PER_TARGET,
MAX_INSTALLATION_DRAWING_TEXT_FONT_SIZE,
MAX_INSTALLATION_DRAWING_TEXT_LENGTH,
MIN_INSTALLATION_DRAWING_TEXT_FONT_SIZE,
clampInstallationDrawingTextFontSize,
cloneInstallationDrawingTextAnnotations,
createInstallationDrawingTextAnnotation,
type InstallationDrawingTextAnnotation,
} from "../lib/installationDrawingTextAnnotations";
interface Props {
title: string;
annotations: InstallationDrawingTextAnnotation[];
onSave: (annotations: InstallationDrawingTextAnnotation[]) => void;
onClose: () => void;
}
export function InstallationDrawingTextAnnotationDialog({
title,
annotations,
onSave,
onClose,
}: Props) {
const [items, setItems] = useState(() =>
cloneInstallationDrawingTextAnnotations(annotations),
);
const updateItem = (
id: string,
patch: Partial<InstallationDrawingTextAnnotation>,
) => {
setItems((current) =>
current.map((item) => (item.id === id ? { ...item, ...patch } : item)),
);
};
return (
<div className="fixed inset-0 z-[99] flex items-center justify-center bg-black/35 p-5 print:hidden">
<div
role="dialog"
aria-modal="true"
aria-label="编辑图纸文字"
className="flex max-h-[82vh] w-full max-w-[720px] flex-col border border-black/10 bg-white shadow-[0_24px_70px_rgba(25,31,33,0.28)]"
>
<header className="flex shrink-0 items-center justify-between border-b border-black/10 px-5 py-3">
<div className="min-w-0">
<p className="truncate text-[14px] font-black text-[#2d3335]">文字标注 · {title}</p>
<p className="mt-0.5 text-[10px] font-bold text-[#7a8284]">
保存后直接在动作画面中拖动文字到目标位置。
</p>
</div>
<button
type="button"
onClick={onClose}
aria-label="关闭文字标注"
title="关闭"
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-[6px] border border-black/10 text-[#596164]"
>
<X size={15} />
</button>
</header>
<div className="min-h-0 flex-1 overflow-auto px-5 py-4">
<div className="mb-3 flex items-center justify-between border-b border-black/10 pb-2">
<span className="text-[10px] font-black text-[#596164]">共 {items.length} 个文本框</span>
<button
type="button"
onClick={() =>
setItems((current) => [
...current,
createInstallationDrawingTextAnnotation(current.length),
])
}
disabled={items.length >= MAX_INSTALLATION_DRAWING_TEXT_ANNOTATIONS_PER_TARGET}
className="inline-flex h-8 items-center gap-1.5 rounded-[6px] border border-black/10 px-3 text-[11px] font-black text-[#2d3335] disabled:opacity-40"
>
<Type size={13} /> 添加文本
</button>
</div>
{items.length === 0 ? (
<div className="flex min-h-32 items-center justify-center border border-dashed border-black/15 text-[11px] font-bold text-[#7a8284]">
点击“添加文本”创建第一个文字标注
</div>
) : (
<div className="grid gap-2.5">
{items.map((item, index) => (
<section
key={item.id}
className="grid grid-cols-[34px_minmax(0,1fr)_32px] items-center gap-3 border border-black/10 px-3 py-2.5 md:grid-cols-[34px_minmax(0,1fr)_146px_32px]"
>
<span className="flex h-7 w-7 items-center justify-center bg-[#2d3335] text-[10px] font-black text-white">
{index + 1}
</span>
<textarea
value={item.text}
onChange={(event) =>
updateItem(item.id, {
text: event.target.value.slice(0, MAX_INSTALLATION_DRAWING_TEXT_LENGTH),
})
}
rows={2}
maxLength={MAX_INSTALLATION_DRAWING_TEXT_LENGTH}
placeholder="输入安装说明"
aria-label={`文字标注 ${index + 1} 内容`}
className="min-h-[52px] w-full resize-y border border-black/10 px-2.5 py-2 text-[12px] font-semibold leading-5 text-[#202526] outline-none focus:border-[#596164]"
/>
<div className="col-start-2 row-start-2 flex items-center justify-start gap-1 md:col-auto md:row-auto md:justify-end">
<button
type="button"
onClick={() =>
updateItem(item.id, {
fontSize: clampInstallationDrawingTextFontSize(item.fontSize - 1),
})
}
disabled={item.fontSize <= MIN_INSTALLATION_DRAWING_TEXT_FONT_SIZE}
aria-label={`缩小文字标注 ${index + 1}`}
title="缩小字号"
className="inline-flex h-8 w-8 items-center justify-center border border-black/10 text-[#596164] disabled:opacity-30"
>
<Minus size={12} />
</button>
<label className="flex h-8 w-[64px] items-center border border-black/10 px-1.5 text-[10px] font-black text-[#596164]">
<input
type="number"
min={MIN_INSTALLATION_DRAWING_TEXT_FONT_SIZE}
max={MAX_INSTALLATION_DRAWING_TEXT_FONT_SIZE}
value={item.fontSize}
onChange={(event) =>
updateItem(item.id, {
fontSize: clampInstallationDrawingTextFontSize(Number(event.target.value)),
})
}
aria-label={`文字标注 ${index + 1} 字号`}
className="min-w-0 flex-1 bg-transparent text-center outline-none"
/>
px
</label>
<button
type="button"
onClick={() =>
updateItem(item.id, {
fontSize: clampInstallationDrawingTextFontSize(item.fontSize + 1),
})
}
disabled={item.fontSize >= MAX_INSTALLATION_DRAWING_TEXT_FONT_SIZE}
aria-label={`放大文字标注 ${index + 1}`}
title="放大字号"
className="inline-flex h-8 w-8 items-center justify-center border border-black/10 text-[#596164] disabled:opacity-30"
>
<Plus size={12} />
</button>
</div>
<button
type="button"
onClick={() => setItems((current) => current.filter((entry) => entry.id !== item.id))}
aria-label={`删除文字标注 ${index + 1}`}
title="删除"
className="col-start-3 row-start-1 inline-flex h-8 w-8 items-center justify-center text-[#a94b3d] md:col-auto md:row-auto"
>
<Trash2 size={14} />
</button>
</section>
))}
</div>
)}
</div>
<footer className="flex shrink-0 justify-end gap-2 border-t border-black/10 px-5 py-3">
<button
type="button"
onClick={onClose}
className="h-8 rounded-[6px] border border-black/10 px-4 text-[11px] font-black text-[#596164]"
>
取消
</button>
<button
type="button"
onClick={() =>
onSave(
items
.map((item) => ({ ...item, text: item.text.trim() }))
.filter((item) => item.text.length > 0),
)
}
className="inline-flex h-8 items-center gap-1.5 rounded-[6px] bg-[#2d3335] px-4 text-[11px] font-black text-white"
>
<Check size={13} /> 保存
</button>
</footer>
</div>
</div>
);
}

View File

@ -0,0 +1,76 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { InstallationDrawingTextAnnotations } from "./InstallationDrawingTextAnnotations";
describe("InstallationDrawingTextAnnotations", () => {
const annotation = {
id: "text-1",
text: "锁紧四颗螺丝",
x: 0.4,
y: 0.3,
fontSize: 14,
};
it("commits the dragged template position", () => {
const onCommit = jest.fn();
render(
<div className="relative h-[100px] w-[200px]">
<InstallationDrawingTextAnnotations
annotations={[annotation]}
editable
onCommit={onCommit}
/>
</div>,
);
const text = screen.getByRole("button", { name: "移动文字标注 锁紧四颗螺丝" });
const layer = text.parentElement as HTMLDivElement;
Object.defineProperty(layer, "getBoundingClientRect", {
configurable: true,
value: () => ({ width: 200, height: 100 }),
});
Object.defineProperty(text, "setPointerCapture", {
configurable: true,
value: jest.fn(),
});
Object.defineProperty(text, "hasPointerCapture", {
configurable: true,
value: jest.fn(() => false),
});
const dispatchPointer = (type: string, clientX: number, clientY: number) => {
const event = new MouseEvent(type, { bubbles: true, clientX, clientY });
Object.defineProperty(event, "pointerId", { value: 5 });
fireEvent(text, event);
};
dispatchPointer("pointerdown", 80, 30);
dispatchPointer("pointermove", 120, 50);
dispatchPointer("pointerup", 120, 50);
expect(onCommit).toHaveBeenCalledTimes(1);
const committed = onCommit.mock.calls[0][0][0];
expect(committed).toMatchObject({
id: "text-1",
text: "锁紧四颗螺丝",
fontSize: 14,
});
expect(committed.x).toBeCloseTo(0.6);
expect(committed.y).toBeCloseTo(0.5);
});
it("keeps a separate static print layer", () => {
const { container } = render(
<InstallationDrawingTextAnnotations
annotations={[annotation]}
editable
onCommit={jest.fn()}
/>,
);
const printText = container.querySelector(
'[data-drawing-print-text-annotation="text-1"]',
);
expect(printText).not.toBeNull();
expect(printText).not.toHaveAttribute("role");
expect(printText).not.toHaveAttribute("title");
expect(printText).toHaveStyle({ fontSize: "14px" });
});
});

View File

@ -0,0 +1,133 @@
"use client";
import { useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import {
clampInstallationDrawingTextPosition,
type InstallationDrawingTextAnnotation,
} from "../lib/installationDrawingTextAnnotations";
interface Props {
annotations: InstallationDrawingTextAnnotation[];
editable?: boolean;
onCommit?: (annotations: InstallationDrawingTextAnnotation[]) => void;
}
interface DragState {
id: string;
pointerId: number;
clientX: number;
clientY: number;
x: number;
y: number;
}
export function InstallationDrawingTextAnnotations({
annotations,
editable = false,
onCommit,
}: Props) {
const layerRef = useRef<HTMLDivElement | null>(null);
const dragRef = useRef<DragState | null>(null);
const latestRef = useRef(annotations);
const [draggedAnnotations, setDraggedAnnotations] = useState<InstallationDrawingTextAnnotation[] | null>(null);
const currentAnnotations = draggedAnnotations ?? annotations;
const beginDrag = (
event: ReactPointerEvent<HTMLSpanElement>,
annotation: InstallationDrawingTextAnnotation,
) => {
if (!editable || !onCommit) return;
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
latestRef.current = annotations;
setDraggedAnnotations(annotations);
dragRef.current = {
id: annotation.id,
pointerId: event.pointerId,
clientX: event.clientX,
clientY: event.clientY,
x: annotation.x,
y: annotation.y,
};
};
const moveText = (event: ReactPointerEvent<HTMLSpanElement>) => {
const drag = dragRef.current;
const layer = layerRef.current;
if (!editable || !drag || drag.pointerId !== event.pointerId || !layer) return;
event.stopPropagation();
const bounds = layer.getBoundingClientRect();
if (bounds.width <= 0 || bounds.height <= 0) return;
const nextX = clampInstallationDrawingTextPosition(
drag.x + (event.clientX - drag.clientX) / bounds.width,
);
const nextY = clampInstallationDrawingTextPosition(
drag.y + (event.clientY - drag.clientY) / bounds.height,
);
const nextAnnotations = latestRef.current.map((item) =>
item.id === drag.id ? { ...item, x: nextX, y: nextY } : item,
);
latestRef.current = nextAnnotations;
setDraggedAnnotations(nextAnnotations);
};
const endDrag = (event: ReactPointerEvent<HTMLSpanElement>) => {
if (dragRef.current?.pointerId !== event.pointerId) return;
event.stopPropagation();
dragRef.current = null;
onCommit?.(latestRef.current);
setDraggedAnnotations(null);
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
};
return (
<>
<div
ref={layerRef}
className="drawing-text-annotation-screen-layer pointer-events-none absolute inset-0 z-[3] select-none print:hidden"
>
{currentAnnotations.map((annotation) => (
<span
key={annotation.id}
role={editable ? "button" : undefined}
aria-label={editable ? `移动文字标注 ${annotation.text}` : undefined}
title={editable ? "拖动文字标注" : undefined}
data-drawing-text-annotation={annotation.id}
onPointerDown={(event) => beginDrag(event, annotation)}
onPointerMove={moveText}
onPointerUp={endDrag}
onPointerCancel={endDrag}
className={`absolute -translate-x-1/2 -translate-y-1/2 whitespace-pre-wrap break-words text-left font-semibold leading-[1.25] text-[#202526] ${editable ? "pointer-events-auto cursor-move rounded-[2px] bg-white/85 px-1 py-0.5 shadow-[0_1px_4px_rgba(32,37,38,0.18)] ring-1 ring-[#202526]/25 touch-none" : "pointer-events-none"}`}
style={{
left: `${annotation.x * 100}%`,
top: `${annotation.y * 100}%`,
maxWidth: "84%",
fontSize: `${annotation.fontSize}px`,
}}
>
{annotation.text}
</span>
))}
</div>
<div className="drawing-text-annotation-print-layer pointer-events-none absolute inset-0 z-[3] hidden overflow-hidden select-none print:block">
{annotations.map((annotation) => (
<span
key={annotation.id}
data-drawing-print-text-annotation={annotation.id}
className="absolute -translate-x-1/2 -translate-y-1/2 whitespace-pre-wrap break-words text-left font-semibold leading-[1.25] text-[#202526]"
style={{
left: `${annotation.x * 100}%`,
top: `${annotation.y * 100}%`,
maxWidth: "84%",
fontSize: `${annotation.fontSize}px`,
}}
>
{annotation.text}
</span>
))}
</div>
</>
);
}

View File

@ -24,7 +24,10 @@ import {
type InstallationDrawingImageMode,
type InstallationDrawingNumberMarker,
} from "../lib/installationDrawingCaptureTypes";
import { resolveInstallationDrawingPendingTargets } from "../lib/installationDrawingAutoCapturePlan";
import {
resolveInstallationDrawingAutoCaptureStartMode,
resolveInstallationDrawingPendingTargets,
} from "../lib/installationDrawingAutoCapturePlan";
import { preserveInstallationDrawingCaptureAnnotations } from "../lib/installationDrawingCapturePreservation";
type AutoCapturePhase =
@ -177,14 +180,14 @@ export function useInstallationDrawingAutoCapture({
);
const start = useCallback(async () => {
if (
!drawingId ||
!modelUrl ||
!configFingerprint ||
phase === "loading" ||
(snapshot?.active && !snapshot.draft)
)
return;
const startMode = resolveInstallationDrawingAutoCaptureStartMode({
drawingId,
modelUrl,
configFingerprint,
hasActiveBatch: Boolean(snapshot?.active),
loading: phase === "loading",
});
if (!startMode || !drawingId || !modelUrl || !configFingerprint) return;
pauseRequestedRef.current = false;
setError(null);
setPhase("seeding");
@ -195,7 +198,7 @@ export function useInstallationDrawingAutoCapture({
configFingerprint,
expectedTargetKeys: targetKeys,
inventoryTransforms: captures.inventoryTransforms,
mode: snapshot?.active ? "replace" : "fill",
mode: startMode,
});
const draft = workingSnapshot?.draft;
if (!draft) throw new Error("自动抓取草稿批次创建失败");

View File

@ -11,6 +11,7 @@ import type {
import { buildInstallationStepMaterialItems } from '../lib/installationStepMaterials'
import { collectInstallationToolAttachmentIdentities } from '../lib/installationToolAttachmentIdentity'
import { preserveInstallationDrawingCaptureAnnotations } from '../lib/installationDrawingCapturePreservation'
import { resolveInstallationDrawingTechnicalLineAnnotation } from '../lib/installationDrawingActionCaptureFrame'
import type {
InstallationDrawingActionCapture,
InstallationDrawingCaptures,
@ -290,6 +291,13 @@ export function useInstallationDrawingCapture({
const lineArtUrl = capture({
...INSTALLATION_DRAWING_LINE_CAPTURE_OPTIONS,
technicalLineStyle: captureTarget.kind === 'finished' ? 'clean' : 'detail',
technicalLineAnnotation: captureTarget.kind === 'action'
? resolveInstallationDrawingTechnicalLineAnnotation({
target: captureTarget,
steps,
numberingParts,
})
: undefined,
})
if (!lineArtUrl) throw new Error('当前模型无法生成打印线稿,请确认模型几何已完整加载。')
const capturedImage = {
@ -323,7 +331,16 @@ export function useInstallationDrawingCapture({
} finally {
setCapturing(false)
}
}, [captureReady, captureRef, captureTarget, captures, capturing, numberingMarkersRef])
}, [
captureReady,
captureRef,
captureTarget,
captures,
capturing,
numberingMarkersRef,
numberingParts,
steps,
])
const applyPersistedBatch = useCallback((batch: InstallationDrawingCaptureBatch | null) => {
if (!batch) {

View File

@ -0,0 +1,117 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import {
cloneInstallationDrawingTextAnnotations,
sanitizeInstallationDrawingTextAnnotations,
type InstallationDrawingTextAnnotation,
type InstallationDrawingTextAnnotationsByTarget,
} from "../lib/installationDrawingTextAnnotations";
interface AnnotationSnapshot {
annotationsByTargetKey?: unknown;
}
interface AnnotationApiResponse {
success?: boolean;
data?: { snapshot?: AnnotationSnapshot };
error?: { message?: string };
}
async function readResponse(response: Response, fallback: string) {
const payload = (await response.json().catch(() => null)) as AnnotationApiResponse | null;
if (!response.ok || !payload?.success) {
throw new Error(payload?.error?.message || fallback);
}
return sanitizeInstallationDrawingTextAnnotations(
payload.data?.snapshot?.annotationsByTargetKey,
);
}
export function useInstallationDrawingTextAnnotations(
drawingId: string | null,
canEdit: boolean,
) {
const [snapshotState, setSnapshotState] = useState<{
drawingId: string | null;
annotations: InstallationDrawingTextAnnotationsByTarget;
}>({ drawingId: null, annotations: {} });
const [messageState, setMessageState] = useState<{
drawingId: string | null;
message: string | null;
}>({ drawingId: null, message: null });
const saveQueueRef = useRef<Promise<void>>(Promise.resolve());
const annotationsByTargetKey =
snapshotState.drawingId === drawingId ? snapshotState.annotations : {};
const message = messageState.drawingId === drawingId ? messageState.message : null;
useEffect(() => {
let cancelled = false;
if (!drawingId) {
return () => {
cancelled = true;
};
}
const searchParams = new URLSearchParams({ drawingId });
void fetch(
`/api/installation-demo-workbench/drawing-text-annotations?${searchParams.toString()}`,
{ credentials: "same-origin" },
)
.then((response) => readResponse(response, "图纸文字标注读取失败"))
.then((annotations) => {
if (!cancelled) setSnapshotState({ drawingId, annotations });
})
.catch((error) => {
if (!cancelled) {
setMessageState({
drawingId,
message: error instanceof Error ? error.message : "图纸文字标注读取失败",
});
}
});
return () => {
cancelled = true;
};
}, [drawingId]);
const saveTargetAnnotations = useCallback(
(targetKey: string, annotations: InstallationDrawingTextAnnotation[]) => {
if (!drawingId || !canEdit) return;
const nextAnnotations = cloneInstallationDrawingTextAnnotations(annotations);
setSnapshotState((current) => {
const next = current.drawingId === drawingId ? { ...current.annotations } : {};
if (nextAnnotations.length === 0) delete next[targetKey];
else next[targetKey] = nextAnnotations;
return { drawingId, annotations: next };
});
setMessageState({ drawingId, message: null });
saveQueueRef.current = saveQueueRef.current
.catch(() => undefined)
.then(async () => {
const response = await fetch(
"/api/installation-demo-workbench/drawing-text-annotations",
{
method: "PATCH",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ drawingId, targetKey, annotations: nextAnnotations }),
},
);
await readResponse(response, "图纸文字标注保存失败");
})
.catch((error) => {
setMessageState({
drawingId,
message: error instanceof Error ? error.message : "图纸文字标注保存失败",
});
});
},
[canEdit, drawingId],
);
return {
annotationsByTargetKey,
message,
saveTargetAnnotations,
};
}

View File

@ -8,6 +8,7 @@ import {
isInstallationDrawingActionCaptureFrameApplied,
isInstallationDrawingActionWorkspaceReady,
resolveInstallationDrawingActionCaptureFrame,
resolveInstallationDrawingTechnicalLineAnnotation,
} from './installationDrawingActionCaptureFrame'
import type { InstallationPartItem, InstallationStepDraft } from '../types'
@ -61,6 +62,87 @@ const step: InstallationStepDraft = {
}
describe('resolveInstallationDrawingActionCaptureFrame', () => {
it('builds red action nodes and blue hardware paths from the saved assembled transforms', () => {
const actionStep = {
...step,
hardwareNodeNames: ['action-2'],
}
const target = {
kind: 'action' as const,
id: 'step-1:action-2',
stepId: 'step-1',
frameKind: 'action' as const,
actionId: 'action-2',
sequence: 3,
}
expect(resolveInstallationDrawingTechnicalLineAnnotation({
target,
steps: [actionStep],
numberingParts: [],
})).toEqual({
activeNodeNames: ['action-2'],
hardwareNodeNames: ['action-2'],
assembledTransforms: [
{ nodeName: 'fixed', position: [0, 0, 0], rotation: [0, 0, 0, 1] },
{ nodeName: 'action-1', position: [1, 0, 0], rotation: [0, 0, 0, 1] },
{ nodeName: 'action-2', position: [2, 0, 0], rotation: [0, 0, 0, 1] },
],
})
})
it('does not add action annotations to a fixed frame', () => {
expect(resolveInstallationDrawingTechnicalLineAnnotation({
target: {
kind: 'action',
id: 'step-1:fixed',
stepId: 'step-1',
frameKind: 'fixed',
actionId: null,
sequence: 1,
},
steps: [step],
numberingParts: [],
})).toBeUndefined()
})
it('keeps a part-only action red without inventing a hardware path', () => {
expect(resolveInstallationDrawingTechnicalLineAnnotation({
target: {
kind: 'action',
id: 'step-1:action-1',
stepId: 'step-1',
frameKind: 'action',
actionId: 'action-1',
sequence: 2,
},
steps: [step],
numberingParts: [],
})?.hardwareNodeNames).toEqual([])
})
it('recognizes hardware from numbering bindings when legacy step role arrays are empty', () => {
const numberingParts = [{
id: 'hardware-part',
nodeName: 'action-2',
nodeNames: ['action-2'],
kind: 'hardware',
}] as InstallationPartItem[]
expect(resolveInstallationDrawingTechnicalLineAnnotation({
target: {
kind: 'action',
id: 'step-1:action-2',
stepId: 'step-1',
frameKind: 'action',
actionId: 'action-2',
sequence: 3,
},
steps: [step],
numberingParts,
})?.hardwareNodeNames).toEqual(['action-2'])
})
it('shows only the step fixed nodes in the fixed opening frame', () => {
const frame = resolveInstallationDrawingActionCaptureFrame({
target: {

View File

@ -1,4 +1,8 @@
import type { NodeLocalTransformState, ThreeDEngine } from '@/lib/3d/engine'
import type {
EngineTechnicalLineAnnotation,
NodeLocalTransformState,
ThreeDEngine,
} from '@/lib/3d/engine'
import { buildInstallationStepEditFrame } from './installationStepActionPlayback'
import { deriveInstallationFinishClosePlaybackStep } from './installationFinishCloseBinding'
import type { InstallationDrawingActionCaptureTarget } from './installationDrawingCaptureTypes'
@ -137,6 +141,42 @@ interface ResolveInstallationDrawingActionCaptureFrameInput {
modelNodeNames: string[]
}
export function resolveInstallationDrawingTechnicalLineAnnotation({
target,
steps,
numberingParts,
}: Omit<ResolveInstallationDrawingActionCaptureFrameInput, 'modelNodeNames'>):
EngineTechnicalLineAnnotation | undefined {
if (!target || target.frameKind !== 'action' || !target.actionId) return undefined
const storedStep = steps.find((item) => item.id === target.stepId) ?? null
if (!storedStep) return undefined
const step = deriveInstallationFinishClosePlaybackStep({
step: storedStep,
numberingParts,
})
const action = step?.actions?.find((item) => item.id === target.actionId) ?? null
if (!step || !action) return undefined
const hardwareNodeNameSet = new Set([
...step.hardwareNodeNames,
...numberingParts
.filter((part) => part.kind === 'hardware')
.flatMap((part) => [part.nodeName, ...part.nodeNames])
.filter((nodeName): nodeName is string => Boolean(nodeName)),
])
const assembledTransforms = Object.entries(step.assembledTransforms ?? {}).flatMap(
([nodeName, transform]) => transform.position
? [{ nodeName, position: transform.position, rotation: transform.rotation }]
: [],
)
return {
activeNodeNames: action.nodeNames,
hardwareNodeNames: action.nodeNames.filter((nodeName) => hardwareNodeNameSet.has(nodeName)),
assembledTransforms,
}
}
export function resolveInstallationDrawingActionCaptureFrame({
target,
steps,

View File

@ -1,4 +1,7 @@
import { resolveInstallationDrawingPendingTargets } from './installationDrawingAutoCapturePlan'
import {
resolveInstallationDrawingAutoCaptureStartMode,
resolveInstallationDrawingPendingTargets,
} from './installationDrawingAutoCapturePlan'
import type { InstallationDrawingCaptureTarget } from './installationDrawingCaptureTypes'
const targets: Array<Exclude<InstallationDrawingCaptureTarget, { kind: 'inventory' }>> = [
@ -25,3 +28,33 @@ describe('resolveInstallationDrawingPendingTargets', () => {
})
})
describe('resolveInstallationDrawingAutoCaptureStartMode', () => {
const readyState = {
drawingId: 'drawing-1',
modelUrl: '/model.glb',
configFingerprint: 'config-1',
loading: false,
}
it('starts a full replacement when a completed batch already exists', () => {
expect(resolveInstallationDrawingAutoCaptureStartMode({
...readyState,
hasActiveBatch: true,
})).toBe('replace')
})
it('starts a fill batch when no completed batch exists', () => {
expect(resolveInstallationDrawingAutoCaptureStartMode({
...readyState,
hasActiveBatch: false,
})).toBe('fill')
})
it('does not start before the drawing context is ready', () => {
expect(resolveInstallationDrawingAutoCaptureStartMode({
...readyState,
hasActiveBatch: true,
loading: true,
})).toBeNull()
})
})

View File

@ -5,6 +5,22 @@ import {
type CaptureTarget = Exclude<InstallationDrawingCaptureTarget, { kind: 'inventory' }>
export function resolveInstallationDrawingAutoCaptureStartMode(input: {
drawingId: string | null
modelUrl: string | null
configFingerprint: string | null
hasActiveBatch: boolean
loading: boolean
}) {
if (
!input.drawingId
|| !input.modelUrl
|| !input.configFingerprint
|| input.loading
) return null
return input.hasActiveBatch ? 'replace' : 'fill'
}
export function resolveInstallationDrawingPendingTargets(
targets: CaptureTarget[],
completedTargetKeys: Iterable<string>,
@ -14,4 +30,3 @@ export function resolveInstallationDrawingPendingTargets(
(target) => !completed.has(getInstallationDrawingCaptureTargetKey(target)),
)
}

View File

@ -0,0 +1,74 @@
export interface InstallationDrawingTextAnnotation {
id: string;
text: string;
x: number;
y: number;
fontSize: number;
}
export type InstallationDrawingTextAnnotationsByTarget = Record<
string,
InstallationDrawingTextAnnotation[]
>;
export const DEFAULT_INSTALLATION_DRAWING_TEXT_FONT_SIZE = 12;
export const MIN_INSTALLATION_DRAWING_TEXT_FONT_SIZE = 8;
export const MAX_INSTALLATION_DRAWING_TEXT_FONT_SIZE = 32;
export const MAX_INSTALLATION_DRAWING_TEXT_LENGTH = 240;
export const MAX_INSTALLATION_DRAWING_TEXT_ANNOTATIONS_PER_TARGET = 24;
export function createInstallationDrawingTextAnnotation(
index = 0,
): InstallationDrawingTextAnnotation {
return {
id: `text-${Date.now()}-${index}`,
text: "",
x: Math.min(0.8, 0.42 + index * 0.04),
y: Math.min(0.82, 0.42 + index * 0.05),
fontSize: DEFAULT_INSTALLATION_DRAWING_TEXT_FONT_SIZE,
};
}
export function clampInstallationDrawingTextPosition(value: number) {
if (!Number.isFinite(value)) return 0.5;
return Math.min(0.98, Math.max(0.02, value));
}
export function clampInstallationDrawingTextFontSize(value: number) {
if (!Number.isFinite(value)) return DEFAULT_INSTALLATION_DRAWING_TEXT_FONT_SIZE;
return Math.min(
MAX_INSTALLATION_DRAWING_TEXT_FONT_SIZE,
Math.max(MIN_INSTALLATION_DRAWING_TEXT_FONT_SIZE, Math.round(value)),
);
}
export function sanitizeInstallationDrawingTextAnnotations(
value: unknown,
): InstallationDrawingTextAnnotationsByTarget {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
const result: InstallationDrawingTextAnnotationsByTarget = {};
for (const [targetKey, entries] of Object.entries(value)) {
if (!targetKey.trim() || !Array.isArray(entries)) continue;
result[targetKey] = entries
.filter((entry): entry is Record<string, unknown> => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry))
.map((entry, index) => {
const text = typeof entry.text === "string" ? entry.text.slice(0, MAX_INSTALLATION_DRAWING_TEXT_LENGTH) : "";
return {
id: typeof entry.id === "string" && entry.id.trim() ? entry.id : `text-${targetKey}-${index}`,
text,
x: clampInstallationDrawingTextPosition(Number(entry.x)),
y: clampInstallationDrawingTextPosition(Number(entry.y)),
fontSize: clampInstallationDrawingTextFontSize(Number(entry.fontSize) || DEFAULT_INSTALLATION_DRAWING_TEXT_FONT_SIZE),
};
})
.filter((entry) => entry.text.trim().length > 0)
.slice(0, MAX_INSTALLATION_DRAWING_TEXT_ANNOTATIONS_PER_TARGET);
}
return result;
}
export function cloneInstallationDrawingTextAnnotations(
annotations: InstallationDrawingTextAnnotation[],
) {
return annotations.map((annotation) => ({ ...annotation }));
}

View File

@ -0,0 +1,5 @@
import { redirect } from 'next/navigation'
export default async function NewProductManagementPage() {
redirect('/supply-chain/new-products/overview')
}

View File

@ -90,6 +90,7 @@ export const SUPPLY_CHAIN_ROUTE_TITLES: Record<string, string> = {
'/supply-chain/database/product-archive': '产品全量档案',
'/supply-chain/database/category-config': '类目配置',
'/supply-chain/database/warehouse-config': '仓库配置',
'/supply-chain/new-products/overview': '新品经营总览',
'/supply-chain/new-products/unified-table': '新品开发进度总表',
'/supply-chain/new-products/sample-progress': '打样进度明细',
'/supply-chain/purchase/daily-affairs': '日常事项',

View File

@ -6,6 +6,11 @@ describe('supply chain menu', () => {
expect(items).toEqual(
expect.arrayContaining([
expect.objectContaining({
label: '新品经营总览',
module: '新品管理',
route: '/supply-chain/new-products/overview',
}),
expect.objectContaining({
label: '新品开发进度总表',
module: '新品管理',

View File

@ -54,6 +54,11 @@ export const SUPPLY_CHAIN_MENU: SupplyChainMenuModule[] = [
label: '新品管理',
icon: 'PackagePlus',
sections: [
{
items: [
{ label: '新品经营总览', route: '/supply-chain/new-products/overview', targetKey: 'newProductOverview' },
],
},
{
title: '公共数据源',
items: [

View File

@ -0,0 +1,49 @@
import { redirect } from 'next/navigation'
import NewProductManagementDashboard from '@/app/intake/new-product-management/NewProductManagementDashboard'
import { requireSession } from '@/lib/server-session'
import { OA_BOARD_DEFAULT_TRACKED_STATUSES } from '@/server/services/oa-board.constants'
import {
createEmptyOaBoardDashboard,
oaBoardService,
} from '@/server/services/oa-board.service'
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
export const dynamic = 'force-dynamic'
export const metadata = { title: '新品经营总览 · 供应链中心' }
export default async function NewProductOverviewPage() {
const { authUser } = await requireSession('/login')
const access = await supplyChainPermissionSettingsService.getUserAccess(
authUser,
'newProductOverview',
)
if (!access.view) redirect('/supply-chain')
const result = await oaBoardService
.getDashboard({
overallStatus: [...OA_BOARD_DEFAULT_TRACKED_STATUSES],
})
.then((dashboard) => ({
dashboard,
loadErrorMessage: null as string | null,
}))
.catch((error) => {
console.error('Failed to load supply chain new product overview', error)
return {
dashboard: createEmptyOaBoardDashboard(),
loadErrorMessage:
error instanceof Error
? `新品管理数据读取失败:${error.message}`
: '新品管理数据读取失败,请稍后重试。',
}
})
return (
<div className="h-full overflow-y-auto">
<NewProductManagementDashboard
dashboard={result.dashboard}
loadErrorMessage={result.loadErrorMessage}
/>
</div>
)
}

View File

@ -22,6 +22,7 @@ const TARGET_ICONS: Record<SupplyChainPermissionTargetKey, typeof LayoutDashboar
productArchive: Database,
categoryConfig: Database,
warehouseConfig: Warehouse,
newProductOverview: LayoutDashboard,
newProductUnifiedTable: PackagePlus,
newProductSampleProgress: PackagePlus,
purchaseDailyAffairs: ShoppingCart,

View File

@ -96,6 +96,7 @@ function AppShellContent({ children, user, localOaBoardPreview = false }: AppShe
const isBomCostingRoute =
pathname === '/bom-costing' || pathname.startsWith('/bom-costing/')
const isOaBoardRoute = pathname === '/intake/oa-board' || pathname.startsWith('/intake/oa-board/')
const isNewProductManagementRoute = pathname === '/intake/new-product-management' || pathname.startsWith('/intake/new-product-management/')
const isSampleProgressRoute = pathname === '/intake/sample-progress' || pathname.startsWith('/intake/sample-progress/')
const isLocalOaBoardPreview = localOaBoardPreview && isOaBoardRoute
const isWarehouseRoute = pathname === '/warehouse' || pathname.startsWith('/warehouse/')
@ -132,6 +133,7 @@ function AppShellContent({ children, user, localOaBoardPreview = false }: AppShe
const shouldLockMainViewport =
isWorkbenchShellRoute &&
!isOaBoardRoute &&
!isNewProductManagementRoute &&
!isSampleProgressRoute &&
!isMobileProductDetailLinkView
@ -290,10 +292,10 @@ function AppShellContent({ children, user, localOaBoardPreview = false }: AppShe
<main
className={
isMobileOrTablet
? isOaBoardRoute
? isOaBoardRoute || isNewProductManagementRoute
? 'mt-14 h-[calc(100dvh-56px-56px)] min-h-[calc(100dvh-56px-56px)] overflow-auto overscroll-contain'
: 'mt-14 h-[calc(100dvh-56px-56px)] min-h-[calc(100dvh-56px-56px)] overflow-hidden overscroll-none'
: isOaBoardRoute
: isOaBoardRoute || isNewProductManagementRoute
? 'mt-16 h-[calc(100dvh-64px)] min-h-[calc(100dvh-64px)] overflow-auto overscroll-contain'
: 'mt-16 h-[calc(100dvh-64px)] min-h-[calc(100dvh-64px)] overflow-hidden overscroll-none'
}

View File

@ -35,7 +35,7 @@ const WORKBENCH_NAV_ITEMS = [
{ href: '/draft-groups', icon: PackageOpen, label: '草稿组' },
{ href: '/intake', icon: ArrowDownToLine, label: '产品资料' },
{ href: '/bom-costing', icon: Calculator, label: 'BOM核算' },
{ href: '/intake/oa-board', icon: ClipboardList, label: 'OA 新品流程看板' },
{ href: '/supply-chain/new-products/overview', icon: ClipboardList, label: '新品管理' },
{ href: '/warehouse', icon: Warehouse, label: '仓库管理' },
{ href: '/warehouse-quality', icon: ClipboardCheck, label: '仓库质检' },
{ href: '/ai-render-workbench', icon: Sparkles, label: 'AI出图' },
@ -47,10 +47,10 @@ 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'))
return pathname === '/intake' || (pathname.startsWith('/intake/') && !pathname.startsWith('/intake/new-product-management') && !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')
if (href === '/supply-chain/new-products/overview') {
return pathname === href || pathname.startsWith(`${href}/`) || pathname.startsWith('/intake/new-product-management') || pathname.startsWith('/intake/oa-board') || pathname.startsWith('/intake/sample-progress')
}
return pathname === href || pathname.startsWith(`${href}/`)
}

View File

@ -76,6 +76,12 @@ export interface NodeLocalTransformState {
rotation: [number, number, number, number];
}
export interface EngineTechnicalLineAnnotation {
activeNodeNames: string[];
hardwareNodeNames: string[];
assembledTransforms: NodeLocalTransformState[];
}
export type EngineWorldNodeSource =
| "desktop"
| "leg"
@ -113,6 +119,7 @@ export interface EngineCaptureOptions {
cropAlphaThreshold?: number;
renderMode?: "default" | "technical-line";
technicalLineStyle?: "clean" | "detail";
technicalLineAnnotation?: EngineTechnicalLineAnnotation;
renderCamera?: THREE.Camera;
}
@ -1676,6 +1683,33 @@ export class ThreeDEngine {
...(this.modelRoot ? [this.modelRoot] : []),
...Array.from(this.externalRaycastRoots),
];
const annotation = options.technicalLineAnnotation
? {
activeObjects: options.technicalLineAnnotation.activeNodeNames.flatMap(
(nodeName) => {
const object = this.resolveObjectByNodeName(nodeName);
return object ? [object] : [];
},
),
hardwareObjects: options.technicalLineAnnotation.hardwareNodeNames.flatMap(
(nodeName) => {
const object = this.resolveObjectByNodeName(nodeName);
return object ? [object] : [];
},
),
assembledTransforms:
options.technicalLineAnnotation.assembledTransforms.flatMap((transform) => {
const object = this.resolveObjectByNodeName(transform.nodeName);
return object
? [{
object,
position: transform.position,
rotation: transform.rotation,
}]
: [];
}),
}
: undefined;
const drawingBufferSize = this.renderer.getDrawingBufferSize(new THREE.Vector2());
return withTechnicalLineCaptureScene(
roots,
@ -1698,6 +1732,7 @@ export class ThreeDEngine {
viewportWidth: drawingBufferSize.x,
viewportHeight: drawingBufferSize.y,
style: options.technicalLineStyle ?? "detail",
annotation,
},
);
}

View File

@ -0,0 +1,293 @@
import * as THREE from 'three'
import {
collectTechnicalLineActiveMeshes,
resolveTechnicalLineHardwarePaths,
type TechnicalLineCaptureAnnotation,
} from './technical-line-annotations'
function createMesh(name: string) {
const mesh = new THREE.Mesh(
new THREE.BoxGeometry(2, 2, 2),
new THREE.MeshBasicMaterial(),
)
mesh.name = name
return mesh
}
function createFrontCamera() {
const camera = new THREE.OrthographicCamera(-5, 5, 5, -5, 0.1, 20)
camera.position.set(0, 0, 10)
camera.lookAt(0, 0, 0)
camera.updateProjectionMatrix()
camera.updateMatrixWorld(true)
return camera
}
function createBoardWithHole() {
const shape = new THREE.Shape()
shape.moveTo(-2, -2)
shape.lineTo(2, -2)
shape.lineTo(2, 2)
shape.lineTo(-2, 2)
shape.closePath()
const hole = new THREE.Path()
hole.absellipse(0, 0, 0.6, 0.6, 0, Math.PI * 2, false, 0)
shape.holes.push(hole)
const geometry = new THREE.ExtrudeGeometry(shape, {
depth: 1,
bevelEnabled: false,
curveSegments: 16,
steps: 1,
})
geometry.translate(0, 0, -0.5)
return new THREE.Mesh(geometry, new THREE.MeshBasicMaterial())
}
describe('technical line annotations', () => {
test('collects only meshes below the current action objects', () => {
const scene = new THREE.Scene()
const activeGroup = new THREE.Group()
const activeMesh = createMesh('active')
const inactiveMesh = createMesh('inactive')
activeGroup.add(activeMesh)
scene.add(activeGroup, inactiveMesh)
scene.updateMatrixWorld(true)
expect(collectTechnicalLineActiveMeshes(
[activeMesh, inactiveMesh],
[activeGroup],
)).toEqual([activeMesh])
})
test('uses the visible opening of the real receiving hole instead of the saved hardware center', () => {
const scene = new THREE.Scene()
const modelRoot = new THREE.Group()
const hardware = new THREE.Mesh(
new THREE.BoxGeometry(1, 1, 0.4),
new THREE.MeshBasicMaterial(),
)
const board = createBoardWithHole()
hardware.position.set(0, 1, 3)
board.position.set(0, 1, 0)
modelRoot.add(hardware, board)
scene.add(modelRoot)
scene.updateMatrixWorld(true)
modelRoot.matrixWorld.makeTranslation(4, 0, 0)
hardware.updateMatrixWorld(true)
board.updateMatrixWorld(true)
const [path] = resolveTechnicalLineHardwarePaths({
activeObjects: [hardware],
hardwareObjects: [hardware],
assembledTransforms: [
{ object: hardware, position: [0, 0, 0.2], rotation: [0, 0, 0, 1] },
{ object: board, position: [0, 0, 0], rotation: [0, 0, 0, 1] },
],
}, [hardware, board], createFrontCamera())
expect(path.start.x).toBeCloseTo(4)
expect(path.start.y).toBeCloseTo(1)
expect(path.start.z).toBeCloseTo(2.8)
expect(path.end.x).toBeCloseTo(4)
expect(path.end.y).toBeCloseTo(1)
expect(path.end.z).toBeCloseTo(0.5)
})
test('skips a nearer hidden hole loop and uses the next visible opening', () => {
const scene = new THREE.Scene()
const hardware = new THREE.Mesh(
new THREE.BoxGeometry(1, 1, 0.4),
new THREE.MeshBasicMaterial(),
)
const board = createBoardWithHole()
const internalBlocker = new THREE.Mesh(
new THREE.BoxGeometry(2, 2, 0.1),
new THREE.MeshBasicMaterial(),
)
hardware.position.set(0, 0, 3)
scene.add(hardware, board, internalBlocker)
scene.updateMatrixWorld(true)
const [path] = resolveTechnicalLineHardwarePaths({
activeObjects: [hardware],
hardwareObjects: [hardware],
assembledTransforms: [
{ object: hardware, position: [0, 0, -0.2], rotation: [0, 0, 0, 1] },
{ object: board, position: [0, 0, 0], rotation: [0, 0, 0, 1] },
],
}, [hardware, board, internalBlocker], createFrontCamera())
expect(path.start.z).toBeCloseTo(2.8)
expect(path.end.x).toBeCloseTo(0)
expect(path.end.y).toBeCloseTo(0)
expect(path.end.z).toBeCloseTo(0.5)
})
test('does not draw a receiving hole hidden behind another board', () => {
const scene = new THREE.Scene()
const hardware = new THREE.Mesh(
new THREE.BoxGeometry(1, 1, 0.4),
new THREE.MeshBasicMaterial(),
)
const board = createBoardWithHole()
const blocker = new THREE.Mesh(
new THREE.BoxGeometry(2, 2, 0.5),
new THREE.MeshBasicMaterial(),
)
hardware.position.set(0, 0, 3)
blocker.position.set(0, 0, 2)
scene.add(hardware, board, blocker)
scene.updateMatrixWorld(true)
expect(resolveTechnicalLineHardwarePaths({
activeObjects: [hardware],
hardwareObjects: [hardware],
assembledTransforms: [
{ object: hardware, position: [0, 0, 0.2], rotation: [0, 0, 0, 1] },
{ object: board, position: [0, 0, 0], rotation: [0, 0, 0, 1] },
],
}, [hardware, board, blocker], createFrontCamera())).toEqual([])
})
test('draws from the hardware installation end along its own axis to the board surface', () => {
const scene = new THREE.Scene()
const hardware = new THREE.Mesh(
new THREE.BoxGeometry(0.2, 2, 0.2),
new THREE.MeshBasicMaterial(),
)
const board = new THREE.Mesh(
new THREE.BoxGeometry(4, 1, 4),
new THREE.MeshBasicMaterial(),
)
hardware.name = 'hardware'
board.name = 'board'
hardware.position.set(0, 2, 0)
scene.add(hardware, board)
scene.updateMatrixWorld(true)
const annotation: TechnicalLineCaptureAnnotation = {
activeObjects: [hardware],
hardwareObjects: [hardware],
assembledTransforms: [
{ object: hardware, position: [0, 0.8, 0], rotation: [0, 0, 0, 1] },
{ object: board, position: [0, 0, 0], rotation: [0, 0, 0, 1] },
],
}
const paths = resolveTechnicalLineHardwarePaths(annotation, [hardware, board])
expect(paths).toHaveLength(1)
expect(paths[0].start.toArray()).toEqual([0, 1, 0])
expect(paths[0].end.x).toBeCloseTo(0)
expect(paths[0].end.y).toBeCloseTo(0.5)
expect(paths[0].end.z).toBeCloseTo(0)
})
test('keeps the path on the hardware axis after rotation', () => {
const scene = new THREE.Scene()
const hardware = new THREE.Mesh(
new THREE.BoxGeometry(0.2, 2, 0.2),
new THREE.MeshBasicMaterial(),
)
const board = new THREE.Mesh(
new THREE.BoxGeometry(1, 4, 4),
new THREE.MeshBasicMaterial(),
)
hardware.name = 'rotated-hardware'
hardware.position.set(2, 0, 0)
hardware.rotation.z = Math.PI / 2
scene.add(hardware, board)
scene.updateMatrixWorld(true)
const [path] = resolveTechnicalLineHardwarePaths({
activeObjects: [hardware],
hardwareObjects: [hardware],
assembledTransforms: [
{
object: hardware,
position: [0.8, 0, 0],
rotation: [0, 0, Math.SQRT1_2, Math.SQRT1_2],
},
{ object: board, position: [0, 0, 0], rotation: [0, 0, 0, 1] },
],
}, [hardware, board])
expect(path.start.x).toBeCloseTo(1)
expect(path.start.y).toBeCloseTo(0)
expect(path.end.x).toBeCloseTo(0.5)
expect(path.end.y).toBeCloseTo(0)
})
test('stops at the receiving surface when the saved hardware pose flips', () => {
const scene = new THREE.Scene()
const hardware = new THREE.Mesh(
new THREE.BoxGeometry(0.2, 2, 0.2),
new THREE.MeshBasicMaterial(),
)
const board = new THREE.Mesh(
new THREE.BoxGeometry(4, 1, 4),
new THREE.MeshBasicMaterial(),
)
hardware.position.set(0, 2, 0)
scene.add(hardware, board)
scene.updateMatrixWorld(true)
const [path] = resolveTechnicalLineHardwarePaths({
activeObjects: [hardware],
hardwareObjects: [hardware],
assembledTransforms: [
{ object: hardware, position: [0, 0.8, 0], rotation: [0, 0, 1, 0] },
{ object: board, position: [0, 0, 0], rotation: [0, 0, 0, 1] },
],
}, [hardware, board])
expect(path.start.y).toBeCloseTo(1)
expect(path.end.y).toBeCloseTo(0.5)
})
test('creates one path per hardware object and skips missing final transforms', () => {
const scene = new THREE.Scene()
const first = new THREE.Mesh(new THREE.BoxGeometry(0.2, 2, 0.2), new THREE.MeshBasicMaterial())
const second = new THREE.Mesh(new THREE.BoxGeometry(0.2, 2, 0.2), new THREE.MeshBasicMaterial())
const missing = new THREE.Mesh(new THREE.BoxGeometry(0.2, 2, 0.2), new THREE.MeshBasicMaterial())
const board = new THREE.Mesh(new THREE.BoxGeometry(10, 1, 4), new THREE.MeshBasicMaterial())
first.name = 'first'
second.name = 'second'
missing.name = 'missing'
first.position.set(-2, 2, 0)
second.position.set(2, 2, 0)
missing.position.set(0, 2, 0)
scene.add(first, second, missing, board)
scene.updateMatrixWorld(true)
const paths = resolveTechnicalLineHardwarePaths({
activeObjects: [first, second, missing],
hardwareObjects: [first, second, missing],
assembledTransforms: [
{ object: first, position: [-2, 0.8, 0], rotation: [0, 0, 0, 1] },
{ object: second, position: [2, 0.8, 0], rotation: [0, 0, 0, 1] },
{ object: board, position: [0, 0, 0], rotation: [0, 0, 0, 1] },
],
}, [first, second, missing, board])
expect(paths.map((path) => path.object.name)).toEqual(['first', 'second'])
})
test('does not fall back to a center line when no receiving surface is on the axis', () => {
const scene = new THREE.Scene()
const hardware = new THREE.Mesh(
new THREE.BoxGeometry(0.2, 2, 0.2),
new THREE.MeshBasicMaterial(),
)
hardware.position.set(3, 0, 0)
scene.add(hardware)
scene.updateMatrixWorld(true)
expect(resolveTechnicalLineHardwarePaths({
activeObjects: [hardware],
hardwareObjects: [hardware],
assembledTransforms: [
{ object: hardware, position: [0, 0, 0], rotation: [0, 0, 0, 1] },
],
}, [hardware])).toEqual([])
})
})

View File

@ -0,0 +1,297 @@
import * as THREE from 'three'
import { collectClosedTechnicalLineFeatureLoops } from './technical-line-feature-loops'
export interface TechnicalLineAnnotationObjectTransform {
object: THREE.Object3D
position: [number, number, number]
rotation: [number, number, number, number]
}
export interface TechnicalLineCaptureAnnotation {
activeObjects: THREE.Object3D[]
hardwareObjects: THREE.Object3D[]
assembledTransforms: TechnicalLineAnnotationObjectTransform[]
}
export interface TechnicalLineHardwarePath {
object: THREE.Object3D
start: THREE.Vector3
end: THREE.Vector3
}
function isDescendantOf(object: THREE.Object3D, ancestor: THREE.Object3D) {
let current: THREE.Object3D | null = object
while (current) {
if (current === ancestor) return true
current = current.parent
}
return false
}
export function collectTechnicalLineActiveMeshes(
meshes: THREE.Mesh[],
activeObjects: THREE.Object3D[],
) {
if (activeObjects.length === 0) return []
return meshes.filter((mesh) => activeObjects.some((object) => isDescendantOf(mesh, object)))
}
function getObjectLocalGeometryBounds(
object: THREE.Object3D,
meshes: THREE.Mesh[],
) {
const objectWorldInverse = object.matrixWorld.clone().invert()
const bounds = new THREE.Box3()
let hasGeometry = false
meshes.forEach((mesh) => {
if (!isDescendantOf(mesh, object)) return
if (!mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox()
if (!mesh.geometry.boundingBox) return
const meshToObject = new THREE.Matrix4().multiplyMatrices(
objectWorldInverse,
mesh.matrixWorld,
)
bounds.union(mesh.geometry.boundingBox.clone().applyMatrix4(meshToObject))
hasGeometry = true
})
return hasGeometry && !bounds.isEmpty() ? bounds : null
}
function getWorldBounds(meshes: THREE.Mesh[]) {
const bounds = new THREE.Box3()
meshes.forEach((mesh) => {
if (!mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox()
if (mesh.geometry.boundingBox) {
bounds.union(mesh.geometry.boundingBox.clone().applyMatrix4(mesh.matrixWorld))
}
})
return bounds
}
function getPrimaryAxisIndex(size: THREE.Vector3) {
if (size.y >= size.x && size.y >= size.z) return 1
return size.z >= size.x ? 2 : 0
}
function createWorldAxisPairs(
bounds: THREE.Box3,
worldMatrix: THREE.Matrix4,
) {
const center = bounds.getCenter(new THREE.Vector3())
return [0, 1, 2].flatMap((axisIndex) => {
const minimum = center.clone().setComponent(axisIndex, bounds.min.getComponent(axisIndex))
.applyMatrix4(worldMatrix)
const maximum = center.clone().setComponent(axisIndex, bounds.max.getComponent(axisIndex))
.applyMatrix4(worldMatrix)
const axis = maximum.clone().sub(minimum)
if (axis.lengthSq() <= 1e-12) return []
return [{ axisIndex, minimum, maximum, axis: axis.normalize() }]
})
}
function createAxialEndpoints(
bounds: THREE.Box3,
worldMatrix: THREE.Matrix4,
) {
const axisIndex = getPrimaryAxisIndex(bounds.getSize(new THREE.Vector3()))
const pair = createWorldAxisPairs(bounds, worldMatrix)
.find((candidate) => candidate.axisIndex === axisIndex)
if (!pair) return []
return [
{ start: pair.minimum, direction: pair.axis.clone().multiplyScalar(-1) },
{ start: pair.maximum, direction: pair.axis },
]
}
function buildFinalWorldMatrix(
object: THREE.Object3D,
transformByObjectUuid: Map<string, TechnicalLineAnnotationObjectTransform>,
cache: Map<string, THREE.Matrix4>,
): THREE.Matrix4 {
const cached = cache.get(object.uuid)
if (cached) return cached
const transform = transformByObjectUuid.get(object.uuid)
if (!transform) {
const currentWorldMatrix = object.matrixWorld.clone()
cache.set(object.uuid, currentWorldMatrix)
return currentWorldMatrix
}
const parentWorld = object.parent
? object.parent.matrixWorld.clone()
: new THREE.Matrix4()
const localMatrix = transform
? new THREE.Matrix4().compose(
new THREE.Vector3(...transform.position),
new THREE.Quaternion(...transform.rotation).normalize(),
object.scale,
)
: object.matrix.clone()
const worldMatrix = parentWorld.clone().multiply(localMatrix)
cache.set(object.uuid, worldMatrix)
return worldMatrix
}
function isWorldPointVisible(
point: THREE.Vector3,
camera: THREE.Camera,
meshes: THREE.Mesh[],
excludedObjects: THREE.Object3D[],
tolerance: number,
) {
const projected = point.clone().project(camera)
if (
projected.x < -1 || projected.x > 1
|| projected.y < -1 || projected.y > 1
|| projected.z < -1 || projected.z > 1
) return false
const visibleMeshes = meshes.filter((mesh) => (
excludedObjects.every((object) => !isDescendantOf(mesh, object))
))
const raycaster = new THREE.Raycaster()
raycaster.setFromCamera(new THREE.Vector2(projected.x, projected.y), camera)
const targetDistance = point.clone().sub(raycaster.ray.origin).dot(raycaster.ray.direction)
if (targetDistance <= tolerance) return false
raycaster.near = 0
raycaster.far = targetDistance + tolerance
const firstHit = raycaster.intersectObjects(visibleMeshes, false)[0]
return !firstHit || firstHit.distance >= targetDistance - tolerance
}
export function resolveTechnicalLineHardwarePaths(
annotation: TechnicalLineCaptureAnnotation,
meshes: THREE.Mesh[],
camera?: THREE.Camera,
) {
const transformByObjectUuid = new Map(
annotation.assembledTransforms.map((transform) => [transform.object.uuid, transform] as const),
)
const finalWorldMatrixCache = new Map<string, THREE.Matrix4>()
const uniqueHardwareObjects = Array.from(
new Map(annotation.hardwareObjects.map((object) => [object.uuid, object] as const)).values(),
)
const hardwareObjectUuids = new Set(uniqueHardwareObjects.map((object) => object.uuid))
const receivingObjects = annotation.assembledTransforms
.map((transform) => transform.object)
.filter((object) => !hardwareObjectUuids.has(object.uuid))
const receivingMeshes = meshes.filter((mesh) => (
uniqueHardwareObjects.every((object) => !isDescendantOf(mesh, object))
&& receivingObjects.some((object) => isDescendantOf(mesh, object))
))
const referenceWorldMatrixByReceivingObjectUuid = new Map(
receivingObjects.map((object) => [
object.uuid,
buildFinalWorldMatrix(object, transformByObjectUuid, finalWorldMatrixCache),
] as const),
)
const referenceWorldMatrixByMeshUuid = new Map(
receivingMeshes.flatMap((mesh) => {
const receivingObject = receivingObjects.find((object) => isDescendantOf(mesh, object))
const referenceReceivingWorld = receivingObject
? referenceWorldMatrixByReceivingObjectUuid.get(receivingObject.uuid)
: null
if (!receivingObject || !referenceReceivingWorld) return []
const meshToReceiving = receivingObject.matrixWorld.clone().invert().multiply(mesh.matrixWorld)
return [[mesh.uuid, referenceReceivingWorld.clone().multiply(meshToReceiving)] as const]
}),
)
const worldBounds = getWorldBounds(meshes)
const sceneDiagonal = worldBounds.isEmpty()
? 1
: Math.max(worldBounds.getSize(new THREE.Vector3()).length(), 1e-4)
const rayOffset = sceneDiagonal * 1e-6
const minimumPathLength = sceneDiagonal * 1e-4
const raycaster = new THREE.Raycaster()
raycaster.near = rayOffset
raycaster.far = sceneDiagonal * 2
return uniqueHardwareObjects.flatMap((object): TechnicalLineHardwarePath[] => {
if (!transformByObjectUuid.has(object.uuid)) return []
const localBounds = getObjectLocalGeometryBounds(object, meshes)
if (!localBounds) return []
if (camera) {
const currentCenter = localBounds.getCenter(new THREE.Vector3())
.applyMatrix4(object.matrixWorld)
const finalCenter = localBounds.getCenter(new THREE.Vector3()).applyMatrix4(
buildFinalWorldMatrix(object, transformByObjectUuid, finalWorldMatrixCache),
)
const hardwareDiameter = localBounds.getSize(new THREE.Vector3()).length()
* object.matrixWorld.getMaxScaleOnAxis()
const featureSearchDistance = Math.max(hardwareDiameter * 2.5, minimumPathLength * 10)
const currentAxisPairs = createWorldAxisPairs(localBounds, object.matrixWorld)
const finalAxisPairs = createWorldAxisPairs(
localBounds,
buildFinalWorldMatrix(object, transformByObjectUuid, finalWorldMatrixCache),
)
const nearbyReceivingMeshes = receivingMeshes.filter((mesh) => {
if (!mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox()
const referenceWorldMatrix = referenceWorldMatrixByMeshUuid.get(mesh.uuid)
if (!mesh.geometry.boundingBox || !referenceWorldMatrix) return false
return mesh.geometry.boundingBox.clone().applyMatrix4(referenceWorldMatrix)
.distanceToPoint(finalCenter) <= featureSearchDistance
})
const featureLoops = collectClosedTechnicalLineFeatureLoops(
nearbyReceivingMeshes,
referenceWorldMatrixByMeshUuid,
)
const candidateLoops = featureLoops
.filter((loop) => loop.diameter <= featureSearchDistance)
.map((loop) => ({ loop, distance: loop.referenceCenter.distanceTo(finalCenter) }))
.filter(({ distance }) => distance <= featureSearchDistance)
.sort((left, right) => left.distance - right.distance)
if (candidateLoops.length > 0) {
const isAlignedWithAxis = (
point: THREE.Vector3,
pairs: ReturnType<typeof createWorldAxisPairs>,
tolerance: number,
) => pairs.some((pair) => {
const offset = point.clone().sub(pair.minimum)
return offset.addScaledVector(pair.axis, -offset.dot(pair.axis)).length() <= tolerance
})
const targetLoop = candidateLoops.find(({ loop }) => {
const alignmentTolerance = Math.max(loop.diameter * 0.5, minimumPathLength * 10)
return currentCenter.distanceTo(loop.center) >= minimumPathLength
&& isAlignedWithAxis(loop.referenceCenter, finalAxisPairs, alignmentTolerance)
&& isAlignedWithAxis(loop.center, currentAxisPairs, alignmentTolerance)
&& isWorldPointVisible(
loop.center,
camera,
meshes,
uniqueHardwareObjects,
rayOffset,
)
})?.loop
if (!targetLoop) return []
const start = currentAxisPairs
.flatMap((pair) => [pair.minimum, pair.maximum])
.sort((left, right) => (
left.distanceToSquared(targetLoop.center) - right.distanceToSquared(targetLoop.center)
))[0]
if (start && start.distanceTo(targetLoop.center) >= minimumPathLength) {
return [{ object, start, end: targetLoop.center }]
}
return []
}
}
const candidates = createAxialEndpoints(localBounds, object.matrixWorld).flatMap(
({ start, direction }) => {
raycaster.set(start.clone().addScaledVector(direction, rayOffset), direction)
const hit = raycaster.intersectObjects(receivingMeshes, false)[0]
if (!hit || hit.distance < minimumPathLength) return []
return [{
start,
end: hit.point.clone(),
pathLength: hit.distance,
}]
},
)
candidates.sort((left, right) => left.pathLength - right.pathLength)
const candidate = candidates[0]
return candidate ? [{ object, start: candidate.start, end: candidate.end }] : []
})
}

View File

@ -46,15 +46,73 @@ describe('withTechnicalLineCaptureScene', () => {
test('restores the scene when capture throws', () => {
const { scene, root, mesh, originalMaterial } = createSceneMesh()
const receivingMesh = new THREE.Mesh(
new THREE.BoxGeometry(1, 4, 4),
new THREE.MeshBasicMaterial(),
)
root.add(receivingMesh)
mesh.position.set(2, 0, 0)
scene.updateMatrixWorld(true)
expect(() => withTechnicalLineCaptureScene([root], () => {
throw new Error('capture failed')
}, {
sourceCamera: createSourceCamera(),
...viewport,
annotation: {
activeObjects: [mesh],
hardwareObjects: [mesh],
assembledTransforms: [
{ object: mesh, position: [0, 3, 0], rotation: [0, 0, 0, 1] },
{ object: receivingMesh, position: [0, 0, 0], rotation: [0, 0, 0, 1] },
],
},
})).toThrow('capture failed')
expect(mesh.material).toBe(originalMaterial)
expect(scene.children.some((child) => child.name === '__technical_line_capture_edge__')).toBe(false)
expect(scene.getObjectByName('__technical_line_capture_active_edge__')).toBeUndefined()
expect(scene.getObjectByName('__technical_line_capture_hardware_path__')).toBeUndefined()
})
test('adds red current-part edges and one blue dashed hardware path temporarily', () => {
const { scene, root, mesh, originalMaterial } = createSceneMesh()
const receivingMesh = new THREE.Mesh(
new THREE.BoxGeometry(1, 4, 4),
new THREE.MeshBasicMaterial(),
)
root.add(receivingMesh)
mesh.position.set(3, 0, 0)
scene.updateMatrixWorld(true)
withTechnicalLineCaptureScene([root], () => {
const activeEdges = scene.getObjectByName('__technical_line_capture_active_edge__')
const hardwarePaths = scene.getObjectByName('__technical_line_capture_hardware_path__')
expect(activeEdges).toBeInstanceOf(THREE.LineSegments)
expect((activeEdges as THREE.LineSegments).material).toMatchObject({ depthTest: true })
expect(((activeEdges as THREE.LineSegments).material as THREE.LineBasicMaterial).color.getHex())
.toBe(0xd3262e)
expect(hardwarePaths).toBeInstanceOf(THREE.LineSegments)
expect((hardwarePaths as THREE.LineSegments).geometry.getAttribute('position').count).toBe(2)
expect((hardwarePaths as THREE.LineSegments).geometry.getAttribute('lineDistance').count).toBe(2)
expect(((hardwarePaths as THREE.LineSegments).material as THREE.LineDashedMaterial).color.getHex())
.toBe(0x1d63d8)
}, {
sourceCamera: createSourceCamera(),
...viewport,
annotation: {
activeObjects: [mesh],
hardwareObjects: [mesh],
assembledTransforms: [
{ object: mesh, position: [0, 3, 0], rotation: [0, 0, 0, 1] },
{ object: receivingMesh, position: [0, 0, 0], rotation: [0, 0, 0, 1] },
],
},
})
expect(mesh.material).toBe(originalMaterial)
expect(scene.getObjectByName('__technical_line_capture_active_edge__')).toBeUndefined()
expect(scene.getObjectByName('__technical_line_capture_hardware_path__')).toBeUndefined()
})
test('rejects scenes without usable visible geometry', () => {

View File

@ -2,8 +2,15 @@ import * as THREE from 'three'
import { isHighlightHelperObjectName } from './engine-highlight-resources'
import { createTechnicalLineOrthographicCamera } from './technical-line-camera'
import { buildMergedTechnicalLineGeometry } from './technical-line-geometry'
import {
collectTechnicalLineActiveMeshes,
resolveTechnicalLineHardwarePaths,
type TechnicalLineCaptureAnnotation,
} from './technical-line-annotations'
const TECHNICAL_LINE_EDGE_NAME = '__technical_line_capture_edge__'
const TECHNICAL_LINE_ACTIVE_EDGE_NAME = '__technical_line_capture_active_edge__'
const TECHNICAL_LINE_HARDWARE_PATH_NAME = '__technical_line_capture_hardware_path__'
export type TechnicalLineCaptureStyle = 'clean' | 'detail'
@ -12,6 +19,7 @@ export interface TechnicalLineCaptureSceneOptions {
viewportWidth: number
viewportHeight: number
style?: TechnicalLineCaptureStyle
annotation?: TechnicalLineCaptureAnnotation
}
interface MaterialSnapshot {
@ -63,6 +71,24 @@ function createOcclusionMaterial() {
})
}
function createHardwarePathGeometry(
paths: ReturnType<typeof resolveTechnicalLineHardwarePaths>,
) {
const positions = paths.flatMap(({ start, end }) => [
start.x, start.y, start.z,
end.x, end.y, end.z,
])
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
return geometry
}
function getSceneDiagonal(meshes: THREE.Mesh[]) {
const bounds = new THREE.Box3()
meshes.forEach((mesh) => bounds.expandByObject(mesh))
return bounds.isEmpty() ? 1 : Math.max(bounds.getSize(new THREE.Vector3()).length(), 1e-4)
}
export function withTechnicalLineCaptureScene<T>(
roots: THREE.Object3D[],
capture: (camera: THREE.Camera) => T,
@ -110,6 +136,65 @@ export function withTechnicalLineCaptureScene<T>(
lines.frustumCulled = false
lines.matrixAutoUpdate = false
lines.matrix.copy(lineParent.matrixWorld).invert()
const annotationObjects: THREE.Object3D[] = []
const annotationResources: Array<THREE.BufferGeometry | THREE.Material> = []
if (options.annotation) {
const activeMeshes = collectTechnicalLineActiveMeshes(
meshes,
options.annotation.activeObjects,
)
if (activeMeshes.length > 0) {
const activeGeometry = buildMergedTechnicalLineGeometry({
meshes: activeMeshes,
camera,
viewportWidth: options.viewportWidth,
viewportHeight: options.viewportHeight,
style: options.style ?? 'detail',
})
if ((activeGeometry.getAttribute('position')?.count ?? 0) > 0) {
const activeMaterial = new THREE.LineBasicMaterial({
color: 0xd3262e,
depthTest: true,
depthWrite: false,
toneMapped: false,
})
const activeLines = new THREE.LineSegments(activeGeometry, activeMaterial)
activeLines.name = TECHNICAL_LINE_ACTIVE_EDGE_NAME
activeLines.renderOrder = 3
activeLines.frustumCulled = false
activeLines.matrixAutoUpdate = false
activeLines.matrix.copy(lineParent.matrixWorld).invert()
annotationObjects.push(activeLines)
annotationResources.push(activeGeometry, activeMaterial)
} else {
activeGeometry.dispose()
}
}
const hardwarePaths = resolveTechnicalLineHardwarePaths(options.annotation, meshes, camera)
if (hardwarePaths.length > 0) {
const pathGeometry = createHardwarePathGeometry(hardwarePaths)
const sceneDiagonal = getSceneDiagonal(meshes)
const pathMaterial = new THREE.LineDashedMaterial({
color: 0x1d63d8,
dashSize: sceneDiagonal * 0.012,
gapSize: sceneDiagonal * 0.008,
depthTest: false,
depthWrite: false,
toneMapped: false,
})
const pathLines = new THREE.LineSegments(pathGeometry, pathMaterial)
pathLines.name = TECHNICAL_LINE_HARDWARE_PATH_NAME
pathLines.renderOrder = 4
pathLines.frustumCulled = false
pathLines.matrixAutoUpdate = false
pathLines.matrix.copy(lineParent.matrixWorld).invert()
pathLines.computeLineDistances()
annotationObjects.push(pathLines)
annotationResources.push(pathGeometry, pathMaterial)
}
}
const materialSnapshots: MaterialSnapshot[] = []
try {
@ -118,12 +203,15 @@ export function withTechnicalLineCaptureScene<T>(
mesh.material = createOcclusionMaterial()
})
lineParent.add(lines)
annotationObjects.forEach((object) => lineParent.add(object))
lineParent.updateMatrixWorld(true)
return capture(camera)
} finally {
lineParent.remove(lines)
annotationObjects.forEach((object) => lineParent.remove(object))
geometry.dispose()
lineMaterial.dispose()
annotationResources.forEach((resource) => resource.dispose())
materialSnapshots.forEach(({ mesh, material }) => {
if (Array.isArray(mesh.material)) {
mesh.material.forEach((item) => item.dispose())

View File

@ -0,0 +1,102 @@
import * as THREE from 'three'
export interface TechnicalLineFeatureLoop {
center: THREE.Vector3
referenceCenter: THREE.Vector3
diameter: number
}
export function collectClosedTechnicalLineFeatureLoops(
meshes: THREE.Mesh[],
referenceWorldMatrixByMeshUuid: Map<string, THREE.Matrix4>,
) {
return meshes.flatMap((mesh): TechnicalLineFeatureLoop[] => {
if (!mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox()
if (!mesh.geometry.boundingBox) return []
const localSize = mesh.geometry.boundingBox.getSize(new THREE.Vector3())
const weldTolerance = Math.max(localSize.length() * 1e-5, 1e-6)
const edgeGeometry = new THREE.EdgesGeometry(mesh.geometry, 18)
try {
const position = edgeGeometry.getAttribute('position')
if (!position || position.count < 6) return []
const nodes = new Map<string, { point: THREE.Vector3; neighbors: Set<string> }>()
const edges = new Set<string>()
const getKey = (point: THREE.Vector3) => [
Math.round(point.x / weldTolerance),
Math.round(point.y / weldTolerance),
Math.round(point.z / weldTolerance),
].join(':')
for (let index = 0; index + 1 < position.count; index += 2) {
const start = new THREE.Vector3().fromBufferAttribute(position, index)
const end = new THREE.Vector3().fromBufferAttribute(position, index + 1)
const startKey = getKey(start)
const endKey = getKey(end)
if (startKey === endKey) continue
const edgeKey = startKey < endKey
? `${startKey}|${endKey}`
: `${endKey}|${startKey}`
if (edges.has(edgeKey)) continue
edges.add(edgeKey)
const startNode = nodes.get(startKey) ?? { point: start, neighbors: new Set<string>() }
const endNode = nodes.get(endKey) ?? { point: end, neighbors: new Set<string>() }
startNode.neighbors.add(endKey)
endNode.neighbors.add(startKey)
nodes.set(startKey, startNode)
nodes.set(endKey, endNode)
}
const visited = new Set<string>()
const loops: TechnicalLineFeatureLoop[] = []
nodes.forEach((_node, initialKey) => {
if (visited.has(initialKey)) return
const stack = [initialKey]
const componentKeys: string[] = []
visited.add(initialKey)
while (stack.length > 0) {
const key = stack.pop()
if (!key) continue
componentKeys.push(key)
nodes.get(key)?.neighbors.forEach((neighborKey) => {
if (visited.has(neighborKey)) return
visited.add(neighborKey)
stack.push(neighborKey)
})
}
if (
componentKeys.length < 3
|| componentKeys.some((key) => nodes.get(key)?.neighbors.size !== 2)
) return
const localBounds = new THREE.Box3()
componentKeys.forEach((key) => {
const point = nodes.get(key)?.point
if (point) localBounds.expandByPoint(point)
})
const sortedSpans = localBounds.getSize(new THREE.Vector3()).toArray()
.sort((left, right) => right - left)
if ((sortedSpans[1] ?? 0) <= weldTolerance * 4) return
const localCenter = localBounds.getCenter(new THREE.Vector3())
const center = localCenter.clone().applyMatrix4(mesh.matrixWorld)
const referenceCenter = localCenter.clone().applyMatrix4(
referenceWorldMatrixByMeshUuid.get(mesh.uuid) ?? mesh.matrixWorld,
)
const worldBounds = new THREE.Box3()
componentKeys.forEach((key) => {
const point = nodes.get(key)?.point
if (point) worldBounds.expandByPoint(point.clone().applyMatrix4(mesh.matrixWorld))
})
loops.push({
center,
referenceCenter,
diameter: worldBounds.getSize(new THREE.Vector3()).length(),
})
})
return loops
} finally {
edgeGeometry.dispose()
}
})
}

View File

@ -5,6 +5,7 @@ import type {
InstallationDrawingImageMode,
} from "@/app/installation-demo-workbench/lib/installationDrawingCaptureTypes";
import type { InstallationDrawingTemplateId } from "@/app/installation-demo-workbench/lib/installationDrawingTemplates";
import type { InstallationDrawingTextAnnotationsByTarget } from "@/app/installation-demo-workbench/lib/installationDrawingTextAnnotations";
import type {
InstallationDetailView,
InstallationPartItem,
@ -35,6 +36,7 @@ export interface InstallationDemoShareDrawingDocument {
templateId: InstallationDrawingTemplateId;
imageMode: InstallationDrawingImageMode;
captures: InstallationDrawingCaptures;
textAnnotationsByTargetKey: InstallationDrawingTextAnnotationsByTarget;
snapshotLineArtUrls: Record<string, string>;
toolSnapshotLineArtUrls: Record<string, string>;
}

View File

@ -0,0 +1,92 @@
import { selectLatestRestartableChildFlows } from './oa-board-flow-selection'
type TestFlowRecord = {
flowType: string
processInstanceId: string
createdAtSource: Date | null
updatedAtSource: Date | null
status: string
}
function buildFlow(
flowType: string,
processInstanceId: string,
createdAtSource: string | null,
status: string,
updatedAtSource = createdAtSource,
): TestFlowRecord {
return {
flowType,
processInstanceId,
createdAtSource: createdAtSource ? new Date(createdAtSource) : null,
updatedAtSource: updatedAtSource ? new Date(updatedAtSource) : null,
status,
}
}
describe('OA 看板子流程版本选择', () => {
it('首单、美术和限价流程各自只保留最晚发起的一条', () => {
const records = [
buildFlow('review', 'review-1', '2026-08-03T02:30:00Z', 'COMPLETED'),
buildFlow('order', 'order-refused', '2026-08-03T08:00:00Z', 'REFUSED'),
buildFlow('order', 'order-running', '2026-08-04T08:00:00Z', 'RUNNING'),
buildFlow('art', 'art-refused', '2026-08-05T08:00:00Z', 'REFUSED'),
buildFlow('art', 'art-completed', '2026-08-06T08:00:00Z', 'COMPLETED'),
buildFlow('pricing', 'pricing-terminated', '2026-08-17T01:49:00Z', 'TERMINATED'),
buildFlow('pricing', 'pricing-completed', '2026-08-17T01:51:00Z', 'COMPLETED'),
buildFlow('main', 'main-1', '2026-08-17T02:00:00Z', 'RUNNING'),
]
expect(
selectLatestRestartableChildFlows(records).map((record) => record.processInstanceId),
).toEqual([
'review-1',
'order-running',
'art-completed',
'pricing-completed',
'main-1',
])
})
it('以发起时间为主,不让旧流程的较晚更新时间覆盖新流程', () => {
const records = [
buildFlow(
'art',
'old-art-updated-later',
'2026-08-05T08:00:00Z',
'REFUSED',
'2026-08-08T08:00:00Z',
),
buildFlow(
'art',
'new-art',
'2026-08-06T08:00:00Z',
'RUNNING',
'2026-08-06T09:00:00Z',
),
]
expect(selectLatestRestartableChildFlows(records)).toEqual([records[1]])
})
it('发起时间相同时用更新时间稳定决定最新记录', () => {
const records = [
buildFlow(
'pricing',
'pricing-older-update',
'2026-08-17T01:51:00Z',
'TERMINATED',
'2026-08-17T01:52:00Z',
),
buildFlow(
'pricing',
'pricing-newer-update',
'2026-08-17T01:51:00Z',
'COMPLETED',
'2026-08-17T01:58:00Z',
),
]
expect(selectLatestRestartableChildFlows(records)).toEqual([records[1]])
})
})

View File

@ -0,0 +1,47 @@
const RESTARTABLE_CHILD_FLOW_TYPES = new Set(['order', 'art', 'pricing'])
export type OaBoardFlowSelectionRecord = {
flowType: string
processInstanceId: string
createdAtSource: Date | null
updatedAtSource: Date | null
}
function toTimestamp(value: Date | null) {
const timestamp = value?.getTime() ?? 0
return Number.isFinite(timestamp) ? timestamp : 0
}
function compareFlowRecency(
left: OaBoardFlowSelectionRecord,
right: OaBoardFlowSelectionRecord,
) {
const createdAtDiff = toTimestamp(left.createdAtSource) - toTimestamp(right.createdAtSource)
if (createdAtDiff !== 0) return createdAtDiff
const updatedAtDiff = toTimestamp(left.updatedAtSource) - toTimestamp(right.updatedAtSource)
if (updatedAtDiff !== 0) return updatedAtDiff
return left.processInstanceId.localeCompare(right.processInstanceId)
}
export function selectLatestRestartableChildFlows<T extends OaBoardFlowSelectionRecord>(
records: T[],
) {
const latestByFlowType = new Map<string, T>()
records.forEach((record) => {
if (!RESTARTABLE_CHILD_FLOW_TYPES.has(record.flowType)) return
const currentLatest = latestByFlowType.get(record.flowType)
if (!currentLatest || compareFlowRecency(record, currentLatest) > 0) {
latestByFlowType.set(record.flowType, record)
}
})
return records.filter(
(record) =>
!RESTARTABLE_CHILD_FLOW_TYPES.has(record.flowType) ||
latestByFlowType.get(record.flowType) === record,
)
}

View File

@ -99,6 +99,9 @@ describe('supply chain permission settings', () => {
})
it('maps new product pages to their permission nodes', () => {
expect(
getSupplyChainPermissionTargetForPath('/supply-chain/new-products/overview'),
).toBe('newProductOverview')
expect(
getSupplyChainPermissionTargetForPath('/supply-chain/new-products/unified-table'),
).toBe('newProductUnifiedTable')

View File

@ -19,6 +19,7 @@ export type SupplyChainPermissionTargetKey =
| 'productArchive'
| 'categoryConfig'
| 'warehouseConfig'
| 'newProductOverview'
| 'newProductUnifiedTable'
| 'newProductSampleProgress'
| 'purchaseDailyAffairs'
@ -67,6 +68,7 @@ export const SUPPLY_CHAIN_PERMISSION_TARGETS: SupplyChainPermissionTarget[] = [
{ key: 'productArchive', label: '产品全量档案', description: '商品、供应链和运营档案' },
{ key: 'categoryConfig', label: '类目配置', description: '产品全量档案类目维护' },
{ key: 'warehouseConfig', label: '仓库配置', description: '出入库仓库维护' },
{ key: 'newProductOverview', label: '新品经营总览', description: '新品经营指标和阶段时效分析' },
{ key: 'newProductUnifiedTable', label: '新品开发进度总表', description: '新品流程公共数据源' },
{ key: 'newProductSampleProgress', label: '打样进度明细', description: '新品打样进度公共数据源' },
{ key: 'purchaseDailyAffairs', label: '日常事项', description: '采购日常协作' },
@ -85,6 +87,7 @@ export const SUPPLY_CHAIN_PERMISSION_TARGET_ROUTES: Partial<
productArchive: '/supply-chain/database/product-archive',
categoryConfig: '/supply-chain/database/category-config',
warehouseConfig: '/supply-chain/database/warehouse-config',
newProductOverview: '/supply-chain/new-products/overview',
newProductUnifiedTable: '/supply-chain/new-products/unified-table',
newProductSampleProgress: '/supply-chain/new-products/sample-progress',
purchaseDailyAffairs: '/supply-chain/purchase/daily-affairs',
@ -238,6 +241,7 @@ export function getSupplyChainPermissionTargetForPath(pathname: string) {
if (pathname === '/supply-chain/database/product-archive') return 'productArchive' as const
if (pathname === '/supply-chain/database/category-config') return 'categoryConfig' as const
if (pathname === '/supply-chain/database/warehouse-config') return 'warehouseConfig' as const
if (pathname === '/supply-chain/new-products/overview') return 'newProductOverview' as const
if (pathname === '/supply-chain/new-products/unified-table') return 'newProductUnifiedTable' as const
if (pathname === '/supply-chain/new-products/sample-progress') return 'newProductSampleProgress' as const
if (pathname === '/supply-chain/purchase/daily-affairs') return 'purchaseDailyAffairs' as const

View File

@ -55,6 +55,15 @@ export type SupplyChainProductArchiveRow = {
purchasePrice: string
costPrice: string
costPriceSecondary: string
shipAmount: string
returnAmount: string
realPerformance: string
shipQty: string
grossProfit: string
grossMargin: string
feeRatio: string
operatingProfit: string
returnQty: string
}
export const PRODUCT_ARCHIVE_SALES_STATUSES = ['销售', '停产', '退市'] as const

View File

@ -0,0 +1,134 @@
import sql from 'mssql'
export type ProductArchiveSalesPerformanceAggregate = {
barcode: string
shipAmount: string
returnAmount: string
realPerformance: string
shipQty: string
returnQty: string
clearSalesCost: string
sendFreight: string
}
export interface ProductArchiveSalesPerformanceSourceRepository {
latestAvailableDate(input: {
periodStart: Date
notAfter: Date
}): Promise<Date | null>
listAggregates(input: {
periodStart: Date
periodEnd: Date
}): Promise<ProductArchiveSalesPerformanceAggregate[]>
close(): Promise<void>
}
function requiredEnv(name: string) {
const value = process.env[name]?.trim()
if (!value) throw new Error(`Missing required environment variable: ${name}`)
return value
}
function booleanEnv(name: string, fallback: boolean) {
const value = process.env[name]?.trim().toLowerCase()
if (!value) return fallback
return value === 'true' || value === '1'
}
function safeIdentifier(value: string, label: string) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
throw new Error(`${label} is not a valid SQL Server identifier`)
}
return value
}
class ProductArchiveSalesPerformanceSourceRepositoryClass
implements ProductArchiveSalesPerformanceSourceRepository
{
private pool: sql.ConnectionPool | null = null
private async getPool() {
if (this.pool?.connected) return this.pool
this.pool = await new sql.ConnectionPool({
server: requiredEnv('JST_REPORT_DB_HOST'),
port: Number(process.env.JST_REPORT_DB_PORT?.trim() || '1433'),
database: requiredEnv('JST_REPORT_DB_NAME'),
user: requiredEnv('JST_REPORT_DB_USER'),
password: requiredEnv('JST_REPORT_DB_PASSWORD'),
options: {
encrypt: booleanEnv('JST_REPORT_DB_ENCRYPT', true),
trustServerCertificate: booleanEnv(
'JST_REPORT_DB_TRUST_SERVER_CERTIFICATE',
true,
),
},
pool: {
min: 0,
max: 2,
idleTimeoutMillis: 30_000,
},
requestTimeout: 180_000,
connectionTimeout: 15_000,
}).connect()
return this.pool
}
private sourceObject() {
const schema = safeIdentifier(
process.env.JST_REPORT_DB_SCHEMA?.trim() || 'rpt',
'JST_REPORT_DB_SCHEMA',
)
return `[${schema}].[dws_sn_sales_full_info_di]`
}
async latestAvailableDate(input: { periodStart: Date; notAfter: Date }) {
const pool = await this.getPool()
const result = await pool
.request()
.input('periodStart', sql.Date, input.periodStart)
.input('notAfter', sql.Date, input.notAfter)
.query<{ latestDate: Date | null }>(`
SELECT MAX(stat_date) AS latestDate
FROM ${this.sourceObject()}
WHERE stat_date >= @periodStart
AND stat_date <= @notAfter
`)
return result.recordset[0]?.latestDate ?? null
}
async listAggregates(input: { periodStart: Date; periodEnd: Date }) {
const pool = await this.getPool()
const result = await pool
.request()
.input('periodStart', sql.Date, input.periodStart)
.input('periodEnd', sql.Date, input.periodEnd)
.query<ProductArchiveSalesPerformanceAggregate>(`
SELECT
UPPER(LTRIM(RTRIM(CAST(skuoid AS nvarchar(100))))) AS barcode,
CONVERT(varchar(64), SUM(CAST(COALESCE(clear_goods_cost, 0) AS decimal(38, 4)))) AS shipAmount,
CONVERT(varchar(64), SUM(CAST(COALESCE(refund_amount, 0) AS decimal(38, 4)))) AS returnAmount,
CONVERT(varchar(64), SUM(CAST(COALESCE(real_performance, 0) AS decimal(38, 4)))) AS realPerformance,
CONVERT(varchar(64), SUM(CAST(COALESCE(goods_count, 0) AS bigint))) AS shipQty,
CONVERT(varchar(64), SUM(CAST(COALESCE(refund_count, 0) AS bigint))) AS returnQty,
CONVERT(varchar(64), SUM(CAST(COALESCE(clear_sales_cost, 0) AS decimal(38, 4)))) AS clearSalesCost,
CONVERT(varchar(64), SUM(CAST(COALESCE(send_freight, 0) AS decimal(38, 4)))) AS sendFreight
FROM ${this.sourceObject()}
WHERE stat_date >= @periodStart
AND stat_date <= @periodEnd
AND NULLIF(LTRIM(RTRIM(CAST(skuoid AS nvarchar(100)))), '') IS NOT NULL
GROUP BY UPPER(LTRIM(RTRIM(CAST(skuoid AS nvarchar(100)))))
ORDER BY barcode
`)
return result.recordset
}
async close() {
if (!this.pool) return
await this.pool.close()
this.pool = null
}
}
export const productArchiveSalesPerformanceSourceRepository =
new ProductArchiveSalesPerformanceSourceRepositoryClass()

View File

@ -0,0 +1,86 @@
import { Prisma } from '@prisma/client'
import { prisma } from '@/lib/prisma'
import type { ProductArchiveSalesPerformanceValues } from '@/server/services/product-archive-sales-performance'
export type ProductArchiveSalesPerformanceTarget = {
id: string
barcode: string
}
export type ProductArchiveSalesPerformanceSnapshot =
ProductArchiveSalesPerformanceValues & {
archiveId: string
periodStart: Date
periodEnd: Date
syncedAt: Date
}
export interface SupplyChainProductArchiveSalesPerformanceSyncRepository {
listTargets(): Promise<ProductArchiveSalesPerformanceTarget[]>
replaceAll(input: {
snapshots: ProductArchiveSalesPerformanceSnapshot[]
batchSize: number
}): Promise<number>
}
function chunks<T>(values: readonly T[], size: number) {
const result: T[][] = []
for (let index = 0; index < values.length; index += size) {
result.push(values.slice(index, index + size))
}
return result
}
class SupplyChainProductArchiveSalesPerformanceSyncRepositoryClass
implements SupplyChainProductArchiveSalesPerformanceSyncRepository
{
async listTargets() {
return prisma.supplyChainProductArchive.findMany({
select: { id: true, barcode: true },
orderBy: { id: 'asc' },
})
}
async replaceAll(input: {
snapshots: ProductArchiveSalesPerformanceSnapshot[]
batchSize: number
}) {
if (input.snapshots.length === 0) {
throw new Error('Refusing to replace sales performance with an empty snapshot')
}
const batchSize = Math.min(2_000, Math.max(1, Math.trunc(input.batchSize)))
const batches = chunks(input.snapshots, batchSize)
await prisma.$transaction(
async (tx) => {
await tx.supplyChainProductArchiveSalesPerformance.deleteMany()
for (const batch of batches) {
await tx.supplyChainProductArchiveSalesPerformance.createMany({
data: batch.map((snapshot) => ({
archiveId: snapshot.archiveId,
barcode: snapshot.barcode,
periodStart: snapshot.periodStart,
periodEnd: snapshot.periodEnd,
shipAmount: new Prisma.Decimal(snapshot.shipAmount),
returnAmount: new Prisma.Decimal(snapshot.returnAmount),
realPerformance: new Prisma.Decimal(snapshot.realPerformance),
shipQty: snapshot.shipQty,
returnQty: snapshot.returnQty,
grossProfit: new Prisma.Decimal(snapshot.grossProfit),
grossMargin:
snapshot.grossMargin === null
? null
: new Prisma.Decimal(snapshot.grossMargin),
syncedAt: snapshot.syncedAt,
})),
})
}
},
{ maxWait: 10_000, timeout: 180_000 },
)
return input.snapshots.length
}
}
export const supplyChainProductArchiveSalesPerformanceSyncRepository =
new SupplyChainProductArchiveSalesPerformanceSyncRepositoryClass()

View File

@ -60,6 +60,17 @@ const productArchiveSelect = {
purchasePrice: true,
costPrice: true,
costPriceSecondary: true,
salesPerformance: {
select: {
shipAmount: true,
returnAmount: true,
realPerformance: true,
shipQty: true,
returnQty: true,
grossProfit: true,
grossMargin: true,
},
},
createdAt: true,
updatedAt: true,
} satisfies Prisma.SupplyChainProductArchiveSelect

View File

@ -0,0 +1,41 @@
import { calculateProductArchiveSalesPerformance } from '../product-archive-sales-performance'
describe('calculateProductArchiveSalesPerformance', () => {
it('calculates gross profit and gross margin from the confirmed components', () => {
expect(calculateProductArchiveSalesPerformance({
barcode: ' sku-001 ',
shipAmount: '70',
returnAmount: '5.5',
realPerformance: '100',
shipQty: '3',
returnQty: '1',
clearSalesCost: '60',
sendFreight: '15',
})).toEqual({
barcode: 'SKU-001',
shipAmount: '70.0000',
returnAmount: '5.5000',
realPerformance: '100.0000',
shipQty: 3n,
returnQty: 1n,
grossProfit: '25.0000',
grossMargin: '0.25000000',
})
})
it('leaves gross margin empty when real performance is zero', () => {
const result = calculateProductArchiveSalesPerformance({
barcode: 'SKU-002',
shipAmount: '0',
returnAmount: '0',
realPerformance: '0',
shipQty: '0',
returnQty: '0',
clearSalesCost: '10',
sendFreight: '2',
})
expect(result.grossProfit).toBe('-12.0000')
expect(result.grossMargin).toBeNull()
})
})

View File

@ -0,0 +1,107 @@
import type { ProductArchiveSalesPerformanceSourceRepository } from '@/server/repositories/product-archive-sales-performance-source.repository'
import type {
ProductArchiveSalesPerformanceSnapshot,
SupplyChainProductArchiveSalesPerformanceSyncRepository,
} from '@/server/repositories/supply-chain-product-archive-sales-performance-sync.repository'
import { SupplyChainProductArchiveSalesPerformanceSyncService } from '../supply-chain-product-archive-sales-performance-sync.service'
jest.mock('@/server/repositories/product-archive-sales-performance-source.repository', () => ({
productArchiveSalesPerformanceSourceRepository: {},
}))
jest.mock('@/server/repositories/supply-chain-product-archive-sales-performance-sync.repository', () => ({
supplyChainProductArchiveSalesPerformanceSyncRepository: {},
}))
describe('SupplyChainProductArchiveSalesPerformanceSyncService', () => {
it('matches source aggregates to archive barcodes and replaces one complete snapshot', async () => {
const source: ProductArchiveSalesPerformanceSourceRepository = {
latestAvailableDate: jest.fn().mockResolvedValue(new Date('2026-08-15T00:00:00Z')),
listAggregates: jest.fn().mockResolvedValue([
{
barcode: ' sku-001 ',
shipAmount: '70',
returnAmount: '5',
realPerformance: '100',
shipQty: '3',
returnQty: '1',
clearSalesCost: '60',
sendFreight: '15',
},
{
barcode: 'NOT-IN-ARCHIVE',
shipAmount: '20',
returnAmount: '0',
realPerformance: '20',
shipQty: '1',
returnQty: '0',
clearSalesCost: '10',
sendFreight: '2',
},
]),
close: jest.fn().mockResolvedValue(undefined),
}
let written: ProductArchiveSalesPerformanceSnapshot[] = []
const repository: SupplyChainProductArchiveSalesPerformanceSyncRepository = {
listTargets: jest.fn().mockResolvedValue([
{ id: 'archive-1', barcode: 'SKU-001' },
{ id: 'archive-2', barcode: 'SKU-002' },
]),
replaceAll: jest.fn().mockImplementation(async ({ snapshots }) => {
written = snapshots
return snapshots.length
}),
}
const service = new SupplyChainProductArchiveSalesPerformanceSyncService({
source,
repository,
now: () => new Date('2026-08-17T02:00:00Z'),
})
await expect(service.syncAll({
asOf: new Date('2026-08-17T00:00:00Z'),
batchSize: 500,
})).resolves.toEqual({
periodStart: '2026-01-01',
periodEnd: '2026-08-15',
sourceAggregates: 2,
archiveTargets: 2,
matched: 1,
unmatchedSource: 1,
unmatchedArchive: 1,
})
expect(written).toHaveLength(1)
expect(written[0]).toMatchObject({
archiveId: 'archive-1',
barcode: 'SKU-001',
grossProfit: '25.0000',
grossMargin: '0.25000000',
periodStart: new Date('2026-01-01T00:00:00Z'),
periodEnd: new Date('2026-08-15T00:00:00Z'),
})
expect(source.close).toHaveBeenCalledTimes(1)
})
it('does not replace local data when the source period is empty', async () => {
const source: ProductArchiveSalesPerformanceSourceRepository = {
latestAvailableDate: jest.fn().mockResolvedValue(null),
listAggregates: jest.fn(),
close: jest.fn().mockResolvedValue(undefined),
}
const repository: SupplyChainProductArchiveSalesPerformanceSyncRepository = {
listTargets: jest.fn(),
replaceAll: jest.fn(),
}
const service = new SupplyChainProductArchiveSalesPerformanceSyncService({
source,
repository,
now: () => new Date('2026-08-17T02:00:00Z'),
})
await expect(service.syncAll()).rejects.toThrow(
'Sales performance source has no data in the requested period',
)
expect(repository.replaceAll).not.toHaveBeenCalled()
expect(source.close).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,87 @@
jest.mock("server-only", () => ({}));
jest.mock("../system-config.service", () => ({
systemConfigService: {
get: jest.fn(),
set: jest.fn(),
},
}));
jest.mock("./installation-demo-access.service", () => ({
installationDemoAccessService: {
getAccess: jest.fn(),
},
}));
import { systemConfigService } from "../system-config.service";
import { installationDemoAccessService } from "./installation-demo-access.service";
import { InstallationDemoDrawingTextAnnotationService } from "./installation-demo-drawing-text-annotation.service";
const user = {
id: "user-1",
email: "user@example.com",
name: "Test user",
role: "USER" as const,
businessRoles: [],
status: "ACTIVE" as const,
};
describe("InstallationDemoDrawingTextAnnotationService", () => {
const mockedAccessService = jest.mocked(installationDemoAccessService);
const mockedConfigService = jest.mocked(systemConfigService);
beforeEach(() => {
jest.clearAllMocks();
mockedAccessService.getAccess.mockResolvedValue({
canView: true,
canEdit: true,
canShare: true,
});
mockedConfigService.get.mockResolvedValue(null);
mockedConfigService.set.mockResolvedValue(undefined);
});
it("returns an empty document before the first annotation is added", async () => {
const service = new InstallationDemoDrawingTextAnnotationService();
const snapshot = await service.get(user, "drawing-1");
expect(snapshot.annotationsByTargetKey).toEqual({});
});
it("updates one action without removing annotations from other actions", async () => {
mockedConfigService.get.mockResolvedValue({
version: 1,
drawingId: "drawing-1",
annotationsByTargetKey: {
"action:step-1:fixed": [
{ id: "existing", text: "先固定底板", x: 0.3, y: 0.7, fontSize: 12 },
],
},
savedAt: "2026-08-17T00:00:00.000Z",
});
const service = new InstallationDemoDrawingTextAnnotationService();
const snapshot = await service.updateTarget(user, {
drawingId: "drawing-1",
targetKey: "action:step-2:action-1",
annotations: [
{ id: "text-2", text: "使用螺丝刀锁紧", x: 0.55, y: 0.25, fontSize: 16 },
],
});
expect(snapshot.annotationsByTargetKey).toEqual({
"action:step-1:fixed": [
{ id: "existing", text: "先固定底板", x: 0.3, y: 0.7, fontSize: 12 },
],
"action:step-2:action-1": [
{ id: "text-2", text: "使用螺丝刀锁紧", x: 0.55, y: 0.25, fontSize: 16 },
],
});
expect(mockedConfigService.set).toHaveBeenCalledWith(
"installation-demo-workbench:drawing-text-annotations:drawing-1",
expect.objectContaining({
annotationsByTargetKey: snapshot.annotationsByTargetKey,
}),
expect.any(String),
);
});
});

View File

@ -0,0 +1,143 @@
import "server-only";
import { AppError, ErrorCode } from "@/server/core/errors";
import type { AuthUser } from "@/server/core/types";
import { systemConfigService } from "../system-config.service";
import { installationDemoAccessService } from "./installation-demo-access.service";
import {
MAX_INSTALLATION_DRAWING_TEXT_ANNOTATIONS_PER_TARGET,
sanitizeInstallationDrawingTextAnnotations,
type InstallationDrawingTextAnnotation,
type InstallationDrawingTextAnnotationsByTarget,
} from "@/app/installation-demo-workbench/lib/installationDrawingTextAnnotations";
const CONFIG_PREFIX = "installation-demo-workbench:drawing-text-annotations:";
const VERSION = 1;
const MAX_TARGET_COUNT = 400;
export interface InstallationDemoDrawingTextAnnotationsDTO {
version: number;
drawingId: string;
annotationsByTargetKey: InstallationDrawingTextAnnotationsByTarget;
savedAt: string;
}
function buildConfigKey(drawingId: string) {
return `${CONFIG_PREFIX}${drawingId}`;
}
function normalizeDrawingId(value: unknown) {
const drawingId = typeof value === "string" ? value.trim() : "";
if (!drawingId) {
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, "缺少图纸 ID");
}
if (drawingId.length > 240) {
throw new AppError(ErrorCode.VALIDATION_OUT_OF_RANGE, "图纸 ID 过长");
}
return drawingId;
}
function normalizeTargetKey(value: unknown) {
const targetKey = typeof value === "string" ? value.trim() : "";
if (!targetKey) {
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, "缺少文字标注目标");
}
if (targetKey.length > 300) {
throw new AppError(ErrorCode.VALIDATION_OUT_OF_RANGE, "文字标注目标过长");
}
return targetKey;
}
function normalizeMap(value: unknown) {
const map = sanitizeInstallationDrawingTextAnnotations(value);
if (Object.keys(map).length > MAX_TARGET_COUNT) {
throw new AppError(ErrorCode.VALIDATION_OUT_OF_RANGE, "文字标注目标数量超出上限");
}
for (const [targetKey, annotations] of Object.entries(map)) {
if (targetKey.length > 300 || annotations.length > MAX_INSTALLATION_DRAWING_TEXT_ANNOTATIONS_PER_TARGET) {
throw new AppError(ErrorCode.VALIDATION_OUT_OF_RANGE, "文字标注数量或目标无效");
}
}
return map;
}
function normalizeStored(value: unknown, drawingId: string): InstallationDemoDrawingTextAnnotationsDTO | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const record = value as Record<string, unknown>;
if (record.version !== VERSION) return null;
const storedAnnotations = sanitizeInstallationDrawingTextAnnotations(
record.annotationsByTargetKey,
);
return {
version: VERSION,
drawingId,
annotationsByTargetKey: Object.fromEntries(
Object.entries(storedAnnotations).slice(0, MAX_TARGET_COUNT),
),
savedAt: typeof record.savedAt === "string" ? record.savedAt : new Date(0).toISOString(),
};
}
async function assertCanView(user: AuthUser) {
if (!(await installationDemoAccessService.getAccess(user)).canView) {
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, "没有权限查看安装图纸文字标注");
}
}
async function assertCanEdit(user: AuthUser) {
if (!(await installationDemoAccessService.getAccess(user)).canEdit) {
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, "没有权限保存安装图纸文字标注");
}
}
export class InstallationDemoDrawingTextAnnotationService {
private async read(drawingId: string) {
return normalizeStored(
await systemConfigService.get<unknown>(buildConfigKey(drawingId)),
drawingId,
);
}
async get(user: AuthUser, rawDrawingId: unknown) {
await assertCanView(user);
const drawingId = normalizeDrawingId(rawDrawingId);
return (await this.read(drawingId)) ?? {
version: VERSION,
drawingId,
annotationsByTargetKey: {},
savedAt: new Date(0).toISOString(),
};
}
async updateTarget(
user: AuthUser,
input: { drawingId: unknown; targetKey: unknown; annotations: unknown },
) {
await assertCanEdit(user);
const drawingId = normalizeDrawingId(input.drawingId);
const targetKey = normalizeTargetKey(input.targetKey);
const targetMap = normalizeMap({ [targetKey]: input.annotations });
const current = await this.read(drawingId);
const annotationsByTargetKey = { ...(current?.annotationsByTargetKey ?? {}) };
const nextAnnotations = targetMap[targetKey] ?? [];
if (nextAnnotations.length === 0) delete annotationsByTargetKey[targetKey];
else annotationsByTargetKey[targetKey] = nextAnnotations;
const next: InstallationDemoDrawingTextAnnotationsDTO = {
version: VERSION,
drawingId,
annotationsByTargetKey: normalizeMap(annotationsByTargetKey),
savedAt: new Date().toISOString(),
};
await systemConfigService.set(
buildConfigKey(drawingId),
next,
`Installation drawing text annotations for ${drawingId}`,
);
return next;
}
}
export const installationDemoDrawingTextAnnotationService =
new InstallationDemoDrawingTextAnnotationService();
export type { InstallationDrawingTextAnnotation };

View File

@ -65,6 +65,17 @@ function createInput() {
actions: [],
inventoryTransforms: {},
},
textAnnotationsByTargetKey: {
"action:step-1:fixed": [
{
id: "text-1",
text: "锁紧四颗螺丝",
x: 0.42,
y: 0.76,
fontSize: 14,
},
],
},
snapshotLineArtUrls: {
part: "/api/storage/object?key=snapshots%2Fpart-line.png",
},
@ -118,6 +129,11 @@ describe("InstallationDemoShareService", () => {
expect(snapshot.drawingDocument).toMatchObject({
templateId: "set-1",
imageMode: "original",
textAnnotationsByTargetKey: {
"action:step-1:fixed": [
expect.objectContaining({ text: "锁紧四颗螺丝", fontSize: 14 }),
],
},
snapshotLineArtUrls: {
part: "/api/storage/object?key=snapshots%2Fpart-line.png",
},

View File

@ -12,6 +12,7 @@ import type {
InstallationDemoShareSnapshot,
} from "@/lib/installation-demo-share-types";
import type { InstallationStepMaterialShareSnapshot } from "@/app/installation-demo-workbench/lib/installationStepMaterialShareSnapshots";
import { sanitizeInstallationDrawingTextAnnotations } from "@/app/installation-demo-workbench/lib/installationDrawingTextAnnotations";
import { systemConfigService } from "../system-config.service";
import { installationDemoAccessService } from "./installation-demo-access.service";
@ -205,6 +206,10 @@ function normalizeDrawingDocument(
imageMode: normalizeDrawingImageMode(value.imageMode),
captures:
value.captures as unknown as InstallationDemoShareDrawingDocument["captures"],
textAnnotationsByTargetKey:
sanitizeInstallationDrawingTextAnnotations(
value.textAnnotationsByTargetKey,
),
snapshotLineArtUrls: normalizeStringRecord(
value.snapshotLineArtUrls,
"部件线稿快照",

View File

@ -6,6 +6,7 @@ import {
import type sql from 'mssql'
import { Prisma } from '@prisma/client'
import { prisma } from '@/lib/prisma'
import { selectLatestRestartableChildFlows } from '@/lib/oa-board-flow-selection'
import { externalEmployeeDirectoryService } from './external-employee-directory.service'
import type { OaBoardProjectGraphInput } from './oa-board.service'
import {
@ -3106,7 +3107,10 @@ function buildProjectGroups(records: NormalizedFlowRecord[]): PreparedProjectGro
})
}
return Array.from(groups.values())
return Array.from(groups.values()).map((group) => ({
...group,
flowRecords: selectLatestRestartableChildFlows(group.flowRecords),
}))
}
function countNestedRecords(projectGraphs: OaBoardProjectGraphInput[]) {

View File

@ -0,0 +1,61 @@
import { Prisma } from '@prisma/client'
import type { ProductArchiveSalesPerformanceAggregate } from '@/server/repositories/product-archive-sales-performance-source.repository'
export type ProductArchiveSalesPerformanceValues = {
barcode: string
shipAmount: string
returnAmount: string
realPerformance: string
shipQty: bigint
returnQty: bigint
grossProfit: string
grossMargin: string | null
}
export function normalizeSalesPerformanceBarcode(value: string) {
return value.trim().toLocaleUpperCase('zh-CN')
}
function decimal(value: string, field: string) {
try {
return new Prisma.Decimal(value || '0')
} catch {
throw new Error(`Invalid ${field} value: ${value}`)
}
}
function integer(value: string, field: string) {
try {
return BigInt(value || '0')
} catch {
throw new Error(`Invalid ${field} value: ${value}`)
}
}
export function calculateProductArchiveSalesPerformance(
aggregate: ProductArchiveSalesPerformanceAggregate,
): ProductArchiveSalesPerformanceValues {
const barcode = normalizeSalesPerformanceBarcode(aggregate.barcode)
if (!barcode) throw new Error('Sales performance barcode is required')
const shipAmount = decimal(aggregate.shipAmount, 'shipAmount')
const returnAmount = decimal(aggregate.returnAmount, 'returnAmount')
const realPerformance = decimal(aggregate.realPerformance, 'realPerformance')
const clearSalesCost = decimal(aggregate.clearSalesCost, 'clearSalesCost')
const sendFreight = decimal(aggregate.sendFreight, 'sendFreight')
const grossProfit = realPerformance.minus(clearSalesCost).minus(sendFreight)
const grossMargin = realPerformance.isZero()
? null
: grossProfit.dividedBy(realPerformance).toFixed(8)
return {
barcode,
shipAmount: shipAmount.toFixed(4),
returnAmount: returnAmount.toFixed(4),
realPerformance: realPerformance.toFixed(4),
shipQty: integer(aggregate.shipQty, 'shipQty'),
returnQty: integer(aggregate.returnQty, 'returnQty'),
grossProfit: grossProfit.toFixed(4),
grossMargin,
}
}

View File

@ -102,6 +102,7 @@ class SupplyChainPermissionSettingsService {
'productArchive',
'categoryConfig',
'warehouseConfig',
'newProductOverview',
'newProductUnifiedTable',
'newProductSampleProgress',
'purchaseDailyAffairs',
@ -119,6 +120,7 @@ class SupplyChainPermissionSettingsService {
'productArchive',
'categoryConfig',
'warehouseConfig',
'newProductOverview',
'newProductUnifiedTable',
'newProductSampleProgress',
'purchaseDailyAffairs',

View File

@ -0,0 +1,117 @@
import {
productArchiveSalesPerformanceSourceRepository,
type ProductArchiveSalesPerformanceSourceRepository,
} from '@/server/repositories/product-archive-sales-performance-source.repository'
import {
supplyChainProductArchiveSalesPerformanceSyncRepository,
type SupplyChainProductArchiveSalesPerformanceSyncRepository,
} from '@/server/repositories/supply-chain-product-archive-sales-performance-sync.repository'
import {
calculateProductArchiveSalesPerformance,
normalizeSalesPerformanceBarcode,
} from './product-archive-sales-performance'
const PERIOD_START = new Date('2026-01-01T00:00:00.000Z')
function utcDate(value: Date) {
return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate()))
}
export type SupplyChainProductArchiveSalesPerformanceSyncResult = {
periodStart: string
periodEnd: string
sourceAggregates: number
archiveTargets: number
matched: number
unmatchedSource: number
unmatchedArchive: number
}
type Dependencies = {
source: ProductArchiveSalesPerformanceSourceRepository
repository: SupplyChainProductArchiveSalesPerformanceSyncRepository
now(): Date
}
export class SupplyChainProductArchiveSalesPerformanceSyncService {
constructor(
private readonly dependencies: Dependencies = {
source: productArchiveSalesPerformanceSourceRepository,
repository: supplyChainProductArchiveSalesPerformanceSyncRepository,
now: () => new Date(),
},
) {}
async syncAll(input: { asOf?: Date; batchSize?: number } = {}) {
const notAfter = utcDate(input.asOf ?? this.dependencies.now())
const batchSize = Math.min(2_000, Math.max(1, Math.trunc(input.batchSize ?? 1_000)))
try {
const periodEnd = await this.dependencies.source.latestAvailableDate({
periodStart: PERIOD_START,
notAfter,
})
if (!periodEnd) {
throw new Error('Sales performance source has no data in the requested period')
}
const normalizedPeriodEnd = utcDate(periodEnd)
const [aggregates, targets] = await Promise.all([
this.dependencies.source.listAggregates({
periodStart: PERIOD_START,
periodEnd: normalizedPeriodEnd,
}),
this.dependencies.repository.listTargets(),
])
if (aggregates.length === 0) {
throw new Error('Sales performance aggregation returned no rows')
}
const targetsByBarcode = new Map(
targets.map((target) => [
normalizeSalesPerformanceBarcode(target.barcode),
target,
]),
)
const syncedAt = this.dependencies.now()
let unmatchedSource = 0
const snapshots = aggregates.flatMap((aggregate) => {
const values = calculateProductArchiveSalesPerformance(aggregate)
const target = targetsByBarcode.get(values.barcode)
if (!target) {
unmatchedSource += 1
return []
}
return [{
...values,
archiveId: target.id,
periodStart: PERIOD_START,
periodEnd: normalizedPeriodEnd,
syncedAt,
}]
})
if (snapshots.length === 0) {
throw new Error('Sales performance source did not match any product archive rows')
}
const matched = await this.dependencies.repository.replaceAll({
snapshots,
batchSize,
})
return {
periodStart: PERIOD_START.toISOString().slice(0, 10),
periodEnd: normalizedPeriodEnd.toISOString().slice(0, 10),
sourceAggregates: aggregates.length,
archiveTargets: targets.length,
matched,
unmatchedSource,
unmatchedArchive: Math.max(0, targets.length - matched),
} satisfies SupplyChainProductArchiveSalesPerformanceSyncResult
} finally {
await this.dependencies.source.close()
}
}
}
export const supplyChainProductArchiveSalesPerformanceSyncService =
new SupplyChainProductArchiveSalesPerformanceSyncService()

View File

@ -12,6 +12,12 @@ function decimalText(value: { toString(): string } | null) {
return value?.toString() ?? ''
}
function percentageText(
value: { mul(multiplier: number): { toFixed(digits: number): string } } | null,
) {
return value ? `${value.mul(100).toFixed(2)}%` : ''
}
export function normalizeSupplierName(value: string) {
return value.trim().toLocaleLowerCase('zh-CN')
}
@ -88,5 +94,14 @@ export function mapProductArchiveRow({
purchasePrice: record.purchasePrice,
costPrice: record.costPrice,
costPriceSecondary: record.costPriceSecondary,
shipAmount: decimalText(record.salesPerformance?.shipAmount ?? null),
returnAmount: decimalText(record.salesPerformance?.returnAmount ?? null),
realPerformance: decimalText(record.salesPerformance?.realPerformance ?? null),
shipQty: record.salesPerformance?.shipQty.toString() ?? '',
grossProfit: decimalText(record.salesPerformance?.grossProfit ?? null),
grossMargin: percentageText(record.salesPerformance?.grossMargin ?? null),
feeRatio: '',
operatingProfit: '',
returnQty: record.salesPerformance?.returnQty.toString() ?? '',
} satisfies SupplyChainProductArchiveRow
}