Merge remote-tracking branch 'origin/codex/xuxiake-mainline' into codex/wuyongjie-bom-1688-realtime-cost
This commit is contained in:
commit
fa48bcc48d
@ -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",
|
||||
|
||||
@ -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;
|
||||
@ -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())
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
})
|
||||
@ -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 },
|
||||
);
|
||||
}
|
||||
@ -65,6 +65,9 @@ function InstallationDemoShareDrawingPanel({
|
||||
imageMode={imageMode}
|
||||
snapshotUrls={snapshot.numberingSnapshotUrls}
|
||||
snapshotLineArtUrls={snapshotLineArtUrls}
|
||||
textAnnotationsByTargetKey={
|
||||
snapshot.drawingDocument?.textAnnotationsByTargetKey ?? {}
|
||||
}
|
||||
allowManualCapture={false}
|
||||
readOnly
|
||||
/>
|
||||
|
||||
@ -41,6 +41,7 @@ function createSnapshot(): InstallationDemoShareSnapshot {
|
||||
actions: [],
|
||||
inventoryTransforms: {},
|
||||
},
|
||||
textAnnotationsByTargetKey: {},
|
||||
snapshotLineArtUrls: {},
|
||||
toolSnapshotLineArtUrls: {},
|
||||
},
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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" });
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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("自动抓取草稿批次创建失败");
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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: {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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 }));
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default async function NewProductManagementPage() {
|
||||
redirect('/supply-chain/new-products/overview')
|
||||
}
|
||||
@ -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': '日常事项',
|
||||
|
||||
@ -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: '新品管理',
|
||||
|
||||
@ -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: [
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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'
|
||||
}
|
||||
|
||||
@ -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}/`)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -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([])
|
||||
})
|
||||
})
|
||||
297
furniture-3d-system/src/lib/3d/technical-line-annotations.ts
Normal file
297
furniture-3d-system/src/lib/3d/technical-line-annotations.ts
Normal 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 }] : []
|
||||
})
|
||||
}
|
||||
@ -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', () => {
|
||||
|
||||
@ -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())
|
||||
|
||||
102
furniture-3d-system/src/lib/3d/technical-line-feature-loops.ts
Normal file
102
furniture-3d-system/src/lib/3d/technical-line-feature-loops.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
92
furniture-3d-system/src/lib/oa-board-flow-selection.test.ts
Normal file
92
furniture-3d-system/src/lib/oa-board-flow-selection.test.ts
Normal 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]])
|
||||
})
|
||||
})
|
||||
47
furniture-3d-system/src/lib/oa-board-flow-selection.ts
Normal file
47
furniture-3d-system/src/lib/oa-board-flow-selection.ts
Normal 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,
|
||||
)
|
||||
}
|
||||
@ -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')
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
@ -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()
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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 };
|
||||
@ -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",
|
||||
},
|
||||
|
||||
@ -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,
|
||||
"部件线稿快照",
|
||||
|
||||
@ -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[]) {
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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()
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user