Merge remote-tracking branch 'origin/codex/xuxiake-mainline' into codex/wuyongjie-assisted-assembly-next

This commit is contained in:
WuYongJie 2026-04-30 10:31:26 +08:00
commit edfe43d15f
3 changed files with 1189 additions and 319 deletions

View File

@ -13,11 +13,25 @@ import {
} from './non-integrated/nonIntegratedStorage'
const push = jest.fn()
const mockAoaToSheet = jest.fn((rows: unknown[][]) => ({ rows }))
const mockBookNew = jest.fn(() => ({}))
const mockBookAppendSheet = jest.fn()
const mockXlsxWrite = jest.fn(() => new ArrayBuffer(8))
jest.mock('next/navigation', () => ({
useRouter: () => ({ push }),
}))
jest.mock('xlsx', () => ({
__esModule: true,
utils: {
aoa_to_sheet: mockAoaToSheet,
book_new: mockBookNew,
book_append_sheet: mockBookAppendSheet,
},
write: mockXlsxWrite,
}))
jest.mock('@/components/platform/PlatformSplitLayout', () => ({
__esModule: true,
PlatformSplitLayout: ({
@ -262,8 +276,13 @@ function createNonIntegratedProduct(overrides: Record<string, unknown> = {}) {
describe('IntakeClient', () => {
beforeEach(() => {
jest.useRealTimers()
window.localStorage.clear()
jest.restoreAllMocks()
mockAoaToSheet.mockClear()
mockBookNew.mockClear()
mockBookAppendSheet.mockClear()
mockXlsxWrite.mockClear()
push.mockReset()
})
@ -311,12 +330,121 @@ describe('IntakeClient', () => {
const leftPanel = screen.getByTestId('left-panel')
expect(screen.getByRole('tab', { name: '产品开发进度' })).toBeInTheDocument()
expect(within(leftPanel).getByText('产品开发进度总览')).toBeInTheDocument()
expect(within(leftPanel).getByRole('button', { name: /总进度/ })).toBeInTheDocument()
expect(within(leftPanel).getByRole('button', { name: /状态总览/ })).toBeInTheDocument()
expect(within(leftPanel).getByText('全部产品明细')).toBeInTheDocument()
expect(within(leftPanel).getByText('SZ0230')).toBeInTheDocument()
})
it('filters product progress rows by clicking a ring dimension item', async () => {
it('shows running total days and node elapsed days in the product progress table', async () => {
jest.useFakeTimers({ now: new Date('2026-04-29T00:00:00.000Z') })
const actor = userEvent.setup({ advanceTimers: jest.advanceTimersByTime })
try {
renderSelector({
eligibleSkuList: [],
initialNonIntegratedProducts: [
createNonIntegratedProduct({
id: 'duration-product',
spu: 'Duration SKU',
createdAt: '2026-04-28T00:00:00.000Z',
updatedAt: '2026-04-28T00:00:00.000Z',
documentWorkspaceApprovedAt: {
[NON_INTEGRATED_DOCUMENT_WORKSPACE_CATEGORIES[0]]:
'2026-04-28T12:00:00.000Z',
},
}),
],
})
const rightPanel = screen.getByTestId('right-panel')
await actor.click(
within(rightPanel).getByRole('button', { name: /产品开发进度/ }),
)
const row = within(screen.getByTestId('left-panel'))
.getByText('Duration SKU')
.closest('tr')
expect(row).not.toBeNull()
expect(
within(row as HTMLElement).getByText('1.00 天'),
).toBeInTheDocument()
expect(
within(row as HTMLElement).getByText('0.50 天'),
).toBeInTheDocument()
} finally {
jest.useRealTimers()
}
})
it('exports current product progress rows as an Excel workbook', async () => {
jest.useFakeTimers({ now: new Date('2026-04-29T00:00:00.000Z') })
const actor = userEvent.setup({ advanceTimers: jest.advanceTimersByTime })
const originalCreateObjectURL = URL.createObjectURL
const originalRevokeObjectURL = URL.revokeObjectURL
const createObjectURL = jest.fn(() => 'blob:product-progress')
const revokeObjectURL = jest.fn()
const clickSpy = jest
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => {})
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
value: createObjectURL,
})
Object.defineProperty(URL, 'revokeObjectURL', {
configurable: true,
value: revokeObjectURL,
})
try {
renderSelector({
eligibleSkuList: [],
initialNonIntegratedProducts: [
createNonIntegratedProduct({
id: 'export-product',
spu: 'Export SKU',
createdAt: '2026-04-28T00:00:00.000Z',
updatedAt: '2026-04-28T00:00:00.000Z',
documentWorkspaceApprovedAt: {
[NON_INTEGRATED_DOCUMENT_WORKSPACE_CATEGORIES[0]]:
'2026-04-28T12:00:00.000Z',
},
}),
],
})
const rightPanel = screen.getByTestId('right-panel')
await actor.click(
within(rightPanel).getByRole('button', { name: /产品开发进度/ }),
)
await actor.click(screen.getByRole('button', { name: '导出明细' }))
await waitFor(() => expect(mockXlsxWrite).toHaveBeenCalled())
const exportedRows = mockAoaToSheet.mock.calls[0]?.[0] as unknown[][]
expect(exportedRows[0]).toContain('总耗时')
expect(exportedRows.flat()).toContain('Export SKU')
expect(exportedRows.flat()).toContain('1.00 天')
expect(exportedRows.flat()).toContain('0.50 天')
expect(createObjectURL).toHaveBeenCalledWith(expect.any(Blob))
expect(clickSpy).toHaveBeenCalled()
expect(revokeObjectURL).toHaveBeenCalledWith('blob:product-progress')
} finally {
clickSpy.mockRestore()
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
value: originalCreateObjectURL,
})
Object.defineProperty(URL, 'revokeObjectURL', {
configurable: true,
value: originalRevokeObjectURL,
})
jest.useRealTimers()
}
})
it('filters product progress rows by clicking a status overview dimension item', async () => {
const actor = userEvent.setup()
renderSelector({
@ -341,11 +469,10 @@ describe('IntakeClient', () => {
)
const leftPanel = screen.getByTestId('left-panel')
await actor.click(within(leftPanel).getByRole('button', { name: /环形视图/ }))
await actor.click(within(leftPanel).getByRole('button', { name: /基本盘 1 个/ }))
expect(
within(leftPanel).getByText('按品牌查看明细:基本盘'),
within(leftPanel).getByText('全部产品明细:基本盘'),
).toBeInTheDocument()
expect(within(leftPanel).getByText('SZ0230')).toBeInTheDocument()
expect(within(leftPanel).queryByText('GBGJ-SZ0230')).not.toBeInTheDocument()

File diff suppressed because it is too large Load Diff

View File

@ -324,6 +324,7 @@ type FieldDefinition = {
type?: BasicFieldType;
placeholder?: string;
options?: string[];
auto?: boolean;
};
type StyleDetailForm = {
@ -834,16 +835,18 @@ const PROJECT_STATUS_COPY: Record<
saveMessage: "立项已通过,已展开产品资料、样品评审、基础信息和后续节点",
},
};
const OPENING_REFERENCE_STAGE_OPTIONS = [
"立项前",
"资料补充中",
"待立项评审",
"已立项",
"退回修改",
"废弃不开",
];
const WORKFLOW_DATE_TIME_ZONE = "Asia/Shanghai";
function getAutoProductYear(createdAt?: string) {
const parsedDate = createdAt ? new Date(createdAt) : null;
const sourceDate =
parsedDate && !Number.isNaN(parsedDate.getTime())
? parsedDate
: new Date();
return String(sourceDate.getFullYear());
}
function formatWorkflowDateTime(value: string) {
const parsedDate = new Date(value);
if (Number.isNaN(parsedDate.getTime())) return value;
@ -1433,7 +1436,7 @@ const BRAND_OPTIONS = ["基本盘", "顾家", "索菲亚", "朴美"];
const CREATE_TOP_FIELD_COLUMNS: FieldDefinition[][] = [
[
{ key: "spu", label: "SPU", required: true, placeholder: "请输入 SPU" },
{ key: "year", label: "年份", placeholder: "请输入年份" },
{ key: "year", label: "年份", placeholder: "系统自动带出", auto: true },
{
key: "salesCategory",
label: "销售类目",
@ -1562,7 +1565,8 @@ const BASIC_INFO_CORE_FIELDS: FieldDefinition[] = [
{
key: "year",
label: "年份",
placeholder: "开品参考带出,可调整",
placeholder: "系统自动带出",
auto: true,
},
{
key: "materialType",
@ -3283,7 +3287,7 @@ function createFormValuesFromProduct(
product: NonIntegratedProductRecord | null,
): FormValues {
if (!product) {
return { ...EMPTY_FORM_VALUES };
return { ...EMPTY_FORM_VALUES, year: getAutoProductYear() };
}
const normalizedBrand = product.brand || "基本盘";
@ -3298,7 +3302,7 @@ function createFormValuesFromProduct(
return {
spu: product.spu ?? "",
year: product.year ?? "",
year: product.year || getAutoProductYear(product.createdAt),
demandFlowNo: product.demandFlowNo ?? "",
processTemplate: product.processTemplate ?? "",
productCategory: product.productCategory ?? "",
@ -3361,6 +3365,26 @@ function getOpeningReferenceStageFromStatus(
return "立项前";
}
const OPENING_REFERENCE_DRAFT_STAGES = new Set([
"立项前",
"资料补充中",
"待立项评审",
]);
function getAutoOpeningReferenceStage(
status: NonIntegratedProjectStatus,
currentStage?: string,
) {
if (status !== "draft") {
return getOpeningReferenceStageFromStatus(status);
}
const normalizedStage = currentStage?.trim() ?? "";
return OPENING_REFERENCE_DRAFT_STAGES.has(normalizedStage)
? normalizedStage
: getOpeningReferenceStageFromStatus(status);
}
function createInitialOpeningReference(
product: NonIntegratedProductRecord | null,
): NonIntegratedOpeningReference {
@ -3370,6 +3394,7 @@ function createInitialOpeningReference(
brand: "基本盘",
adaptableBrands: ["基本盘"],
currentStage: "立项前",
year: getAutoProductYear(),
urgent: "否",
};
}
@ -3414,9 +3439,11 @@ function createInitialOpeningReference(
salesCategory: normalized.salesCategory || product.salesCategory || "",
designer: normalized.designer || product.designer || "",
planningOwner: normalized.planningOwner || product.productPlanning || "",
currentStage:
normalized.currentStage || getOpeningReferenceStageFromStatus(projectStatus),
year: normalized.year || product.year || "",
currentStage: getAutoOpeningReferenceStage(
projectStatus,
normalized.currentStage,
),
year: product.year || normalized.year || getAutoProductYear(product.createdAt),
targetPriceBand: normalized.targetPriceBand || product.priceBand || "",
size: normalized.size || product.size || "",
material:
@ -3479,7 +3506,7 @@ function buildOpeningReferenceFormValues(
previewImage: reference.productImage.trim() || baseValues.previewImage,
previewImageName:
reference.productImageName.trim() || baseValues.previewImageName,
year: reference.year.trim() || baseValues.year,
year: baseValues.year || reference.year.trim() || getAutoProductYear(),
brand: normalizedBrand,
adaptableBrands: normalizedAdaptableBrands,
productCategory:
@ -5702,14 +5729,23 @@ function buildProductPayload(
}));
const primaryReviewSheet =
normalizedReviewRounds[0]?.reviewSheet ?? createEmptyReviewSheet();
const autoYear = formValues.year.trim() || getAutoProductYear(createdAt);
const normalizedOpeningReference = normalizeNonIntegratedOpeningReference({
...openingReference,
currentStage: getAutoOpeningReferenceStage(
projectStatus,
openingReference.currentStage,
),
year: autoYear,
});
return {
id,
projectStatus,
projectReview: normalizeNonIntegratedProjectReview(projectReview),
openingReference: normalizeNonIntegratedOpeningReference(openingReference),
openingReference: normalizedOpeningReference,
spu: formValues.spu.trim(),
year: formValues.year.trim(),
year: autoYear,
demandFlowNo: formValues.demandFlowNo.trim(),
processTemplate: formValues.processTemplate.trim(),
productCategory: formValues.productCategory.trim(),
@ -8619,6 +8655,7 @@ export default function NonIntegratedDetailClient({
function updateField(key: keyof FormValues, value: string) {
if (isReadOnly && key !== "brand") return;
if (key === "year") return;
if (key === "spu") {
setFormValues((current) => ({
@ -9766,6 +9803,7 @@ export default function NonIntegratedDetailClient({
value: string,
) {
if (isReadOnly) return;
if (field === "currentStage" || field === "year") return;
setOpeningReference((current) => {
if (field !== "brand") {
@ -13117,17 +13155,6 @@ export default function NonIntegratedDetailClient({
label: "企划负责人",
placeholder: "请输入企划负责人",
},
{
key: "currentStage",
label: "当前阶段",
type: "select",
options: OPENING_REFERENCE_STAGE_OPTIONS,
},
{
key: "year",
label: "年份",
placeholder: "如:2026",
},
];
const demandFields: {
key: OpeningReferenceFieldKey;
@ -13174,6 +13201,31 @@ export default function NonIntegratedDetailClient({
},
];
const openingReferenceFiles = activeDocumentWorkspaceFiles;
const autoOpeningReferenceStage =
getAutoOpeningReferenceStage(
projectStatus,
openingReference.currentStage,
);
const autoOpeningReferenceYear =
openingReference.year ||
formValues.year ||
getAutoProductYear(existingProductCreatedAt);
const renderAutoOpeningReferenceField = ({
label,
value,
}: {
label: string;
value: string;
}) => (
<div className="rounded-apple-lg border border-apple-separator bg-apple-bg-secondary/35 p-1.5">
<p className="mb-1 block text-[11px] leading-4 text-apple-label-secondary">
{label}
</p>
<div className="flex h-8 items-center rounded-apple-md border border-apple-separator bg-apple-bg-primary px-2 text-xs font-apple-medium text-apple-label-primary">
{value || "-"}
</div>
</div>
);
const renderCompactOpeningReferenceField = (field: {
key: OpeningReferenceFieldKey;
label: string;
@ -13287,6 +13339,14 @@ export default function NonIntegratedDetailClient({
{topFields.map((field) =>
renderCompactOpeningReferenceField(field),
)}
{renderAutoOpeningReferenceField({
label: "当前阶段",
value: autoOpeningReferenceStage,
})}
{renderAutoOpeningReferenceField({
label: "年份",
value: autoOpeningReferenceYear,
})}
<div className="rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-1.5">
<AdaptableBrandField
value={openingReference.adaptableBrands}
@ -15120,7 +15180,7 @@ export default function NonIntegratedDetailClient({
placeholder={field.placeholder}
options={field.options}
value={formValues[field.key] as string}
readOnly={isReadOnly}
readOnly={isReadOnly || field.auto === true}
onChange={(value) =>
updateField(field.key, value)
}
@ -15174,6 +15234,7 @@ export default function NonIntegratedDetailClient({
value={formValues[field.key] as string}
readOnly={
isReadOnly ||
field.auto === true ||
(field.key === "brand" &&
(isCopiedBrandProduct ||
availableBrandOptions.length === 0))