feat(asset-center): make modular assets immediately available

This commit is contained in:
WuYongJie 2026-06-15 18:51:19 +08:00
parent 2e42fb220f
commit 6b4dcbdd31
11 changed files with 213 additions and 14 deletions

View File

@ -14,6 +14,7 @@ const assetStatusSchema = z.enum([
'AVAILABLE',
'DISABLED',
])
const assetCreationStatusSchema = z.enum(['DRAFT', 'AVAILABLE'])
const assetCadImportFamilyModeSchema = z.enum(['existing', 'create'])
const singleSubstrateFinishIdsSchema = z
.array(z.string().trim().min(1))
@ -54,7 +55,7 @@ export const assetFamilyCreateSchema = z.object({
compatibleSeriesIds: z.array(z.string().trim().min(1)).optional(),
connectionRoles: z.array(z.string().trim().min(1)).optional(),
metadata: z.record(z.string(), z.unknown()).optional().nullable(),
status: z.enum(['DRAFT']).optional(),
status: assetCreationStatusSchema.optional(),
})
export const assetFamilyUpdateSchema = z.object({
@ -113,7 +114,7 @@ export const assetVariantCreateSchema = z.object({
skuGroupId: z.string().trim().min(1).optional().nullable(),
code: z.string().trim().min(1).optional(),
name: z.string().trim().min(1),
status: z.enum(['DRAFT']).optional(),
status: assetCreationStatusSchema.optional(),
sizeLabel: z.string().trim().optional(),
substrateId: z.string().trim().min(1).optional().nullable(),
finishId: z.string().trim().min(1).optional().nullable(),

View File

@ -3506,7 +3506,7 @@ export default function AssetCenterClient({
compatibleSeriesIds: submittedCompatibleSeriesIds,
connectionRoles: normalizedSelectedRoles,
metadata,
status: 'DRAFT',
status: 'AVAILABLE',
})
startTransition(() => {
setFamilies((current) => sortAssetFamiliesByCreatedTime([...current, created]))
@ -4914,7 +4914,7 @@ export default function AssetCenterClient({
assetFamilyId: variantForm.assetFamilyId,
skuGroupId: variantForm.skuGroupId || null,
name: skuPayload.name,
status: 'DRAFT',
status: 'AVAILABLE',
sizeLabel: skuPayload.sizeLabel || undefined,
substrateId: resolvedVariantSubstrateId,
finishId: resolvedVariantFinishId,
@ -5052,7 +5052,7 @@ export default function AssetCenterClient({
skuGroupId: variantForm.skuGroupId || null,
code: variantForm.code || undefined,
name: skuPayload.name,
status: 'DRAFT',
status: 'AVAILABLE',
sizeLabel: skuPayload.sizeLabel || undefined,
substrateId: null,
finishId: resolvedVariantFinishId,

View File

@ -368,7 +368,7 @@ function instantiateLaunchSelectedFamilies(input: {
const inventoryItem = snapshot.inventory.find((item) => item.familyId === familyId)
const variant =
input.variantsByFamilyId.get(familyId)?.find((item) => item.status === 'AVAILABLE') ??
input.variantsByFamilyId.get(familyId)?.find((item) => item.status !== 'DISABLED') ??
input.variantsByFamilyId.get(familyId)?.[0] ??
null
@ -841,7 +841,7 @@ export default function AssistedAssemblyClient({
setMaterialFamilies(nextFamilies)
setMaterialVariants(
nextVariants
.filter((variant) => variant.status === 'AVAILABLE')
.filter((variant) => variant.status !== 'DISABLED')
.map(toRuntimeAssistedAssemblyVariant),
)
} catch (error) {

View File

@ -884,7 +884,7 @@ export default async function AssistedAssemblyPage({
const sourceVariantIdSet = new Set(initialWorkspace?.nodes.map((node) => node.variantId) ?? [])
const selectableVariants = variants.filter(
(variant) =>
variant.status === 'AVAILABLE' ||
variant.status !== 'DISABLED' ||
launchSelectedFamilyIds.has(variant.assetFamilyId) ||
sourceVariantIdSet.has(variant.id),
)

View File

@ -1502,6 +1502,11 @@ describe("NonIntegratedDetailClient", () => {
/>,
);
expect(screen.getByText("商品标签")).toBeInTheDocument();
expect(
screen.getByRole("combobox", { name: "商品标签" }),
).toHaveTextContent("模块化");
const detailButton = screen
.getAllByRole("button", { name: /MKH-DNZ0384K01/ })
.find((button) =>
@ -1773,6 +1778,10 @@ describe("NonIntegratedDetailClient", () => {
"family-packaging",
]),
);
expect(screen.getByText("商品标签")).toBeInTheDocument();
expect(
screen.getByRole("combobox", { name: "商品标签" }),
).toHaveTextContent("模块化");
expect(await screen.findByDisplayValue("DBZ-SPJ11")).toBeInTheDocument();
expect(screen.getAllByDisplayValue("DBZ-SPJK06").length).toBeGreaterThan(0);
});

View File

@ -42,6 +42,8 @@ export function BasicField({
readOnly = false,
compact = false,
searchableSelect = false,
includeEmptyOption = false,
clearable = false,
onChange,
}: {
label: string;
@ -53,6 +55,8 @@ export function BasicField({
readOnly?: boolean;
compact?: boolean;
searchableSelect?: boolean;
includeEmptyOption?: boolean;
clearable?: boolean;
onChange: (value: string) => void;
}) {
if (type === "select") {
@ -72,6 +76,7 @@ export function BasicField({
options={options}
placeholder={placeholder}
readOnly={readOnly}
includeEmptyOption={includeEmptyOption}
onChange={onChange}
inputClassName={`h-9 w-full rounded-apple-lg border border-apple-separator px-3 pr-9 text-sm text-apple-label-primary outline-none disabled:cursor-not-allowed ${
readOnly
@ -86,6 +91,8 @@ export function BasicField({
options={options}
placeholder={placeholder}
readOnly={readOnly}
includeEmptyOption={includeEmptyOption}
clearable={clearable}
onChange={onChange}
buttonClassName={`h-9 w-full rounded-apple-lg border border-apple-separator px-3 text-sm text-apple-label-primary outline-none disabled:cursor-not-allowed ${
readOnly

View File

@ -10,7 +10,7 @@ import {
useState,
} from "react";
import { createPortal } from "react-dom";
import { ChevronDown } from "lucide-react";
import { ChevronDown, X } from "lucide-react";
function getUniqueNonEmptyStrings(
values: ReadonlyArray<string | null | undefined>,
@ -313,6 +313,7 @@ export function InlineSelect({
includeEmptyOption,
emptyOptionDisabled = false,
showIndicatorWhenReadOnly = false,
clearable = false,
onChange,
}: {
ariaLabel: string;
@ -328,6 +329,7 @@ export function InlineSelect({
includeEmptyOption?: boolean;
emptyOptionDisabled?: boolean;
showIndicatorWhenReadOnly?: boolean;
clearable?: boolean;
onChange: (value: string) => void;
}) {
const listId = useId();
@ -397,7 +399,9 @@ export function InlineSelect({
if (hasOptions) setIsOpen((current) => !current);
}
}}
className={`flex items-center justify-between gap-2 text-left ${buttonClassName ?? ""}`}
className={`flex items-center justify-between gap-2 text-left ${
buttonClassName ?? ""
} ${clearable && value && !readOnly ? "pr-16" : ""}`}
>
<span className="min-w-0 flex-1 truncate">
{displayValue || placeholder}
@ -409,6 +413,19 @@ export function InlineSelect({
/>
) : null}
</button>
{clearable && value && !readOnly ? (
<button
type="button"
aria-label={`清空${ariaLabel}`}
onMouseDown={(event) => {
event.preventDefault();
}}
onClick={() => onChange("")}
className="absolute right-8 top-1/2 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-apple-md text-apple-label-secondary transition hover:bg-apple-bg-tertiary hover:text-apple-label-primary"
>
<X className="h-3.5 w-3.5" aria-hidden="true" />
</button>
) : null}
{hasOptions && isOpen && floatingPosition
? createPortal(
<div

View File

@ -343,6 +343,7 @@ export type NonIntegratedSkuItem = {
image: string;
imageName: string;
model: string;
productTag?: string;
skuCode: string;
setCount: string;
specName: string;
@ -401,6 +402,15 @@ export type NonIntegratedSkuHolePoint = {
};
export const DRAFT_GROUP_MODULAR_SKU_SOURCE = "draft_group_modular_sku";
export const DRAFT_GROUP_MODULAR_SERIES_SOURCE = "draft_group_modular_series";
export const NON_INTEGRATED_ASSET_SERIES_COMPONENT_SKU_SOURCE =
"asset_series_component";
export const NON_INTEGRATED_ASSET_VARIANT_SOURCE_TABLE = "asset_variant";
export const NON_INTEGRATED_ASSET_SKU_GROUP_SOURCE_TABLE = "asset_sku_group";
function normalizeSkuSourceMarker(value: unknown) {
return normalizeString(value).trim().toLowerCase().replace(/-/g, "_");
}
export function isDraftGroupModularFormalSkuCode(value: unknown) {
const normalizedValue = normalizeString(value).trim().toUpperCase();
@ -432,6 +442,39 @@ export function resolveDraftGroupModularSkuIdentity(item: {
return "";
}
export function isNonIntegratedModularSkuItem(item: {
source?: unknown;
sourceTable?: unknown;
skuCode?: unknown;
barcode?: unknown;
}) {
const source = normalizeSkuSourceMarker(item.source);
const sourceTable = normalizeSkuSourceMarker(item.sourceTable);
return (
source === DRAFT_GROUP_MODULAR_SKU_SOURCE ||
source === NON_INTEGRATED_ASSET_SERIES_COMPONENT_SKU_SOURCE ||
sourceTable === NON_INTEGRATED_ASSET_VARIANT_SOURCE_TABLE ||
sourceTable === NON_INTEGRATED_ASSET_SKU_GROUP_SOURCE_TABLE ||
Boolean(resolveDraftGroupModularSkuIdentity(item))
);
}
export function resolveNonIntegratedSkuProductTag(item: {
productTag?: unknown;
source?: unknown;
sourceTable?: unknown;
skuCode?: unknown;
barcode?: unknown;
}) {
return (
normalizeString(item.productTag).trim() ||
(isNonIntegratedModularSkuItem(item)
? NON_INTEGRATED_MODULAR_PRODUCT_TAG
: "")
);
}
export type NonIntegratedFactoryRow = {
skuId: string;
confirmed: boolean;

View File

@ -741,6 +741,25 @@ describe('assetCenterService', () => {
})
})
it('creates asset families as available when a creator is present', async () => {
await assetCenterService.createFamily({
type: 'BOARD',
assetSeriesId: defaultAssetSeries.id,
code: 'DBZ-SPJK009',
name: 'Immediate available family',
status: 'DRAFT',
createdBy: 'user-creator',
})
expect(mockedAssetFamilyRepository.createWithRelations).toHaveBeenCalledWith(
expect.objectContaining({
status: 'AVAILABLE',
reviewedAt: expect.any(Date),
reviewer: { connect: { id: 'user-creator' } },
}),
)
})
it('new asset variants use sku groups for item numbers and model codes for model details', async () => {
const existingGroups = [
{

View File

@ -40,6 +40,7 @@ import {
createEmptyNonIntegratedOpeningReference,
createEmptyNonIntegratedPatentApplication,
createEmptyNonIntegratedProjectReview,
resolveNonIntegratedSkuProductTag,
type NonIntegratedProductRecord,
type NonIntegratedSkuItem,
} from '@/app/intake/non-integrated/nonIntegratedStorage'
@ -591,6 +592,10 @@ function createEmptySeriesComponentSkuPayloadItem(
image: '',
imageName: '',
model: '',
productTag: resolveNonIntegratedSkuProductTag({
source: SERIES_COMPONENT_ASSET_SKU_SOURCE,
sourceTable: ASSET_VARIANT_SOURCE_TABLE,
}),
skuCode: '',
setCount: '1',
specName: '',
@ -639,6 +644,10 @@ function createEmptySeriesComponentSkuGroupPayloadItem(
image: '',
imageName: '',
model: '',
productTag: resolveNonIntegratedSkuProductTag({
source: SERIES_COMPONENT_ASSET_SKU_SOURCE,
sourceTable: ASSET_SKU_GROUP_SOURCE_TABLE,
}),
skuCode: '',
setCount: '1',
specName: '',
@ -1177,6 +1186,11 @@ function updateSeriesComponentSkuPayloadItemFromVariant(
'nationalStandardCompliant',
DEFAULT_ASSET_VARIANT_NATIONAL_STANDARD_COMPLIANT,
),
productTag: resolveNonIntegratedSkuProductTag({
...item,
source: SERIES_COMPONENT_ASSET_SKU_SOURCE,
sourceTable: ASSET_VARIANT_SOURCE_TABLE,
}),
manufacturer: getVariantSkuContentField(skuContentFields, 'manufacturer', readPayloadString(item, 'manufacturer') || (variant.supplierName ?? '')),
manufacturerCode,
salesStatus: getVariantSkuContentFieldWithDefault(
@ -1324,6 +1338,11 @@ function updateSeriesComponentSkuPayloadItemFromSkuGroup(
assetFamilyCode: group.assetFamilyCode,
fallback: readPayloadString(nextItem, 'specName') || buildSeriesComponentSkuGroupDisplayCode(group),
}),
productTag: resolveNonIntegratedSkuProductTag({
...nextItem,
source: SERIES_COMPONENT_ASSET_SKU_SOURCE,
sourceTable: ASSET_SKU_GROUP_SOURCE_TABLE,
}),
componentDetails: group.variants.map((variant) => ({
code: variant.code.trim().toUpperCase(),
barcode: resolveSeriesComponentVariantOwnBarcode(variant),
@ -1429,6 +1448,11 @@ function updateSeriesComponentSkuPayloadItemFromSkuGroupOnly(
DEFAULT_ASSET_VARIANT_SALES_STATUS,
),
batchNo,
productTag: resolveNonIntegratedSkuProductTag({
...item,
source: SERIES_COMPONENT_ASSET_SKU_SOURCE,
sourceTable: ASSET_SKU_GROUP_SOURCE_TABLE,
}),
image: '',
imageName: '',
model3dFileName: '',
@ -1823,6 +1847,33 @@ function ensureDraftCreationStatus(status?: AssetStatus): AssetStatus {
return status
}
function buildImmediateAvailableCreationWorkflow(
status: AssetStatus | undefined,
actorId: string | null | undefined,
): {
status: AssetStatus
reviewedAt?: Date
reviewer?: { connect: { id: string } }
} {
if (status && status !== 'DRAFT' && status !== 'AVAILABLE') {
throw new AppError(ErrorCode.VALIDATION_FAILED, 'New assets can only be created as available.')
}
if (status === 'AVAILABLE' && !actorId) {
throw new AppError(ErrorCode.VALIDATION_FAILED, 'Creating available assets requires a creator.')
}
if (!actorId) {
return { status: ensureDraftCreationStatus(status) }
}
return {
status: 'AVAILABLE',
reviewedAt: new Date(),
reviewer: { connect: { id: actorId } },
}
}
function assertReviewableStatus(status: AssetStatus, action: 'SUBMIT' | 'APPROVE' | 'REJECT'): void {
if (action === 'SUBMIT' && status !== 'DRAFT' && status !== 'REJECTED') {
throw new AppError(ErrorCode.VALIDATION_FAILED, '当前状态不能提审')
@ -3916,6 +3967,7 @@ class AssetCenterService {
await ensureFamilyCodeAvailable(code)
await ensureCategoryExists(input.productCategoryId ?? null)
const compatibleSeriesIds = [series.id]
const creationWorkflow = buildImmediateAvailableCreationWorkflow(input.status, input.createdBy)
const created = await assetFamilyRepository.createWithRelations({
type: input.type,
@ -3932,7 +3984,9 @@ class AssetCenterService {
: undefined,
connectionRoles: input.connectionRoles ?? [],
metadata: toJsonValue(input.metadata),
status: ensureDraftCreationStatus(input.status),
status: creationWorkflow.status,
reviewedAt: creationWorkflow.reviewedAt,
reviewer: creationWorkflow.reviewer,
productCategory: input.productCategoryId
? { connect: { id: input.productCategoryId } }
: undefined,
@ -4620,6 +4674,7 @@ class AssetCenterService {
barcode: input.barcode,
})
await ensureVariantBarcodeAvailable(commercialFields.barcode, undefined, skuGroup.id)
const creationWorkflow = buildImmediateAvailableCreationWorkflow(input.status, input.createdBy)
const created = await assetVariantRepository.createWithRelations({
assetFamily: { connect: { id: input.assetFamilyId } },
@ -4627,7 +4682,9 @@ class AssetCenterService {
name,
identityKey,
assetType: family.type,
status: ensureDraftCreationStatus(input.status),
status: creationWorkflow.status,
reviewedAt: creationWorkflow.reviewedAt,
reviewer: creationWorkflow.reviewer,
sizeLabel: trimOrNull(input.sizeLabel),
substrate: input.substrateId ? { connect: { id: input.substrateId } } : undefined,
finish: resolvedFinishId ? { connect: { id: resolvedFinishId } } : undefined,

View File

@ -4,12 +4,14 @@ import {
NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY,
NON_INTEGRATED_DEFAULT_BRAND,
NON_INTEGRATED_DUPLICATE_SPU_MESSAGE,
NON_INTEGRATED_MODULAR_PRODUCT_TAG,
NON_INTEGRATED_WORKFLOW_NODE_CATEGORIES,
applyBrandPrefixToNonIntegratedCode,
buildNonIntegratedProductId,
createDefaultDocumentWorkspaceConfirmations as createSharedDocumentWorkspaceConfirmations,
normalizeNonIntegratedBrand,
normalizeNonIntegratedProductRecord,
resolveNonIntegratedSkuProductTag,
resolveDraftGroupModularSkuIdentity,
resolveNonIntegratedPersistedSpu,
stripNonIntegratedBrandPrefix,
@ -284,6 +286,7 @@ const BASIC_INFO_FIELD_KEYS = [
'purchaser',
'previewImage',
'previewImageName',
'productTag',
'productCode',
'size',
'color',
@ -485,6 +488,11 @@ function assignFormalSpuToSkuItems(
skuCode,
barcode,
versionNo,
productTag: resolveNonIntegratedSkuProductTag({
...item,
skuCode,
barcode,
}),
}
})
}
@ -2179,6 +2187,13 @@ function buildStructuredSkuItemOverlay(
image: readStructuredText(row.image) || legacy?.image || '',
imageName: readStructuredText(row.imageName) || legacy?.imageName || '',
model: readStructuredText(row.model),
productTag: resolveNonIntegratedSkuProductTag({
...legacy,
source,
sourceTable: readStructuredText(row.sourceTable),
skuCode: modularSkuIdentity || skuCode,
barcode: modularSkuIdentity || barcode,
}),
skuCode: modularSkuIdentity || skuCode,
setCount: legacy?.setCount || '1',
specName: readStructuredText(row.specName),
@ -3445,6 +3460,15 @@ function withSeriesProductDisplayName(
}
}
function withDefaultModularProductTag(product: SharedNonIntegratedProductRecord) {
return Object.prototype.hasOwnProperty.call(product, 'productTag')
? product
: {
...product,
productTag: NON_INTEGRATED_MODULAR_PRODUCT_TAG,
}
}
async function resolveDraftGroupSeriesName(sourceDraftGroupId: unknown) {
const draftGroupId = normalizeSeriesDisplayPart(sourceDraftGroupId)
if (!draftGroupId) return null
@ -3483,12 +3507,14 @@ async function hydrateSeriesProductDisplayName(
)
const baseSeriesName = productName.replace(/-部件$/, '')
return productName
const nextProduct = productName
? withSeriesProductDisplayName(product, productName, {
sourceSeriesName: baseSeriesName,
sourceSeriesCode,
})
: product
return withDefaultModularProductTag(nextProduct)
}
if (
@ -3514,12 +3540,14 @@ async function hydrateSeriesProductDisplayName(
const resolvedSourceSeriesCode =
resolvedDraftGroupSeriesName?.sourceSeriesCode || sourceSeriesCode
return productName
const nextProduct = productName
? withSeriesProductDisplayName(product, productName, {
sourceSeriesName: productName,
sourceSeriesCode: resolvedSourceSeriesCode,
})
: product
return withDefaultModularProductTag(nextProduct)
}
async function hydrateSeriesProductDisplayNames(
@ -3779,6 +3807,10 @@ function createEmptyAssetSeriesComponentSkuItem(
image: '',
imageName: '',
model: '',
productTag: resolveNonIntegratedSkuProductTag({
source: SERIES_COMPONENT_ASSET_SKU_SOURCE,
sourceTable: ASSET_VARIANT_SOURCE_TABLE,
}),
skuCode: '',
setCount: '1',
specName: '',
@ -3827,6 +3859,10 @@ function createEmptyAssetSeriesComponentSkuGroupItem(
image: '',
imageName: '',
model: '',
productTag: resolveNonIntegratedSkuProductTag({
source: SERIES_COMPONENT_ASSET_SKU_SOURCE,
sourceTable: ASSET_SKU_GROUP_SOURCE_TABLE,
}),
skuCode: '',
setCount: '1',
specName: '',
@ -4084,6 +4120,11 @@ function updateAssetSeriesComponentSkuItemFromSkuGroupOnly(
id: `asset-sku-group-${group.id}`,
skuSequence,
model,
productTag: resolveNonIntegratedSkuProductTag({
...item,
source: SERIES_COMPONENT_ASSET_SKU_SOURCE,
sourceTable: ASSET_SKU_GROUP_SOURCE_TABLE,
}),
skuCode: group.code,
barcode,
setCount: getVariantSkuContentField(fields, 'setCount', readPayloadString(itemRecord, 'setCount') || '1'),
@ -4289,6 +4330,11 @@ function updateAssetSeriesComponentSkuItemFromVariant(
'nationalStandardCompliant',
DEFAULT_ASSET_VARIANT_NATIONAL_STANDARD_COMPLIANT,
),
productTag: resolveNonIntegratedSkuProductTag({
...item,
source: SERIES_COMPONENT_ASSET_SKU_SOURCE,
sourceTable: ASSET_VARIANT_SOURCE_TABLE,
}),
manufacturer: getVariantSkuContentField(
skuContentFields,
'manufacturer',