fix(intake): align non-integrated quick scopes

This commit is contained in:
WuYongJie 2026-06-29 21:33:03 +08:00
parent e0bc1db6bf
commit 614e568763
5 changed files with 445 additions and 60 deletions

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

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

@ -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,93 @@ 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('caps non-integrated list page size at 100 before hitting the repository', async () => {
mockedRepository.listActivePage.mockResolvedValue({
records: [],

View File

@ -26,6 +26,9 @@ 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 { isCurrentUserResponsibleForNonIntegratedProduct } from '@/app/intake/product-development/nonIntegratedProductListLogic'
import { normalizeNonIntegratedAssetUrl } from '@/app/intake/non-integrated/nonIntegratedAssetUrl'
import {
buildAssetVariantBarcode,
@ -3312,7 +3315,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 +3326,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 +3338,60 @@ 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,
) {
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),
),
)
}
return products
}
function normalizeSupplierCodeForScope(value: unknown) {
return typeof value === 'string' ? value.trim().toUpperCase() : ''
}
@ -5235,6 +5294,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,