merge: restore intake shared persistence and package corner saves #4

Merged
WuYongJie merged 2 commits from codex/xuxiake-mainline into main 2026-04-22 12:25:17 +08:00
6 changed files with 362 additions and 51 deletions

View File

@ -35,6 +35,11 @@ import {
saveNonIntegratedProduct,
type NonIntegratedProductRecord,
} from "./non-integrated/nonIntegratedStorage";
import {
deleteSharedNonIntegratedProduct,
listSharedNonIntegratedProducts,
saveSharedNonIntegratedProduct,
} from "./non-integrated/nonIntegratedApi";
import type {
CategoryDTO,
DraftGroupDetailDTO,
@ -70,6 +75,7 @@ interface IntakeClientSelectorProps {
materialLibraryFinishes: MaterialDTO[];
materialLibraryCategories: MaterialCategoryDTO[];
materialLibraryDictionaries: SubstrateDictionaryBundleDTO;
initialNonIntegratedProducts: NonIntegratedProductRecord[];
}
interface IntakeClientEditorProps {
@ -91,6 +97,7 @@ interface IntakeClientEditorProps {
materialLibraryFinishes: MaterialDTO[];
materialLibraryCategories: MaterialCategoryDTO[];
materialLibraryDictionaries: SubstrateDictionaryBundleDTO;
initialNonIntegratedProducts: NonIntegratedProductRecord[];
}
type IntakeClientProps = IntakeClientSelectorProps | IntakeClientEditorProps;
@ -861,9 +868,14 @@ function IntegratedPendingDetail({
);
}
function NonIntegratedPendingDetail() {
function NonIntegratedPendingDetail({
initialProducts,
}: {
initialProducts: NonIntegratedProductRecord[];
}) {
const router = useRouter();
const [products, setProducts] = useState<NonIntegratedProductRecord[]>([]);
const [products, setProducts] =
useState<NonIntegratedProductRecord[]>(initialProducts);
const [previewProduct, setPreviewProduct] = useState<{
src: string;
alt: string;
@ -886,8 +898,14 @@ function NonIntegratedPendingDetail() {
operator: "",
purchaser: "",
});
const shouldUseSharedNonIntegratedApi = process.env.NODE_ENV !== "test";
useEffect(() => {
if (shouldUseSharedNonIntegratedApi) {
setProducts(initialProducts);
return;
}
function syncProducts() {
setProducts(listNonIntegratedProducts());
}
@ -896,17 +914,31 @@ function NonIntegratedPendingDetail() {
window.addEventListener(NON_INTEGRATED_PRODUCTS_UPDATED, syncProducts);
return () =>
window.removeEventListener(NON_INTEGRATED_PRODUCTS_UPDATED, syncProducts);
}, []);
}, [initialProducts, shouldUseSharedNonIntegratedApi]);
function handleDelete(product: NonIntegratedProductRecord) {
async function handleDelete(product: NonIntegratedProductRecord) {
const productLabel = product.spu?.trim() || "当前产品";
if (!window.confirm(`确认删除“${productLabel}”吗?\n删除后不可恢复。`)) {
return;
}
deleteNonIntegratedProduct(product.id);
setProducts(listNonIntegratedProducts());
try {
if (shouldUseSharedNonIntegratedApi) {
await deleteSharedNonIntegratedProduct(product.id);
setProducts((current) =>
current.filter((currentProduct) => currentProduct.id !== product.id),
);
return;
}
deleteNonIntegratedProduct(product.id);
setProducts(listNonIntegratedProducts());
} catch (error) {
window.alert(
error instanceof Error ? error.message : "删除失败,请稍后再试",
);
}
}
function getAvailableCopyBrands(product: NonIntegratedProductRecord) {
@ -947,8 +979,14 @@ function NonIntegratedPendingDetail() {
copyDialogState.product,
copyDialogState.targetBrand,
);
const savedProduct = saveNonIntegratedProduct(copiedProduct);
setProducts(listNonIntegratedProducts());
const savedProduct = shouldUseSharedNonIntegratedApi
? await saveSharedNonIntegratedProduct(copiedProduct)
: saveNonIntegratedProduct(copiedProduct);
setProducts(
shouldUseSharedNonIntegratedApi
? await listSharedNonIntegratedProducts()
: listNonIntegratedProducts(),
);
setCopyDialogState(null);
router.push(
`/intake/non-integrated/${encodeURIComponent(savedProduct.id)}`,
@ -1494,7 +1532,9 @@ export default function IntakeClient(props: IntakeClientProps) {
pendingEntryTab === "integrated" ? (
<IntegratedPendingDetail rows={skuList} />
) : (
<NonIntegratedPendingDetail />
<NonIntegratedPendingDetail
initialProducts={props.initialNonIntegratedProducts}
/>
);
} else if (activeSection === "bom") {
leftContent = (
@ -1541,7 +1581,9 @@ export default function IntakeClient(props: IntakeClientProps) {
pendingEntryTab === "integrated" ? (
<IntegratedPendingDetail rows={skuList} />
) : (
<NonIntegratedPendingDetail />
<NonIntegratedPendingDetail
initialProducts={props.initialNonIntegratedProducts}
/>
);
} else if (activeSection === "bom") {
leftContent = (

View File

@ -6,7 +6,10 @@ import type { ReactNode } from "react";
import NonIntegratedDetailClient from "./NonIntegratedDetailClient";
import {
createDefaultDocumentWorkspaceConfirmations,
getNonIntegratedProductById,
NON_INTEGRATED_STORAGE_KEY,
saveNonIntegratedProduct,
type NonIntegratedSkuItem,
type NonIntegratedProductRecord,
} from "./nonIntegratedStorage";
import { parseZipFile } from "@/lib/zip-parser";
@ -100,6 +103,39 @@ function createProductRecord(
};
}
function createSkuItem(
overrides: Partial<NonIntegratedSkuItem> = {},
): NonIntegratedSkuItem {
return {
id: "sku-test-1",
skuSequence: 1,
packageGroupId: "",
packageSuffix: "",
image: "",
imageName: "",
model: "SZ0230K01",
skuCode: "SZ023001",
setCount: "1",
specName: "款式一 140/60/75",
barcode: "",
size: "140/60/75",
color: "胡桃色",
material: "橡胶木",
attributeCode: "A",
drawingId: "SZ0230K01AV0",
installMethod: "拆装",
executeStandard: "",
manufacturer: "",
manufacturerCode: "",
tagMaterial: "",
model3dFileName: "",
model3dDataUrl: "",
model3dNodeOffsets: {},
model3dNodeRotations: {},
...overrides,
};
}
describe("NonIntegratedDetailClient", () => {
let anchorClickSpy: jest.SpyInstance;
@ -509,4 +545,207 @@ describe("NonIntegratedDetailClient", () => {
within(detailList).getByAltText("扶手细节图.png"),
).toBeInTheDocument();
});
it("persists 泡沫护角 rows after submit and reload", async () => {
const actor = userEvent.setup();
saveNonIntegratedProduct(
createProductRecord({
id: "DNZ0001-package-corner-save",
spu: "SZ0230",
skuItems: [createSkuItem()],
bomDetails: [
{
styleKey: "K01",
rows: [],
hiddenRowIds: [],
syntheticUnitPriceOverrides: {},
},
],
}),
);
const { unmount } = render(
<NonIntegratedDetailClient
mode="edit"
productId="DNZ0001-package-corner-save"
/>,
);
await actor.click(screen.getByRole("button", { name: "新增泡沫护角" }));
const packageHeading = await screen.findByRole("heading", {
name: "泡沫护角",
});
const packageSection = packageHeading.closest("div.overflow-hidden");
expect(packageSection).not.toBeNull();
const rowFields = packageSection!.querySelectorAll<
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
>("tbody tr input, tbody tr textarea, tbody tr select");
expect(rowFields.length).toBeGreaterThanOrEqual(9);
await actor.clear(rowFields[0]!);
await actor.type(rowFields[0]!, "BM-HJ-001");
await actor.clear(rowFields[1]!);
await actor.type(rowFields[1]!, "泡沫护角");
await actor.clear(rowFields[3]!);
await actor.type(rowFields[3]!, "泡沫");
await actor.clear(rowFields[4]!);
await actor.type(rowFields[4]!, "0.06");
await actor.clear(rowFields[5]!);
await actor.type(rowFields[5]!, "4");
await actor.clear(rowFields[7]!);
await actor.type(rowFields[7]!, "2");
await actor.click(screen.getByRole("button", { name: "提交完成" }));
await waitFor(() => {
const savedProduct = getNonIntegratedProductById(
"DNZ0001-package-corner-save",
);
const sharedDetail = savedProduct?.bomDetails.find(
(detail) => detail.styleKey === "K01" && !detail.skuId,
);
const cornerRow = sharedDetail?.rows.find(
(row) => row.packageSubcategory === "泡沫护角",
);
expect(cornerRow).toBeDefined();
expect(cornerRow?.partCode).toBe("BM-HJ-001");
expect(cornerRow?.partName).toBe("泡沫护角");
expect(cornerRow?.material).toBe("泡沫");
expect(cornerRow?.length).toBe("0.06");
expect(cornerRow?.quantity).toBe("4");
expect(cornerRow?.unitPrice).toBe("2");
});
unmount();
render(
<NonIntegratedDetailClient
mode="edit"
productId="DNZ0001-package-corner-save"
/>,
);
const reloadedHeading = await screen.findByRole("heading", {
name: "泡沫护角",
});
const reloadedSection = reloadedHeading.closest("div.overflow-hidden");
expect(reloadedSection).not.toBeNull();
expect(within(reloadedSection!).getByText("当前 1 行")).toBeInTheDocument();
const reloadedFields = reloadedSection!.querySelectorAll<
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
>("tbody tr input, tbody tr textarea, tbody tr select");
expect(reloadedFields[0]).toHaveValue("BM-HJ-001");
expect(reloadedFields[1]).toHaveValue("泡沫护角");
expect(reloadedFields[3]).toHaveValue("泡沫");
expect(reloadedFields[4]).toHaveValue("0.06");
expect(reloadedFields[5]).toHaveValue("4");
expect(reloadedFields[7]).toHaveValue("2");
});
it("keeps sku override rows isolated when adding package corner shared rows", async () => {
const actor = userEvent.setup();
saveNonIntegratedProduct(
createProductRecord({
id: "DNZ0001-package-corner-with-override",
spu: "SZ0230",
skuItems: [createSkuItem()],
bomDetails: [
{
styleKey: "K01",
rows: [],
hiddenRowIds: [],
syntheticUnitPriceOverrides: {},
},
{
styleKey: "K01",
skuId: "sku-test-1",
rows: [
{
id: "override-steel-row-1",
category: "钢架",
partCode: "GJ-001",
partImage: "",
partName: "桌腿",
structure: "承重",
material: "方管 20×20×0.6",
process: "",
remark: "",
specSize: "",
length: "700",
width: "",
longEdgeCount: "",
shortEdgeCount: "",
unit: "米",
factor: "1",
unitPrice: "1.16",
unitWeight: "0.38",
quantity: "1",
},
],
hiddenRowIds: [],
syntheticUnitPriceOverrides: {},
},
],
}),
);
render(
<NonIntegratedDetailClient
mode="edit"
productId="DNZ0001-package-corner-with-override"
/>,
);
await actor.click(screen.getByRole("button", { name: "新增泡沫护角" }));
const packageHeading = await screen.findByRole("heading", {
name: "泡沫护角",
});
const packageSection = packageHeading.closest("div.overflow-hidden");
expect(packageSection).not.toBeNull();
const rowFields = packageSection!.querySelectorAll<
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
>("tbody tr input, tbody tr textarea, tbody tr select");
expect(rowFields.length).toBeGreaterThanOrEqual(9);
await actor.clear(rowFields[1]!);
await actor.type(rowFields[1]!, "泡沫护角");
await actor.clear(rowFields[3]!);
await actor.type(rowFields[3]!, "泡沫");
await actor.clear(rowFields[4]!);
await actor.type(rowFields[4]!, "0.06");
await actor.clear(rowFields[5]!);
await actor.type(rowFields[5]!, "4");
await actor.clear(rowFields[7]!);
await actor.type(rowFields[7]!, "2");
await actor.click(screen.getByRole("button", { name: "提交完成" }));
await waitFor(() => {
const savedProduct = getNonIntegratedProductById(
"DNZ0001-package-corner-with-override",
);
const sharedDetail = savedProduct?.bomDetails.find(
(detail) => detail.styleKey === "K01" && !detail.skuId,
);
const overrideDetail = savedProduct?.bomDetails.find(
(detail) => detail.styleKey === "K01" && detail.skuId === "sku-test-1",
);
expect(
sharedDetail?.rows.some((row) => row.id === "override-steel-row-1"),
).toBe(false);
expect(
overrideDetail?.rows.some((row) => row.id === "override-steel-row-1"),
).toBe(true);
expect(
sharedDetail?.rows.some((row) => row.packageSubcategory === "泡沫护角"),
).toBe(true);
});
});
});

View File

@ -118,6 +118,7 @@ import {
type NonIntegratedStyleDetail,
type NonIntegratedUploadFile,
} from "./nonIntegratedStorage";
import { saveSharedNonIntegratedProduct } from "./nonIntegratedApi";
type BasicFieldType = "text" | "textarea" | "select";
type SkuTabKey = "skuInfo" | "fileUpload";
@ -4260,9 +4261,11 @@ function buildProductPayload(
export default function NonIntegratedDetailClient({
mode,
productId,
initialProduct = null,
}: {
mode: "create" | "edit" | "view";
productId?: string;
initialProduct?: NonIntegratedProductRecord | null;
}) {
const router = useRouter();
const previewFileInputRef = useRef<HTMLInputElement | null>(null);
@ -4285,7 +4288,9 @@ export default function NonIntegratedDetailClient({
const model3dInputRef = useRef<HTMLInputElement | null>(null);
const existingProduct =
mode !== "create" && productId
? getNonIntegratedProductById(productId)
? shouldUseRemoteAssetStorage()
? initialProduct
: getNonIntegratedProductById(productId)
: null;
const initialBomDetailsResultRef = useRef<InitialBomDetailsResult | null>(null);
if (!initialBomDetailsResultRef.current) {
@ -5816,7 +5821,11 @@ export default function NonIntegratedDetailClient({
),
);
saveNonIntegratedProduct(nextProduct);
if (shouldUseRemoteAssetStorage()) {
await saveSharedNonIntegratedProduct(nextProduct);
} else {
saveNonIntegratedProduct(nextProduct);
}
setSaveMessage(successMessage);
setSaveMessageType("success");
return true;
@ -6616,37 +6625,6 @@ export default function NonIntegratedDetailClient({
setSaveMessageType("success");
}
const updateCurrentBomRows = useCallback(
(updater: (rows: NonIntegratedBomRow[]) => NonIntegratedBomRow[]) => {
if (isReadOnly || !selectedBomStyleKey) return;
setBomDetails((current) => {
const sharedDetail =
current[selectedBomStyleKey] ?? createEmptyBomDetailState();
const sharedRows = sharedDetail.rows;
const currentSkuRows =
selectedBomSkuKey && hasBomDetailOverride(current, selectedBomSkuKey)
? (current[selectedBomSkuKey]?.rows ?? [])
: [];
const baseRows =
currentSkuRows.length > 0
? mergeBomRows(sharedRows, currentSkuRows)
: sharedRows;
const nextRows = updater(cloneBomRows(baseRows));
return {
...current,
[selectedBomStyleKey]: {
...sharedDetail,
rows: cloneBomRows(nextRows),
},
};
});
setSaveMessage("");
setSaveMessageType("success");
},
[isReadOnly, selectedBomSkuKey, selectedBomStyleKey],
);
function resolveNextBomRow(
row: NonIntegratedBomRow,
key: BomRowFieldKey,
@ -6687,8 +6665,23 @@ export default function NonIntegratedDetailClient({
let appendedRowId = "";
updateCurrentBomRows((current) => {
const scopedRows = current.filter(
setBomDetails((current) => {
const sharedDetail =
current[selectedBomStyleKey] ?? createEmptyBomDetailState();
const sharedRows = sharedDetail.rows;
const currentOverrideRows =
selectedBomSkuKey && hasBomDetailOverride(current, selectedBomSkuKey)
? (current[selectedBomSkuKey]?.rows ?? [])
: [];
const currentHiddenRowIds =
selectedBomSkuKey && hasBomDetailOverride(current, selectedBomSkuKey)
? (current[selectedBomSkuKey]?.hiddenRowIds ?? [])
: [];
const visibleRows =
currentOverrideRows.length > 0 || currentHiddenRowIds.length > 0
? mergeBomRows(sharedRows, currentOverrideRows, currentHiddenRowIds)
: cloneBomRows(sharedRows);
const scopedVisibleRows = visibleRows.filter(
(row) =>
getBomRowCategory(row) === category &&
(category !== "包材" ||
@ -6696,18 +6689,29 @@ export default function NonIntegratedDetailClient({
getBomRowPackageSubcategory(row) === packageSubcategory),
);
const previousRow =
scopedRows.length > 0 ? scopedRows[scopedRows.length - 1] : null;
scopedVisibleRows.length > 0
? scopedVisibleRows[scopedVisibleRows.length - 1]
: null;
const nextRow = applyBomRowLibraryMetrics(
createNextBomRowFromPrevious(previousRow, category, packageSubcategory),
);
appendedRowId = nextRow.id;
return [...current, nextRow];
return {
...current,
[selectedBomStyleKey]: {
...sharedDetail,
rows: [...sharedRows, nextRow],
},
};
});
setActiveSharedBomRowIds((current) =>
appendedRowId
? Array.from(new Set([...current, appendedRowId]))
: current,
);
setSaveMessage("");
setSaveMessageType("success");
}
function updateBomRow(rowId: string, key: BomRowFieldKey, value: string) {
@ -8451,7 +8455,9 @@ export default function NonIntegratedDetailClient({
documentWorkspaceConfirmations,
),
);
const savedProduct = saveNonIntegratedProduct(normalizedProduct);
const savedProduct = shouldUseRemoteAssetStorage()
? await saveSharedNonIntegratedProduct(normalizedProduct)
: saveNonIntegratedProduct(normalizedProduct);
router.push(
`/intake/non-integrated/${encodeURIComponent(savedProduct.id)}`,
);

View File

@ -1,4 +1,5 @@
import { requireSession } from '@/lib/server-session'
import { intakeNonIntegratedProductService } from '@/server/services'
import NonIntegratedDetailClient from '../NonIntegratedDetailClient'
export default async function NonIntegratedDetailPage({
@ -8,10 +9,18 @@ export default async function NonIntegratedDetailPage({
params: Promise<{ id: string }>
searchParams: Promise<{ mode?: string }>
}) {
await requireSession('/login')
const { authUser } = await requireSession('/login')
const { id } = await params
const resolvedSearchParams = await searchParams
const mode = resolvedSearchParams.mode === 'view' ? 'view' : 'edit'
const decodedId = decodeURIComponent(id)
const initialProduct = await intakeNonIntegratedProductService.getById(decodedId, authUser)
return <NonIntegratedDetailClient mode={mode} productId={decodeURIComponent(id)} />
return (
<NonIntegratedDetailClient
mode={mode}
productId={decodedId}
initialProduct={initialProduct}
/>
)
}

View File

@ -28,6 +28,16 @@ export async function listSharedNonIntegratedProducts() {
return data.products
}
export async function getSharedNonIntegratedProductById(id: string) {
const response = await fetch(`/api/intake/non-integrated/${encodeURIComponent(id)}`, {
method: 'GET',
cache: 'no-store',
})
const data = await readApiData<{ product: NonIntegratedProductRecord }>(response)
return data.product
}
export async function saveSharedNonIntegratedProduct(product: NonIntegratedProductRecord) {
const response = await fetch('/api/intake/non-integrated', {
method: 'POST',

View File

@ -4,6 +4,7 @@ import {
categoryService,
draftGroupService,
intakeService,
intakeNonIntegratedProductService,
materialService,
warehouseService,
} from '@/server/services'
@ -45,7 +46,7 @@ export default async function IntakePage({
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
const { user } = await requireSession('/login')
const { user, authUser } = await requireSession('/login')
const params = await searchParams
const draftGroupId = typeof params.draftGroupId === 'string' ? params.draftGroupId : null
@ -56,6 +57,7 @@ export default async function IntakePage({
materialLibraryFinishes,
materialLibraryCategories,
materialLibraryDictionaries,
initialNonIntegratedProducts,
] = await Promise.all([
draftGroupService.list(),
warehouseService.listLocations(),
@ -63,6 +65,7 @@ export default async function IntakePage({
materialService.findAll({ limit: 500 }),
materialService.findAllCategories(),
assetCenterService.getSubstrateDictionaryBundle(),
intakeNonIntegratedProductService.list(authUser),
])
const eligibleDraftGroups = draftGroups.filter((group) =>
@ -128,6 +131,7 @@ export default async function IntakePage({
materialLibraryFinishes={materialLibraryFinishes}
materialLibraryCategories={materialLibraryCategories}
materialLibraryDictionaries={materialLibraryDictionaries}
initialNonIntegratedProducts={initialNonIntegratedProducts}
/>
)
}
@ -145,6 +149,7 @@ export default async function IntakePage({
materialLibraryFinishes={materialLibraryFinishes}
materialLibraryCategories={materialLibraryCategories}
materialLibraryDictionaries={materialLibraryDictionaries}
initialNonIntegratedProducts={initialNonIntegratedProducts}
/>
)
}