feat(intake): notify quality inspectors and keep product photo uploads available
This commit is contained in:
parent
8a10b7f72f
commit
a8b5c84759
@ -0,0 +1,13 @@
|
||||
CREATE TABLE "IntakeProductPhotoUploadGrant" (
|
||||
"id" TEXT NOT NULL,
|
||||
"productId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"grantedBy" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "IntakeProductPhotoUploadGrant_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "IntakeProductPhotoUploadGrant_productId_fkey" FOREIGN KEY ("productId") REFERENCES "IntakeNonIntegratedProduct"("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "IntakeProductPhotoUploadGrant_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "IntakeProductPhotoUploadGrant_grantedBy_fkey" FOREIGN KEY ("grantedBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX "IntakeProductPhotoUploadGrant_productId_userId_key" ON "IntakeProductPhotoUploadGrant"("productId", "userId");
|
||||
CREATE INDEX "IntakeProductPhotoUploadGrant_userId_idx" ON "IntakeProductPhotoUploadGrant"("userId");
|
||||
@ -482,6 +482,7 @@ model IntakeNonIntegratedProduct {
|
||||
fixedVirtualInventoryTasks IntakeJushuitanFixedVirtualInventoryTask[]
|
||||
profile IntakeNonIntegratedProductProfile?
|
||||
modelAssets IntakeProductModelAsset[]
|
||||
photoUploadGrants IntakeProductPhotoUploadGrant[]
|
||||
|
||||
@@index([spu])
|
||||
@@index([spuBase])
|
||||
@ -492,6 +493,20 @@ model IntakeNonIntegratedProduct {
|
||||
@@index([deletedAt, createdAt])
|
||||
}
|
||||
|
||||
model IntakeProductPhotoUploadGrant {
|
||||
id String @id
|
||||
productId String
|
||||
userId String
|
||||
grantedBy String
|
||||
createdAt DateTime @default(now())
|
||||
product IntakeNonIntegratedProduct @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
user User @relation("IntakePhotoUploadRecipient", fields: [userId], references: [id], onDelete: Cascade)
|
||||
grantor User @relation("IntakePhotoUploadGrantor", fields: [grantedBy], references: [id], onDelete: Restrict)
|
||||
|
||||
@@unique([productId, userId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model BarcodePrintImportRecord {
|
||||
id String @id @default(cuid())
|
||||
kind String
|
||||
@ -3437,6 +3452,8 @@ model User {
|
||||
|
||||
// 关联
|
||||
createdProducts Product[] @relation("ProductCreator")
|
||||
intakePhotoUploadGrants IntakeProductPhotoUploadGrant[] @relation("IntakePhotoUploadRecipient")
|
||||
issuedIntakePhotoUploadGrants IntakeProductPhotoUploadGrant[] @relation("IntakePhotoUploadGrantor")
|
||||
ownedSKUs ProductSKU[] @relation("SKUOwner")
|
||||
createdPlanningTemplates PlanningTemplate[] @relation("PlanningTemplateCreator")
|
||||
createdPlanningWorkspaces PlanningWorkspace[] @relation("PlanningWorkspaceCreator")
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
|
||||
import { ErrorCode } from '@/server/core/errors'
|
||||
import { NotificationDeliveryError } from '@/server/services/notification-delivery.service'
|
||||
import { intakeProductPhotoNotificationService } from '@/server/services/intake-product-photo-notification.service'
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string }> }
|
||||
|
||||
export async function GET(request: NextRequest, context: RouteContext) {
|
||||
return handleRequest(request, async ({ user, requestId }) => {
|
||||
const { id } = await context.params
|
||||
const data = await intakeProductPhotoNotificationService.getAccess(id, user, request.nextUrl.searchParams.get('recipients') === 'true')
|
||||
return jsonSuccess(data, 200, requestId)
|
||||
}, { requireAuth: true })
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest, context: RouteContext) {
|
||||
return handleRequest(request, async ({ user, requestId }) => {
|
||||
const { id } = await context.params
|
||||
const body: unknown = await request.json().catch(() => null)
|
||||
try {
|
||||
return jsonSuccess(await intakeProductPhotoNotificationService.send(id, body, user), 200, requestId)
|
||||
} catch (error) {
|
||||
if (error instanceof NotificationDeliveryError) return jsonError(ErrorCode.VALIDATION_FAILED, error.message, 400, requestId)
|
||||
throw error
|
||||
}
|
||||
}, { requireAuth: true })
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import { handleRequest, jsonSuccess } from '@/server/core/response'
|
||||
import { intakeProductPhotoUploadService } from '@/server/services/intake-product-photo-upload.service'
|
||||
|
||||
export async function POST(request: NextRequest, context: { params: Promise<{ id: string }> }) {
|
||||
return handleRequest(request, async ({ user, requestId }) => {
|
||||
const { id } = await context.params
|
||||
const form = await request.formData()
|
||||
const file = form.get('file')
|
||||
if (!(file instanceof File)) throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请选择产品实拍图片')
|
||||
let metadata: unknown
|
||||
try { metadata = JSON.parse(String(form.get('metadata') || 'null')) }
|
||||
catch { throw new AppError(ErrorCode.VALIDATION_FAILED, '图片位置信息格式不正确') }
|
||||
return jsonSuccess(await intakeProductPhotoUploadService.upload(id, file, metadata, user), 201, requestId)
|
||||
}, { requireAuth: true })
|
||||
}
|
||||
@ -263,7 +263,7 @@ describe("NonIntegratedDetailClient media workspaces", () => {
|
||||
).toHaveAttribute("alt", "IMG_0001.JPG");
|
||||
});
|
||||
|
||||
it("uploads grouped product photos from the empty detail panel", async () => {
|
||||
it.each(["detail", "quality-check", "competitor-sample", "competitor-disassembly"])("keeps the grouped product photos upload entry available for %s", async (panel) => {
|
||||
const actor = userEvent.setup();
|
||||
|
||||
replaceNonIntegratedProducts([
|
||||
@ -282,9 +282,9 @@ describe("NonIntegratedDetailClient media workspaces", () => {
|
||||
await actor.click(
|
||||
screen.getByTestId("document-workspace-trigger-产品实拍图"),
|
||||
);
|
||||
await actor.click(screen.getByTestId("product-photo-panel-tab-detail"));
|
||||
await actor.click(screen.getByTestId(`product-photo-panel-tab-${panel}`));
|
||||
await actor.click(
|
||||
screen.getByTestId("product-photo-empty-panel-upload-detail"),
|
||||
screen.getByTestId(`product-photo-panel-upload-${panel}`),
|
||||
);
|
||||
await actor.upload(
|
||||
screen.getByTestId("product-photo-detail-upload-input"),
|
||||
@ -295,6 +295,21 @@ describe("NonIntegratedDetailClient media workspaces", () => {
|
||||
expect(
|
||||
within(detailList).getByText("detail-click-upload.png"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId(`product-photo-panel-upload-${panel}`)).toHaveTextContent("已上传 1 张,点击继续添加");
|
||||
expect(within(screen.getByTestId(`product-photo-panel-upload-${panel}`)).queryByRole("img")).not.toBeInTheDocument();
|
||||
expect(within(screen.getByTestId("product-photo-preview")).getByTestId("product-photo-detail-list")).toBe(detailList);
|
||||
|
||||
await actor.click(screen.getByTestId(`product-photo-panel-upload-${panel}`));
|
||||
await actor.upload(
|
||||
screen.getByTestId("product-photo-detail-upload-input"),
|
||||
new File(["second"], "second-photo.png", { type: "image/png" }),
|
||||
);
|
||||
await waitFor(() => expect(within(detailList).getAllByRole("button")).toHaveLength(2));
|
||||
expect(screen.getByTestId(`product-photo-panel-upload-${panel}`)).toHaveTextContent("已上传 2 张,点击继续添加");
|
||||
await actor.click(within(detailList).getByRole("button", { name: /detail-click-upload.png/ }));
|
||||
expect(screen.getByTestId("document-workspace-image-preview")).toHaveAttribute("alt", "detail-click-upload.png");
|
||||
await actor.click(within(detailList).getByRole("button", { name: /second-photo.png/ }));
|
||||
expect(screen.getByTestId("document-workspace-image-preview")).toHaveAttribute("alt", "second-photo.png");
|
||||
});
|
||||
|
||||
it("hides product photo styles that are not present in sku models", async () => {
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { ProductPhotoRequestProvider, useProductPhotoRequest } from './workspaces/photo-request/ProductPhotoRequestContext';
|
||||
|
||||
import { buildNonIntegratedDetailRenderers } from "./buildNonIntegratedDetailRenderers";
|
||||
import { buildNonIntegratedDetailViewModel } from "./buildNonIntegratedDetailViewModel";
|
||||
import { NonIntegratedDetailView } from "./components/NonIntegratedDetailView";
|
||||
@ -14,8 +17,24 @@ export { buildNonIntegratedBomDraftStorageKey } from "./workspaces/bom/bomDetail
|
||||
export default function NonIntegratedDetailClient(
|
||||
props: NonIntegratedDetailClientProps,
|
||||
) {
|
||||
return <ProductPhotoRequestProvider productId={props.productId || props.initialProduct?.id || ''}>
|
||||
<NonIntegratedDetailContent {...props} />
|
||||
</ProductPhotoRequestProvider>;
|
||||
}
|
||||
|
||||
function NonIntegratedDetailContent(props: NonIntegratedDetailClientProps) {
|
||||
const base = useNonIntegratedDetailBaseState(props);
|
||||
const controllers = useNonIntegratedDetailDomainControllers(props, base);
|
||||
const { uploadedFiles } = useProductPhotoRequest();
|
||||
const setUploadFiles = base.documentState.setUploadFiles;
|
||||
useEffect(() => {
|
||||
if (!uploadedFiles.length) return;
|
||||
setUploadFiles((current) => {
|
||||
const ids = new Set(current.map((file) => file.id));
|
||||
const additions = uploadedFiles.filter((file) => !ids.has(file.id));
|
||||
return additions.length ? [...current, ...additions] : current;
|
||||
});
|
||||
}, [uploadedFiles, setUploadFiles]);
|
||||
|
||||
if (
|
||||
props.mode !== "create" &&
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useProductPhotoRequest } from './workspaces/photo-request/ProductPhotoRequestContext';
|
||||
import { OPENING_REFERENCE_WORKSPACE_CATEGORY } from "./workspaces/documents/documentWorkspaceConfig";
|
||||
import type { FormValues } from "./workspaces/basic-info/productFormValueHelpers";
|
||||
import type { DocumentWorkspaceCategory } from "./workspaces/documents/documentWorkspaceHelpers";
|
||||
@ -54,6 +55,7 @@ export function useNonIntegratedPermissionState({
|
||||
linkEntryView,
|
||||
openingReference,
|
||||
}: UseNonIntegratedPermissionStateParams) {
|
||||
const { access: photoRequestAccess } = useProductPhotoRequest();
|
||||
const [nodePermissionOverrides, setNodePermissionOverrides] =
|
||||
useState<IntakeNodePermissionOverrideMap>({});
|
||||
const hasCurrentUserContext = Boolean(currentUserRole);
|
||||
@ -247,6 +249,7 @@ export function useNonIntegratedPermissionState({
|
||||
));
|
||||
const isDocumentWorkspaceCategoryVisibleForCurrentUser = useCallback(
|
||||
(category: DocumentWorkspaceCategory) => {
|
||||
if (category === '产品实拍图' && photoRequestAccess?.canUpload) return true;
|
||||
if (linkEntryView) {
|
||||
return category === OPENING_REFERENCE_WORKSPACE_CATEGORY;
|
||||
}
|
||||
@ -268,6 +271,7 @@ export function useNonIntegratedPermissionState({
|
||||
isCurrentUserAdmin,
|
||||
isCurrentUserFullDetailEditor,
|
||||
linkEntryView,
|
||||
photoRequestAccess?.canUpload,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@ -0,0 +1,62 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { NonIntegratedUploadFile } from "../../nonIntegratedStorage";
|
||||
import { QualityProductPhotoUpload } from "../photo-request/QualityProductPhotoUpload";
|
||||
import type { ProductPhotoFileGroup, ProductPhotoGroup, ProductPhotoSelection } from "./documentWorkspaceHelpers";
|
||||
import { getUploadFileTypeLabel, isImageUploadFile } from "./uploadFilePreview";
|
||||
|
||||
type ProductPhotoPreviewProps = {
|
||||
activePanel: ProductPhotoGroup;
|
||||
files: NonIntegratedUploadFile[];
|
||||
selectedTarget: ProductPhotoSelection;
|
||||
isReadOnly: boolean;
|
||||
barcode: string;
|
||||
styleKey: string;
|
||||
title: string;
|
||||
description: string;
|
||||
children: ReactNode;
|
||||
onSelectGroupedFile: (group: ProductPhotoFileGroup, fileId: string) => void;
|
||||
};
|
||||
|
||||
export function ProductPhotoPreview({
|
||||
activePanel, files, selectedTarget, isReadOnly, barcode, styleKey,
|
||||
title, description, children, onSelectGroupedFile,
|
||||
}: ProductPhotoPreviewProps) {
|
||||
return (
|
||||
<div data-testid="product-photo-preview" className="flex min-w-0 flex-col rounded-apple-xl border border-apple-separator bg-apple-bg-secondary/20 xl:min-h-0 xl:flex-1">
|
||||
<div className="border-b border-apple-separator px-3 py-1.5">
|
||||
{isReadOnly && activePanel === "nine-view" && <QualityProductPhotoUpload barcode={barcode} styleKey={styleKey} target={selectedTarget} />}
|
||||
<p className="truncate text-[13px] font-apple-medium text-apple-label-primary sm:text-sm">{title}</p>
|
||||
<p className="mt-0.5 text-[10px] text-apple-label-secondary sm:text-[11px]">{description}</p>
|
||||
</div>
|
||||
{activePanel !== "nine-view" && files.length > 0 && (
|
||||
<div data-testid="product-photo-detail-list" aria-label="已上传图片" className="flex shrink-0 gap-2 overflow-x-auto border-b border-apple-separator p-2">
|
||||
{files.map((file) => {
|
||||
const isActive = selectedTarget.group === activePanel && selectedTarget.fileId === file.id;
|
||||
return (
|
||||
<button
|
||||
key={file.id}
|
||||
type="button"
|
||||
title={file.name}
|
||||
aria-pressed={isActive}
|
||||
data-testid={`product-photo-detail-file-${file.id}`}
|
||||
onClick={() => onSelectGroupedFile(activePanel, file.id)}
|
||||
className={`w-20 shrink-0 rounded-apple-lg border p-1 text-left transition ${
|
||||
isActive ? "border-apple-blue bg-apple-blue/10" : "border-apple-separator bg-white hover:bg-apple-bg-secondary/40"
|
||||
}`}
|
||||
>
|
||||
<div className="flex h-14 items-center justify-center overflow-hidden rounded-md bg-white">
|
||||
{isImageUploadFile(file) ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img data-testid={`product-photo-detail-image-${file.id}`} src={file.dataUrl} alt={file.name} className="h-full w-full object-cover" />
|
||||
) : <span className="text-[11px] text-apple-label-secondary">{getUploadFileTypeLabel(file)}</span>}
|
||||
</div>
|
||||
<p className="mt-1 truncate text-[10px] text-apple-label-secondary">{file.name}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-h-[280px] items-center justify-center p-2 xl:min-h-0 xl:flex-1">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,111 @@
|
||||
import { Camera } from "lucide-react";
|
||||
import type { NonIntegratedUploadFile } from "../../nonIntegratedStorage";
|
||||
import { QualityProductPhotoUpload } from "../photo-request/QualityProductPhotoUpload";
|
||||
import { useProductPhotoRequest } from "../photo-request/ProductPhotoRequestContext";
|
||||
import type { ProductPhotoWorkspaceData } from "../style-assets/styleWorkspaceEntries";
|
||||
import {
|
||||
PRODUCT_PHOTO_NINE_VIEW_SLOTS,
|
||||
type ProductPhotoGroup,
|
||||
type ProductPhotoNineViewSlot,
|
||||
type ProductPhotoSelection,
|
||||
} from "./documentWorkspaceHelpers";
|
||||
import { isImageUploadFile } from "./uploadFilePreview";
|
||||
|
||||
type ProductPhotoSourcePanelProps = {
|
||||
activePanel: ProductPhotoGroup;
|
||||
activePanelTitle: string;
|
||||
files: NonIntegratedUploadFile[];
|
||||
workspaceData: ProductPhotoWorkspaceData;
|
||||
selectedTarget: ProductPhotoSelection;
|
||||
isReadOnly: boolean;
|
||||
barcode: string;
|
||||
styleKey: string;
|
||||
onOpenPanelUpload: (panel: ProductPhotoGroup) => void;
|
||||
onOpenCurrentUpload: (target: ProductPhotoSelection) => void;
|
||||
onSelectSlot: (slot: ProductPhotoNineViewSlot) => void;
|
||||
};
|
||||
|
||||
export function ProductPhotoSourcePanel({
|
||||
activePanel, activePanelTitle, files, workspaceData, selectedTarget,
|
||||
isReadOnly, barcode, styleKey, onOpenPanelUpload, onOpenCurrentUpload, onSelectSlot,
|
||||
}: ProductPhotoSourcePanelProps) {
|
||||
const { access } = useProductPhotoRequest();
|
||||
|
||||
if (activePanel === "nine-view") {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 xl:h-full xl:min-h-0 xl:overflow-hidden">
|
||||
<div
|
||||
data-testid="product-photo-nine-view-grid"
|
||||
className="grid flex-none grid-cols-2 gap-2 xl:min-h-[220px] xl:flex-1 xl:auto-rows-auto xl:grid-cols-3 xl:grid-rows-3"
|
||||
>
|
||||
{PRODUCT_PHOTO_NINE_VIEW_SLOTS.map((slot) => {
|
||||
const slotFile = workspaceData.slotFiles[slot.key];
|
||||
const isActive = selectedTarget.group === "nine-view" && selectedTarget.slot === slot.key;
|
||||
return (
|
||||
<button
|
||||
key={slot.key}
|
||||
type="button"
|
||||
data-testid={`product-photo-slot-${slot.key}`}
|
||||
onClick={() => {
|
||||
if (!slotFile && !isReadOnly) {
|
||||
onOpenCurrentUpload({ group: "nine-view", slot: slot.key });
|
||||
return;
|
||||
}
|
||||
onSelectSlot(slot.key);
|
||||
}}
|
||||
className={`grid grid-rows-[auto_auto_auto] rounded-apple-lg border p-1.5 text-left transition xl:h-full xl:min-h-0 ${
|
||||
isActive ? "border-apple-blue bg-apple-blue/10" : "border-apple-separator bg-apple-bg-secondary/20 hover:bg-apple-bg-secondary/40"
|
||||
}`}
|
||||
>
|
||||
<p className="mb-0.5 text-[10px] font-apple-semibold leading-4 text-apple-label-primary sm:mb-1 sm:text-[11px]">{slot.label}</p>
|
||||
<div className="flex aspect-square min-h-0 items-center justify-center overflow-hidden rounded-apple-lg border border-apple-separator bg-white">
|
||||
{slotFile && isImageUploadFile(slotFile) ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img data-testid={`product-photo-slot-image-${slot.key}`} src={slotFile.dataUrl} alt={slotFile.name} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<span className="px-1 text-[11px] text-apple-label-secondary sm:px-2 sm:text-xs">{isReadOnly ? "暂无图片" : "待上传"}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-[9px] leading-4 text-apple-label-secondary sm:mt-1 sm:text-[10px]">
|
||||
{slotFile ? "已匹配" : isReadOnly ? "暂无图片" : "点击上传"}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const uploadEntry = (open: () => void) => (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`product-photo-panel-upload-${activePanel}`}
|
||||
onClick={open}
|
||||
className="flex min-h-[140px] w-full flex-1 flex-col items-center justify-center gap-2 rounded-apple-xl border border-dashed border-apple-separator bg-white px-5 py-5 text-center transition hover:border-apple-blue/50 hover:bg-apple-bg-secondary/40"
|
||||
>
|
||||
<Camera aria-hidden="true" className="h-6 w-6 text-apple-label-secondary" />
|
||||
<span className="text-sm font-apple-medium text-apple-label-primary">拍照 / 上传{activePanelTitle}</span>
|
||||
<span className="text-xs leading-5 text-apple-label-secondary">
|
||||
{files.length ? `已上传 ${files.length} 张,点击继续添加` : "点击拍照或选择图片,可连续添加"}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col xl:h-full xl:min-h-0">
|
||||
{!isReadOnly ? uploadEntry(() => onOpenPanelUpload(activePanel)) : access?.canUpload ? (
|
||||
<QualityProductPhotoUpload
|
||||
barcode={barcode}
|
||||
styleKey={styleKey}
|
||||
target={{ group: activePanel, fileId: "" }}
|
||||
renderTrigger={uploadEntry}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex min-h-[140px] flex-1 items-center justify-center text-sm text-apple-label-secondary">
|
||||
{files.length ? `已上传 ${files.length} 张,请在预览区查看` : `当前还没有${activePanelTitle}资料。`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,7 +1,8 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { ProductPhotoSourcePanel } from "./ProductPhotoSourcePanel";
|
||||
import { ProductPhotoPreview } from "./ProductPhotoPreview";
|
||||
import type { NonIntegratedUploadFile } from "../../nonIntegratedStorage";
|
||||
import {
|
||||
PRODUCT_PHOTO_NINE_VIEW_SLOTS,
|
||||
PRODUCT_PHOTO_SOURCE_PANEL_ORDER,
|
||||
getProductPhotoPanelCompactTitle,
|
||||
type ProductPhotoFileGroup,
|
||||
@ -10,7 +11,6 @@ import {
|
||||
type ProductPhotoSelection,
|
||||
type ProductPhotoWorkspaceMode,
|
||||
} from "./documentWorkspaceHelpers";
|
||||
import { getUploadFileTypeLabel, isImageUploadFile } from "./uploadFilePreview";
|
||||
import type {
|
||||
ProductPhotoBarcodeEntry,
|
||||
ProductPhotoStyleEntry,
|
||||
@ -62,7 +62,6 @@ export function ProductPhotoWorkspace({
|
||||
activePanelFiles,
|
||||
activePanelTitle,
|
||||
selectedTarget,
|
||||
groupUploadLabel,
|
||||
isReadOnly,
|
||||
previewTitle,
|
||||
previewDescription,
|
||||
@ -239,163 +238,35 @@ export function ProductPhotoWorkspace({
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-visible p-2.5 xl:min-h-0 xl:overflow-hidden">
|
||||
{activePanel === "nine-view" ? (
|
||||
<div className="flex flex-col gap-2 xl:h-full xl:min-h-0 xl:overflow-hidden">
|
||||
<div
|
||||
data-testid="product-photo-nine-view-grid"
|
||||
className="grid flex-none grid-cols-2 gap-2 xl:min-h-[220px] xl:flex-1 xl:auto-rows-auto xl:grid-cols-3 xl:grid-rows-3"
|
||||
>
|
||||
{PRODUCT_PHOTO_NINE_VIEW_SLOTS.map((slot) => {
|
||||
const slotFile = workspaceData.slotFiles[slot.key];
|
||||
const isActive =
|
||||
selectedTarget.group === "nine-view" &&
|
||||
selectedTarget.slot === slot.key;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={slot.key}
|
||||
type="button"
|
||||
data-testid={`product-photo-slot-${slot.key}`}
|
||||
onClick={() => {
|
||||
const nextTarget: ProductPhotoSelection = {
|
||||
group: "nine-view",
|
||||
slot: slot.key,
|
||||
};
|
||||
|
||||
if (!slotFile && !isReadOnly) {
|
||||
onOpenCurrentUpload(nextTarget);
|
||||
return;
|
||||
}
|
||||
|
||||
onSelectSlot(slot.key);
|
||||
}}
|
||||
className={`grid grid-rows-[auto_auto_auto] rounded-apple-lg border p-1.5 text-left transition xl:h-full xl:min-h-0 ${
|
||||
isActive
|
||||
? "border-apple-blue bg-apple-blue/10"
|
||||
: "border-apple-separator bg-apple-bg-secondary/20 hover:bg-apple-bg-secondary/40"
|
||||
}`}
|
||||
>
|
||||
<p className="mb-0.5 text-[10px] font-apple-semibold leading-4 text-apple-label-primary sm:mb-1 sm:text-[11px]">
|
||||
{slot.label}
|
||||
</p>
|
||||
<div className="flex aspect-square min-h-0 items-center justify-center overflow-hidden rounded-apple-lg border border-apple-separator bg-white">
|
||||
{slotFile && isImageUploadFile(slotFile) ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
data-testid={`product-photo-slot-image-${slot.key}`}
|
||||
src={slotFile.dataUrl}
|
||||
alt={slotFile.name}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="px-1 text-[11px] text-apple-label-secondary sm:px-2 sm:text-xs">
|
||||
{isReadOnly ? "暂无图片" : "待上传"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-[9px] leading-4 text-apple-label-secondary sm:mt-1 sm:text-[10px]">
|
||||
{slotFile
|
||||
? "已匹配"
|
||||
: isReadOnly
|
||||
? "暂无图片"
|
||||
: "点击上传"}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col xl:h-full xl:min-h-0">
|
||||
{activePanelFiles.length === 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`product-photo-empty-panel-upload-${activePanel}`}
|
||||
onClick={() => onOpenPanelUpload(activePanel)}
|
||||
disabled={isReadOnly}
|
||||
className="flex min-h-[140px] flex-1 flex-col items-center justify-center rounded-apple-xl border border-dashed border-apple-separator px-5 text-center transition hover:border-apple-blue/50 hover:bg-apple-bg-secondary/40 disabled:cursor-default disabled:hover:border-apple-separator disabled:hover:bg-transparent"
|
||||
>
|
||||
<p className="text-sm font-apple-medium text-apple-label-primary">
|
||||
当前还没有{activePanelTitle}
|
||||
</p>
|
||||
<p className="mt-2 text-xs leading-5 text-apple-label-secondary">
|
||||
{isReadOnly
|
||||
? `当前还没有${activePanelTitle}资料。`
|
||||
: `点击这里或上方“${groupUploadLabel}”后,这里会显示图片缩略图列表。`}
|
||||
</p>
|
||||
</button>
|
||||
) : (
|
||||
<div
|
||||
data-testid="product-photo-detail-list"
|
||||
className="grid gap-2 pr-1 xl:min-h-0 xl:flex-1 xl:overflow-y-auto"
|
||||
>
|
||||
{activePanelFiles.map((file) => {
|
||||
const isActive =
|
||||
selectedTarget.group === activePanel &&
|
||||
selectedTarget.fileId === file.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={file.id}
|
||||
type="button"
|
||||
data-testid={`product-photo-detail-file-${file.id}`}
|
||||
onClick={() =>
|
||||
onSelectGroupedFile(activePanel, file.id)
|
||||
}
|
||||
className={`rounded-apple-lg border p-2 text-left transition ${
|
||||
isActive
|
||||
? "border-apple-blue bg-apple-blue/10"
|
||||
: "border-apple-separator bg-apple-bg-secondary/20 hover:bg-apple-bg-secondary/40"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-[64px] w-[64px] shrink-0 items-center justify-center overflow-hidden rounded-apple-lg border border-apple-separator bg-white">
|
||||
{isImageUploadFile(file) ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
data-testid={`product-photo-detail-image-${file.id}`}
|
||||
src={file.dataUrl}
|
||||
alt={file.name}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-[11px] text-apple-label-secondary">
|
||||
{getUploadFileTypeLabel(file)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-apple-medium text-apple-label-primary">
|
||||
{file.name}
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-apple-label-secondary">
|
||||
点击查看大图预览
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<ProductPhotoSourcePanel
|
||||
activePanel={activePanel}
|
||||
activePanelTitle={activePanelTitle}
|
||||
files={activePanelFiles}
|
||||
workspaceData={workspaceData}
|
||||
selectedTarget={selectedTarget}
|
||||
isReadOnly={isReadOnly}
|
||||
barcode={activeBarcodeEntry?.barcode || ""}
|
||||
styleKey={activeStyleEntry?.key || ""}
|
||||
onOpenPanelUpload={onOpenPanelUpload}
|
||||
onOpenCurrentUpload={onOpenCurrentUpload}
|
||||
onSelectSlot={onSelectSlot}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-col rounded-apple-xl border border-apple-separator bg-apple-bg-secondary/20 xl:min-h-0 xl:flex-1">
|
||||
<div className="border-b border-apple-separator px-3 py-1.5">
|
||||
<p className="truncate text-[13px] font-apple-medium text-apple-label-primary sm:text-sm">
|
||||
{previewTitle}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[10px] text-apple-label-secondary sm:text-[11px]">
|
||||
{previewDescription}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex min-h-[280px] items-center justify-center p-2 xl:min-h-0 xl:flex-1">
|
||||
{previewContent}
|
||||
</div>
|
||||
</div>
|
||||
<ProductPhotoPreview
|
||||
activePanel={activePanel}
|
||||
files={activePanelFiles}
|
||||
selectedTarget={selectedTarget}
|
||||
isReadOnly={isReadOnly}
|
||||
barcode={activeBarcodeEntry?.barcode || ""}
|
||||
styleKey={activeStyleEntry?.key || ""}
|
||||
title={previewTitle}
|
||||
description={previewDescription}
|
||||
onSelectGroupedFile={onSelectGroupedFile}
|
||||
>
|
||||
{previewContent}
|
||||
</ProductPhotoPreview>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
|
||||
import type { NonIntegratedUploadFile } from '../../nonIntegratedStorage';
|
||||
import type { ProductPhotoRequestAccess } from '@/lib/intake-product-photo-request';
|
||||
|
||||
type PhotoRequestContext = {
|
||||
productId: string;
|
||||
access: ProductPhotoRequestAccess | null;
|
||||
uploadedFiles: NonIntegratedUploadFile[];
|
||||
acceptUploadedFile: (file: NonIntegratedUploadFile) => void;
|
||||
};
|
||||
const Context = createContext<PhotoRequestContext>({
|
||||
productId: '', access: null, uploadedFiles: [], acceptUploadedFile: () => {},
|
||||
});
|
||||
|
||||
export function ProductPhotoRequestProvider({ productId, children }: { productId: string; children: ReactNode }) {
|
||||
return <ProductPhotoRequestState key={productId} productId={productId}>{children}</ProductPhotoRequestState>;
|
||||
}
|
||||
|
||||
function ProductPhotoRequestState({ productId, children }: { productId: string; children: ReactNode }) {
|
||||
const [access, setAccess] = useState<ProductPhotoRequestAccess | null>(null);
|
||||
const [uploadedFiles, setUploadedFiles] = useState<NonIntegratedUploadFile[]>([]);
|
||||
useEffect(() => {
|
||||
if (!productId) return;
|
||||
const controller = new AbortController();
|
||||
void fetch(`/api/intake/non-integrated/${encodeURIComponent(productId)}/photo-upload-request`, {
|
||||
cache: 'no-store', signal: controller.signal,
|
||||
}).then(async (response) => {
|
||||
const body = await response.json();
|
||||
if (!controller.signal.aborted && response.ok && body.success) setAccess(body.data);
|
||||
}).catch(() => {});
|
||||
return () => controller.abort();
|
||||
}, [productId]);
|
||||
return <Context.Provider value={{ productId, access, uploadedFiles,
|
||||
acceptUploadedFile: (file) => setUploadedFiles((current) => [...current, file]),
|
||||
}}>{children}</Context.Provider>;
|
||||
}
|
||||
|
||||
export const useProductPhotoRequest = () => useContext(Context);
|
||||
@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import { PRODUCT_PHOTO_NINE_VIEW_SLOTS, type ProductPhotoSelection } from '../documents/documentWorkspaceHelpers';
|
||||
import { prepareNonIntegratedImageFileForUpload } from '../documents/uploadRecordHelpers';
|
||||
import { useProductPhotoRequest } from './ProductPhotoRequestContext';
|
||||
|
||||
export function QualityProductPhotoUpload({ barcode, styleKey, target, renderTrigger }: {
|
||||
barcode: string; styleKey: string; target: ProductPhotoSelection;
|
||||
renderTrigger?: (open: () => void) => ReactNode;
|
||||
}) {
|
||||
const { productId, access, acceptUploadedFile } = useProductPhotoRequest();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [progress, setProgress] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const busyRef = useRef(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [previews, setPreviews] = useState<string[]>([]);
|
||||
|
||||
const chooseFiles = useCallback((selected: File[]) => {
|
||||
setFiles(selected); setPreviews(selected.map((file) => URL.createObjectURL(file)));
|
||||
setProgress(''); setFailed(false);
|
||||
}, []);
|
||||
useEffect(() => () => previews.forEach((url) => URL.revokeObjectURL(url)), [previews]);
|
||||
useEffect(() => {
|
||||
if (!open || busy) return;
|
||||
const paste = (event: ClipboardEvent) => {
|
||||
const pasted = Array.from(event.clipboardData?.files || []).filter((file) => file.type.startsWith('image/'));
|
||||
if (pasted.length) { event.preventDefault(); chooseFiles(pasted); }
|
||||
};
|
||||
document.addEventListener('paste', paste);
|
||||
return () => document.removeEventListener('paste', paste);
|
||||
}, [open, busy, chooseFiles]);
|
||||
function close() { if (!busyRef.current) { chooseFiles([]); setOpen(false); } }
|
||||
async function upload() {
|
||||
if (busyRef.current || files.length === 0) return;
|
||||
busyRef.current = true; setBusy(true); setFailed(false);
|
||||
let succeeded = 0;
|
||||
const failures: string[] = [];
|
||||
for (const [index, file] of files.entries()) {
|
||||
setProgress(`正在上传 ${index + 1}/${files.length}:${file.name}`);
|
||||
try {
|
||||
const prepared = await prepareNonIntegratedImageFileForUpload(file);
|
||||
const form = new FormData(); form.set('file', prepared);
|
||||
form.set('metadata', JSON.stringify({ barcode, styleKey, group: target.group,
|
||||
...(target.group === 'nine-view' ? { slot: target.slot } : {}),
|
||||
}));
|
||||
const response = await fetch(`/api/intake/non-integrated/${encodeURIComponent(productId)}/photo-upload-request/upload`, { method: 'POST', body: form });
|
||||
const body = await response.json();
|
||||
if (!response.ok || !body.success) throw new Error(body.error?.message || '上传失败');
|
||||
acceptUploadedFile(body.data.file); succeeded += 1;
|
||||
} catch (error) { failures.push(`${file.name}:${error instanceof Error ? error.message : '上传失败'}`); }
|
||||
}
|
||||
setProgress(`上传完成:成功 ${succeeded} 张,失败 ${failures.length} 张。${failures.length ? '\n' + failures.join('\n') : ''}`);
|
||||
setFailed(failures.length > 0); setFiles([]); setPreviews([]);
|
||||
busyRef.current = false; setBusy(false);
|
||||
}
|
||||
if (!access?.canUpload) return null;
|
||||
const openUpload = () => { if (barcode) { setProgress(''); setFailed(false); setOpen(true); } };
|
||||
const position = target.group === 'nine-view' ? PRODUCT_PHOTO_NINE_VIEW_SLOTS.find((slot) => slot.key === target.slot)?.label : '当前分类';
|
||||
return <>
|
||||
{renderTrigger ? renderTrigger(openUpload) : <Button size="small" disabled={!barcode} onClick={openUpload} data-testid="quality-product-photo-upload">上传实拍图片</Button>}
|
||||
<Modal isOpen={open} onClose={close} title="上传产品实拍" size="small" variant="compact" footer={<>
|
||||
<Button variant="plain" size="small" disabled={busy} onClick={close}>关闭</Button>
|
||||
<Button size="small" disabled={busy || files.length === 0} onClick={() => void upload()}>{busy ? '上传中...' : '确认上传'}</Button>
|
||||
</>}>
|
||||
<div className="space-y-3 text-sm">
|
||||
<p className="text-xs text-zinc-500">{barcode} · {position}</p>
|
||||
<label className={`block rounded-lg border border-zinc-300 bg-white px-4 py-3 text-center ${busy ? 'opacity-50' : 'cursor-pointer hover:bg-zinc-50'}`}>
|
||||
拍照
|
||||
<input aria-label="拍照上传实拍" type="file" accept="image/*" capture="environment" disabled={busy} className="sr-only" onChange={(event) => { chooseFiles(Array.from(event.target.files || [])); event.target.value = ''; }} />
|
||||
</label>
|
||||
<label className="block rounded-lg border border-dashed border-zinc-300 bg-zinc-50 p-4 text-center">
|
||||
<span>选择图片,或 Ctrl+V 粘贴截图</span>
|
||||
<input aria-label="选择实拍图片" type="file" accept="image/*" multiple disabled={busy} onChange={(event) => { chooseFiles(Array.from(event.target.files || [])); event.target.value = ''; }} className="mt-3 block w-full text-xs" />
|
||||
</label>
|
||||
{/* Local blob previews must not go through the remote image optimizer. */}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
{previews.length > 0 && <div className="flex flex-wrap gap-2">{previews.map((url) => <img key={url} src={url} alt="待上传实拍预览" className="h-20 w-20 rounded-lg object-cover" />)}</div>}
|
||||
{progress && <p role={failed ? 'alert' : 'status'} className={`whitespace-pre-line text-xs leading-5 ${failed ? 'text-red-600' : 'text-zinc-700'}`}>{progress}</p>}
|
||||
</div>
|
||||
</Modal>
|
||||
</>;
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
/* Component-layer important rules outrank the workspace's Tailwind overrides. */
|
||||
@layer components {
|
||||
.trigger.trigger,
|
||||
.dialog.dialog button {
|
||||
background: #ffffff !important;
|
||||
background-image: none !important;
|
||||
border-color: #dfe3e7 !important;
|
||||
color: #525b65 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.trigger.trigger:hover,
|
||||
.dialog.dialog button:hover {
|
||||
background: #f3f5f7 !important;
|
||||
}
|
||||
|
||||
.dialog.dialog button[role='combobox'] {
|
||||
border: 1px solid #dfe3e7 !important;
|
||||
}
|
||||
|
||||
.dialog.dialog button:disabled {
|
||||
background: #f4f5f7 !important;
|
||||
color: #9299a1 !important;
|
||||
}
|
||||
|
||||
.dialog.dialog button:focus-visible {
|
||||
outline: 2px solid #becbd6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.dialog.dialog [role='listbox'] {
|
||||
background: #ffffff !important;
|
||||
}
|
||||
|
||||
.dialog.dialog [role='option'][aria-selected='true'] {
|
||||
background: #edf2f6 !important;
|
||||
color: #435568 !important;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import Select from '@/components/ui/Select';
|
||||
import type { ProductPhotoRequestRecipient } from '@/lib/intake-product-photo-request';
|
||||
import { useProductPhotoRequest } from './ProductPhotoRequestContext';
|
||||
import styles from './SampleMeasurementNotifyButton.module.css';
|
||||
|
||||
export function SampleMeasurementNotifyButton() {
|
||||
const { productId, access } = useProductPhotoRequest();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [recipients, setRecipients] = useState<ProductPhotoRequestRecipient[]>([]);
|
||||
const [selectedId, setSelectedId] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
const attempt = useRef<string>('');
|
||||
const inFlight = useRef(false);
|
||||
const endpoint = `/api/intake/non-integrated/${encodeURIComponent(productId)}/photo-upload-request`;
|
||||
|
||||
async function showDialog() {
|
||||
setOpen(true); setLoading(true); setMessage(''); setSuccess(false);
|
||||
setSelectedId(''); attempt.current = crypto.randomUUID();
|
||||
try {
|
||||
const response = await fetch(`${endpoint}?recipients=true`, { cache: 'no-store' });
|
||||
const body = await response.json();
|
||||
if (!response.ok || !body.success) throw new Error(body.error?.message || '质检名单读取失败');
|
||||
setRecipients(body.data.recipients);
|
||||
} catch (error) { setRecipients([]); setMessage(error instanceof Error ? error.message : '质检名单读取失败'); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
|
||||
async function send() {
|
||||
if (!selectedId || inFlight.current) return;
|
||||
inFlight.current = true; setSending(true); setMessage('');
|
||||
try {
|
||||
const response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ recipientId: selectedId, requestId: attempt.current }),
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok || !body.success) throw new Error(body.error?.message || '通知发送失败,请重试');
|
||||
if (['failed', 'expired'].includes(body.data.status)) throw new Error('通知投递失败,请查看通知记录');
|
||||
setSuccess(true);
|
||||
setMessage(`已提交发送给${body.data.recipientName},该人员已获得当前产品实拍上传权限。`);
|
||||
} catch (error) { setMessage(error instanceof Error ? error.message : '通知发送失败,请重试'); }
|
||||
finally { inFlight.current = false; setSending(false); }
|
||||
}
|
||||
|
||||
if (!access?.canNotify) return null;
|
||||
return <>
|
||||
<Button variant="plain" size="small" className={styles.trigger} data-testid="sample-measurement-notify" onClick={() => void showDialog()}>通知</Button>
|
||||
<Modal isOpen={open} onClose={() => { if (!sending) setOpen(false); }} title="通知质检" size="small" variant="compact" className={styles.dialog} bodyClassName="min-h-[340px]"
|
||||
footer={<><Button variant="plain" size="small" disabled={sending} onClick={() => setOpen(false)}>{success ? '关闭' : '取消'}</Button>
|
||||
{!success && <Button size="small" disabled={loading || sending || !selectedId} onClick={() => void send()}>{sending ? '发送中...' : '确认发送'}</Button>}</>}>
|
||||
<div className="space-y-4 text-sm text-zinc-800">
|
||||
<fieldset disabled={sending || success}>
|
||||
<legend className="mb-2 font-medium">选择质检</legend>
|
||||
<Select
|
||||
aria-label="选择质检"
|
||||
value={selectedId}
|
||||
onChange={(event) => { setSelectedId(event.target.value); attempt.current = crypto.randomUUID(); setMessage(''); }}
|
||||
options={recipients.map((item) => ({ value: item.id, label: item.issue ? `${item.name}(${item.issue})` : item.name, disabled: !item.deliverable }))}
|
||||
placeholder={loading ? '正在读取质检名单...' : '请选择质检'}
|
||||
disabled={loading || sending || success}
|
||||
searchable
|
||||
searchPlaceholder="搜索姓名"
|
||||
emptyText="没有匹配的人员"
|
||||
tone="obsidian"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
/>
|
||||
</fieldset>
|
||||
<div><p className="mb-2 font-medium">通知内容</p><p className="rounded-lg border border-zinc-200 bg-zinc-50 p-3 text-sm leading-6">{access.content}</p></div>
|
||||
<div><p className="mb-2 font-medium">开品明细链接</p><a href={access.detailUrl} target="_blank" rel="noreferrer" className="block break-all text-xs leading-5 text-blue-700 underline">{access.detailUrl}</a></div>
|
||||
{message && <p role={success ? 'status' : 'alert'} className={success ? 'text-sm text-emerald-700' : 'text-sm text-red-600'}>{message}</p>}
|
||||
</div>
|
||||
</Modal>
|
||||
</>;
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
import type { ChangeEvent, RefObject } from "react";
|
||||
import Button from "@/components/ui/Button";
|
||||
import { SampleMeasurementNotifyButton } from '../photo-request/SampleMeasurementNotifyButton';
|
||||
|
||||
type SampleMeasurementHeaderActionsProps = {
|
||||
actionGroupClassName: string;
|
||||
@ -46,6 +47,7 @@ export function SampleMeasurementHeaderActions({
|
||||
}: SampleMeasurementHeaderActionsProps) {
|
||||
return (
|
||||
<div className={actionGroupClassName}>
|
||||
<SampleMeasurementNotifyButton />
|
||||
{canDownloadWorkspaceFiles ? (
|
||||
<Button
|
||||
variant="plain"
|
||||
|
||||
32
furniture-3d-system/src/lib/intake-product-photo-request.ts
Normal file
32
furniture-3d-system/src/lib/intake-product-photo-request.ts
Normal file
@ -0,0 +1,32 @@
|
||||
export const PRODUCT_PHOTO_REQUEST_EVENT = 'intake_product_photo_requested'
|
||||
export const PRODUCT_PHOTO_REQUEST_CONTENT = '请点击开品明细链接,上传该产品的实拍图片。'
|
||||
|
||||
export function buildProductOpeningDetailUrl(productId: string) {
|
||||
return `https://cp.jiansun.vip/intake/non-integrated/${encodeURIComponent(productId)}`
|
||||
}
|
||||
|
||||
export function buildProductPhotoNotificationContent(productId: string, spu: string) {
|
||||
const detailUrl = buildProductOpeningDetailUrl(productId)
|
||||
// DingTalk PC uses this jump protocol to open the system browser instead of its side panel.
|
||||
const browserUrl = `dingtalk://dingtalkclient/page/link?pc_slide=false&url=${encodeURIComponent(detailUrl)}`
|
||||
return [
|
||||
PRODUCT_PHOTO_REQUEST_CONTENT,
|
||||
`产品:${spu}`,
|
||||
`[开品明细链接(浏览器打开)](${browserUrl})`,
|
||||
].join('\n\n')
|
||||
}
|
||||
|
||||
export type ProductPhotoRequestAccess = {
|
||||
canNotify: boolean
|
||||
canUpload: boolean
|
||||
detailUrl: string
|
||||
content: string
|
||||
}
|
||||
|
||||
export type ProductPhotoRequestRecipient = {
|
||||
id: string
|
||||
name: string
|
||||
department: string
|
||||
deliverable: boolean
|
||||
issue: string | null
|
||||
}
|
||||
@ -0,0 +1,59 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
function departmentExternalIds(value: unknown): string[] {
|
||||
if (Array.isArray(value)) return value.filter((id): id is string => typeof id === 'string').map((id) => id.trim()).filter(Boolean)
|
||||
if (typeof value !== 'string' || !value.trim()) return []
|
||||
try { return departmentExternalIds(JSON.parse(value)) } catch { return [value.trim()] }
|
||||
}
|
||||
|
||||
export const intakeProductPhotoRequestRepository = {
|
||||
async hasGrant(productId: string, userId: string) {
|
||||
const rows = await prisma.$queryRaw<Array<{ id: string }>>`
|
||||
SELECT g."id" FROM "IntakeProductPhotoUploadGrant" g
|
||||
JOIN "IntakeNonIntegratedProduct" p ON p."id" = g."productId"
|
||||
WHERE g."productId" = ${productId} AND g."userId" = ${userId}
|
||||
AND p."deletedAt" IS NULL
|
||||
LIMIT 1
|
||||
`
|
||||
return rows.length > 0
|
||||
},
|
||||
|
||||
async grant(productId: string, userId: string, grantedBy: string) {
|
||||
await prisma.$executeRaw`
|
||||
INSERT INTO "IntakeProductPhotoUploadGrant" ("id", "productId", "userId", "grantedBy")
|
||||
VALUES (${randomUUID()}, ${productId}, ${userId}, ${grantedBy})
|
||||
ON CONFLICT ("productId", "userId") DO NOTHING
|
||||
`
|
||||
},
|
||||
|
||||
async listCandidates(userId?: string) {
|
||||
const users = await prisma.user.findMany({
|
||||
where: { ...(userId ? { id: userId } : {}), status: 'ACTIVE', NOT: { businessRoles: { hasSome: ['SUPPLIER', 'EXTERNAL_INTAKE'] } } },
|
||||
select: {
|
||||
id: true, name: true, status: true, businessRoles: true, dingtalkUserId: true,
|
||||
department: { select: { name: true, fullPath: true } },
|
||||
team: { select: { department: { select: { name: true, fullPath: true } } } },
|
||||
employeeDirectory: { select: { primaryDepartmentExternalId: true, departmentExternalIds: true } },
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
})
|
||||
const externalIdsByUser = new Map(users.map((user) => [user.id, [
|
||||
user.employeeDirectory?.primaryDepartmentExternalId,
|
||||
...departmentExternalIds(user.employeeDirectory?.departmentExternalIds),
|
||||
].filter((id): id is string => Boolean(id))]))
|
||||
const ids = [...new Set([...externalIdsByUser.values()].flat())]
|
||||
const departments = ids.length ? await prisma.department.findMany({
|
||||
where: { externalId: { in: ids } }, select: { externalId: true, name: true, fullPath: true },
|
||||
}) : []
|
||||
const byExternalId = new Map(departments.map((department) => [department.externalId, department]))
|
||||
return users.map((user) => ({ ...user, departmentPaths: [...new Set([
|
||||
user.department?.fullPath, user.department?.name,
|
||||
user.team?.department?.fullPath, user.team?.department?.name,
|
||||
...(externalIdsByUser.get(user.id) || []).flatMap((id) => {
|
||||
const department = byExternalId.get(id)
|
||||
return [department?.fullPath, department?.name]
|
||||
}),
|
||||
].filter((path): path is string => Boolean(path?.trim()))) ] }))
|
||||
},
|
||||
}
|
||||
@ -0,0 +1,53 @@
|
||||
/** @jest-environment node */
|
||||
import type { AuthUser } from '@/server/core/types'
|
||||
import { ErrorCode } from '@/server/core/errors'
|
||||
import { intakeProductPhotoAccessService as service } from '../intake-product-photo-access.service'
|
||||
import { intakeNonIntegratedScopeService } from '../intake-non-integrated-scope.service'
|
||||
|
||||
const mockHasGrant = jest.fn()
|
||||
jest.mock('@/server/repositories/intake-product-photo-request.repository', () => ({
|
||||
intakeProductPhotoRequestRepository: { hasGrant: (...args: unknown[]) => mockHasGrant(...args) },
|
||||
}))
|
||||
jest.mock('../intake-node-permission-settings.service', () => ({
|
||||
intakeNodePermissionSettingsService: { getSettings: jest.fn().mockResolvedValue({ overrides: {} }) },
|
||||
}))
|
||||
|
||||
const inspector: AuthUser = {
|
||||
id: 'quality-1', email: 'quality@example.test', name: '质检', role: 'USER', status: 'ACTIVE',
|
||||
businessRoles: [], departmentPaths: ['公司 > 供应链中心 > 跟单质检部 > 质检组'],
|
||||
}
|
||||
|
||||
beforeEach(() => { jest.clearAllMocks(); mockHasGrant.mockImplementation(async (productId, userId) => productId === 'product-1' && userId === 'quality-1') })
|
||||
|
||||
test('授权仅对指定产品与指定质检生效', async () => {
|
||||
expect(await service.canUpload('product-1', inspector)).toBe(true)
|
||||
expect(await service.canUpload('product-2', inspector)).toBe(false)
|
||||
expect(await service.canUpload('product-1', { ...inspector, id: 'quality-2' })).toBe(false)
|
||||
})
|
||||
|
||||
test.each([
|
||||
{ status: 'INACTIVE' }, { status: 'SUSPENDED' },
|
||||
{ businessRoles: ['SUPPLIER'] }, { businessRoles: ['EXTERNAL_INTAKE'] },
|
||||
{ departmentPaths: ['公司 > 供应链中心 > 采购部'] },
|
||||
])('停用、外部或非质检账号不能使用上传授权:%j', async (overrides) => {
|
||||
await expect(service.assertCanUpload('product-1', { ...inspector, ...overrides } as AuthUser))
|
||||
.rejects.toMatchObject({ code: ErrorCode.AUTH_PERMISSION_DENIED })
|
||||
expect(mockHasGrant).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('单产品上传授权不提升通用编辑和下载权限', async () => {
|
||||
const product = { id: 'product-1', createdBy: 'designer-1', designer: '设计', purchaser: '采购',
|
||||
operator: '', visibility: 'DEPARTMENT', departmentId: null, teamId: null, payload: {}, profile: null }
|
||||
mockHasGrant.mockResolvedValue(false)
|
||||
const before = await Promise.all(['产品实拍图', '样包测量', '基础信息'].map((category) =>
|
||||
intakeNonIntegratedScopeService.canWriteWorkspaceRecordWithSettings(product, inspector, category)))
|
||||
const downloadBefore = await intakeNonIntegratedScopeService.canDownloadWorkspaceRecordWithSettings(product, inspector, '产品实拍图')
|
||||
mockHasGrant.mockResolvedValue(true)
|
||||
expect(await service.canUpload('product-1', inspector)).toBe(true)
|
||||
const after = await Promise.all(['产品实拍图', '样包测量', '基础信息'].map((category) =>
|
||||
intakeNonIntegratedScopeService.canWriteWorkspaceRecordWithSettings(product, inspector, category)))
|
||||
expect(after).toEqual(before)
|
||||
expect(after[0]).toBe(false)
|
||||
expect(await intakeNonIntegratedScopeService.canDownloadWorkspaceRecordWithSettings(product, inspector, '产品实拍图')).toBe(downloadBefore)
|
||||
expect(await intakeNonIntegratedScopeService.canPreviewWorkspaceRecordWithSettings(product, inspector, '产品实拍图')).toBe(true)
|
||||
})
|
||||
@ -0,0 +1,102 @@
|
||||
/** @jest-environment node */
|
||||
import type { AuthUser } from '@/server/core/types'
|
||||
import { ErrorCode } from '@/server/core/errors'
|
||||
import { PRODUCT_PHOTO_REQUEST_CONTENT } from '@/lib/intake-product-photo-request'
|
||||
import { intakeProductPhotoNotificationService as service } from '../intake-product-photo-notification.service'
|
||||
|
||||
const mockFind = jest.fn()
|
||||
const mockCanRead = jest.fn()
|
||||
const mockCanWrite = jest.fn()
|
||||
const mockCanUpload = jest.fn()
|
||||
const mockCandidates = jest.fn()
|
||||
const mockGrant = jest.fn()
|
||||
const mockSend = jest.fn()
|
||||
jest.mock('@/server/repositories/intake-non-integrated-product.repository', () => ({
|
||||
intakeNonIntegratedProductRepository: { findActiveById: (...args: unknown[]) => mockFind(...args) },
|
||||
}))
|
||||
jest.mock('@/server/repositories/intake-product-photo-request.repository', () => ({
|
||||
intakeProductPhotoRequestRepository: {
|
||||
listCandidates: (...args: unknown[]) => mockCandidates(...args), grant: (...args: unknown[]) => mockGrant(...args),
|
||||
},
|
||||
}))
|
||||
jest.mock('../intake-non-integrated-scope.service', () => ({ intakeNonIntegratedScopeService: {
|
||||
canReadRecord: (...args: unknown[]) => mockCanRead(...args),
|
||||
canWriteWorkspaceRecordWithSettings: (...args: unknown[]) => mockCanWrite(...args),
|
||||
} }))
|
||||
jest.mock('../intake-product-photo-access.service', () => ({ intakeProductPhotoAccessService: { canUpload: (...args: unknown[]) => mockCanUpload(...args) } }))
|
||||
jest.mock('../notification-delivery.service', () => ({ notificationDeliveryService: { sendToEmployee: (...args: unknown[]) => mockSend(...args) } }))
|
||||
|
||||
const sender: AuthUser = { id: 'sender', name: '采购', email: 'sender@example.test', role: 'USER', status: 'ACTIVE' }
|
||||
const recipient = { id: 'quality-1', name: '质检', status: 'ACTIVE', businessRoles: [], dingtalkUserId: 'ding-1',
|
||||
department: null, departmentPaths: ['公司 > 供应链中心 > 质检组'] }
|
||||
const input = { recipientId: 'quality-1', requestId: '405f9544-dc76-4b24-831b-a9c3563c61af' }
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockFind.mockResolvedValue({ id: 'product-1', spu: 'BJ0226' })
|
||||
mockCanRead.mockReturnValue(true); mockCanWrite.mockResolvedValue(true); mockCanUpload.mockResolvedValue(false)
|
||||
mockCandidates.mockResolvedValue([recipient]); mockGrant.mockResolvedValue(undefined)
|
||||
mockSend.mockResolvedValue({ id: 'delivery-1', status: 'pending' })
|
||||
})
|
||||
|
||||
test('先授权再发送纯文字及电脑浏览器链接,重试保持相同幂等键', async () => {
|
||||
await service.send('product-1', input, sender)
|
||||
expect(mockGrant).toHaveBeenCalledWith('product-1', 'quality-1', 'sender')
|
||||
expect(mockGrant.mock.invocationCallOrder[0]).toBeLessThan(mockSend.mock.invocationCallOrder[0])
|
||||
expect(mockCanWrite).toHaveBeenCalledWith(expect.anything(), sender, '样包测量')
|
||||
expect(mockSend).toHaveBeenCalledWith(expect.objectContaining({
|
||||
recipientUserId: 'quality-1',
|
||||
title: '产品实拍上传通知',
|
||||
content: expect.stringContaining(PRODUCT_PHOTO_REQUEST_CONTENT),
|
||||
}))
|
||||
const notification = mockSend.mock.calls[0][0]
|
||||
expect(notification).not.toHaveProperty('actionTitle')
|
||||
expect(notification).not.toHaveProperty('actionUrl')
|
||||
const browserLink = notification.content.match(/\]\((dingtalk:\/\/[^)]+)\)/)?.[1]
|
||||
expect(browserLink).toBeDefined()
|
||||
const link = new URL(browserLink!)
|
||||
expect(`${link.protocol}//${link.hostname}${link.pathname}`).toBe('dingtalk://dingtalkclient/page/link')
|
||||
expect(link.searchParams.get('pc_slide')).toBe('false')
|
||||
expect(link.searchParams.get('url')).toBe('https://cp.jiansun.vip/intake/non-integrated/product-1')
|
||||
const firstKey = mockSend.mock.calls[0][0].idempotencyKey
|
||||
await service.send('product-1', input, sender)
|
||||
expect(mockSend.mock.calls[1][0].idempotencyKey).toBe(firstKey)
|
||||
expect(firstKey.length).toBeLessThanOrEqual(160)
|
||||
})
|
||||
|
||||
test('无测量写权限不能查看名单、授权或发送', async () => {
|
||||
mockCanWrite.mockResolvedValue(false)
|
||||
await expect(service.getAccess('product-1', sender, true)).rejects.toMatchObject({ code: ErrorCode.AUTH_PERMISSION_DENIED })
|
||||
await expect(service.send('product-1', input, sender)).rejects.toMatchObject({ code: ErrorCode.AUTH_PERMISSION_DENIED })
|
||||
expect(mockCandidates).not.toHaveBeenCalled(); expect(mockGrant).not.toHaveBeenCalled(); expect(mockSend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test.each([[], [{ ...recipient, dingtalkUserId: null }], [{ ...recipient, departmentPaths: ['公司 > 供应链中心 > 采购部'] }]])(
|
||||
'无效收件人不能获得权限或收到消息', async (...users) => {
|
||||
mockCandidates.mockResolvedValue(users)
|
||||
await expect(service.send('product-1', input, sender)).rejects.toMatchObject({ code: ErrorCode.VALIDATION_FAILED })
|
||||
expect(mockGrant).not.toHaveBeenCalled(); expect(mockSend).not.toHaveBeenCalled()
|
||||
},
|
||||
)
|
||||
|
||||
test('授权失败阻止发送;发送失败保留已授权状态以便重试', async () => {
|
||||
mockGrant.mockRejectedValueOnce(new Error('grant unavailable'))
|
||||
await expect(service.send('product-1', input, sender)).rejects.toThrow('grant unavailable')
|
||||
expect(mockSend).not.toHaveBeenCalled()
|
||||
mockSend.mockRejectedValueOnce(new Error('delivery unavailable'))
|
||||
await expect(service.send('product-1', input, sender)).rejects.toThrow('delivery unavailable')
|
||||
expect(mockGrant).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
test('持有上传授权也不能借此获得通知权限', async () => {
|
||||
mockCanRead.mockReturnValue(false); mockCanUpload.mockResolvedValue(true); mockCanWrite.mockResolvedValue(false)
|
||||
expect(await service.getAccess('product-1', sender, false)).toMatchObject({ canUpload: true, canNotify: false })
|
||||
await expect(service.send('product-1', input, sender)).rejects.toMatchObject({ code: ErrorCode.AUTH_PERMISSION_DENIED })
|
||||
expect(mockGrant).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('不存在或无权读取产品时不返回名单', async () => {
|
||||
mockCanRead.mockReturnValue(false)
|
||||
await expect(service.getAccess('product-2', sender, true)).rejects.toMatchObject({ code: ErrorCode.PRODUCT_NOT_FOUND })
|
||||
expect(mockCandidates).not.toHaveBeenCalled()
|
||||
})
|
||||
@ -0,0 +1,91 @@
|
||||
/** @jest-environment node */
|
||||
import sharp from 'sharp'
|
||||
import type { AuthUser } from '@/server/core/types'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import { intakeProductPhotoUploadService as service } from '../intake-product-photo-upload.service'
|
||||
|
||||
const mockAuthorize = jest.fn()
|
||||
const mockSave = jest.fn()
|
||||
const mockUpload = jest.fn()
|
||||
const mockDelete = jest.fn()
|
||||
const mockLog = jest.fn()
|
||||
jest.mock('../intake-product-photo-access.service', () => ({ intakeProductPhotoAccessService: { assertCanUpload: (...args: unknown[]) => mockAuthorize(...args) } }))
|
||||
jest.mock('@/server/repositories/intake-non-integrated-product.repository', () => ({ intakeNonIntegratedProductRepository: { saveActiveWithLock: (...args: unknown[]) => mockSave(...args) } }))
|
||||
jest.mock('@/lib/storage', () => ({
|
||||
buildStableObjectAccessUrl: (key: string) => `/api/storage/object?key=${encodeURIComponent(key)}`,
|
||||
uploadBufferToObjectStorage: (...args: unknown[]) => mockUpload(...args),
|
||||
deleteObject: (...args: unknown[]) => mockDelete(...args),
|
||||
}))
|
||||
jest.mock('@/app/intake/non-integrated/nonIntegratedAssetServer', () => ({ buildNonIntegratedAssetObjectStorageKey: (key: string) => `intake/${key}` }))
|
||||
jest.mock('../intake-non-integrated-operation-log.service', () => ({ recordIntakeFileUploadSucceeded: (...args: unknown[]) => mockLog(...args) }))
|
||||
|
||||
const user = { id: 'quality-1' } as AuthUser
|
||||
const metadata = { barcode: '123456789', styleKey: 'K01', group: 'nine-view', slot: 'front' }
|
||||
const oldFile = { id: 'old-photo', category: '产品实拍图', dataUrl: '/old.png' }
|
||||
const record = {
|
||||
skuItems: [{ barcode: '123456789', model: 'BJ0226K01' }],
|
||||
payload: { uploadFiles: [oldFile], sampleMeasurementRows: [{ width: '120' }],
|
||||
documentWorkspaceConfirmations: { 样包测量: true, 产品实拍图: true },
|
||||
documentWorkspaceApprovedAt: { 样包测量: '2026-10-01', 产品实拍图: '2026-10-02' },
|
||||
},
|
||||
}
|
||||
let photo: File
|
||||
beforeAll(async () => {
|
||||
const buffer = await sharp({ create: { width: 2, height: 2, channels: 3, background: '#ffffff' } }).png().toBuffer()
|
||||
photo = new File([new Uint8Array(buffer)], 'real-photo.png', { type: 'image/png' })
|
||||
})
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks(); mockAuthorize.mockResolvedValue(undefined); mockUpload.mockResolvedValue(undefined)
|
||||
mockDelete.mockResolvedValue(undefined); mockLog.mockResolvedValue(undefined)
|
||||
mockSave.mockImplementation(async (_id, prepare) => ({ status: 'saved', ...await prepare(structuredClone(record)) }))
|
||||
})
|
||||
|
||||
test('上传前检查产品授权,未授权时不接触图片、数据库写入和对象存储', async () => {
|
||||
mockAuthorize.mockRejectedValue(new AppError(ErrorCode.AUTH_PERMISSION_DENIED))
|
||||
await expect(service.upload('product-2', photo, metadata, user)).rejects.toMatchObject({ code: ErrorCode.AUTH_PERMISSION_DENIED })
|
||||
expect(mockUpload).not.toHaveBeenCalled(); expect(mockSave).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('仅追加实拍URL,保留测量及其确认结果,不使用通用产品写接口', async () => {
|
||||
let mutation: { data: { payload: typeof record.payload; updatedBy: string } }
|
||||
mockSave.mockImplementation(async (_id, prepare) => {
|
||||
mutation = await prepare(structuredClone(record))
|
||||
return { status: 'saved' }
|
||||
})
|
||||
const result = await service.upload('product-1', photo, metadata, user)
|
||||
expect(result.file).toMatchObject({ category: '产品实拍图', productPhotoBarcode: metadata.barcode, productPhotoStyleKey: 'K01', photoSlot: 'front' })
|
||||
expect(result.file.dataUrl).toMatch(/^\/api\/storage\/object\?key=/)
|
||||
expect(mutation!.data.payload.uploadFiles).toEqual([oldFile, result.file])
|
||||
expect(mutation!.data.payload.sampleMeasurementRows).toEqual(record.payload.sampleMeasurementRows)
|
||||
expect(mutation!.data.payload.documentWorkspaceConfirmations).toEqual({ 样包测量: true, 产品实拍图: false })
|
||||
expect(mutation!.data.payload.documentWorkspaceApprovedAt).toEqual({ 样包测量: '2026-10-01' })
|
||||
expect(mockAuthorize).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
test.each([{ ...metadata, barcode: 'other-product-barcode' }, { ...metadata, styleKey: 'K02' }])(
|
||||
'不能将图片写入其他产品条码或不符的款式', async (invalidMetadata) => {
|
||||
await expect(service.upload('product-1', photo, invalidMetadata, user)).rejects.toMatchObject({ code: ErrorCode.VALIDATION_FAILED })
|
||||
expect(mockDelete).toHaveBeenCalledTimes(1); expect(mockLog).not.toHaveBeenCalled()
|
||||
},
|
||||
)
|
||||
|
||||
test('不接受伪装图片与任意字段,避免改变其他产品内容', async () => {
|
||||
await expect(service.upload('product-1', new File(['invalid'], 'fake.png', { type: 'image/png' }), metadata, user))
|
||||
.rejects.toMatchObject({ code: ErrorCode.VALIDATION_FAILED })
|
||||
await expect(service.upload('product-1', photo, { ...metadata, sampleMeasurementRows: [] }, user))
|
||||
.rejects.toMatchObject({ code: ErrorCode.VALIDATION_FAILED })
|
||||
expect(mockUpload).not.toHaveBeenCalled(); expect(mockSave).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('旧型号没有K款式后缀时沿用实拍工作区的唯一款式', async () => {
|
||||
mockSave.mockImplementation(async (_id, prepare) => ({ status: 'saved', ...await prepare({
|
||||
...structuredClone(record), skuItems: [{ barcode: metadata.barcode, model: 'LEGACY' }],
|
||||
}) }))
|
||||
expect((await service.upload('product-1', photo, metadata, user)).file.productPhotoStyleKey).toBe('K01')
|
||||
})
|
||||
|
||||
test('保存失败清理本次新对象并保留原错误', async () => {
|
||||
mockSave.mockRejectedValue(new Error('database unavailable'))
|
||||
await expect(service.upload('product-1', photo, metadata, user)).rejects.toThrow('database unavailable')
|
||||
expect(mockDelete).toHaveBeenCalledWith(mockUpload.mock.calls[0][1])
|
||||
})
|
||||
@ -6265,7 +6265,9 @@ class IntakeNonIntegratedProductService {
|
||||
|
||||
private async canReadRecord(record: IntakeNonIntegratedRecord, user: AuthUser) {
|
||||
const externalIntakeCreatorIds = await this.getExternalIntakeCreatorIdsForRead(user)
|
||||
return intakeNonIntegratedScopeService.canReadRecord(record, user, externalIntakeCreatorIds)
|
||||
if (intakeNonIntegratedScopeService.canReadRecord(record, user, externalIntakeCreatorIds)) return true
|
||||
const { intakeProductPhotoAccessService } = await import('./intake-product-photo-access.service')
|
||||
return intakeProductPhotoAccessService.canUpload(record.id, user)
|
||||
}
|
||||
|
||||
private async buildBarcodePrintReadScopeWhere(user: AuthUser) {
|
||||
|
||||
@ -23,6 +23,7 @@ import {
|
||||
resolveIntakeNodePermissionSubjectKeys,
|
||||
} from '@/lib/intake-node-permission-settings'
|
||||
import { intakeNodePermissionSettingsService } from './intake-node-permission-settings.service'
|
||||
import { intakeProductPhotoAccessService } from './intake-product-photo-access.service'
|
||||
import {
|
||||
OperationLogAction,
|
||||
OperationLogStatus,
|
||||
@ -561,6 +562,9 @@ class IntakeNonIntegratedScopeService {
|
||||
user: AuthUser,
|
||||
category?: string | null,
|
||||
): Promise<boolean> {
|
||||
if (category === '产品实拍图' && await intakeProductPhotoAccessService.canUpload(record.id, user)) {
|
||||
return true
|
||||
}
|
||||
if (isIntakeNonIntegratedFullEditor(user)) {
|
||||
return true
|
||||
}
|
||||
|
||||
@ -0,0 +1,17 @@
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import type { AuthUser } from '@/server/core/types'
|
||||
import { intakeProductPhotoRequestRepository } from '@/server/repositories/intake-product-photo-request.repository'
|
||||
import { isIntakeProductPhotoRecipient } from './intake-product-photo-recipient.policy'
|
||||
|
||||
export const intakeProductPhotoAccessService = {
|
||||
async canUpload(productId: string, user: AuthUser) {
|
||||
if (!isIntakeProductPhotoRecipient(user)) return false
|
||||
return intakeProductPhotoRequestRepository.hasGrant(productId, user.id)
|
||||
},
|
||||
|
||||
async assertCanUpload(productId: string, user: AuthUser) {
|
||||
if (!(await this.canUpload(productId, user))) {
|
||||
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '未获授权上传该产品的实拍图片')
|
||||
}
|
||||
},
|
||||
}
|
||||
@ -0,0 +1,84 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
buildProductOpeningDetailUrl, buildProductPhotoNotificationContent,
|
||||
PRODUCT_PHOTO_REQUEST_CONTENT, PRODUCT_PHOTO_REQUEST_EVENT,
|
||||
type ProductPhotoRequestRecipient,
|
||||
} from '@/lib/intake-product-photo-request'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import type { AuthUser } from '@/server/core/types'
|
||||
import { intakeProductPhotoRequestRepository as repository } from '@/server/repositories/intake-product-photo-request.repository'
|
||||
import { intakeNonIntegratedProductRepository } from '@/server/repositories/intake-non-integrated-product.repository'
|
||||
import { intakeNonIntegratedScopeService } from './intake-non-integrated-scope.service'
|
||||
import { intakeProductPhotoAccessService } from './intake-product-photo-access.service'
|
||||
import { isIntakeProductPhotoRecipient } from './intake-product-photo-recipient.policy'
|
||||
import { notificationDeliveryService } from './notification-delivery.service'
|
||||
|
||||
const sendSchema = z.object({ recipientId: z.string().trim().min(1).max(100), requestId: z.string().uuid() }).strict()
|
||||
|
||||
async function loadProduct(productId: string, user: AuthUser) {
|
||||
const product = await intakeNonIntegratedProductRepository.findActiveById(productId)
|
||||
if (!product || !(intakeNonIntegratedScopeService.canReadRecord(product, user)
|
||||
|| await intakeProductPhotoAccessService.canUpload(productId, user))) {
|
||||
throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, '产品不存在或无权查看')
|
||||
}
|
||||
return product
|
||||
}
|
||||
|
||||
async function canNotify(product: Awaited<ReturnType<typeof loadProduct>>, user: AuthUser) {
|
||||
if (user.status !== 'ACTIVE' || user.businessRoles?.some((role) => role === 'SUPPLIER' || role === 'EXTERNAL_INTAKE')) return false
|
||||
return intakeNonIntegratedScopeService.canWriteWorkspaceRecordWithSettings(product, user, '样包测量')
|
||||
}
|
||||
|
||||
async function listRecipients(userId?: string): Promise<ProductPhotoRequestRecipient[]> {
|
||||
const users = await repository.listCandidates(userId)
|
||||
const result: ProductPhotoRequestRecipient[] = []
|
||||
// Resolve directory memberships as well as the primary department, matching login permissions.
|
||||
for (const user of users) {
|
||||
const { departmentPaths } = user
|
||||
if (!isIntakeProductPhotoRecipient(user)) continue
|
||||
result.push({
|
||||
id: user.id, name: user.name,
|
||||
department: user.department?.fullPath || user.department?.name || departmentPaths[0] || '',
|
||||
deliverable: Boolean(user.dingtalkUserId?.trim()),
|
||||
issue: user.dingtalkUserId?.trim() ? null : '尚未绑定钉钉账号',
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export const intakeProductPhotoNotificationService = {
|
||||
async getAccess(productId: string, user: AuthUser, includeRecipients: boolean) {
|
||||
const product = await loadProduct(productId, user)
|
||||
const allowed = await canNotify(product, user)
|
||||
if (includeRecipients && !allowed) throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '没有权限发送样包测量通知')
|
||||
return {
|
||||
canNotify: allowed,
|
||||
canUpload: await intakeProductPhotoAccessService.canUpload(productId, user),
|
||||
detailUrl: buildProductOpeningDetailUrl(productId),
|
||||
content: PRODUCT_PHOTO_REQUEST_CONTENT,
|
||||
...(includeRecipients ? { recipients: await listRecipients() } : {}),
|
||||
}
|
||||
},
|
||||
|
||||
async send(productId: string, input: unknown, user: AuthUser) {
|
||||
const parsed = sendSchema.safeParse(input)
|
||||
if (!parsed.success) throw new AppError(ErrorCode.VALIDATION_FAILED, '请选择质检人员并提供有效的发送请求编号')
|
||||
const product = await loadProduct(productId, user)
|
||||
if (!(await canNotify(product, user))) throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '没有权限发送样包测量通知')
|
||||
const recipient = (await listRecipients(parsed.data.recipientId)).find((item) => item.id === parsed.data.recipientId)
|
||||
if (!recipient?.deliverable) throw new AppError(ErrorCode.VALIDATION_FAILED, recipient?.issue || '所选人员不在可接收通知名单中')
|
||||
|
||||
// Authorization is durable before the message can reach its recipient. A failed send can be retried.
|
||||
await repository.grant(productId, recipient.id, user.id)
|
||||
const delivery = await notificationDeliveryService.sendToEmployee({
|
||||
idempotencyKey: `photo-request:${createHash('sha256').update(JSON.stringify([productId, recipient.id, parsed.data.requestId])).digest('hex')}`,
|
||||
businessEvent: PRODUCT_PHOTO_REQUEST_EVENT, businessRecordId: productId,
|
||||
source: 'USER', initiatedById: user.id, initiatedByName: user.name,
|
||||
recipientUserId: recipient.id,
|
||||
title: '产品实拍上传通知',
|
||||
content: buildProductPhotoNotificationContent(productId, product.spu),
|
||||
})
|
||||
return { deliveryId: delivery.id, status: delivery.status, recipientName: recipient.name }
|
||||
},
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
import {
|
||||
isIntakeNonIntegratedQualityGroupUser,
|
||||
type IntakeNonIntegratedPermissionUser,
|
||||
} from '@/lib/non-integrated-permissions'
|
||||
|
||||
// 用户指定徐霞客参与通知和上传联调;使用账号 ID 避免给同名人员授权。
|
||||
const PHOTO_REQUEST_TEST_RECIPIENT_ID = 'cmqu91kmz00008kvuj5beestv'
|
||||
|
||||
export function isIntakeProductPhotoRecipient(
|
||||
user: IntakeNonIntegratedPermissionUser & { status: string },
|
||||
) {
|
||||
if (user.status !== 'ACTIVE'
|
||||
|| user.businessRoles?.some((role) => role === 'SUPPLIER' || role === 'EXTERNAL_INTAKE')) return false
|
||||
|
||||
// 进入名单不授予权限,上传仍需当前产品的单独授权。
|
||||
return isIntakeNonIntegratedQualityGroupUser(user) || user.id === PHOTO_REQUEST_TEST_RECIPIENT_ID
|
||||
}
|
||||
@ -0,0 +1,93 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import sharp from 'sharp'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { z } from 'zod'
|
||||
import { buildStableObjectAccessUrl, deleteObject, uploadBufferToObjectStorage } from '@/lib/storage'
|
||||
import { buildNonIntegratedAssetObjectStorageKey } from '@/app/intake/non-integrated/nonIntegratedAssetServer'
|
||||
import type { NonIntegratedUploadFile } from '@/app/intake/non-integrated/nonIntegratedStorage'
|
||||
import { getStyleKeyFromModel } from '@/app/intake/non-integrated/workspaces/style-assets/styleFileHelpers'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import type { AuthUser } from '@/server/core/types'
|
||||
import { intakeNonIntegratedProductRepository } from '@/server/repositories/intake-non-integrated-product.repository'
|
||||
import { intakeProductPhotoAccessService } from './intake-product-photo-access.service'
|
||||
import { recordIntakeFileUploadSucceeded } from './intake-non-integrated-operation-log.service'
|
||||
|
||||
const metadataSchema = z.object({
|
||||
barcode: z.string().trim().min(1).max(200),
|
||||
styleKey: z.string().trim().max(100),
|
||||
group: z.enum(['nine-view', 'detail', 'quality-check', 'competitor-sample', 'competitor-disassembly']),
|
||||
slot: z.enum(['perspective', 'top', 'back', 'left', 'positive', 'right', 'front', 'bottom', 'reverse']).optional(),
|
||||
}).strict().refine((value) => value.group !== 'nine-view' || Boolean(value.slot))
|
||||
|
||||
export const intakeProductPhotoUploadService = {
|
||||
async upload(productId: string, file: File, metadata: unknown, user: AuthUser) {
|
||||
await intakeProductPhotoAccessService.assertCanUpload(productId, user)
|
||||
const parsed = metadataSchema.safeParse(metadata)
|
||||
if (!parsed.success) throw new AppError(ErrorCode.VALIDATION_FAILED, '请选择当前产品的款式、条码和图片位置')
|
||||
if (file.size <= 0 || file.size > 20 * 1024 * 1024) throw new AppError(ErrorCode.VALIDATION_FAILED, '图片不能为空且不能超过 20 MB')
|
||||
const bytes = Buffer.from(await file.arrayBuffer())
|
||||
const image = await sharp(bytes, { limitInputPixels: 80_000_000 }).metadata().catch(() => null)
|
||||
if (!image?.format || !['jpeg', 'png', 'webp', 'gif', 'heif', 'avif'].includes(image.format)) {
|
||||
throw new AppError(ErrorCode.VALIDATION_FAILED, '请选择有效的产品实拍图片')
|
||||
}
|
||||
const extension = image.format === 'jpeg' ? 'jpg' : image.format
|
||||
const relativePath = `${productId}/产品实拍图/${randomUUID()}.${extension}`
|
||||
const objectKey = buildNonIntegratedAssetObjectStorageKey(relativePath)
|
||||
const uploadedFile: NonIntegratedUploadFile & { storageKey: string; size: number } = {
|
||||
id: `file-${randomUUID()}`, name: file.name,
|
||||
category: '产品实拍图', dataUrl: buildStableObjectAccessUrl(objectKey),
|
||||
mimeType: image.format === 'heif' ? 'image/heif' : `image/${image.format}`,
|
||||
storageKey: relativePath, size: bytes.length,
|
||||
photoGroup: parsed.data.group,
|
||||
...(parsed.data.slot ? { photoSlot: parsed.data.slot } : {}),
|
||||
productPhotoStyleKey: parsed.data.styleKey,
|
||||
productPhotoBarcode: parsed.data.barcode,
|
||||
}
|
||||
await uploadBufferToObjectStorage(bytes, objectKey, uploadedFile.mimeType!)
|
||||
try {
|
||||
const saved = await intakeNonIntegratedProductRepository.saveActiveWithLock(productId, async (record) => {
|
||||
await intakeProductPhotoAccessService.assertCanUpload(productId, user)
|
||||
const payload = record.payload as Record<string, Prisma.JsonValue>
|
||||
const uploadFiles = Array.isArray(payload.uploadFiles) ? payload.uploadFiles : []
|
||||
const sku = record.skuItems.find((item) => item.barcode.trim() === parsed.data.barcode)
|
||||
let styleKey = sku ? getStyleKeyFromModel(sku.model) : ''
|
||||
if (sku && !styleKey) {
|
||||
// Match the photo workspace's single-style fallback for legacy SKU models.
|
||||
const keys = new Set(record.skuItems.map((item) => getStyleKeyFromModel(item.model)).filter(Boolean))
|
||||
if (!keys.size) {
|
||||
for (const file of uploadFiles) {
|
||||
if (file && typeof file === 'object' && !Array.isArray(file) && file.category === '产品实拍图'
|
||||
&& typeof file.productPhotoStyleKey === 'string' && file.productPhotoStyleKey.trim()) {
|
||||
keys.add(file.productPhotoStyleKey.trim().toUpperCase())
|
||||
}
|
||||
}
|
||||
if (!keys.size) keys.add('K01')
|
||||
}
|
||||
if (keys.size === 1) styleKey = [...keys][0]
|
||||
}
|
||||
if (!sku || !styleKey || styleKey !== parsed.data.styleKey) {
|
||||
throw new AppError(ErrorCode.VALIDATION_FAILED, '图片只能关联当前产品的有效款式和条码')
|
||||
}
|
||||
const confirmations = (payload.documentWorkspaceConfirmations || {}) as Record<string, Prisma.JsonValue>
|
||||
const approvedAt = { ...(payload.documentWorkspaceApprovedAt || {}) as Record<string, Prisma.JsonValue> }
|
||||
delete approvedAt['产品实拍图']
|
||||
return {
|
||||
data: {
|
||||
payload: { ...payload, uploadFiles: [...uploadFiles, uploadedFile],
|
||||
documentWorkspaceConfirmations: { ...confirmations, 产品实拍图: false },
|
||||
documentWorkspaceApprovedAt: approvedAt, updatedAt: new Date().toISOString(),
|
||||
} as Prisma.InputJsonValue,
|
||||
updatedBy: user.id,
|
||||
},
|
||||
result: uploadedFile,
|
||||
}
|
||||
})
|
||||
if (saved.status !== 'saved') throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, '产品不存在')
|
||||
} catch (error) {
|
||||
await deleteObject(objectKey)
|
||||
throw error
|
||||
}
|
||||
await recordIntakeFileUploadSucceeded({ product: { id: productId }, file: uploadedFile, user })
|
||||
return { file: uploadedFile }
|
||||
},
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user