Merge remote-tracking branch 'origin/codex/xuxiake-mainline' into codex/wuyongjie-ai-white-background-color-fix
This commit is contained in:
commit
9b799dd838
@ -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') ?? '',
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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" : ""
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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: [],
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user