feat(intake): notify quality inspectors and keep product photo uploads available

This commit is contained in:
WuYongJie 2026-10-16 17:26:59 +08:00
parent 8a10b7f72f
commit a8b5c84759
26 changed files with 1122 additions and 161 deletions

View File

@ -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");

View File

@ -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")

View File

@ -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 })
}

View File

@ -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 })
}

View File

@ -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 () => {

View File

@ -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" &&

View File

@ -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,
],
);

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>

View File

@ -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);

View File

@ -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>
</>;
}

View File

@ -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;
}
}

View File

@ -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>
</>;
}

View File

@ -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"

View 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
}

View File

@ -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()))) ] }))
},
}

View File

@ -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)
})

View File

@ -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()
})

View File

@ -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])
})

View File

@ -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) {

View File

@ -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
}

View File

@ -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, '未获授权上传该产品的实拍图片')
}
},
}

View File

@ -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 }
},
}

View File

@ -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
}

View File

@ -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 }
},
}