Merge remote-tracking branch 'origin/codex/xuxiake-mainline' into codex/wuyongjie-ai-white-background-color-fix

This commit is contained in:
WuYongJie 2026-06-30 17:41:13 +08:00
commit 9b799dd838
15 changed files with 1269 additions and 100 deletions

View File

@ -30,6 +30,8 @@ function readListParams(request: NextRequest): IntakeNonIntegratedProductListPar
? 'created'
: searchParams.get('quickScope') === 'pending'
? 'pending'
: searchParams.get('quickScope') === 'processed'
? 'processed'
: 'all',
seriesName: searchParams.get('seriesName') ?? '',
spu: searchParams.get('spu') ?? '',

View File

@ -2576,7 +2576,7 @@ describe('IntakeClient', () => {
expect(screen.queryByText('BUY0002')).not.toBeInTheDocument()
})
it('filters responsible products by space designer for space visual users', async () => {
it('shows responsible products whenever the current user name appears in any owner field', async () => {
const actor = userEvent.setup()
replaceNonIntegratedProducts([
@ -2607,7 +2607,7 @@ describe('IntakeClient', () => {
await actor.selectOptions(screen.getByLabelText('资料范围'), 'created')
expect(screen.getByText('SPACE0001')).toBeInTheDocument()
expect(screen.queryByText('SPACE0002')).not.toBeInTheDocument()
expect(screen.getByText('SPACE0002')).toBeInTheDocument()
})
it('lets space visual department users open stock products for planning layout editing', async () => {
@ -2664,7 +2664,7 @@ describe('IntakeClient', () => {
createNonIntegratedProduct({
id: 'operation-owner-b',
spu: 'OPS0002',
designer: 'Operation A',
designer: 'Designer B',
spuOwner: 'Operation B',
operationsOwner: 'Operation B',
operator: 'Operation B',
@ -2685,7 +2685,7 @@ describe('IntakeClient', () => {
expect(screen.queryByText('OPS0002')).not.toBeInTheDocument()
})
it('shows pending products only when the current node belongs to the user', async () => {
it('shows pending products only after the purchase branch is actually triggered', async () => {
const actor = userEvent.setup()
replaceNonIntegratedProducts([
@ -2723,6 +2723,76 @@ describe('IntakeClient', () => {
expect(screen.queryByText('BUY-NOT-YET')).not.toBeInTheDocument()
})
it('shows structure designers pending flows after their branch is triggered even if another branch is currently earlier in the list', async () => {
const actor = userEvent.setup()
replaceNonIntegratedProducts([
createNonIntegratedProduct({
...createApprovedWorkflowStateBefore('企划布局'),
id: 'structure-branch-pending',
spu: 'STRUCT-PENDING',
designer: 'Appearance A',
openingReference: {
...createEmptyNonIntegratedOpeningReference(),
designer: 'Appearance A',
structureDesigner: 'Structure A',
},
visualPlanning: 'Space A',
visualDirector: 'Space A',
}),
])
renderSelector({
eligibleSkuList: [],
userRole: 'USER',
userName: 'Structure A',
userBusinessRoles: ['DESIGN'],
userDepartmentPaths: ['研发中心 > 结构设计部'],
})
await actor.selectOptions(screen.getByLabelText('资料范围'), 'pending')
expect(screen.getByText('STRUCT-PENDING')).toBeInTheDocument()
})
it('shows product planning pending once any SKU has been recorded into the system', async () => {
const actor = userEvent.setup()
replaceNonIntegratedProducts([
createNonIntegratedProduct({
...createApprovedWorkflowStateBefore('产品核价'),
id: 'planning-system-entered-pending',
spu: 'PLAN-PENDING',
productPlanning: 'Planning A',
pricingRows: [
{
skuId: 'sku-entered',
costPrice: '199',
productTag: '常规',
systemEnteredAt: '2026-06-29T09:00:00.000Z',
},
{
skuId: 'sku-waiting',
costPrice: '299',
productTag: '常规',
},
],
}),
])
renderSelector({
eligibleSkuList: [],
userRole: 'USER',
userName: 'Planning A',
userBusinessRoles: ['PLANNING'],
userDepartmentPaths: ['品牌中心 > 商品企划部'],
})
await actor.selectOptions(screen.getByLabelText('资料范围'), 'pending')
expect(screen.getByText('PLAN-PENDING')).toBeInTheDocument()
})
it('lets planning users open products for opening-reference editing only', async () => {
const actor = userEvent.setup()

View File

@ -39,6 +39,7 @@ import { useSeriesComponentAssetSkuMerge } from "./workspaces/basic-info/useSeri
import { useSeriesComponentDerivedSkuState } from "./workspaces/basic-info/useSeriesComponentDerivedSkuState";
import { useSeriesComponentPreviewImageSync } from "./workspaces/basic-info/useSeriesComponentPreviewImageSync";
import { useSkuImageUploadController } from "./workspaces/basic-info/useSkuImageUploadController";
import { useOpeningCodeOaPrefill } from "./workspaces/basic-info/useOpeningCodeOaPrefill";
import { buildFirstQualityWorkspaceRenderers } from "./workspaces/first-quality/firstQualityWorkspaceRenderers";
import { usePatentLedgerEntryController } from "./workspaces/patent/usePatentLedgerEntryController";
import { useRawMaterialProcessConfigEntryController } from "./workspaces/useRawMaterialProcessConfigEntryController";
@ -2783,6 +2784,25 @@ export default function NonIntegratedDetailClient({
setSpuCreateMode,
setStockProductSpu,
});
const {
handleOpeningCodeCommit,
handleOpeningCodeInputChange,
openingCodeLookupMessage,
openingCodeLookupState,
} = useOpeningCodeOaPrefill({
enabled:
mode === "create" &&
!isReadOnly &&
!isSeriesDevelopment &&
!isSeriesComponentCreate &&
!isStockProductCreate,
formValuesRef,
openingReferenceRef,
refreshSpuEstimate,
scheduleAutoSave,
setFormValuesAndRef,
setOpeningReferenceAndRef,
});
const {
applySkuSupplierName,
@ -4817,6 +4837,8 @@ export default function NonIntegratedDetailClient({
isSeriesDevelopment={isSeriesDevelopment}
isStockProductCreate={isStockProductCreate}
openingReference={openingReference}
openingCodeLookupMessage={openingCodeLookupMessage}
openingCodeLookupState={openingCodeLookupState}
previewFileInputRef={previewFileInputRef}
canUseExistingSpuCreateMode={
canUseExistingSpuCreateMode
@ -4836,6 +4858,8 @@ export default function NonIntegratedDetailClient({
stockProductSpu={stockProductSpu}
onAdaptableBrandsChange={updateAdaptableBrands}
onExistingSpuSelection={handleExistingSpuSelection}
onOpeningCodeCommit={handleOpeningCodeCommit}
onOpeningCodeInputChange={handleOpeningCodeInputChange}
onOpeningReferenceFieldChange={
handleOpeningReferenceFieldChange
}

View File

@ -44,6 +44,7 @@ export function BasicField({
searchableSelect = false,
includeEmptyOption = false,
clearable = false,
onCommit,
onChange,
}: {
label: string;
@ -57,6 +58,7 @@ export function BasicField({
searchableSelect?: boolean;
includeEmptyOption?: boolean;
clearable?: boolean;
onCommit?: () => void;
onChange: (value: string) => void;
}) {
if (type === "select") {
@ -150,6 +152,7 @@ export function BasicField({
placeholder={placeholder}
readOnly={readOnly}
filterOptions={Boolean((options ?? []).length)}
onCommit={onCommit}
onChange={onChange}
inputClassName={`h-9 w-full rounded-apple-lg border border-apple-separator px-3 text-sm text-apple-label-primary outline-none ${
!readOnly && (options ?? []).length > 0 ? "pr-9" : ""

View File

@ -52,7 +52,7 @@ export type NonIntegratedProductListParams = {
pageSize?: number
productDevelopmentKind?: 'product' | 'series'
productKind?: 'all' | 'new' | 'stock'
quickScope?: 'all' | 'created' | 'pending'
quickScope?: 'all' | 'created' | 'pending' | 'processed'
seriesName?: string
spu?: string
appearanceDesigner?: string
@ -69,6 +69,39 @@ export type NonIntegratedProductListParams = {
stockDataScope?: string
}
export type OaBoardProjectLookupItem = {
title: string
productName: string | null
brand: string | null
productCategory: string | null
categoryName: string | null
openingCode: string | null
currentStageName: string | null
currentApprovalStageName: string | null
styleImages: Array<{
url: string
alt: string
}>
formSummary: {
openingCode: string
demandType: string
category: string
categoryName: string
productPlanner: string
operationBrandOwner: string
spuOwner: string
appearanceDesigner: string
structureDesigner: string
purchaseOwner: string
merchandiserOwner: string
competitorLink: string
openingReason: string
salesPriceBand: string
platformPriceBand: string
openingBrand: string
}
}
export type GenerateNonIntegratedAiWhiteBackgroundSessionPayload = {
sessionId?: string
styleKey: string
@ -336,6 +369,28 @@ export async function listSharedNonIntegratedProductPage(
}>(response)
}
export async function listOaBoardProjects(params: {
approvalNumber?: string
keyword?: string
} = {}) {
const searchParams = new URLSearchParams()
Object.entries(params).forEach(([key, value]) => {
if (!value?.trim()) return
searchParams.set(key, value.trim())
})
const query = searchParams.toString()
const response = await fetchWithTimeout(`/api/intake/oa-board${query ? `?${query}` : ''}`, {
method: 'GET',
cache: 'no-store',
})
return readApiData<{
items: OaBoardProjectLookupItem[]
}>(response)
}
export async function listSharedNonIntegratedStockCategoryOptions() {
const response = await fetchWithTimeout('/api/intake/non-integrated/stock-category-options', {
method: 'GET',

View File

@ -1,4 +1,5 @@
import type { ChangeEvent, RefObject } from "react";
import { Loader2 } from "lucide-react";
import { AdaptableBrandField } from "../../components/BrandControls";
import { BasicField } from "../../components/FormControls";
@ -37,6 +38,8 @@ export function BasicInfoCreateSection({
isSeriesDevelopment,
isStockProductCreate,
openingReference,
openingCodeLookupMessage,
openingCodeLookupState,
previewFileInputRef,
canUseExistingSpuCreateMode,
createSpuDisplayLabel,
@ -49,6 +52,8 @@ export function BasicInfoCreateSection({
stockProductSpu,
onAdaptableBrandsChange,
onExistingSpuSelection,
onOpeningCodeCommit,
onOpeningCodeInputChange,
onOpeningReferenceFieldChange,
onPreviewImageChange,
onPreviewProductImage,
@ -72,6 +77,13 @@ export function BasicInfoCreateSection({
isSeriesDevelopment: boolean;
isStockProductCreate: boolean;
openingReference: NonIntegratedOpeningReference;
openingCodeLookupMessage?: string;
openingCodeLookupState?:
| "idle"
| "loading"
| "success"
| "missing"
| "error";
previewFileInputRef: RefObject<HTMLInputElement | null>;
canUseExistingSpuCreateMode: boolean;
createSpuDisplayLabel: string;
@ -84,6 +96,8 @@ export function BasicInfoCreateSection({
stockProductSpu: string;
onAdaptableBrandsChange: (value: string[]) => void;
onExistingSpuSelection: (value: string) => void;
onOpeningCodeCommit?: () => void;
onOpeningCodeInputChange?: (value: string) => void;
onOpeningReferenceFieldChange: (
field: OpeningReferenceFieldKey,
value: string,
@ -98,6 +112,14 @@ export function BasicInfoCreateSection({
}) {
const handlePreview = () =>
formValues.previewImage ? onPreviewProductImage() : undefined;
const openingCodeLookupMessageClassName =
openingCodeLookupState === "error"
? "text-[#a32135]"
: openingCodeLookupState === "success"
? "text-[#156b3f]"
: openingCodeLookupState === "missing"
? "text-[#8a5a00]"
: "text-apple-label-secondary";
return (
<SectionCard title={developmentCreateTitle} compact>
@ -183,16 +205,38 @@ export function BasicInfoCreateSection({
readOnly={isReadOnly}
/>
</div>
<BasicField
label="开款编号"
placeholder="请输入开款编号"
value={openingReference.temporaryCode}
readOnly={isReadOnly}
compact
onChange={(value) =>
onOpeningReferenceFieldChange("temporaryCode", value)
}
/>
<div className="space-y-1">
<BasicField
label="开款编号"
placeholder="请输入开款编号"
value={openingReference.temporaryCode}
readOnly={isReadOnly}
compact
onCommit={onOpeningCodeCommit}
onChange={(value) => {
onOpeningReferenceFieldChange("temporaryCode", value);
onOpeningCodeInputChange?.(value);
}}
/>
{openingCodeLookupState === "loading" ? (
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] leading-4 text-apple-label-secondary">
<Loader2 className="h-3 w-3 animate-spin" />
<span>正在从 OA 新品流程看板带入信息...</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-apple-bg-tertiary">
<div className="h-full w-2/5 rounded-full bg-[#5b7c83] animate-pulse" />
</div>
</div>
) : null}
{openingCodeLookupState !== "loading" && openingCodeLookupMessage ? (
<p
className={`text-[11px] leading-4 ${openingCodeLookupMessageClassName}`}
>
{openingCodeLookupMessage}
</p>
) : null}
</div>
<BasicField
label={developmentNameLabel}
required

View File

@ -0,0 +1,150 @@
import {
NON_INTEGRATED_DEFAULT_BRAND,
normalizeNonIntegratedBrand,
normalizeNonIntegratedBrandList,
type NonIntegratedOpeningReference,
} from "../../nonIntegratedStorage";
import type { OaBoardProjectLookupItem } from "../../core/nonIntegratedApi";
import type { FormValues } from "./productFormValueHelpers";
import { BRAND_OPTIONS } from "./basicInfoConfig";
type OaBoardImportPayload = {
formValues: FormValues;
openingReference: NonIntegratedOpeningReference;
};
function pickFirstNonEmpty(...values: Array<string | null | undefined>) {
for (const value of values) {
const normalized = String(value ?? "").trim();
if (normalized) {
return normalized;
}
}
return "";
}
function resolveImportedBrand(
currentBrand: string,
...brandCandidates: Array<string | null | undefined>
) {
const rawBrand = pickFirstNonEmpty(...brandCandidates);
const normalizedBrand = normalizeNonIntegratedBrand(rawBrand);
if (normalizedBrand && BRAND_OPTIONS.includes(normalizedBrand)) {
return normalizedBrand;
}
const fuzzyMatchedBrand = BRAND_OPTIONS.find((option) =>
rawBrand ? option.includes(rawBrand) : false,
);
if (fuzzyMatchedBrand) {
return fuzzyMatchedBrand;
}
return currentBrand || NON_INTEGRATED_DEFAULT_BRAND;
}
export function buildOaBoardOpeningCodeImportPayload(
item: OaBoardProjectLookupItem,
current: OaBoardImportPayload,
): OaBoardImportPayload {
const styleImage = item.styleImages[0];
const normalizedBrand = resolveImportedBrand(
current.formValues.brand,
item.formSummary.openingBrand,
item.brand,
);
const nextAdaptableBrands = normalizeNonIntegratedBrandList([
normalizedBrand,
...current.formValues.adaptableBrands,
...current.openingReference.adaptableBrands,
]).filter((brand) => BRAND_OPTIONS.includes(brand));
const nextSalesCategory = pickFirstNonEmpty(
item.formSummary.category,
item.productCategory,
);
const nextProductCategory = pickFirstNonEmpty(
item.formSummary.categoryName,
item.categoryName,
);
const nextProductName = pickFirstNonEmpty(item.productName, item.title);
const nextTargetPriceBand = pickFirstNonEmpty(
item.formSummary.salesPriceBand,
item.formSummary.platformPriceBand,
);
const nextPurchaseOwner = pickFirstNonEmpty(
item.formSummary.purchaseOwner,
item.formSummary.merchandiserOwner,
);
const nextCurrentStage = pickFirstNonEmpty(
item.currentApprovalStageName,
item.currentStageName,
);
return {
openingReference: {
...current.openingReference,
temporaryCode:
pickFirstNonEmpty(item.formSummary.openingCode, item.openingCode) ||
current.openingReference.temporaryCode,
productImage: pickFirstNonEmpty(styleImage?.url) || current.openingReference.productImage,
productImageName:
pickFirstNonEmpty(styleImage?.alt) || current.openingReference.productImageName,
productName: nextProductName || current.openingReference.productName,
brand: normalizedBrand,
adaptableBrands: nextAdaptableBrands,
salesCategory: nextSalesCategory || current.openingReference.salesCategory,
productCategory:
nextProductCategory || current.openingReference.productCategory,
designer:
pickFirstNonEmpty(item.formSummary.appearanceDesigner) ||
current.openingReference.designer,
planningOwner:
pickFirstNonEmpty(item.formSummary.productPlanner) ||
current.openingReference.planningOwner,
structureDesigner:
pickFirstNonEmpty(item.formSummary.structureDesigner) ||
current.openingReference.structureDesigner,
currentStage: nextCurrentStage || current.openingReference.currentStage,
competitorLink:
pickFirstNonEmpty(item.formSummary.competitorLink) ||
current.openingReference.competitorLink,
targetPriceBand:
nextTargetPriceBand || current.openingReference.targetPriceBand,
urgent:
pickFirstNonEmpty(item.formSummary.demandType) ||
current.openingReference.urgent,
reason:
pickFirstNonEmpty(item.formSummary.openingReason) ||
current.openingReference.reason,
},
formValues: {
...current.formValues,
previewImage: pickFirstNonEmpty(styleImage?.url) || current.formValues.previewImage,
previewImageName:
pickFirstNonEmpty(styleImage?.alt) || current.formValues.previewImageName,
brand: normalizedBrand,
adaptableBrands: nextAdaptableBrands,
salesCategory: nextSalesCategory || current.formValues.salesCategory,
productCategory:
nextProductCategory || current.formValues.productCategory,
designer:
pickFirstNonEmpty(item.formSummary.appearanceDesigner) ||
current.formValues.designer,
productPlanning:
pickFirstNonEmpty(item.formSummary.productPlanner) ||
current.formValues.productPlanning,
spuOwner:
pickFirstNonEmpty(item.formSummary.spuOwner) || current.formValues.spuOwner,
operator:
pickFirstNonEmpty(item.formSummary.operationBrandOwner) ||
current.formValues.operator,
operationsOwner:
pickFirstNonEmpty(item.formSummary.operationBrandOwner) ||
current.formValues.operationsOwner,
purchaseOwner: nextPurchaseOwner || current.formValues.purchaseOwner,
purchaser: nextPurchaseOwner || current.formValues.purchaser,
priceBand: nextTargetPriceBand || current.formValues.priceBand,
},
};
}

View File

@ -0,0 +1,182 @@
import { useCallback, useRef, useState, type MutableRefObject } from "react";
import type { FormValues } from "./productFormValueHelpers";
import type { NonIntegratedOpeningReference } from "../../nonIntegratedStorage";
import {
listOaBoardProjects,
type OaBoardProjectLookupItem,
} from "../../core/nonIntegratedApi";
import { buildOaBoardOpeningCodeImportPayload } from "./oaBoardOpeningCodeImport";
type LookupState = "idle" | "loading" | "success" | "missing" | "error";
type UseOpeningCodeOaPrefillParams = {
enabled: boolean;
formValuesRef: MutableRefObject<FormValues>;
openingReferenceRef: MutableRefObject<NonIntegratedOpeningReference>;
refreshSpuEstimate: () => void;
scheduleAutoSave: () => void;
setFormValuesAndRef: (updater: (current: FormValues) => FormValues) => void;
setOpeningReferenceAndRef: (
updater: (
current: NonIntegratedOpeningReference,
) => NonIntegratedOpeningReference,
) => void;
};
function normalizeOpeningCode(value: string | null | undefined) {
return String(value ?? "").trim().toUpperCase();
}
function matchesOpeningCode(
item: OaBoardProjectLookupItem,
openingCode: string,
) {
return [
item.formSummary.openingCode,
item.openingCode,
].some((value) => normalizeOpeningCode(value) === openingCode);
}
function resolveMatchingOaBoardItem(
items: OaBoardProjectLookupItem[],
openingCode: string,
) {
const exactMatches = items.filter((item) => matchesOpeningCode(item, openingCode));
if (exactMatches.length === 1) {
return exactMatches[0];
}
if (exactMatches.length > 1) {
throw new Error("这个开款编号在 OA 看板里查到了多条记录,请先人工确认。");
}
return items.length === 1 ? items[0] : null;
}
export function useOpeningCodeOaPrefill({
enabled,
formValuesRef,
openingReferenceRef,
refreshSpuEstimate,
scheduleAutoSave,
setFormValuesAndRef,
setOpeningReferenceAndRef,
}: UseOpeningCodeOaPrefillParams) {
const [lookupState, setLookupState] = useState<LookupState>("idle");
const [lookupMessage, setLookupMessage] = useState("");
const lastImportedOpeningCodeRef = useRef("");
const activeRequestIdRef = useRef(0);
const handleOpeningCodeInputChange = useCallback((value: string) => {
const normalizedValue = normalizeOpeningCode(value);
if (!normalizedValue) {
lastImportedOpeningCodeRef.current = "";
setLookupState("idle");
setLookupMessage("");
return;
}
if (normalizedValue !== lastImportedOpeningCodeRef.current) {
setLookupState("idle");
setLookupMessage("");
}
}, []);
const handleOpeningCodeCommit = useCallback(async () => {
if (!enabled) return;
const openingCode = normalizeOpeningCode(
openingReferenceRef.current.temporaryCode,
);
if (!openingCode) {
lastImportedOpeningCodeRef.current = "";
setLookupState("idle");
setLookupMessage("");
return;
}
if (openingCode === lastImportedOpeningCodeRef.current) {
return;
}
const requestId = activeRequestIdRef.current + 1;
activeRequestIdRef.current = requestId;
setLookupState("loading");
setLookupMessage("正在从 OA 新品流程看板带入信息...");
try {
const { items } = await listOaBoardProjects({
approvalNumber: openingCode,
});
if (activeRequestIdRef.current !== requestId) {
return;
}
if (
normalizeOpeningCode(openingReferenceRef.current.temporaryCode) !==
openingCode
) {
return;
}
const matchedItem = resolveMatchingOaBoardItem(items, openingCode);
if (!matchedItem) {
lastImportedOpeningCodeRef.current = "";
setLookupState("missing");
setLookupMessage("未找到对应 OA 开款信息,先手动填写也可以。");
return;
}
const nextState = buildOaBoardOpeningCodeImportPayload(matchedItem, {
formValues: formValuesRef.current,
openingReference: openingReferenceRef.current,
});
const importedDisplayName =
nextState.openingReference.productName.trim() || openingCode;
setFormValuesAndRef(() => nextState.formValues);
setOpeningReferenceAndRef(() => nextState.openingReference);
refreshSpuEstimate();
scheduleAutoSave();
lastImportedOpeningCodeRef.current = openingCode;
setLookupState("success");
setLookupMessage(`已带入「${importedDisplayName}」的 OA 信息。`);
} catch (error) {
if (activeRequestIdRef.current !== requestId) {
return;
}
if (
normalizeOpeningCode(openingReferenceRef.current.temporaryCode) !==
openingCode
) {
return;
}
setLookupState("error");
setLookupMessage(
error instanceof Error ? error.message : "读取 OA 信息失败,请稍后重试。",
);
}
}, [
enabled,
formValuesRef,
openingReferenceRef,
refreshSpuEstimate,
scheduleAutoSave,
setFormValuesAndRef,
setOpeningReferenceAndRef,
]);
return {
handleOpeningCodeCommit,
handleOpeningCodeInputChange,
openingCodeLookupMessage: lookupMessage,
openingCodeLookupState: lookupState,
};
}

View File

@ -74,6 +74,7 @@ import {
isCurrentUserPendingNonIntegratedProduct,
isDocumentWorkspaceReadyForList,
} from "@/app/intake/product-development/nonIntegratedCurrentNode";
import { isCurrentUserProcessedNonIntegratedProduct } from "@/app/intake/product-development/nonIntegratedProcessedScope";
import {
canEditNonIntegratedProduct,
canOpenNonIntegratedProductForEdit,
@ -97,11 +98,19 @@ function createRestoredNonIntegratedProduct(
const PENDING_PAGE_SIZE = 50;
const TABLE_ACTION_BUTTON_BASE =
"!h-8 !min-h-8 !w-8 !rounded-md !px-0 !py-0 !shadow-none";
"!h-8 !min-h-8 !w-8 !rounded-apple-md !px-0 !py-0 !shadow-none";
const TABLE_ACTION_BUTTON_SECONDARY = `${TABLE_ACTION_BUTTON_BASE} !border-zinc-200 !bg-white !text-zinc-500 hover:!border-zinc-300 hover:!bg-zinc-50 hover:!text-zinc-950 focus:!border-zinc-400 disabled:!border-zinc-100 disabled:!bg-zinc-50 disabled:!text-zinc-300`;
const TABLE_ACTION_BUTTON_PRIMARY = `${TABLE_ACTION_BUTTON_BASE} !border-zinc-950 !bg-zinc-950 !text-white hover:!border-zinc-800 hover:!bg-zinc-800 focus:!border-zinc-600 disabled:!border-zinc-200 disabled:!bg-zinc-200 disabled:!text-zinc-400`;
const INTAKE_PRIMARY_BUTTON_CLASS =
"!border-zinc-950 !bg-zinc-950 !bg-none !text-white !shadow-none hover:!border-zinc-800 hover:!bg-zinc-800 focus:!border-zinc-600 disabled:!border-zinc-200 disabled:!bg-zinc-200 disabled:!text-zinc-400";
const INTAKE_TOOLBAR_BUTTON_CLASS =
"!h-8 !min-h-8 !rounded-apple-md !px-3 text-xs max-md:!h-7 max-md:!min-h-7 max-md:!px-2 max-md:text-[11px]";
const INTAKE_TOOLBAR_DARK_SELECT_CLASS =
"h-8 w-full rounded-apple-md border border-zinc-950 bg-zinc-950 px-3 text-xs font-apple-medium text-white shadow-none outline-none transition-colors max-md:h-7 max-md:px-2 max-md:text-[11px]";
const TABLE_HEADER_CELL_CLASS =
"sticky top-0 z-10 bg-apple-bg-primary px-3 py-2.5 font-apple-medium whitespace-nowrap";
const TABLE_HEADER_CENTER_CELL_CLASS =
"sticky top-0 z-10 bg-apple-bg-primary px-3 py-2.5 text-center font-apple-medium whitespace-nowrap";
function isDefaultInitialListRequest(params: {
activeQuickScope: NonIntegratedQuickScope;
@ -636,11 +645,17 @@ export function NonIntegratedPendingDetail({
product,
currentUser,
)
: isCurrentUserPendingNonIntegratedProduct(
product,
currentUser,
documentWorkspaceCountByCategory,
));
: activeQuickScope === "pending"
? isCurrentUserPendingNonIntegratedProduct(
product,
currentUser,
documentWorkspaceCountByCategory,
)
: isCurrentUserProcessedNonIntegratedProduct(
product,
currentUser,
documentWorkspaceCountByCategory,
));
return (
productKindMatched &&
@ -880,18 +895,28 @@ export function NonIntegratedPendingDetail({
const filterFields = isStockDevelopmentList
? stockFilterFields
: seriesProductFilterFields;
const createActionCount = [
onOpenNewNonIntegratedProductWorkspace,
onOpenSecondaryNewNonIntegratedProductWorkspace,
].filter(Boolean).length;
const hasMultipleCreateActions = createActionCount > 1;
const filterGridColumnClass =
filterFields.length === 8
? hasCreateAction
? "md:grid-cols-[116px_repeat(8,minmax(0,1fr))_72px_216px]"
? hasMultipleCreateActions
? "md:grid-cols-[116px_repeat(8,minmax(0,1fr))_72px_216px]"
: "md:grid-cols-[116px_repeat(8,minmax(0,1fr))_72px_132px]"
: "md:grid-cols-[116px_repeat(8,minmax(0,1fr))_72px]"
: hasCreateAction
? "md:grid-cols-[116px_repeat(7,minmax(0,1fr))_72px_216px]"
? hasMultipleCreateActions
? "md:grid-cols-[116px_repeat(7,minmax(0,1fr))_72px_216px]"
: "md:grid-cols-[116px_repeat(7,minmax(0,1fr))_72px_132px]"
: "md:grid-cols-[116px_repeat(7,minmax(0,1fr))_72px]";
const quickScopes: Array<{ key: NonIntegratedQuickScope; label: string }> = [
{ key: "all", label: "全部" },
{ key: "created", label: "我负责的" },
{ key: "pending", label: "待处理" },
{ key: "processed", label: "已处理" },
];
const listColumnCount =
11 +
@ -915,7 +940,7 @@ export function NonIntegratedPendingDetail({
);
setCurrentPage(1);
}}
className="h-8 w-full rounded-apple-md border border-zinc-950 bg-zinc-950 px-2.5 text-xs font-apple-medium text-white outline-none max-md:h-7 max-md:px-2 max-md:text-[11px]"
className={INTAKE_TOOLBAR_DARK_SELECT_CLASS}
>
{quickScopes.map((scope) => (
<option key={scope.key} value={scope.key}>
@ -967,7 +992,7 @@ export function NonIntegratedPendingDetail({
<div className="flex min-w-[68px] items-end md:min-w-0">
<Button
size="small"
className={`h-8 w-full text-xs max-md:!h-7 max-md:!min-h-7 max-md:px-2 max-md:text-[11px] ${INTAKE_PRIMARY_BUTTON_CLASS}`}
className={`${INTAKE_TOOLBAR_BUTTON_CLASS} w-full ${INTAKE_PRIMARY_BUTTON_CLASS}`}
onClick={() => {
setAppliedQuery(queryDraft);
setCurrentPage(1);
@ -977,11 +1002,17 @@ export function NonIntegratedPendingDetail({
</Button>
</div>
{hasCreateAction ? (
<div className="flex min-w-[172px] items-end justify-end gap-2 max-md:min-w-[126px] md:min-w-0">
<div
className={`flex items-end justify-end gap-2 md:min-w-0 ${
hasMultipleCreateActions
? "min-w-[172px] max-md:min-w-[126px]"
: "min-w-[118px] max-md:min-w-[104px]"
}`}
>
{onOpenNewNonIntegratedProductWorkspace ? (
<Button
size="small"
className={`h-8 min-w-0 flex-1 px-3 text-xs max-md:!h-7 max-md:!min-h-7 max-md:px-2 max-md:text-[11px] ${INTAKE_PRIMARY_BUTTON_CLASS}`}
className={`${INTAKE_TOOLBAR_BUTTON_CLASS} min-w-0 flex-1 ${INTAKE_PRIMARY_BUTTON_CLASS}`}
onClick={onOpenNewNonIntegratedProductWorkspace}
>
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
@ -992,7 +1023,7 @@ export function NonIntegratedPendingDetail({
secondaryCreateButtonLabel ? (
<Button
size="small"
className={`h-8 min-w-0 flex-1 px-3 text-xs max-md:!h-7 max-md:!min-h-7 max-md:px-2 max-md:text-[11px] ${INTAKE_PRIMARY_BUTTON_CLASS}`}
className={`${INTAKE_TOOLBAR_BUTTON_CLASS} min-w-0 flex-1 ${INTAKE_PRIMARY_BUTTON_CLASS}`}
onClick={onOpenSecondaryNewNonIntegratedProductWorkspace}
>
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
@ -1014,45 +1045,45 @@ export function NonIntegratedPendingDetail({
>
<thead className="bg-apple-bg-primary text-apple-label-secondary">
<tr className="border-b border-apple-separator">
<th className="min-w-[56px] px-3 py-2.5 font-apple-medium whitespace-nowrap">
<th className={`min-w-[56px] ${TABLE_HEADER_CELL_CLASS}`}>
序号
</th>
<th className="min-w-[96px] px-3 py-2.5 font-apple-medium whitespace-nowrap">
<th className={`min-w-[96px] ${TABLE_HEADER_CELL_CLASS}`}>
成品预览图
</th>
{isSeriesDevelopmentList ? (
<th className="min-w-[144px] px-3 py-2.5 font-apple-medium whitespace-nowrap">
<th className={`min-w-[144px] ${TABLE_HEADER_CELL_CLASS}`}>
系列名称
</th>
) : null}
<th className="min-w-[144px] px-3 py-2.5 font-apple-medium whitespace-nowrap">
<th className={`min-w-[144px] ${TABLE_HEADER_CELL_CLASS}`}>
<SpuLabel />
</th>
<th className="min-w-[96px] px-3 py-2.5 font-apple-medium whitespace-nowrap">
<th className={`min-w-[96px] ${TABLE_HEADER_CELL_CLASS}`}>
外观设计师
</th>
<th className="min-w-[96px] px-3 py-2.5 font-apple-medium whitespace-nowrap">
<th className={`min-w-[96px] ${TABLE_HEADER_CELL_CLASS}`}>
结构设计师
</th>
<th className="min-w-[96px] px-3 py-2.5 font-apple-medium whitespace-nowrap">
<th className={`min-w-[96px] ${TABLE_HEADER_CELL_CLASS}`}>
SPU负责人
</th>
<th className="min-w-[96px] px-3 py-2.5 font-apple-medium whitespace-nowrap">
<th className={`min-w-[96px] ${TABLE_HEADER_CELL_CLASS}`}>
商品企划
</th>
<th className="min-w-[96px] px-3 py-2.5 font-apple-medium whitespace-nowrap">
<th className={`min-w-[96px] ${TABLE_HEADER_CELL_CLASS}`}>
空间设计师
</th>
<th className="min-w-[96px] px-3 py-2.5 font-apple-medium whitespace-nowrap">
<th className={`min-w-[96px] ${TABLE_HEADER_CELL_CLASS}`}>
采购负责人
</th>
<th className="min-w-[96px] px-3 py-2.5 font-apple-medium whitespace-nowrap">
<th className={`min-w-[96px] ${TABLE_HEADER_CELL_CLASS}`}>
当前节点
</th>
{NON_INTEGRATED_DOCUMENT_STATUS_COLUMNS.map((category) => (
<th
key={category}
className="min-w-[118px] px-3 py-2.5 text-center font-apple-medium whitespace-nowrap"
className={`min-w-[118px] ${TABLE_HEADER_CENTER_CELL_CLASS}`}
>
<div className="mx-auto flex w-max items-center justify-center gap-1.5 whitespace-nowrap">
<span>{getNonIntegratedListNodeLabel(category)}</span>
@ -1064,7 +1095,7 @@ export function NonIntegratedPendingDetail({
</div>
</th>
))}
<th className="sticky right-0 z-20 min-w-[144px] border-l border-apple-separator bg-apple-bg-primary px-2.5 py-2.5 text-center font-apple-medium whitespace-nowrap shadow-[-10px_0_14px_-14px_rgba(15,23,42,0.28)]">
<th className="sticky top-0 right-0 z-20 min-w-[144px] border-l border-apple-separator bg-apple-bg-primary px-2.5 py-2.5 text-center font-apple-medium whitespace-nowrap shadow-[-10px_0_14px_-14px_rgba(15,23,42,0.28)]">
操作
</th>
</tr>

View File

@ -22,6 +22,29 @@ import {
isIntakeNonIntegratedQualityGroupUser,
} from "@/lib/non-integrated-permissions";
const APPEARANCE_PENDING_CATEGORIES: NonIntegratedDocumentStatusCategory[] = [
"开品参考",
"设计卖点",
"产品模型",
"专利申请",
];
const STRUCTURE_PENDING_CATEGORIES: NonIntegratedDocumentStatusCategory[] = [
"产品模型",
"评审资料",
NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY,
"产品实拍图",
"安装方式",
"包装细节",
];
const PURCHASE_PENDING_CATEGORIES: NonIntegratedDocumentStatusCategory[] = [
"产品定厂",
"样包测量",
"成品寄样",
"产品核价",
];
function getUniqueNonEmptyValues(values: Array<string | undefined | null>) {
return Array.from(
new Set(
@ -57,6 +80,42 @@ export function hasProductPricingRowsSystemEnteredForList(
);
}
function hasAnyProductPricingRowSystemEnteredForList(
product: NonIntegratedProductRecord,
) {
return (product.pricingRows ?? []).some(
(row) => parseProgressDate(row.systemEnteredAt) !== null,
);
}
function hasSampleMeasurementContentForPending(
product: NonIntegratedProductRecord,
) {
return (product.sampleMeasurementRows ?? []).some((row) =>
[row.lengthCm, row.widthCm, row.heightCm, row.weightKg].some(
(value) => value.trim().length > 0,
),
);
}
function hasIncompleteTriggeredCategories(
product: NonIntegratedProductRecord,
categories: NonIntegratedDocumentStatusCategory[],
documentWorkspaceCountByCategory: Record<
NonIntegratedDocumentStatusCategory,
number
>,
) {
return categories.some(
(category) =>
!isDocumentWorkspaceReadyForList(
product,
category,
documentWorkspaceCountByCategory,
),
);
}
function getCurrentNodeOwnerCandidates(
product: NonIntegratedProductRecord,
category: NonIntegratedDocumentStatusCategory,
@ -258,13 +317,95 @@ export function isCurrentUserPendingNonIntegratedProduct(
number
>,
) {
const currentCategory = getCurrentNodeCategoryForList(
if (isAdminUser(user)) {
return getCurrentNodeCategoryForList(product, documentWorkspaceCountByCategory) !== null;
}
const people = getNonIntegratedListPeople(product);
const basicInfoReady = isDocumentWorkspaceReadyForList(
product,
NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY,
documentWorkspaceCountByCategory,
);
if (!currentCategory) return false;
const designSellPointReady = isDocumentWorkspaceReadyForList(
product,
"设计卖点",
documentWorkspaceCountByCategory,
);
const factoryConfirmed = hasProductFactoryRowsConfirmedForList(product);
const pricingGroupTriggered =
factoryConfirmed && hasSampleMeasurementContentForPending(product);
const systemEnteredTriggered =
hasAnyProductPricingRowSystemEnteredForList(product);
return isCurrentUserResponsibleForCurrentNode(product, user, currentCategory);
const appearancePending =
matchesCurrentUserName([people.appearanceDesigner], user) &&
hasIncompleteTriggeredCategories(
product,
APPEARANCE_PENDING_CATEGORIES,
documentWorkspaceCountByCategory,
);
if (appearancePending) return true;
const structurePending =
matchesCurrentUserName([people.structureDesigner], user) &&
designSellPointReady &&
hasIncompleteTriggeredCategories(
product,
STRUCTURE_PENDING_CATEGORIES,
documentWorkspaceCountByCategory,
);
if (structurePending) return true;
const spacePending =
matchesCurrentUserName([people.spaceDesigner], user) &&
basicInfoReady &&
!isDocumentWorkspaceReadyForList(
product,
"企划布局",
documentWorkspaceCountByCategory,
);
if (spacePending) return true;
const purchasePending =
matchesCurrentUserName([people.purchaseOwner], user) &&
basicInfoReady &&
hasIncompleteTriggeredCategories(
product,
PURCHASE_PENDING_CATEGORIES,
documentWorkspaceCountByCategory,
);
if (purchasePending) return true;
const pricingPending =
isIntakeNonIntegratedPricingGroupUser(user) &&
pricingGroupTriggered &&
!isDocumentWorkspaceReadyForList(
product,
"产品核价",
documentWorkspaceCountByCategory,
);
if (pricingPending) return true;
const planningPending =
matchesCurrentUserName([people.planningOwner], user) &&
systemEnteredTriggered &&
!isDocumentWorkspaceReadyForList(
product,
"产品限价",
documentWorkspaceCountByCategory,
);
if (planningPending) return true;
return (
isIntakeNonIntegratedQualityGroupUser(user) &&
systemEnteredTriggered &&
!isDocumentWorkspaceReadyForList(
product,
"工厂质检",
documentWorkspaceCountByCategory,
)
);
}
export function isDocumentWorkspaceApprovedForList(

View File

@ -1,6 +1,10 @@
import type { ProductDevelopmentKind } from "@/app/intake/intakeWorkspaceTypes";
export type NonIntegratedQuickScope = "all" | "created" | "pending";
export type NonIntegratedQuickScope =
| "all"
| "created"
| "pending"
| "processed";
export type NonIntegratedProductKindFilter = "all" | "new" | "stock";
export type NonIntegratedListQuery = {
@ -86,7 +90,11 @@ export function normalizeNonIntegratedListQuery(
export function normalizePersistedQuickScope(
value: unknown,
): NonIntegratedQuickScope {
return value === "created" || value === "pending" ? value : "all";
return value === "created" ||
value === "pending" ||
value === "processed"
? value
: "all";
}
export function normalizePersistedPage(value: unknown) {

View File

@ -0,0 +1,193 @@
import {
NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY,
type NonIntegratedProductRecord,
} from "@/app/intake/non-integrated/nonIntegratedStorage";
import { hasProductFactoryRowsConfirmedForList } from "@/app/intake/product-development/nonIntegratedDocumentCounts";
import type { NonIntegratedDocumentStatusCategory } from "@/app/intake/product-development/nonIntegratedDocumentStatus";
import { isDocumentWorkspaceReadyForList } from "@/app/intake/product-development/nonIntegratedCurrentNode";
import {
getNonIntegratedListPeople,
matchesCurrentUserName,
type NonIntegratedListPermissionUser,
} from "@/app/intake/product-development/nonIntegratedProductListLogic";
import { parseProgressDate } from "@/app/intake/product-development/progress-dashboard/productProgressHelpers";
import {
isIntakeNonIntegratedPricingGroupUser,
isIntakeNonIntegratedQualityGroupUser,
} from "@/lib/non-integrated-permissions";
const APPEARANCE_PENDING_CATEGORIES: NonIntegratedDocumentStatusCategory[] = [
"开品参考",
"设计卖点",
"产品模型",
"专利申请",
];
const STRUCTURE_PENDING_CATEGORIES: NonIntegratedDocumentStatusCategory[] = [
"产品模型",
"评审资料",
NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY,
"产品实拍图",
"安装方式",
"包装细节",
];
const PURCHASE_PENDING_CATEGORIES: NonIntegratedDocumentStatusCategory[] = [
"产品定厂",
"样包测量",
"成品寄样",
"产品核价",
];
function hasAnyProductPricingRowSystemEnteredForProcessed(
product: NonIntegratedProductRecord,
) {
return (product.pricingRows ?? []).some(
(row) => parseProgressDate(row.systemEnteredAt) !== null,
);
}
function hasSampleMeasurementContentForProcessed(
product: NonIntegratedProductRecord,
) {
return (product.sampleMeasurementRows ?? []).some((row) =>
[row.lengthCm, row.widthCm, row.heightCm, row.weightKg].some(
(value) => value.trim().length > 0,
),
);
}
function areAllTriggeredCategoriesReady(
product: NonIntegratedProductRecord,
categories: NonIntegratedDocumentStatusCategory[],
documentWorkspaceCountByCategory: Record<
NonIntegratedDocumentStatusCategory,
number
>,
) {
return categories.every((category) =>
isDocumentWorkspaceReadyForList(
product,
category,
documentWorkspaceCountByCategory,
),
);
}
type ResponsibilitySegmentStatus = {
applies: boolean;
triggered: boolean;
complete: boolean;
};
function getCurrentUserTriggeredResponsibilitySegments(
product: NonIntegratedProductRecord,
user: NonIntegratedListPermissionUser,
documentWorkspaceCountByCategory: Record<
NonIntegratedDocumentStatusCategory,
number
>,
): ResponsibilitySegmentStatus[] {
const people = getNonIntegratedListPeople(product);
const basicInfoReady = isDocumentWorkspaceReadyForList(
product,
NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY,
documentWorkspaceCountByCategory,
);
const designSellPointReady = isDocumentWorkspaceReadyForList(
product,
"设计卖点",
documentWorkspaceCountByCategory,
);
const factoryConfirmed = hasProductFactoryRowsConfirmedForList(product);
const pricingGroupTriggered =
factoryConfirmed && hasSampleMeasurementContentForProcessed(product);
const systemEnteredTriggered =
hasAnyProductPricingRowSystemEnteredForProcessed(product);
return [
{
applies: matchesCurrentUserName([people.appearanceDesigner], user),
triggered: true,
complete: areAllTriggeredCategoriesReady(
product,
APPEARANCE_PENDING_CATEGORIES,
documentWorkspaceCountByCategory,
),
},
{
applies: matchesCurrentUserName([people.structureDesigner], user),
triggered: designSellPointReady,
complete: areAllTriggeredCategoriesReady(
product,
STRUCTURE_PENDING_CATEGORIES,
documentWorkspaceCountByCategory,
),
},
{
applies: matchesCurrentUserName([people.spaceDesigner], user),
triggered: basicInfoReady,
complete: isDocumentWorkspaceReadyForList(
product,
"企划布局",
documentWorkspaceCountByCategory,
),
},
{
applies: matchesCurrentUserName([people.purchaseOwner], user),
triggered: basicInfoReady,
complete: areAllTriggeredCategoriesReady(
product,
PURCHASE_PENDING_CATEGORIES,
documentWorkspaceCountByCategory,
),
},
{
applies: isIntakeNonIntegratedPricingGroupUser(user),
triggered: pricingGroupTriggered,
complete: isDocumentWorkspaceReadyForList(
product,
"产品核价",
documentWorkspaceCountByCategory,
),
},
{
applies: matchesCurrentUserName([people.planningOwner], user),
triggered: systemEnteredTriggered,
complete: isDocumentWorkspaceReadyForList(
product,
"产品限价",
documentWorkspaceCountByCategory,
),
},
{
applies: isIntakeNonIntegratedQualityGroupUser(user),
triggered: systemEnteredTriggered,
complete: isDocumentWorkspaceReadyForList(
product,
"工厂质检",
documentWorkspaceCountByCategory,
),
},
];
}
export function isCurrentUserProcessedNonIntegratedProduct(
product: NonIntegratedProductRecord,
user: NonIntegratedListPermissionUser,
documentWorkspaceCountByCategory: Record<
NonIntegratedDocumentStatusCategory,
number
>,
) {
const triggeredSegments = getCurrentUserTriggeredResponsibilitySegments(
product,
user,
documentWorkspaceCountByCategory,
).filter((segment) => segment.applies && segment.triggered);
return (
triggeredSegments.length > 0 &&
triggeredSegments.every((segment) => segment.complete)
);
}

View File

@ -83,63 +83,17 @@ export function matchesCurrentUserName(
);
}
function hasBusinessRole(user: NonIntegratedListPermissionUser, role: string) {
return Boolean(user.businessRoles?.includes(role));
}
export function isAdminUser(user: NonIntegratedListPermissionUser) {
return user.role === "SUPER_ADMIN" || user.role === "ADMIN";
}
function getUserDepartmentText(user: NonIntegratedListPermissionUser) {
return [
user.department?.name,
user.department?.fullPath,
...(user.departmentPaths ?? []),
]
.map((value) => value?.trim() ?? "")
.filter(Boolean)
.join(" ");
}
function isSpaceDesignUser(user: NonIntegratedListPermissionUser) {
const departmentText = getUserDepartmentText(user);
return hasBusinessRole(user, "DESIGN") && /空间|视觉/.test(departmentText);
}
function isOperationsOwnerUser(user: NonIntegratedListPermissionUser) {
return /运营/.test(getUserDepartmentText(user));
function isExternalIntakeListUser(user: NonIntegratedListPermissionUser) {
return Boolean(user.businessRoles?.includes("EXTERNAL_INTAKE"));
}
export function getCurrentUserResponsibleOwnerValues(
product: NonIntegratedProductRecord,
user: NonIntegratedListPermissionUser,
) {
const people = getNonIntegratedListPeople(product);
const scopedValues: string[] = [];
if (hasBusinessRole(user, "DESIGN")) {
if (isSpaceDesignUser(user)) {
scopedValues.push(people.spaceDesigner);
} else {
scopedValues.push(people.appearanceDesigner, people.structureDesigner);
}
}
if (hasBusinessRole(user, "PLANNING")) {
scopedValues.push(people.planningOwner);
}
if (hasBusinessRole(user, "PURCHASING")) {
scopedValues.push(people.purchaseOwner);
}
if (isOperationsOwnerUser(user)) {
scopedValues.push(product.spuOwner, product.operator, product.operationsOwner);
}
if (scopedValues.length > 0) {
return scopedValues;
}
return getLegacyNonIntegratedProductOwnerNames(product);
}
@ -151,8 +105,10 @@ export function isCurrentUserResponsibleForNonIntegratedProduct(
return true;
}
return matchesCurrentUserName(
getCurrentUserResponsibleOwnerValues(product, user),
user,
);
if (isExternalIntakeListUser(user)) {
const userId = safeTrim(user.id);
return userId.length > 0 && safeTrim(product.createdBy) === userId;
}
return matchesCurrentUserName(getCurrentUserResponsibleOwnerValues(product), user);
}

View File

@ -3,6 +3,7 @@ import {
NON_INTEGRATED_DEFAULT_BRAND,
createDefaultDocumentWorkspaceConfirmations,
NON_INTEGRATED_DOCUMENT_WORKSPACE_CATEGORIES,
NON_INTEGRATED_WORKFLOW_NODE_CATEGORIES,
} from '@/app/intake/non-integrated/nonIntegratedStorage'
import { buildIntakeNodePermissionOverrideKey } from '@/lib/intake-node-permission-settings'
@ -186,6 +187,41 @@ function createRecord(overrides: Record<string, unknown> = {}) {
}
}
function createApprovedWorkflowStateBefore(targetCategory: string) {
const targetIndex = NON_INTEGRATED_WORKFLOW_NODE_CATEGORIES.findIndex(
(category) => category === targetCategory,
)
const hiddenListCategories = new Set(['立项评审', '检测报告'])
const readyCategories = NON_INTEGRATED_WORKFLOW_NODE_CATEGORIES.slice(
0,
targetIndex < 0 ? 0 : targetIndex,
).filter((category) => !hiddenListCategories.has(category))
const uploadFiles = readyCategories
.filter((category) =>
NON_INTEGRATED_DOCUMENT_WORKSPACE_CATEGORIES.includes(
category as (typeof NON_INTEGRATED_DOCUMENT_WORKSPACE_CATEGORIES)[number],
),
)
.map((category, index) => ({
id: `ready-file-${index + 1}`,
name: `ready-${index + 1}.pdf`,
category,
dataUrl: 'data:application/pdf;base64,ready',
mimeType: 'application/pdf',
}))
return {
uploadFiles,
documentWorkspaceConfirmations: createDefaultDocumentWorkspaceConfirmations(),
documentWorkspaceApprovedAt: Object.fromEntries(
readyCategories.map((category, index) => [
category,
`2026-05-01T00:${String(index).padStart(2, '0')}:00.000Z`,
]),
),
}
}
function createAssetVariant(overrides: Record<string, unknown> = {}) {
const code = typeof overrides.code === 'string' ? overrides.code : 'DBZ-SPJ11'
const id = typeof overrides.id === 'string' ? overrides.id : `variant-${code}`
@ -407,6 +443,170 @@ describe('intakeNonIntegratedProductService', () => {
)
})
it('filters internal responsible scope in memory so any listed owner name can match', async () => {
mockedRepository.listActive.mockResolvedValue([
createRecord({
id: 'space-created-visible',
spu: 'SPACE-CREATED',
designer: 'Space A',
payload: createPayload({
id: 'space-created-visible',
spu: 'SPACE-CREATED',
designer: 'Space A',
visualPlanning: 'Space B',
visualDirector: 'Space B',
}),
}),
createRecord({
id: 'space-created-hidden',
spu: 'SPACE-HIDDEN',
designer: 'Other Designer',
payload: createPayload({
id: 'space-created-hidden',
spu: 'SPACE-HIDDEN',
designer: 'Other Designer',
visualPlanning: 'Space B',
visualDirector: 'Space B',
}),
}),
] as never)
const result = await intakeNonIntegratedProductService.listPage(
{
...baseUser,
name: 'Space A',
businessRoles: ['DESIGN'] as const,
departmentPaths: ['品牌中心 > 空间视觉部'],
},
{ quickScope: 'created' },
)
expect(mockedRepository.listActive).toHaveBeenCalled()
expect(mockedRepository.listActivePage).not.toHaveBeenCalled()
expect(result.products.map((product) => product.spu)).toEqual(['SPACE-CREATED'])
expect(result.pageInfo.total).toBe(1)
})
it('filters internal pending scope in memory instead of reusing responsible ownership', async () => {
mockedRepository.listActive.mockResolvedValue([
createRecord({
id: 'purchase-factory-pending',
spu: 'BUY-PENDING',
purchaser: 'Purchasing A',
payload: createPayload({
id: 'purchase-factory-pending',
spu: 'BUY-PENDING',
purchaser: 'Purchasing A',
purchaseOwner: 'Purchasing A',
...createApprovedWorkflowStateBefore('产品定厂'),
}),
}),
createRecord({
id: 'purchase-owner-but-not-triggered',
spu: 'BUY-NOT-YET',
purchaser: 'Purchasing A',
payload: createPayload({
id: 'purchase-owner-but-not-triggered',
spu: 'BUY-NOT-YET',
purchaser: 'Purchasing A',
purchaseOwner: 'Purchasing A',
}),
}),
] as never)
const result = await intakeNonIntegratedProductService.listPage(
{
...baseUser,
name: 'Purchasing A',
businessRoles: ['PURCHASING'] as const,
departmentPaths: ['供应链中心 > 采购部'],
},
{ quickScope: 'pending' },
)
expect(mockedRepository.listActive).toHaveBeenCalled()
expect(mockedRepository.listActivePage).not.toHaveBeenCalled()
expect(result.products.map((product) => product.spu)).toEqual(['BUY-PENDING'])
expect(result.pageInfo.total).toBe(1)
})
it('filters internal processed scope in memory to show completed personal tasks only', async () => {
mockedRepository.listActive.mockResolvedValue([
createRecord({
id: 'appearance-processed',
spu: 'APPEARANCE-DONE',
designer: 'Designer A',
payload: createPayload({
id: 'appearance-processed',
spu: 'APPEARANCE-DONE',
designer: 'Designer A',
...createApprovedWorkflowStateBefore(
'产品实拍图',
),
}),
}),
createRecord({
id: 'appearance-still-pending',
spu: 'APPEARANCE-TODO',
designer: 'Designer A',
payload: createPayload({
id: 'appearance-still-pending',
spu: 'APPEARANCE-TODO',
designer: 'Designer A',
...createApprovedWorkflowStateBefore('产品模型'),
}),
}),
] as never)
const result = await intakeNonIntegratedProductService.listPage(
{
...baseUser,
name: 'Designer A',
businessRoles: ['DESIGN'] as const,
},
{ quickScope: 'processed' },
)
expect(mockedRepository.listActive).toHaveBeenCalled()
expect(mockedRepository.listActivePage).not.toHaveBeenCalled()
expect(result.products.map((product) => product.spu)).toEqual([
'APPEARANCE-DONE',
])
expect(result.pageInfo.total).toBe(1)
})
it('does not treat a product as processed when the same user still has another triggered segment pending', async () => {
mockedRepository.listActive.mockResolvedValue([
createRecord({
id: 'shared-owner-still-pending',
spu: 'SHARED-OWNER-TODO',
designer: 'Designer A',
payload: createPayload({
id: 'shared-owner-still-pending',
spu: 'SHARED-OWNER-TODO',
designer: 'Designer A',
openingReference: {
structureDesigner: 'Designer A',
},
...createApprovedWorkflowStateBefore('产品实拍图'),
}),
}),
] as never)
const result = await intakeNonIntegratedProductService.listPage(
{
...baseUser,
name: 'Designer A',
businessRoles: ['DESIGN'] as const,
},
{ quickScope: 'processed' },
)
expect(mockedRepository.listActive).toHaveBeenCalled()
expect(result.products).toEqual([])
expect(result.pageInfo.total).toBe(0)
})
it('caps non-integrated list page size at 100 before hitting the repository', async () => {
mockedRepository.listActivePage.mockResolvedValue({
records: [],

View File

@ -26,6 +26,10 @@ import {
type NonIntegratedSkuItem,
type NonIntegratedUploadFile,
} from '@/app/intake/non-integrated/nonIntegratedStorage'
import { getDocumentWorkspaceCountByCategory } from '@/app/intake/product-development/nonIntegratedDocumentCounts'
import { isCurrentUserPendingNonIntegratedProduct } from '@/app/intake/product-development/nonIntegratedCurrentNode'
import { isCurrentUserProcessedNonIntegratedProduct } from '@/app/intake/product-development/nonIntegratedProcessedScope'
import { isCurrentUserResponsibleForNonIntegratedProduct } from '@/app/intake/product-development/nonIntegratedProductListLogic'
import { normalizeNonIntegratedAssetUrl } from '@/app/intake/non-integrated/nonIntegratedAssetUrl'
import {
buildAssetVariantBarcode,
@ -192,7 +196,7 @@ export type IntakeNonIntegratedProductListParams = {
pageSize?: number
productDevelopmentKind?: 'product' | 'series'
productKind?: 'all' | 'new' | 'stock'
quickScope?: 'all' | 'created' | 'pending'
quickScope?: 'all' | 'created' | 'pending' | 'processed'
seriesName?: string
spu?: string
appearanceDesigner?: string
@ -3312,7 +3316,9 @@ function buildQuickScopeWhere(
function buildListFilterWhere(
params: IntakeNonIntegratedProductListParams,
user: AuthUser,
options: { includeQuickScope?: boolean } = {},
): Prisma.IntakeNonIntegratedProductWhereInput | undefined {
const includeQuickScope = options.includeQuickScope ?? true
const filters = [
intakeNonIntegratedScopeService.isSupplierScopeUser(user)
? intakeNonIntegratedScopeService.buildSupplierRecordScopeWhere(user)
@ -3321,7 +3327,7 @@ function buildListFilterWhere(
buildProductKindWhere(params.productKind),
buildSeriesNameSearchWhere(params.seriesName),
buildSpuSearchWhere(params.spu ?? ''),
buildQuickScopeWhere(params.quickScope, user),
includeQuickScope ? buildQuickScopeWhere(params.quickScope, user) : null,
...buildStockSourceSearchWhere(params),
...buildPeopleSearchWhere(params),
].filter((filter): filter is Prisma.IntakeNonIntegratedProductWhereInput => filter !== null)
@ -3333,6 +3339,74 @@ function buildListFilterWhere(
return { AND: filters }
}
function combineWhereInputs(
...filters: Array<Prisma.IntakeNonIntegratedProductWhereInput | undefined | null>
) {
const normalizedFilters = filters.filter(
(filter): filter is Prisma.IntakeNonIntegratedProductWhereInput => filter != null,
)
if (normalizedFilters.length === 0) {
return undefined
}
if (normalizedFilters.length === 1) {
return normalizedFilters[0]
}
return {
AND: normalizedFilters,
} satisfies Prisma.IntakeNonIntegratedProductWhereInput
}
function shouldFilterListPageQuickScopeInMemory(
params: IntakeNonIntegratedProductListParams,
user: AuthUser,
) {
if (params.quickScope === 'processed') {
return true
}
return (
(params.quickScope === 'created' || params.quickScope === 'pending') &&
!intakeNonIntegratedScopeService.isExternalIntakeScopeUser(user)
)
}
function filterProductsByQuickScopeInMemory(
products: SharedNonIntegratedProductRecord[],
quickScope: IntakeNonIntegratedProductListParams['quickScope'],
user: AuthUser,
) {
if (quickScope === 'created') {
return products.filter((product) =>
isCurrentUserResponsibleForNonIntegratedProduct(product, user),
)
}
if (quickScope === 'pending') {
return products.filter((product) =>
isCurrentUserPendingNonIntegratedProduct(
product,
user,
getDocumentWorkspaceCountByCategory(product),
),
)
}
if (quickScope === 'processed') {
return products.filter((product) =>
isCurrentUserProcessedNonIntegratedProduct(
product,
user,
getDocumentWorkspaceCountByCategory(product),
),
)
}
return products
}
function normalizeSupplierCodeForScope(value: unknown) {
return typeof value === 'string' ? value.trim().toUpperCase() : ''
}
@ -5235,6 +5309,42 @@ class IntakeNonIntegratedProductService {
async listPage(user: AuthUser, params: IntakeNonIntegratedProductListParams = {}) {
const page = normalizeListPage(params.page)
const pageSize = normalizeListPageSize(params.pageSize)
if (shouldFilterListPageQuickScopeInMemory(params, user)) {
const scopeWhere = intakeNonIntegratedScopeService.buildReadScopeWhere(user)
const filterWhere = buildListFilterWhere(params, user, {
includeQuickScope: false,
})
const records = await this.repository.listActive(
combineWhereInputs(scopeWhere, filterWhere),
)
const scopedProducts = filterProductsForSupplierUser(
records
.map((record) => buildStructuredProductRecordFromRecord(record))
.filter((record): record is SharedNonIntegratedProductRecord => record !== null),
user,
)
const filteredProducts = filterProductsByQuickScopeInMemory(
scopedProducts,
params.quickScope,
user,
)
const total = filteredProducts.length
const pageStart = (page - 1) * pageSize
const pagedProducts = filteredProducts.slice(pageStart, pageStart + pageSize)
const products = await hydrateSeriesProductDisplayNames(pagedProducts)
return {
products,
pageInfo: {
page,
pageSize,
total,
totalPages: Math.max(1, Math.ceil(total / pageSize)),
},
}
}
const { records, total } = await this.repository.listActivePage({
page,
pageSize,