feat(intake): add brand filing workspace and planning export

This commit is contained in:
WuYongJie 2026-07-09 13:55:52 +08:00
parent 5f5dc2ead7
commit cf44f1c371
36 changed files with 4466 additions and 3 deletions

View File

@ -0,0 +1,173 @@
import { NextRequest, NextResponse } from "next/server";
import type { BrandFilingBrand } from "@/app/intake/brand-filing/brandFilingConfig";
import { normalizeBrandFilingPlanningRecord } from "@/app/intake/brand-filing/brandFilingTypes";
import type { NonIntegratedProductRecord } from "@/app/intake/non-integrated/nonIntegratedStorage";
import type { BrandFilingPlanningTableRecordInput } from "@/app/intake/brand-filing/brandFilingPlanningTableRequest";
import { AppError, ErrorCode } from "@/server/core/errors";
import { handleRequest, jsonError } from "@/server/core/response";
import { buildBrandFilingPlanningTableDownload } from "@/server/services/brand-filing-planning-table.service";
import { intakeNonIntegratedProductService } from "@/server/services/intake-non-integrated-product.service";
function buildAttachmentHeaders(fileName: string, contentType: string) {
const encodedFileName = encodeURIComponent(fileName);
return {
"Content-Type": contentType,
"Content-Disposition": `attachment; filename*=UTF-8''${encodedFileName}`,
"Cache-Control": "no-store",
};
}
function isBrandFilingBrand(value: unknown): value is BrandFilingBrand {
return value === "gujia" || value === "suofeiya";
}
function parsePlanningRecordInputs(
value: unknown,
): BrandFilingPlanningTableRecordInput[] {
if (!Array.isArray(value)) return [];
return value
.map((entry) => {
if (!entry || typeof entry !== "object") {
return null;
}
const input = entry as Record<string, unknown>;
if (typeof input.productId !== "string") return null;
const planningTable = normalizeBrandFilingPlanningRecord(
input.planningTable,
);
if (!planningTable) return null;
return {
productId: input.productId.trim(),
planningTable,
} satisfies BrandFilingPlanningTableRecordInput;
})
.filter(
(entry): entry is BrandFilingPlanningTableRecordInput =>
Boolean(entry?.productId),
);
}
function readPlanningRecordTimestamp(value: string | undefined) {
if (!value) return Number.NEGATIVE_INFINITY;
const timestamp = Date.parse(value);
return Number.isNaN(timestamp) ? Number.NEGATIVE_INFINITY : timestamp;
}
function applyPlanningRecordOverrides(
products: NonIntegratedProductRecord[],
brand: BrandFilingBrand,
overrides: BrandFilingPlanningTableRecordInput[],
) {
if (overrides.length === 0) return products;
const overrideMap = new Map(
overrides.map((entry) => [entry.productId, entry.planningTable]),
);
return products.map((product) => {
const override = overrideMap.get(product.id);
if (!override) return product;
const currentRecord = product.brandFilingData?.[brand]?.planningTable;
const nextPlanningTable =
readPlanningRecordTimestamp(override.updatedAt) >=
readPlanningRecordTimestamp(currentRecord?.updatedAt)
? override
: currentRecord;
if (!nextPlanningTable) return product;
return {
...product,
brandFilingData: {
...(product.brandFilingData ?? {}),
[brand]: {
...(product.brandFilingData?.[brand] ?? {}),
planningTable: nextPlanningTable,
},
},
};
});
}
export async function POST(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
const rawBody = await request.json().catch(() => null);
const brand = isBrandFilingBrand(rawBody?.brand) ? rawBody.brand : null;
const productIds = Array.isArray(rawBody?.productIds)
? rawBody.productIds.filter(
(value): value is string =>
typeof value === "string" && value.trim().length > 0,
)
: [];
const planningRecords = parsePlanningRecordInputs(rawBody?.planningRecords);
if (!brand) {
return jsonError(
ErrorCode.VALIDATION_REQUIRED_FIELD,
"缺少品牌参数",
400,
ctx.requestId,
);
}
if (productIds.length === 0) {
return jsonError(
ErrorCode.VALIDATION_REQUIRED_FIELD,
"请先选择产品",
400,
ctx.requestId,
);
}
try {
const products = await Promise.all(
productIds.map((productId) =>
intakeNonIntegratedProductService.getById(productId, ctx.user),
),
);
const missingProductId = productIds.find((_, index) => !products[index]);
if (missingProductId) {
return jsonError(
ErrorCode.PRODUCT_NOT_FOUND,
`${missingProductId} 不存在或无权限访问`,
404,
ctx.requestId,
);
}
const result = await buildBrandFilingPlanningTableDownload({
brand,
products: applyPlanningRecordOverrides(
products.filter(Boolean),
brand,
planningRecords,
),
});
const body = Uint8Array.from(result.bytes).buffer;
return new NextResponse(body, {
status: 200,
headers: buildAttachmentHeaders(result.fileName, result.contentType),
});
} catch (error) {
if (error instanceof AppError) {
return jsonError(
error.code,
error.message,
error.statusCode,
ctx.requestId,
);
}
throw error;
}
},
{ requireAuth: true },
);
}

View File

@ -2,6 +2,7 @@
import type { ReactNode } from "react";
import { BarcodePrintWorkspace } from "@/app/intake/barcode-print/BarcodePrintWorkspace";
import { BrandFilingWorkspace } from "@/app/intake/brand-filing/BrandFilingWorkspace";
import {
BomLibraryWorkspaceContent,
isBomLibraryWorkspaceTab,
@ -223,5 +224,15 @@ export function IntakeActiveWorkspaceContent({
);
}
if (activeWorkspaceTab.kind === "brandFiling") {
return (
<BrandFilingWorkspace
brand={activeWorkspaceTab.brand}
products={nonIntegratedProducts}
onProductSaved={onProductSaved}
/>
);
}
return <>{fallback}</>;
}

View File

@ -2,6 +2,7 @@
import type { ComponentProps, ReactNode } from "react";
import { BarcodePrintDomain } from "@/app/intake/barcode-print/BarcodePrintDomain";
import { BrandFilingDomain } from "@/app/intake/brand-filing/BrandFilingDomain";
import { BomLibraryDomain } from "@/app/intake/bom-library/BomLibraryDomain";
import type { IntakeSection } from "@/app/intake/intakeWorkspaceTypes";
import { ProductDevelopmentDomain } from "@/app/intake/product-development/ProductDevelopmentDomain";
@ -27,6 +28,7 @@ export type IntakePrimaryDomainContentProps = {
productPatent: ComponentProps<typeof ProductPatentDashboard>;
canViewBarcodePrint: boolean;
barcodePrint: ComponentProps<typeof BarcodePrintDomain>;
canViewBrandFiling: boolean;
};
export function IntakePrimaryDomainContent({
@ -43,6 +45,7 @@ export function IntakePrimaryDomainContent({
productPatent,
canViewBarcodePrint,
barcodePrint,
canViewBrandFiling,
}: IntakePrimaryDomainContentProps) {
if (activeSection === "pending" || activeSection === "progress") {
return (
@ -73,5 +76,9 @@ export function IntakePrimaryDomainContent({
return <BarcodePrintDomain {...barcodePrint} />;
}
if (activeSection === "brandFiling" && canViewBrandFiling) {
return <BrandFilingDomain />;
}
return <>{fallback}</>;
}

View File

@ -0,0 +1,11 @@
"use client";
import { BrandFilingWorkbenchSurface } from "@/app/intake/brand-filing/BrandFilingWorkbenchSurface";
export function BrandFilingDomain() {
return (
<BrandFilingWorkbenchSurface>
<div className="h-full" />
</BrandFilingWorkbenchSurface>
);
}

View File

@ -0,0 +1,241 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { BadgeCheck, X } from "lucide-react";
import { BrandFilingPlanningTemplateSheet } from "@/app/intake/brand-filing/BrandFilingPlanningTemplateSheet";
import {
getBrandFilingBrandLabel,
type BrandFilingBrand,
} from "@/app/intake/brand-filing/brandFilingConfig";
import {
buildBrandFilingPlanningInitialState,
type BrandFilingPlanningEditorState,
} from "@/app/intake/brand-filing/brandFilingProductData";
import {
type BrandFilingPlanningForm,
type BrandFilingPlanningLineItem,
type BrandFilingPlanningRecord,
} from "@/app/intake/brand-filing/brandFilingTypes";
import type { NonIntegratedProductRecord } from "@/app/intake/non-integrated/nonIntegratedStorage";
export function BrandFilingPlanningTableDialog({
isOpen,
brand,
product,
initialRecord,
saving,
onClose,
onSave,
}: {
isOpen: boolean;
brand: BrandFilingBrand;
product: NonIntegratedProductRecord | null;
initialRecord?: BrandFilingPlanningRecord;
saving: boolean;
onClose: () => void;
onSave: (
form: BrandFilingPlanningForm,
lineItems: BrandFilingPlanningLineItem[],
completed: boolean,
) => void;
}) {
if (!isOpen || !product) return null;
const initialState = buildBrandFilingPlanningInitialState(product, initialRecord);
const dialogKey = `${product.id}:${initialRecord?.updatedAt ?? "new"}`;
return (
<BrandFilingPlanningTableDialogContent
key={dialogKey}
brand={brand}
product={product}
initialRecord={initialRecord}
initialState={initialState}
saving={saving}
onClose={onClose}
onSave={onSave}
/>
);
}
function BrandFilingPlanningTableDialogContent({
brand,
product,
initialRecord,
initialState,
saving,
onClose,
onSave,
}: {
brand: BrandFilingBrand;
product: NonIntegratedProductRecord;
initialRecord?: BrandFilingPlanningRecord;
initialState: BrandFilingPlanningEditorState;
saving: boolean;
onClose: () => void;
onSave: (
form: BrandFilingPlanningForm,
lineItems: BrandFilingPlanningLineItem[],
completed: boolean,
) => void;
}) {
const [form, setForm] = useState<BrandFilingPlanningForm>(initialState.form);
const [lineItems, setLineItems] = useState<BrandFilingPlanningLineItem[]>(
initialState.lineItems,
);
const initialSnapshot = useMemo(
() =>
JSON.stringify({
form: initialState.form,
lineItems: initialState.lineItems,
}),
[initialState],
);
const currentSnapshot = JSON.stringify({ form, lineItems });
const isDirty = initialSnapshot !== currentSnapshot;
const handleAttemptClose = useCallback(() => {
if (!isDirty) {
onClose();
return;
}
if (window.confirm("当前企划表还有未保存的内容,确定现在关闭吗?")) {
onClose();
}
}, [isDirty, onClose]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
handleAttemptClose();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [handleAttemptClose]);
const completed = initialRecord?.completed === true;
const sequenceText = initialRecord?.sequenceNumber
? `序号 ${initialRecord.sequenceNumber}`
: "序号待生成";
const updateForm = <K extends keyof BrandFilingPlanningForm>(
key: K,
value: BrandFilingPlanningForm[K],
) => {
setForm((current) => {
const nextForm = {
...current,
[key]: value,
};
if (key === "productSeries") {
nextForm.seriesName = current.seriesName || String(value);
}
return nextForm;
});
};
const updateLineItem = (
itemCode: string,
key: keyof BrandFilingPlanningLineItem,
value: string,
) => {
setLineItems((current) =>
current.map((item) =>
item.itemCode === itemCode
? {
...item,
[key]: value,
}
: item,
),
);
};
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="brand-filing-planning-dialog-title"
className="fixed inset-0 z-50 flex items-start justify-center overflow-auto bg-black/35 px-4 py-[5vh]"
onClick={handleAttemptClose}
>
<div
className="flex w-[1320px] max-w-full flex-col overflow-hidden rounded-[10px] border border-[var(--obs-shell-border)] bg-white shadow-[0_30px_80px_rgba(15,23,42,0.2)]"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-start justify-between gap-4 border-b border-[var(--obs-shell-border)] px-5 py-4">
<div className="min-w-0">
<h2
id="brand-filing-planning-dialog-title"
className="text-[16px] font-semibold text-[var(--obs-shell-title)]"
>
{getBrandFilingBrandLabel(brand)}企划表
</h2>
<div className="mt-1 flex flex-wrap items-center gap-3 text-xs text-[var(--obs-shell-muted)]">
<span>{product.spu.trim() || "-"}</span>
<span>
{product.openingReference?.productName?.trim() ||
product.spu.trim() ||
"未命名产品"}
</span>
<span>{sequenceText}</span>
<span className="inline-flex items-center gap-1 rounded-full bg-[var(--obs-shell-soft-bg)] px-2 py-0.5 text-[11px] text-[var(--obs-shell-title)]">
<BadgeCheck className="h-3.5 w-3.5" aria-hidden="true" />
{completed ? "已确认" : "待确认"}
</span>
</div>
</div>
<button
type="button"
aria-label="关闭"
onClick={handleAttemptClose}
className="inline-flex h-8 w-8 items-center justify-center rounded-[6px] border border-[var(--obs-shell-border)] bg-white text-[var(--obs-shell-muted)] transition hover:bg-[var(--obs-shell-hover-bg)] hover:text-[var(--obs-shell-title)]"
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
</div>
<div className="min-h-0 overflow-y-auto px-5 py-5">
<BrandFilingPlanningTemplateSheet
product={product}
form={form}
onChange={updateForm}
lineItems={lineItems}
onLineItemChange={updateLineItem}
/>
</div>
<div className="flex items-center justify-end gap-2 border-t border-[var(--obs-shell-border)] bg-[var(--obs-shell-content-bg)] px-5 py-4">
<button
type="button"
onClick={handleAttemptClose}
className="inline-flex h-8 items-center rounded-[6px] border border-[var(--obs-shell-border)] bg-white px-4 text-[12px] font-apple-semibold text-[var(--obs-shell-control-text)] transition hover:bg-[var(--obs-shell-hover-bg)] hover:text-[var(--obs-shell-title)]"
>
取消
</button>
<button
type="button"
disabled={saving}
onClick={() => onSave(form, lineItems, completed)}
className="inline-flex h-8 items-center rounded-[6px] border border-[var(--obs-shell-border)] bg-white px-4 text-[12px] font-apple-semibold text-[var(--obs-shell-control-text)] transition hover:bg-[var(--obs-shell-hover-bg)] hover:text-[var(--obs-shell-title)] disabled:cursor-not-allowed disabled:opacity-50"
>
保存
</button>
<button
type="button"
disabled={saving}
onClick={() => onSave(form, lineItems, true)}
className="inline-flex h-8 items-center rounded-[6px] border border-[var(--obs-shell-action-border)] bg-[var(--obs-shell-action-bg)] px-4 text-[12px] font-apple-semibold text-[var(--obs-shell-action-text)] transition hover:bg-[var(--obs-shell-action-hover-bg)] disabled:cursor-not-allowed disabled:opacity-50"
>
保存并确认
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,543 @@
"use client";
import { Fragment, type ChangeEvent, type ReactNode } from "react";
import {
buildBrandFilingLineItemChannelSummary,
buildBrandFilingPlanningDisplayRows,
type BrandFilingPlanningDisplayRow,
} from "@/app/intake/brand-filing/brandFilingPlanningLineItems";
import { BRAND_FILING_PLANNING_FIELD_OPTIONS } from "@/app/intake/brand-filing/brandFilingPlanningTableSchema";
import type {
BrandFilingPlanningForm,
BrandFilingPlanningLineItem,
} from "@/app/intake/brand-filing/brandFilingTypes";
import type { NonIntegratedProductRecord } from "@/app/intake/non-integrated/nonIntegratedStorage";
const SHEET_BORDER = "border-slate-500/90";
const SECTION_BAND_CLASS =
`flex min-h-9 items-center justify-center border-b ${SHEET_BORDER} bg-[#dceef4] px-4 text-[18px] font-semibold tracking-[0.16em] text-slate-900`;
const TITLE_BAND_CLASS =
`flex min-h-12 items-center justify-center border-b ${SHEET_BORDER} bg-white px-4 text-[26px] font-semibold tracking-[0.04em] text-slate-950`;
const LABEL_CELL_CLASS =
`flex min-h-11 items-center justify-center border-r border-b ${SHEET_BORDER} bg-white px-3 py-2 text-center text-[15px] font-semibold leading-tight text-slate-900`;
const VALUE_CELL_CLASS =
`min-h-11 border-r border-b ${SHEET_BORDER} bg-white px-3 py-2 text-[15px] text-slate-700`;
const INPUT_CLASS =
"h-full min-h-[28px] w-full bg-transparent text-center text-[15px] leading-tight text-slate-700 outline-none placeholder:text-slate-300";
const INPUT_LEFT_CLASS =
"h-full min-h-[28px] w-full bg-transparent text-left text-[15px] leading-tight text-slate-700 outline-none placeholder:text-slate-300";
const TEXTAREA_CLASS =
"h-full min-h-[160px] w-full resize-none bg-transparent text-left text-[15px] leading-7 text-slate-700 outline-none placeholder:text-slate-300";
function SectionBand({ title }: { title: string }) {
return <div className={SECTION_BAND_CLASS}>{title}</div>;
}
function LabelCell({
children,
className = "",
}: {
children: ReactNode;
className?: string;
}) {
return <div className={`${LABEL_CELL_CLASS} ${className}`}>{children}</div>;
}
function InputCell({
value,
onChange,
placeholder,
className = "",
inputClassName = INPUT_CLASS,
listId,
options,
}: {
value: string;
onChange: (event: ChangeEvent<HTMLInputElement>) => void;
placeholder?: string;
className?: string;
inputClassName?: string;
listId?: string;
options?: readonly string[];
}) {
return (
<div className={`${VALUE_CELL_CLASS} ${className}`}>
<input
value={value}
onChange={onChange}
placeholder={placeholder}
list={listId}
className={inputClassName}
/>
{listId && options?.length ? (
<datalist id={listId}>
{options.map((option) => (
<option key={option} value={option} />
))}
</datalist>
) : null}
</div>
);
}
function ReadonlyValueCell({
value,
className = "",
textClassName = "text-center",
emptyPlaceholder = "-",
}: {
value: string;
className?: string;
textClassName?: string;
emptyPlaceholder?: string;
}) {
return (
<div
className={`${VALUE_CELL_CLASS} flex items-center justify-center ${className}`}
>
<div className={`w-full text-[15px] leading-tight text-slate-700 ${textClassName}`}>
{value.trim() || emptyPlaceholder}
</div>
</div>
);
}
function TextareaCell({
value,
onChange,
className = "",
}: {
value: string;
onChange: (event: ChangeEvent<HTMLTextAreaElement>) => void;
className?: string;
}) {
return (
<div className={`${VALUE_CELL_CLASS} ${className}`}>
<textarea value={value} onChange={onChange} className={TEXTAREA_CLASS} />
</div>
);
}
function ImageCell({ imageUrl }: { imageUrl: string }) {
return (
<div className={`min-h-[200px] border-r border-b ${SHEET_BORDER} bg-white p-3`}>
<div className="flex h-full min-h-[172px] items-center justify-center overflow-hidden bg-white">
{imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={imageUrl}
alt="产品图片"
className="max-h-[172px] w-full object-contain"
/>
) : (
<div className="text-[14px] text-slate-300">暂无图片</div>
)}
</div>
</div>
);
}
function PlanSectionRow({
displayRow,
productHasSkuRows,
legacyIterationNote,
onChange,
onLineItemChange,
}: {
displayRow: BrandFilingPlanningDisplayRow;
productHasSkuRows: boolean;
legacyIterationNote: string;
onChange: <K extends keyof BrandFilingPlanningForm>(
key: K,
value: BrandFilingPlanningForm[K],
) => void;
onLineItemChange: (
itemCode: string,
key: keyof BrandFilingPlanningLineItem,
value: string,
) => void;
}) {
return (
<>
{productHasSkuRows ? (
<ReadonlyValueCell value={displayRow.itemCode} />
) : (
<InputCell
value={displayRow.itemCode}
onChange={(event) =>
onLineItemChange(displayRow.itemCode, "itemCode", event.target.value)
}
/>
)}
<InputCell
value={displayRow.retailPriceDemand}
onChange={(event) =>
onLineItemChange(
displayRow.itemCode,
"retailPriceDemand",
event.target.value,
)
}
/>
<InputCell
value={displayRow.purchasePriceDemand}
onChange={(event) =>
onLineItemChange(
displayRow.itemCode,
"purchasePriceDemand",
event.target.value,
)
}
/>
{productHasSkuRows ? (
<ReadonlyValueCell value={displayRow.sizeRequirement} />
) : (
<InputCell
value={displayRow.sizeRequirement}
onChange={(event) =>
onLineItemChange(
displayRow.itemCode,
"sizeRequirement",
event.target.value,
)
}
/>
)}
{productHasSkuRows ? (
<ReadonlyValueCell
value={displayRow.materialAndColor}
textClassName="text-left"
/>
) : (
<InputCell
value={displayRow.materialAndColor}
onChange={(event) =>
onLineItemChange(
displayRow.itemCode,
"materialAndColor",
event.target.value,
)
}
inputClassName={INPUT_LEFT_CLASS}
/>
)}
<InputCell
value={legacyIterationNote}
onChange={(event) =>
onChange("legacyIterationNote", event.target.value)
}
listId="legacy-iteration-note-options"
options={BRAND_FILING_PLANNING_FIELD_OPTIONS.legacyIterationNote}
/>
</>
);
}
function PromotionSectionRow({
displayRow,
onLineItemChange,
}: {
displayRow: BrandFilingPlanningDisplayRow;
onLineItemChange: (
itemCode: string,
key: keyof BrandFilingPlanningLineItem,
value: string,
) => void;
}) {
return (
<>
<InputCell
value={displayRow.seriesName}
onChange={(event) =>
onLineItemChange(displayRow.itemCode, "seriesName", event.target.value)
}
/>
<ReadonlyValueCell value={displayRow.itemCode} />
<ReadonlyValueCell value={displayRow.annualSalesTarget} />
<InputCell
value={displayRow.channel}
onChange={(event) =>
onLineItemChange(displayRow.itemCode, "channel", event.target.value)
}
/>
<InputCell
value={displayRow.month1Sales}
onChange={(event) =>
onLineItemChange(displayRow.itemCode, "month1Sales", event.target.value)
}
/>
<InputCell
value={displayRow.month2Sales}
onChange={(event) =>
onLineItemChange(displayRow.itemCode, "month2Sales", event.target.value)
}
/>
<InputCell
value={displayRow.month3Sales}
onChange={(event) =>
onLineItemChange(displayRow.itemCode, "month3Sales", event.target.value)
}
/>
<InputCell
value={displayRow.month4To6Sales}
onChange={(event) =>
onLineItemChange(
displayRow.itemCode,
"month4To6Sales",
event.target.value,
)
}
/>
<InputCell
value={displayRow.month7To9Sales}
onChange={(event) =>
onLineItemChange(
displayRow.itemCode,
"month7To9Sales",
event.target.value,
)
}
/>
<InputCell
value={displayRow.month10To12Sales}
onChange={(event) =>
onLineItemChange(
displayRow.itemCode,
"month10To12Sales",
event.target.value,
)
}
/>
</>
);
}
export function BrandFilingPlanningTemplateSheet({
product,
form,
lineItems,
onChange,
onLineItemChange,
}: {
product: NonIntegratedProductRecord;
form: BrandFilingPlanningForm;
lineItems: BrandFilingPlanningLineItem[];
onChange: <K extends keyof BrandFilingPlanningForm>(
key: K,
value: BrandFilingPlanningForm[K],
) => void;
onLineItemChange: (
itemCode: string,
key: keyof BrandFilingPlanningLineItem,
value: string,
) => void;
}) {
const previewImage = form.imageUrl.trim();
const planningProductLineOptions =
BRAND_FILING_PLANNING_FIELD_OPTIONS.planningProductLine;
const productLineOptions = BRAND_FILING_PLANNING_FIELD_OPTIONS.productLine;
const annualPlanStatusOptions =
BRAND_FILING_PLANNING_FIELD_OPTIONS.annualPlanStatus;
const developmentModeOptions =
BRAND_FILING_PLANNING_FIELD_OPTIONS.developmentMode;
const developmentCategoryOptions =
BRAND_FILING_PLANNING_FIELD_OPTIONS.developmentCategory;
const productManagerOptions =
BRAND_FILING_PLANNING_FIELD_OPTIONS.productManager;
const warehousingStatusOptions =
BRAND_FILING_PLANNING_FIELD_OPTIONS.warehousingStatus;
const activityStyleStatusOptions =
BRAND_FILING_PLANNING_FIELD_OPTIONS.activityStyleStatus;
const displayRows = buildBrandFilingPlanningDisplayRows({
product,
form,
lineItems,
});
const productHasSkuRows = product.skuItems.length > 0;
const channelSummary =
buildBrandFilingLineItemChannelSummary(lineItems) || form.channel.trim();
return (
<div className="overflow-auto rounded-[12px] border border-[var(--obs-shell-border)] bg-[var(--obs-shell-panel-bg)] p-4">
<div className="mx-auto min-w-[1100px] max-w-[1240px] overflow-hidden border border-slate-500/90 bg-white shadow-[0_8px_20px_rgba(15,23,42,0.06)]">
<div className={TITLE_BAND_CLASS}>电商外协产品企划单</div>
<SectionBand title="基本信息" />
<div className="grid grid-cols-[1.5fr_1.1fr_1.35fr_1.15fr_1.45fr_1.05fr]">
<LabelCell>企划产品线</LabelCell>
<InputCell
value={form.planningProductLine}
onChange={(event) =>
onChange("planningProductLine", event.target.value)
}
listId="planning-product-line-options"
options={planningProductLineOptions}
/>
<LabelCell>产品线</LabelCell>
<InputCell
value={form.productLine}
onChange={(event) => onChange("productLine", event.target.value)}
listId="product-line-options"
options={productLineOptions}
/>
<LabelCell>是否年度规划内</LabelCell>
<InputCell
value={form.annualPlanStatus}
onChange={(event) =>
onChange("annualPlanStatus", event.target.value)
}
listId="annual-plan-status-options"
options={annualPlanStatusOptions}
/>
</div>
<div className="grid grid-cols-[1.5fr_1.1fr_1.35fr_1.15fr_1.45fr_1.05fr]">
<LabelCell>开发方式</LabelCell>
<InputCell
value={form.developmentMode}
onChange={(event) =>
onChange("developmentMode", event.target.value)
}
listId="development-mode-options"
options={developmentModeOptions}
/>
<LabelCell>产品系列</LabelCell>
<InputCell
value={form.productSeries}
onChange={(event) => onChange("productSeries", event.target.value)}
/>
<LabelCell>开发类别</LabelCell>
<InputCell
value={form.developmentCategory}
onChange={(event) =>
onChange("developmentCategory", event.target.value)
}
listId="development-category-options"
options={developmentCategoryOptions}
/>
</div>
<div className="grid grid-cols-[1.55fr_1.55fr_1fr_0.95fr_1.55fr_1.55fr_1.45fr_1.45fr]">
<LabelCell>买采供应商名称</LabelCell>
<InputCell
value={form.supplierName}
onChange={(event) => onChange("supplierName", event.target.value)}
inputClassName={INPUT_LEFT_CLASS}
/>
<LabelCell>是否入仓</LabelCell>
<InputCell
value={form.warehousingStatus}
onChange={(event) =>
onChange("warehousingStatus", event.target.value)
}
listId="warehousing-status-options"
options={warehousingStatusOptions}
/>
<LabelCell>需求上市时间</LabelCell>
<InputCell
value={form.launchDate}
onChange={(event) => onChange("launchDate", event.target.value)}
/>
<LabelCell>是否活动款</LabelCell>
<InputCell
value={form.activityStyleStatus}
onChange={(event) =>
onChange("activityStyleStatus", event.target.value)
}
listId="activity-style-status-options"
options={activityStyleStatusOptions}
/>
</div>
<SectionBand title="企划背景介绍" />
<div className="grid grid-cols-[190px_minmax(0,1fr)]">
<LabelCell className="min-h-[200px] text-[22px]">图片</LabelCell>
<ImageCell imageUrl={previewImage} />
<LabelCell className="min-h-[200px] text-[18px]">
产品需求分析
</LabelCell>
<TextareaCell
value={form.productDemandAnalysis}
onChange={(event) =>
onChange("productDemandAnalysis", event.target.value)
}
className="min-h-[200px]"
/>
</div>
<SectionBand title="企划方案" />
<div className="grid grid-cols-[1.1fr_1.8fr_1.5fr_1.6fr_1.7fr_2fr]">
<LabelCell>货号</LabelCell>
<LabelCell>零售价需求</LabelCell>
<LabelCell>采购价需求</LabelCell>
<LabelCell>尺寸要求</LabelCell>
<LabelCell>材质/颜色</LabelCell>
<LabelCell>特殊说明(是否老款迭代)</LabelCell>
{displayRows.map((displayRow, index) => (
<Fragment key={`plan-${displayRow.itemCode || index}`}>
<PlanSectionRow
displayRow={displayRow}
productHasSkuRows={productHasSkuRows}
legacyIterationNote={form.legacyIterationNote}
onChange={onChange}
onLineItemChange={onLineItemChange}
/>
</Fragment>
))}
</div>
<SectionBand title="推广计划" />
<div className="grid grid-cols-[1.35fr_1.35fr_1.2fr_0.95fr_0.9fr_0.9fr_0.9fr_1fr_1.25fr_1.4fr] auto-rows-[58px]">
<LabelCell className="row-span-2">系列</LabelCell>
<LabelCell className="row-span-2">货号</LabelCell>
<LabelCell className="col-span-2">上市计划</LabelCell>
<LabelCell className="col-span-6">上市季度销量</LabelCell>
<LabelCell className="whitespace-nowrap">全年销售总量</LabelCell>
<LabelCell>渠道</LabelCell>
<LabelCell>第1个月</LabelCell>
<LabelCell>第2个月</LabelCell>
<LabelCell>第3个月</LabelCell>
<LabelCell>第4-6月</LabelCell>
<LabelCell>第7-9月</LabelCell>
<LabelCell>第10-12月</LabelCell>
{displayRows.map((displayRow, index) => (
<Fragment key={`promotion-${displayRow.itemCode || index}`}>
<PromotionSectionRow
displayRow={displayRow}
onLineItemChange={onLineItemChange}
/>
</Fragment>
))}
</div>
<SectionBand title="会签" />
<div className="grid grid-cols-[1.15fr_2.2fr_1fr_2.2fr_1fr_2.25fr]">
<LabelCell>产品经理</LabelCell>
<InputCell
value={form.productManager}
onChange={(event) => onChange("productManager", event.target.value)}
listId="product-manager-options"
options={productManagerOptions}
/>
<LabelCell>企划负责人</LabelCell>
<InputCell
value={form.planningOwner}
onChange={(event) => onChange("planningOwner", event.target.value)}
/>
<LabelCell>采购负责人</LabelCell>
<InputCell
value={form.purchaseOwner}
onChange={(event) => onChange("purchaseOwner", event.target.value)}
/>
</div>
<div className="grid grid-cols-[1.15fr_5.45fr]">
<LabelCell>渠道</LabelCell>
<ReadonlyValueCell
value={channelSummary}
emptyPlaceholder=""
/>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,145 @@
import {
getNonIntegratedProductDisplayName,
normalizeNonIntegratedBrand,
type NonIntegratedProductRecord,
} from "@/app/intake/non-integrated/nonIntegratedStorage";
import {
getProductDevelopmentKind,
isJstSkuImportedStockProduct,
readNonIntegratedProductCategory,
} from "@/app/intake/product-development/nonIntegratedProductIdentity";
import { getNonIntegratedListPeople } from "@/app/intake/product-development/nonIntegratedProductListLogic";
import {
getBrandFilingBrandLabel,
type BrandFilingBrand,
} from "@/app/intake/brand-filing/brandFilingConfig";
import { getBrandFilingPlanningRecord } from "@/app/intake/brand-filing/brandFilingProductData";
import type { BrandFilingPlanningRecord } from "@/app/intake/brand-filing/brandFilingTypes";
export type BrandFilingRow = {
id: string;
product: NonIntegratedProductRecord;
planningRecord?: BrandFilingPlanningRecord;
sequenceNumber?: number;
planningCompleted: boolean;
displayName: string;
spu: string;
productName: string;
category: string;
appearanceDesigner: string;
structureDesigner: string;
previewImage: string;
updatedAt: string;
};
export function getBrandFilingNormalizedBrand(brand: BrandFilingBrand) {
return normalizeNonIntegratedBrand(getBrandFilingBrandLabel(brand));
}
function getProductPrimaryBrands(product: NonIntegratedProductRecord) {
return Array.from(
new Set(
[product.brand, product.openingReference?.brand]
.map((value) => normalizeNonIntegratedBrand(value))
.filter((value) => value.length > 0),
),
);
}
export function getBrandFilingProducts(
products: NonIntegratedProductRecord[],
brand: BrandFilingBrand,
) {
const normalizedBrand = getBrandFilingNormalizedBrand(brand);
return products.filter((product) => {
if (getProductDevelopmentKind(product) !== "product") {
return false;
}
if (isJstSkuImportedStockProduct(product)) {
return false;
}
return getProductPrimaryBrands(product).includes(normalizedBrand);
});
}
export function buildBrandFilingRows(
products: NonIntegratedProductRecord[],
brand: BrandFilingBrand,
): BrandFilingRow[] {
return getBrandFilingProducts(products, brand).map((product) => {
const people = getNonIntegratedListPeople(product);
const previewImage =
product.openingReference?.productImage?.trim() || product.previewImage;
const planningRecord = getBrandFilingPlanningRecord(product, brand);
return {
id: product.id,
product,
planningRecord,
sequenceNumber: planningRecord?.sequenceNumber,
planningCompleted: planningRecord?.completed ?? false,
displayName: getNonIntegratedProductDisplayName(product),
spu: product.spu.trim() || "-",
productName:
product.openingReference?.productName?.trim() ||
getNonIntegratedProductDisplayName(product),
category: readNonIntegratedProductCategory(product) || "-",
appearanceDesigner: people.appearanceDesigner.trim() || "-",
structureDesigner: people.structureDesigner.trim() || "-",
previewImage,
updatedAt: product.updatedAt ?? "",
};
});
}
export function upsertBrandFilingProductInList(
products: NonIntegratedProductRecord[],
nextProduct: NonIntegratedProductRecord,
) {
const existingIndex = products.findIndex(
(product) => product.id === nextProduct.id,
);
if (existingIndex < 0) {
return [nextProduct, ...products];
}
return products.map((product) =>
product.id === nextProduct.id ? nextProduct : product,
);
}
export function getBrandFilingProductsByIds(
products: NonIntegratedProductRecord[],
productIds: string[],
) {
const productMap = new Map(products.map((product) => [product.id, product]));
return productIds
.map((productId) => productMap.get(productId))
.filter((product): product is NonIntegratedProductRecord => Boolean(product));
}
export function formatBrandFilingSequenceNumber(value: number | undefined) {
return value && value > 0 ? String(value) : "-";
}
export function getBrandFilingPlanningStatusLabel(completed: boolean) {
return completed ? "已完成" : "待填写";
}
export function formatBrandFilingUpdatedAt(value: string) {
const trimmedValue = value.trim();
if (!trimmedValue) return "-";
const date = new Date(trimmedValue);
if (Number.isNaN(date.getTime())) {
return trimmedValue;
}
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(
date.getDate(),
).padStart(2, "0")}`;
}

View File

@ -0,0 +1,603 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { CheckCircle2 } from "lucide-react";
import { ImagePreviewDialog } from "@/app/intake/components/IntakeSharedPanels";
import {
BrandFilingPlanningTableDialog,
} from "@/app/intake/brand-filing/BrandFilingPlanningTableDialog";
import {
BrandFilingProductTableToolbar,
} from "@/app/intake/brand-filing/BrandFilingProductTableToolbar";
import { downloadBrandFilingPlanningTables } from "@/app/intake/brand-filing/brandFilingPlanningTableDownload";
import {
ensureBrandFilingPlanningRecord,
getBrandFilingPlanningRecord,
upsertBrandFilingPlanningRecord,
} from "@/app/intake/brand-filing/brandFilingProductData";
import {
buildBrandFilingRows,
formatBrandFilingSequenceNumber,
formatBrandFilingUpdatedAt,
getBrandFilingProductsByIds,
getBrandFilingPlanningStatusLabel,
upsertBrandFilingProductInList,
type BrandFilingRow,
} from "@/app/intake/brand-filing/BrandFilingProductHelpers";
import {
getBrandFilingBrandLabel,
type BrandFilingBrand,
} from "@/app/intake/brand-filing/brandFilingConfig";
import type {
BrandFilingPlanningForm,
BrandFilingPlanningLineItem,
} from "@/app/intake/brand-filing/brandFilingTypes";
import type { BrandFilingPlanningTableRecordInput } from "@/app/intake/brand-filing/brandFilingPlanningTableRequest";
import { saveSharedNonIntegratedProduct } from "@/app/intake/non-integrated/core/nonIntegratedApi";
import {
NON_INTEGRATED_PLANNING_LAYOUT_WORKSPACE_CATEGORY,
type NonIntegratedProductRecord,
} from "@/app/intake/non-integrated/nonIntegratedStorage";
import Button from "@/components/ui/Button";
import { ToastContainer, useToast } from "@/components/ui/Toast";
const HEADER_CELL_CLASS =
"bg-[var(--obs-shell-soft-bg)] px-4 py-3 text-left text-xs font-medium whitespace-nowrap text-[var(--obs-shell-muted)]";
const PAGE_SIZE = 50;
const TEXT = {
productCountUnit: "个产品",
rowUnit: "条",
index: "序号",
planningStatus: "企划表状态",
preview: "预览图",
name: "名称",
category: "产品类别",
appearanceDesigner: "外观设计师",
structureDesigner: "结构设计师",
updatedAt: "更新时间",
empty: "暂无数据",
noPreview: "暂无",
previous: "上一页",
next: "下一页",
page: "页",
perPage: "每页",
} as const;
function compareBrandFilingRows(
left: BrandFilingRow & { sourceIndex: number },
right: BrandFilingRow & { sourceIndex: number },
) {
const leftSequence = left.sequenceNumber ?? 0;
const rightSequence = right.sequenceNumber ?? 0;
if (leftSequence !== rightSequence) {
return rightSequence - leftSequence;
}
const leftTime = Date.parse(left.updatedAt);
const rightTime = Date.parse(right.updatedAt);
const safeLeftTime = Number.isNaN(leftTime) ? -Infinity : leftTime;
const safeRightTime = Number.isNaN(rightTime) ? -Infinity : rightTime;
if (safeLeftTime !== safeRightTime) {
return safeRightTime - safeLeftTime;
}
return right.sourceIndex - left.sourceIndex;
}
export function BrandFilingProductTable({
brand,
products,
onProductSaved,
}: {
brand: BrandFilingBrand;
products: NonIntegratedProductRecord[];
onProductSaved: (product: NonIntegratedProductRecord) => void;
}) {
const toast = useToast();
const [previewImage, setPreviewImage] = useState<{
src: string;
alt: string;
} | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [pageJumpInput, setPageJumpInput] = useState("1");
const [isPageJumpEditing, setIsPageJumpEditing] = useState(false);
const [selectedRowIds, setSelectedRowIds] = useState<string[]>([]);
const [activePlanningProductId, setActivePlanningProductId] = useState<
string | null
>(null);
const [isSavingPlanningTable, setIsSavingPlanningTable] = useState(false);
const [isDownloadingPlanningTable, setIsDownloadingPlanningTable] =
useState(false);
const selectAllRef = useRef<HTMLInputElement | null>(null);
const [savedProductOverrides, setSavedProductOverrides] = useState<
Record<string, NonIntegratedProductRecord>
>({});
const tableProductsRef = useRef(products);
const tableProducts = useMemo(() => {
const sourceProductIds = new Set(products.map((product) => product.id));
const mergedProducts = products.map((product) => {
const override = savedProductOverrides[product.id];
if (!override) return product;
return (product.updatedAt ?? "") >= (override.updatedAt ?? "")
? product
: override;
});
const detachedOverrides = Object.values(savedProductOverrides).filter(
(product) => !sourceProductIds.has(product.id),
);
const nextProducts =
detachedOverrides.length > 0
? [...detachedOverrides, ...mergedProducts]
: mergedProducts;
return nextProducts;
}, [products, savedProductOverrides]);
useEffect(() => {
tableProductsRef.current = tableProducts;
}, [tableProducts]);
const applySavedProduct = useCallback(
(savedProduct: NonIntegratedProductRecord) => {
tableProductsRef.current = upsertBrandFilingProductInList(
tableProductsRef.current,
savedProduct,
);
setSavedProductOverrides((current) => ({
...current,
[savedProduct.id]: savedProduct,
}));
onProductSaved(savedProduct);
},
[onProductSaved],
);
const rows = useMemo(
() => buildBrandFilingRows(tableProducts, brand),
[brand, tableProducts],
);
const orderedRows = useMemo(
() =>
rows
.map((row, sourceIndex) => ({ ...row, sourceIndex }))
.sort(compareBrandFilingRows)
.map(({ sourceIndex: _sourceIndex, ...row }) => row),
[rows],
);
const validRowIdSet = useMemo(
() => new Set(orderedRows.map((row) => row.id)),
[orderedRows],
);
const rowMap = useMemo(
() => new Map(orderedRows.map((row) => [row.id, row])),
[orderedRows],
);
const totalRows = orderedRows.length;
const completedRows = orderedRows.filter((row) => row.planningCompleted).length;
const totalPages = Math.max(1, Math.ceil(totalRows / PAGE_SIZE));
const safeCurrentPage = Math.min(currentPage, totalPages);
const pageStartIndex = (safeCurrentPage - 1) * PAGE_SIZE;
const pagedRows = orderedRows.slice(pageStartIndex, pageStartIndex + PAGE_SIZE);
const pageRowIdSet = useMemo(
() => new Set(pagedRows.map((row) => row.id)),
[pagedRows],
);
const normalizedSelectedRowIds = useMemo(
() => selectedRowIds.filter((id) => validRowIdSet.has(id)),
[selectedRowIds, validRowIdSet],
);
const selectedRowSet = useMemo(
() => new Set(normalizedSelectedRowIds),
[normalizedSelectedRowIds],
);
const visibleSelectedRowIds = useMemo(
() => normalizedSelectedRowIds.filter((id) => pageRowIdSet.has(id)),
[normalizedSelectedRowIds, pageRowIdSet],
);
const selectedRows = useMemo(
() => orderedRows.filter((row) => selectedRowSet.has(row.id)),
[orderedRows, selectedRowSet],
);
const brandLabel = getBrandFilingBrandLabel(brand);
const pageJumpValue = isPageJumpEditing
? pageJumpInput
: String(safeCurrentPage);
const allSelected =
pagedRows.length > 0 && visibleSelectedRowIds.length === pagedRows.length;
const partiallySelected =
visibleSelectedRowIds.length > 0 &&
visibleSelectedRowIds.length < pagedRows.length;
const activePlanningRow = activePlanningProductId
? rowMap.get(activePlanningProductId) ?? null
: null;
const busy = isSavingPlanningTable || isDownloadingPlanningTable;
useEffect(() => {
if (selectAllRef.current) {
selectAllRef.current.indeterminate = partiallySelected;
}
}, [partiallySelected]);
const toggleAllRows = () => {
setSelectedRowIds((current) => {
const nextBase = current.filter((id) => validRowIdSet.has(id));
if (allSelected) {
return nextBase.filter((id) => !pageRowIdSet.has(id));
}
const nextIds = new Set(nextBase);
pagedRows.forEach((row) => nextIds.add(row.id));
return Array.from(nextIds);
});
};
const toggleRow = (rowId: string) => {
setSelectedRowIds((current) =>
current.includes(rowId)
? current.filter((id) => id !== rowId)
: [...current.filter((id) => validRowIdSet.has(id)), rowId],
);
};
const commitPageJump = (value = pageJumpInput) => {
const parsedPage = Number.parseInt(value.trim(), 10);
const nextPage = Number.isFinite(parsedPage)
? Math.min(totalPages, Math.max(1, parsedPage))
: safeCurrentPage;
setCurrentPage(nextPage);
setPageJumpInput(String(nextPage));
};
const handleOpenPlanningTable = () => {
if (selectedRows.length !== 1) {
toast.warning("请选择 1 条产品后再填写企划表");
return;
}
setActivePlanningProductId(selectedRows[0]!.id);
};
const handleSavePlanningTable = async (
form: BrandFilingPlanningForm,
lineItems: BrandFilingPlanningLineItem[],
completed: boolean,
) => {
if (!activePlanningRow) return;
setIsSavingPlanningTable(true);
try {
const { product: nextProduct } = upsertBrandFilingPlanningRecord({
product: activePlanningRow.product,
products: tableProductsRef.current,
brand,
form,
lineItems,
completed,
});
const savedProduct = await saveSharedNonIntegratedProduct(nextProduct, {
workspaceCategory: NON_INTEGRATED_PLANNING_LAYOUT_WORKSPACE_CATEGORY,
});
applySavedProduct(savedProduct);
toast.success(completed ? "企划表已保存并确认" : "企划表已保存");
setActivePlanningProductId(null);
} catch (error) {
toast.error(
error instanceof Error ? error.message : "企划表保存失败,请稍后重试",
);
} finally {
setIsSavingPlanningTable(false);
}
};
const handleDownloadPlanningTable = async () => {
const selectedProducts = getBrandFilingProductsByIds(
tableProductsRef.current,
normalizedSelectedRowIds,
);
if (selectedProducts.length === 0) {
toast.warning("请先选择要下载的产品");
return;
}
setIsDownloadingPlanningTable(true);
try {
let workingProducts = tableProductsRef.current;
const productsNeedingRecord = selectedProducts
.filter((product) => !getBrandFilingPlanningRecord(product, brand))
.slice()
.reverse();
for (const product of productsNeedingRecord) {
const { product: nextProduct } = ensureBrandFilingPlanningRecord({
product,
products: workingProducts,
brand,
});
const savedProduct = await saveSharedNonIntegratedProduct(nextProduct, {
workspaceCategory: NON_INTEGRATED_PLANNING_LAYOUT_WORKSPACE_CATEGORY,
});
workingProducts = upsertBrandFilingProductInList(
workingProducts,
savedProduct,
);
applySavedProduct(savedProduct);
}
const latestSelectedProducts = getBrandFilingProductsByIds(
workingProducts,
normalizedSelectedRowIds,
);
const planningRecords: BrandFilingPlanningTableRecordInput[] =
latestSelectedProducts
.map((product) => {
const planningTable = getBrandFilingPlanningRecord(product, brand);
if (!planningTable) return null;
return {
productId: product.id,
planningTable,
};
})
.filter(
(
record,
): record is BrandFilingPlanningTableRecordInput => Boolean(record),
);
await downloadBrandFilingPlanningTables({
brand,
productIds: selectedProducts.map((product) => product.id),
planningRecords,
});
toast.success(
selectedRows.length > 1 ? "企划表压缩包已开始下载" : "企划表已开始下载",
);
} catch (error) {
toast.error(
error instanceof Error ? error.message : "企划表下载失败,请稍后重试",
);
} finally {
setIsDownloadingPlanningTable(false);
}
};
return (
<>
<BrandFilingProductTableToolbar
brandLabel={brandLabel}
totalRows={totalRows}
completedRows={completedRows}
selectedCount={selectedRows.length}
busy={busy}
onOpenPlanningTable={handleOpenPlanningTable}
onDownloadPlanningTable={handleDownloadPlanningTable}
/>
<div className="min-h-0 flex-1 overflow-hidden">
<div className="relative flex h-full min-h-0 flex-col overflow-hidden">
<div className="min-h-0 flex-1 overflow-x-auto overflow-y-auto pb-16">
<table className="w-full min-w-[1220px] border-collapse">
<thead>
<tr className="border-b border-[var(--obs-shell-border)]">
<th className={`${HEADER_CELL_CLASS} w-[56px]`}>
<input
ref={selectAllRef}
type="checkbox"
checked={allSelected}
onChange={toggleAllRows}
aria-label="Select all"
className="h-4 w-4 rounded border-zinc-300 text-zinc-900 focus:ring-zinc-300"
/>
</th>
<th className={`${HEADER_CELL_CLASS} w-[80px]`}>{TEXT.index}</th>
<th className={`${HEADER_CELL_CLASS} w-[120px]`}>
{TEXT.planningStatus}
</th>
<th className={`${HEADER_CELL_CLASS} w-[112px]`}>{TEXT.preview}</th>
<th className={`${HEADER_CELL_CLASS} min-w-[156px]`}>SPU</th>
<th className={`${HEADER_CELL_CLASS} min-w-[220px]`}>{TEXT.name}</th>
<th className={`${HEADER_CELL_CLASS} min-w-[150px]`}>
{TEXT.category}
</th>
<th className={`${HEADER_CELL_CLASS} min-w-[120px]`}>
{TEXT.appearanceDesigner}
</th>
<th className={`${HEADER_CELL_CLASS} min-w-[120px]`}>
{TEXT.structureDesigner}
</th>
<th className={`${HEADER_CELL_CLASS} min-w-[120px]`}>
{TEXT.updatedAt}
</th>
</tr>
</thead>
<tbody>
{pagedRows.length === 0 ? (
<tr>
<td
colSpan={10}
className="px-4 py-12 text-center text-sm text-[var(--obs-shell-muted)]"
>
{TEXT.empty}
</td>
</tr>
) : (
pagedRows.map((row) => (
<tr
key={row.id}
className="border-b border-[var(--obs-shell-border)] last:border-b-0"
>
<td className="px-4 py-3">
<input
type="checkbox"
checked={selectedRowSet.has(row.id)}
onChange={() => toggleRow(row.id)}
aria-label={`Select ${row.productName}`}
className="h-4 w-4 rounded border-zinc-300 text-zinc-900 focus:ring-zinc-300"
/>
</td>
<td className="px-4 py-3 text-sm font-medium text-[var(--obs-shell-title)]">
{formatBrandFilingSequenceNumber(row.sequenceNumber)}
</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs ${
row.planningCompleted
? "bg-emerald-50 text-emerald-700"
: "bg-[var(--obs-shell-soft-bg)] text-[var(--obs-shell-muted)]"
}`}
>
{row.planningCompleted ? (
<CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" />
) : null}
{getBrandFilingPlanningStatusLabel(row.planningCompleted)}
</span>
</td>
<td className="px-4 py-3">
{row.previewImage ? (
<button
type="button"
onClick={() =>
setPreviewImage({
src: row.previewImage,
alt: `${row.displayName} ${TEXT.preview}`,
})
}
className="flex h-12 w-12 items-center justify-center overflow-hidden rounded-[10px] border border-[var(--obs-shell-border)] bg-[var(--obs-shell-soft-bg)]"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={row.previewImage}
alt={`${row.displayName} ${TEXT.preview}`}
className="h-full w-full object-cover"
/>
</button>
) : (
<div className="flex h-12 w-12 items-center justify-center rounded-[10px] border border-[var(--obs-shell-border)] bg-[var(--obs-shell-soft-bg)] text-[10px] text-[var(--obs-shell-muted)]">
{TEXT.noPreview}
</div>
)}
</td>
<td className="px-4 py-3 text-sm text-[var(--obs-shell-title)]">
{row.spu}
</td>
<td className="px-4 py-3 text-sm text-[var(--obs-shell-title)]">
{row.productName}
</td>
<td className="px-4 py-3 text-sm text-[var(--obs-shell-title)]">
{row.category}
</td>
<td className="px-4 py-3 text-sm text-[var(--obs-shell-title)]">
{row.appearanceDesigner}
</td>
<td className="px-4 py-3 text-sm text-[var(--obs-shell-title)]">
{row.structureDesigner}
</td>
<td className="px-4 py-3 text-sm text-[var(--obs-shell-title)]">
{formatBrandFilingUpdatedAt(row.updatedAt)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{totalRows > 0 ? (
<div className="pointer-events-none absolute inset-x-0 bottom-5 z-30 flex justify-center px-4">
<div className="pointer-events-auto flex max-w-full items-center gap-2 rounded-full border border-apple-separator bg-white/92 px-2 py-1.5 text-xs text-apple-label-secondary shadow-[0_12px_32px_rgba(15,23,42,0.12)] backdrop-blur-xl">
<Button
variant="plain"
size="small"
className="!h-7 !min-h-7 rounded-full px-3 text-xs"
onClick={() =>
setCurrentPage((page) => Math.max(1, page - 1))
}
disabled={safeCurrentPage === 1}
>
{TEXT.previous}
</Button>
<div className="flex min-w-0 items-center justify-center gap-1.5 whitespace-nowrap text-center">
<span className="text-apple-label-tertiary">
{totalRows} {TEXT.rowUnit}
</span>
<input
aria-label="Jump to page"
inputMode="numeric"
pattern="[0-9]*"
value={pageJumpValue}
onFocus={() => {
setIsPageJumpEditing(true);
setPageJumpInput(String(safeCurrentPage));
}}
onChange={(event) =>
setPageJumpInput(event.target.value.replace(/\D/g, ""))
}
onBlur={() => {
commitPageJump();
setIsPageJumpEditing(false);
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
commitPageJump();
setIsPageJumpEditing(false);
}
}}
className="h-7 w-11 rounded-full border border-apple-separator bg-apple-bg-primary px-2 text-center text-xs text-apple-label-primary outline-none focus:border-zinc-400"
/>
<span>
/ {totalPages} {TEXT.page}
</span>
<span className="hidden text-apple-label-tertiary sm:inline">
{TEXT.perPage} {PAGE_SIZE}
</span>
</div>
<Button
variant="plain"
size="small"
className="!h-7 !min-h-7 rounded-full px-3 text-xs"
onClick={() =>
setCurrentPage((page) => Math.min(totalPages, page + 1))
}
disabled={safeCurrentPage === totalPages}
>
{TEXT.next}
</Button>
</div>
</div>
) : null}
</div>
</div>
<BrandFilingPlanningTableDialog
isOpen={Boolean(activePlanningRow)}
brand={brand}
product={activePlanningRow?.product ?? null}
initialRecord={activePlanningRow?.planningRecord}
saving={isSavingPlanningTable}
onClose={() => setActivePlanningProductId(null)}
onSave={handleSavePlanningTable}
/>
{previewImage ? (
<ImagePreviewDialog
src={previewImage.src}
alt={previewImage.alt}
onClose={() => setPreviewImage(null)}
/>
) : null}
<ToastContainer
toasts={toast.toasts}
onClose={toast.removeToast}
position="top"
/>
</>
);
}

View File

@ -0,0 +1,84 @@
"use client";
import type { ReactNode } from "react";
import { Download, SquarePen } from "lucide-react";
function ToolbarButton({
disabled,
icon,
label,
onClick,
primary = false,
}: {
disabled?: boolean;
icon: ReactNode;
label: string;
onClick: () => void;
primary?: boolean;
}) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className={`inline-flex h-8 items-center gap-1.5 rounded-[6px] border px-3 text-[12px] font-apple-semibold transition disabled:cursor-not-allowed disabled:opacity-50 ${
primary
? "border-[var(--obs-shell-action-border)] bg-[var(--obs-shell-action-bg)] text-[var(--obs-shell-action-text)] hover:bg-[var(--obs-shell-action-hover-bg)]"
: "border-[var(--obs-shell-border)] bg-white text-[var(--obs-shell-control-text)] hover:bg-[var(--obs-shell-hover-bg)] hover:text-[var(--obs-shell-title)]"
}`}
>
{icon}
<span>{label}</span>
</button>
);
}
export function BrandFilingProductTableToolbar({
brandLabel,
totalRows,
completedRows,
selectedCount,
busy,
onOpenPlanningTable,
onDownloadPlanningTable,
}: {
brandLabel: string;
totalRows: number;
completedRows: number;
selectedCount: number;
busy: boolean;
onOpenPlanningTable: () => void;
onDownloadPlanningTable: () => void;
}) {
return (
<div className="border-b border-[var(--obs-shell-border)] px-5 py-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-medium text-[var(--obs-shell-title)]">
{brandLabel}
</div>
<div className="mt-1 flex flex-wrap items-center gap-3 text-xs text-[var(--obs-shell-muted)]">
<span>{totalRows} 个产品</span>
<span>{completedRows} 个已完成</span>
<span>{selectedCount} 个已选中</span>
</div>
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
<ToolbarButton
disabled={busy || selectedCount !== 1}
icon={<SquarePen className="h-3.5 w-3.5" aria-hidden="true" />}
label="填写企划表"
onClick={onOpenPlanningTable}
/>
<ToolbarButton
disabled={busy || selectedCount === 0}
icon={<Download className="h-3.5 w-3.5" aria-hidden="true" />}
label="下载企划表"
onClick={onDownloadPlanningTable}
primary
/>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,62 @@
"use client";
import { FileSpreadsheet } from "lucide-react";
import { AccordionSection } from "@/components/platform/AccordionSection";
import {
FORMAL_SIDEBAR_SECTION_ICON_CLASS,
FormalSidebarIcon,
} from "@/app/intake/components/FormalSidebarIcon";
import type { IntakeSection } from "@/app/intake/intakeWorkspaceTypes";
import {
BRAND_FILING_GROUP_LABEL,
BRAND_FILING_OPTIONS,
type BrandFilingBrand,
} from "@/app/intake/brand-filing/brandFilingConfig";
export function BrandFilingWorkbenchSection({
activeSection,
activeBrand,
canViewBrandFiling,
onToggle,
onOpenBrandFilingWorkspace,
}: {
activeSection: IntakeSection;
activeBrand: BrandFilingBrand | null;
canViewBrandFiling: boolean;
onToggle: (section: Exclude<IntakeSection, null>) => void;
onOpenBrandFilingWorkspace: (brand: BrandFilingBrand) => void;
}) {
if (!canViewBrandFiling) return null;
return (
<AccordionSection
title={BRAND_FILING_GROUP_LABEL}
compact
icon={
<FormalSidebarIcon
icon={FileSpreadsheet}
className={FORMAL_SIDEBAR_SECTION_ICON_CLASS}
/>
}
expanded={activeSection === "brandFiling"}
onToggle={() => onToggle("brandFiling")}
>
<div className="space-y-2">
{BRAND_FILING_OPTIONS.map((option) => (
<button
key={option.key}
type="button"
onClick={() => onOpenBrandFilingWorkspace(option.key)}
className={`flex w-full items-center rounded-apple-lg border px-3 py-2 text-left text-xs transition ${
activeBrand === option.key
? "border-apple-blue bg-apple-blue/10 text-apple-blue"
: "border-apple-separator bg-apple-bg-secondary text-apple-label-secondary hover:border-apple-blue/40 hover:bg-apple-blue/5"
}`}
>
<span>{option.label}</span>
</button>
))}
</div>
</AccordionSection>
);
}

View File

@ -0,0 +1,19 @@
"use client";
import type { ReactNode } from "react";
export function BrandFilingWorkbenchSurface({
children,
}: {
children: ReactNode;
}) {
return (
<section className="flex h-full min-h-0 flex-col overflow-hidden rounded-[8px] border border-[var(--obs-shell-border)] bg-[var(--obs-shell-panel-bg)] text-[var(--obs-shell-title)] shadow-[0_1px_2px_rgba(17,19,21,0.04)]">
<div className="min-h-0 flex-1 p-4">
<div className="flex h-full min-h-[420px] flex-col overflow-hidden rounded-[10px] border border-[var(--obs-shell-border)] bg-white">
{children}
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,27 @@
"use client";
import type { NonIntegratedProductRecord } from "@/app/intake/non-integrated/nonIntegratedStorage";
import { BrandFilingProductTable } from "@/app/intake/brand-filing/BrandFilingProductTable";
import { BrandFilingWorkbenchSurface } from "@/app/intake/brand-filing/BrandFilingWorkbenchSurface";
import type { BrandFilingBrand } from "@/app/intake/brand-filing/brandFilingConfig";
export function BrandFilingWorkspace({
brand,
products,
onProductSaved,
}: {
brand: BrandFilingBrand;
products: NonIntegratedProductRecord[];
onProductSaved: (product: NonIntegratedProductRecord) => void;
}) {
return (
<BrandFilingWorkbenchSurface>
<BrandFilingProductTable
key={brand}
brand={brand}
products={products}
onProductSaved={onProductSaved}
/>
</BrandFilingWorkbenchSurface>
);
}

View File

@ -0,0 +1,22 @@
export type BrandFilingBrand = "gujia" | "suofeiya";
export const BRAND_FILING_GROUP_LABEL = "\u54c1\u724c\u62a5\u5907";
export const BRAND_FILING_OPTIONS = [
{ key: "gujia", label: "\u987e\u5bb6" },
{ key: "suofeiya", label: "\u7d22\u83f2\u4e9a" },
] as const satisfies ReadonlyArray<{
key: BrandFilingBrand;
label: string;
}>;
export function getBrandFilingBrandLabel(brand: BrandFilingBrand) {
return (
BRAND_FILING_OPTIONS.find((option) => option.key === brand)?.label ??
"\u54c1\u724c"
);
}
export function getBrandFilingWorkspaceLabel(brand: BrandFilingBrand) {
return `${getBrandFilingBrandLabel(brand)}\u54c1\u724c\u62a5\u5907`;
}

View File

@ -0,0 +1,208 @@
import { buildBrandFilingPlanningSkuRows } from "@/app/intake/brand-filing/brandFilingPlanningSkuRows";
import {
createEmptyBrandFilingPlanningLineItem,
normalizeBrandFilingPlanningLineItem,
type BrandFilingPlanningForm,
type BrandFilingPlanningLineItem,
type BrandFilingPlanningRecord,
} from "@/app/intake/brand-filing/brandFilingTypes";
import type { NonIntegratedProductRecord } from "@/app/intake/non-integrated/nonIntegratedStorage";
export type BrandFilingPlanningDisplayRow = BrandFilingPlanningLineItem & {
annualSalesTarget: string;
};
function safeTrim(value: string | null | undefined) {
return value?.trim() ?? "";
}
function normalizeMatchKey(value: string) {
return safeTrim(value).toUpperCase();
}
function pickNonEmptyText(...values: Array<string | null | undefined>) {
for (const value of values) {
const normalized = safeTrim(value);
if (normalized) return normalized;
}
return "";
}
function parseNumericValue(value: string) {
const normalized = safeTrim(value).replace(/,/g, "");
if (!normalized) return 0;
const parsed = Number(normalized);
return Number.isFinite(parsed) ? parsed : 0;
}
export function calculateBrandFilingAnnualSalesTarget(
lineItem: Pick<
BrandFilingPlanningLineItem,
| "month1Sales"
| "month2Sales"
| "month3Sales"
| "month4To6Sales"
| "month7To9Sales"
| "month10To12Sales"
>,
) {
const total =
parseNumericValue(lineItem.month1Sales) +
parseNumericValue(lineItem.month2Sales) +
parseNumericValue(lineItem.month3Sales) +
parseNumericValue(lineItem.month4To6Sales) +
parseNumericValue(lineItem.month7To9Sales) +
parseNumericValue(lineItem.month10To12Sales);
return total > 0 ? String(total) : "";
}
export function buildBrandFilingLineItemChannelSummary(
lineItems: BrandFilingPlanningLineItem[],
) {
return Array.from(
new Set(lineItems.map((item) => safeTrim(item.channel)).filter(Boolean)),
).join(" / ");
}
export function buildBrandFilingPlanningLineItems(params: {
product: NonIntegratedProductRecord;
form: BrandFilingPlanningForm;
currentRecord?: BrandFilingPlanningRecord;
defaultPurchasePriceDemand?: (itemCode?: string) => string;
defaultChannel?: string;
}) {
const {
product,
form,
currentRecord,
defaultPurchasePriceDemand,
defaultChannel = "",
} = params;
const baseRows = buildBrandFilingPlanningSkuRows(product, {
itemCode: form.itemCode,
sizeRequirement: form.sizeRequirement,
materialAndColor: form.materialAndColor,
});
const currentLineItems = currentRecord?.lineItems ?? [];
return baseRows.map((row) => {
const matchedItem = currentLineItems.find(
(item) => normalizeMatchKey(item.itemCode) === normalizeMatchKey(row.itemCode),
);
return normalizeBrandFilingPlanningLineItem({
...createEmptyBrandFilingPlanningLineItem(),
itemCode: pickNonEmptyText(matchedItem?.itemCode, row.itemCode),
sizeRequirement: pickNonEmptyText(
matchedItem?.sizeRequirement,
row.sizeRequirement,
),
materialAndColor: pickNonEmptyText(
matchedItem?.materialAndColor,
row.materialAndColor,
),
retailPriceDemand:
pickNonEmptyText(matchedItem?.retailPriceDemand, form.retailPriceDemand),
purchasePriceDemand:
pickNonEmptyText(
matchedItem?.purchasePriceDemand,
defaultPurchasePriceDemand?.(row.itemCode),
form.purchasePriceDemand,
),
seriesName: pickNonEmptyText(
matchedItem?.seriesName,
form.seriesName,
form.productSeries,
),
channel: pickNonEmptyText(
matchedItem?.channel,
form.channel,
defaultChannel,
),
month1Sales: pickNonEmptyText(matchedItem?.month1Sales, form.month1Sales),
month2Sales: pickNonEmptyText(matchedItem?.month2Sales, form.month2Sales),
month3Sales: pickNonEmptyText(matchedItem?.month3Sales, form.month3Sales),
month4To6Sales:
pickNonEmptyText(matchedItem?.month4To6Sales, form.month4To6Sales),
month7To9Sales:
pickNonEmptyText(matchedItem?.month7To9Sales, form.month7To9Sales),
month10To12Sales:
pickNonEmptyText(
matchedItem?.month10To12Sales,
form.month10To12Sales,
),
});
});
}
export function buildBrandFilingPlanningDisplayRows(params: {
product: NonIntegratedProductRecord;
form: BrandFilingPlanningForm;
lineItems: BrandFilingPlanningLineItem[];
}) {
const { product, form, lineItems } = params;
const baseRows = buildBrandFilingPlanningSkuRows(product, {
itemCode: form.itemCode,
sizeRequirement: form.sizeRequirement,
materialAndColor: form.materialAndColor,
});
return baseRows.map((row, index) => {
const matchedItem =
lineItems.find(
(item) => normalizeMatchKey(item.itemCode) === normalizeMatchKey(row.itemCode),
) ?? lineItems[index];
const normalizedItem = normalizeBrandFilingPlanningLineItem({
...createEmptyBrandFilingPlanningLineItem(),
...matchedItem,
itemCode: pickNonEmptyText(matchedItem?.itemCode, row.itemCode),
sizeRequirement: pickNonEmptyText(
matchedItem?.sizeRequirement,
row.sizeRequirement,
form.sizeRequirement,
),
materialAndColor: pickNonEmptyText(
matchedItem?.materialAndColor,
row.materialAndColor,
form.materialAndColor,
),
retailPriceDemand: pickNonEmptyText(
matchedItem?.retailPriceDemand,
form.retailPriceDemand,
),
purchasePriceDemand: pickNonEmptyText(
matchedItem?.purchasePriceDemand,
form.purchasePriceDemand,
),
seriesName: pickNonEmptyText(
matchedItem?.seriesName,
form.seriesName,
form.productSeries,
),
channel: pickNonEmptyText(matchedItem?.channel, form.channel),
month1Sales: pickNonEmptyText(matchedItem?.month1Sales, form.month1Sales),
month2Sales: pickNonEmptyText(matchedItem?.month2Sales, form.month2Sales),
month3Sales: pickNonEmptyText(matchedItem?.month3Sales, form.month3Sales),
month4To6Sales: pickNonEmptyText(
matchedItem?.month4To6Sales,
form.month4To6Sales,
),
month7To9Sales: pickNonEmptyText(
matchedItem?.month7To9Sales,
form.month7To9Sales,
),
month10To12Sales: pickNonEmptyText(
matchedItem?.month10To12Sales,
form.month10To12Sales,
),
});
return {
...normalizedItem,
annualSalesTarget: calculateBrandFilingAnnualSalesTarget(normalizedItem),
} satisfies BrandFilingPlanningDisplayRow;
});
}

View File

@ -0,0 +1,174 @@
import type {
NonIntegratedProductRecord,
NonIntegratedSkuItem,
} from "@/app/intake/non-integrated/nonIntegratedStorage";
export type BrandFilingPlanningSkuRow = {
itemCode: string;
sizeRequirement: string;
materialAndColor: string;
};
function safeTrim(value: string | null | undefined) {
return value?.trim() ?? "";
}
function collectUniqueValues(values: Array<string | null | undefined>) {
return Array.from(new Set(values.map((value) => safeTrim(value)).filter(Boolean)));
}
function buildSkuItemCode(skuItem: NonIntegratedSkuItem) {
return (
safeTrim(skuItem.skuCode) ||
safeTrim(skuItem.barcode) ||
safeTrim(skuItem.model) ||
safeTrim(skuItem.specName)
);
}
function buildSkuMaterialAndColor(
product: NonIntegratedProductRecord,
skuItem: NonIntegratedSkuItem,
fallbackValue: string,
) {
const material =
safeTrim(skuItem.tagMaterial) ||
safeTrim(skuItem.material) ||
safeTrim(product.tagMaterial) ||
safeTrim(product.openingReference?.material);
const color = safeTrim(skuItem.color) || safeTrim(product.color);
return [material, color].filter(Boolean).join(" / ") || fallbackValue;
}
export function resolveBrandFilingPlanningItemCode(
product: NonIntegratedProductRecord,
itemCode?: string,
) {
return (
safeTrim(itemCode) ||
safeTrim(product.productCode) ||
safeTrim(product.spu) ||
safeTrim(product.openingReference?.temporaryCode)
);
}
export function getBrandFilingMatchedSkuItemsByItemCode(
product: NonIntegratedProductRecord,
itemCode?: string,
) {
const normalizedItemCode = resolveBrandFilingPlanningItemCode(
product,
itemCode,
).toUpperCase();
if (!normalizedItemCode) return product.skuItems;
const matchedSkuItems = product.skuItems.filter((skuItem) => {
const candidateValues = [
buildSkuItemCode(skuItem),
safeTrim(skuItem.specName),
]
.map((value) => value.toUpperCase())
.filter(Boolean);
return candidateValues.some(
(candidate) =>
candidate === normalizedItemCode ||
candidate.startsWith(normalizedItemCode) ||
normalizedItemCode.startsWith(candidate),
);
});
return matchedSkuItems.length > 0 ? matchedSkuItems : product.skuItems;
}
export function buildBrandFilingPlanningSizeRequirement(
product: NonIntegratedProductRecord,
itemCode?: string,
) {
const directValue = safeTrim(product.size) || safeTrim(product.openingReference?.size);
if (directValue) return directValue;
return collectUniqueValues(
getBrandFilingMatchedSkuItemsByItemCode(product, itemCode).map(
(skuItem) => skuItem.size,
),
).join("; ");
}
export function buildBrandFilingPlanningMaterialAndColor(
product: NonIntegratedProductRecord,
itemCode?: string,
) {
const baseMaterial =
safeTrim(product.tagMaterial) || safeTrim(product.openingReference?.material);
const baseColor = safeTrim(product.color);
const skuValues = collectUniqueValues(
getBrandFilingMatchedSkuItemsByItemCode(product, itemCode).map((skuItem) => {
const material =
safeTrim(skuItem.tagMaterial) || safeTrim(skuItem.material) || baseMaterial;
const color = safeTrim(skuItem.color) || baseColor;
return [material, color].filter(Boolean).join(" / ");
}),
);
if (skuValues.length > 0) {
return skuValues.join("; ");
}
return [baseMaterial, baseColor].filter(Boolean).join(" / ");
}
export function buildBrandFilingPlanningSkuRows(
product: NonIntegratedProductRecord,
fallback?: {
itemCode?: string;
sizeRequirement?: string;
materialAndColor?: string;
},
): BrandFilingPlanningSkuRow[] {
const matchedSkuItems = product.skuItems;
const fallbackItemCode = resolveBrandFilingPlanningItemCode(
product,
fallback?.itemCode,
);
const fallbackSize =
safeTrim(fallback?.sizeRequirement) ||
buildBrandFilingPlanningSizeRequirement(product, fallback?.itemCode);
const fallbackMaterialAndColor =
safeTrim(fallback?.materialAndColor) ||
buildBrandFilingPlanningMaterialAndColor(product, fallback?.itemCode);
const skuRows = matchedSkuItems
.map((skuItem) => {
const itemCode = buildSkuItemCode(skuItem);
if (!itemCode) return null;
return {
itemCode,
sizeRequirement: safeTrim(skuItem.size) || fallbackSize,
materialAndColor: buildSkuMaterialAndColor(
product,
skuItem,
fallbackMaterialAndColor,
),
} satisfies BrandFilingPlanningSkuRow;
})
.filter((row): row is BrandFilingPlanningSkuRow => row !== null);
const deduplicatedRows = Array.from(
new Map(skuRows.map((row) => [row.itemCode, row])).values(),
);
if (deduplicatedRows.length > 0) {
return deduplicatedRows;
}
return [
{
itemCode: fallbackItemCode,
sizeRequirement: fallbackSize,
materialAndColor: fallbackMaterialAndColor,
},
].filter((row) => Boolean(row.itemCode || row.sizeRequirement || row.materialAndColor));
}

View File

@ -0,0 +1,57 @@
import type { BrandFilingBrand } from "@/app/intake/brand-filing/brandFilingConfig";
import type {
BrandFilingPlanningTableDownloadRequest,
BrandFilingPlanningTableRecordInput,
} from "@/app/intake/brand-filing/brandFilingPlanningTableRequest";
function resolveDownloadFileName(response: Response) {
const contentDisposition = response.headers.get("Content-Disposition") ?? "";
const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i);
if (utf8Match?.[1]) {
return decodeURIComponent(utf8Match[1]);
}
const basicMatch = contentDisposition.match(/filename=\"?([^\"]+)\"?/i);
return basicMatch?.[1] ?? "brand-filing-planning-table.xlsx";
}
export async function downloadBrandFilingPlanningTables(params: {
brand: BrandFilingBrand;
productIds: string[];
planningRecords?: BrandFilingPlanningTableRecordInput[];
}) {
const payload: BrandFilingPlanningTableDownloadRequest = {
brand: params.brand,
productIds: params.productIds,
...(params.planningRecords?.length
? { planningRecords: params.planningRecords }
: {}),
};
const response = await fetch("/api/intake/brand-filing/planning-table", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const payload = (await response.json().catch(() => null)) as
| {
error?: { message?: string };
}
| null;
throw new Error(payload?.error?.message || "企划表下载失败");
}
const blob = await response.blob();
const downloadUrl = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = downloadUrl;
anchor.download = resolveDownloadFileName(response);
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(downloadUrl);
}

View File

@ -0,0 +1,13 @@
import type { BrandFilingBrand } from "@/app/intake/brand-filing/brandFilingConfig";
import type { BrandFilingPlanningRecord } from "@/app/intake/brand-filing/brandFilingTypes";
export type BrandFilingPlanningTableRecordInput = {
productId: string;
planningTable: BrandFilingPlanningRecord;
};
export type BrandFilingPlanningTableDownloadRequest = {
brand: BrandFilingBrand;
productIds: string[];
planningRecords?: BrandFilingPlanningTableRecordInput[];
};

View File

@ -0,0 +1,183 @@
import type { BrandFilingPlanningFieldKey } from "@/app/intake/brand-filing/brandFilingTypes";
export type BrandFilingPlanningFieldDefinition = {
key: BrandFilingPlanningFieldKey;
label: string;
input: "text" | "textarea" | "date";
};
export type BrandFilingPlanningSection = {
title: string;
columns: 1 | 2 | 3;
fields: readonly BrandFilingPlanningFieldDefinition[];
};
export const BRAND_FILING_PLANNING_SECTIONS: readonly BrandFilingPlanningSection[] = [
{
title: "基本信息",
columns: 3,
fields: [
{ key: "planningProductLine", label: "企划产品线", input: "text" },
{ key: "productLine", label: "产品线", input: "text" },
{ key: "annualPlanStatus", label: "是否年度规划内", input: "text" },
{ key: "developmentMode", label: "开发方式", input: "text" },
{ key: "productSeries", label: "产品系列", input: "text" },
{ key: "developmentCategory", label: "开发类别", input: "text" },
{ key: "supplierName", label: "买采供应商名称", input: "text" },
{ key: "warehousingStatus", label: "是否入仓", input: "text" },
{ key: "launchDate", label: "需求上市时间", input: "date" },
{ key: "activityStyleStatus", label: "是否活动款", input: "text" },
],
},
{
title: "企划背景介绍",
columns: 1,
fields: [
{
key: "productDemandAnalysis",
label: "产品需求分析",
input: "textarea",
},
],
},
{
title: "企划方案",
columns: 2,
fields: [
{ key: "itemCode", label: "货号", input: "text" },
{ key: "retailPriceDemand", label: "零售价需求", input: "text" },
{ key: "purchasePriceDemand", label: "采购价需求", input: "text" },
{ key: "sizeRequirement", label: "尺寸要求", input: "text" },
{ key: "materialAndColor", label: "材质/颜色", input: "text" },
{
key: "legacyIterationNote",
label: "特殊说明(是否老款迭代)",
input: "text",
},
],
},
{
title: "推广计划",
columns: 3,
fields: [
{ key: "seriesName", label: "系列", input: "text" },
{ key: "annualSalesTarget", label: "全年销售总量", input: "text" },
{ key: "channel", label: "渠道", input: "text" },
{ key: "month1Sales", label: "第1个月", input: "text" },
{ key: "month2Sales", label: "第2个月", input: "text" },
{ key: "month3Sales", label: "第3个月", input: "text" },
{ key: "month4To6Sales", label: "第4-6月", input: "text" },
{ key: "month7To9Sales", label: "第7-9月", input: "text" },
{ key: "month10To12Sales", label: "第10-12月", input: "text" },
],
},
{
title: "会签",
columns: 3,
fields: [
{ key: "productManager", label: "产品经理", input: "text" },
{ key: "planningOwner", label: "企划负责人", input: "text" },
{ key: "purchaseOwner", label: "采购负责人", input: "text" },
],
},
] as const;
export const BRAND_FILING_PLANNING_CELL_MAP: Record<
Exclude<BrandFilingPlanningFieldKey, "imageUrl">,
string
> = {
planningProductLine: "C3",
productLine: "G3",
annualPlanStatus: "K3",
developmentMode: "C4",
productSeries: "G4",
developmentCategory: "K4",
supplierName: "B5",
warehousingStatus: "D5",
launchDate: "G5",
activityStyleStatus: "K5",
productDemandAnalysis: "B8",
itemCode: "A11",
retailPriceDemand: "B11",
purchasePriceDemand: "D11",
sizeRequirement: "F11",
materialAndColor: "H11",
legacyIterationNote: "J11",
seriesName: "A15",
annualSalesTarget: "C15",
channel: "D15",
month1Sales: "E15",
month2Sales: "F15",
month3Sales: "G15",
month4To6Sales: "H15",
month7To9Sales: "I15",
month10To12Sales: "K15",
productManager: "B17",
planningOwner: "F17",
purchaseOwner: "J17",
};
export const BRAND_FILING_PLANNING_EXTRA_CELL_MAP = {
channelFooter: "B18",
} as const;
export const BRAND_FILING_PLANNING_ROW_LAYOUT = {
plan: {
dataStartRow: 11,
mergedRanges: [
["B", "C"],
["D", "E"],
["F", "G"],
["H", "I"],
["J", "L"],
] as const,
},
promotion: {
sectionTitleRow: 12,
headerTopRow: 13,
headerBottomRow: 14,
dataStartRow: 15,
mergedRanges: [
["I", "J"],
["K", "L"],
] as const,
},
sign: {
sectionTitleRow: 16,
dataRow: 17,
channelFooterRow: 18,
},
} as const;
export const BRAND_FILING_PLANNING_FIELD_OPTIONS: Partial<
Record<BrandFilingPlanningFieldKey, readonly string[]>
> = {
planningProductLine: ["客餐厅经营部", "卧室经营部"],
productLine: [
"皮功能",
"布功能",
"皮固定",
"布固定",
"皮床",
"布床",
"板木床",
"生活家居",
"客厅配套",
"卧室配套",
"餐厅配套",
"单椅",
],
annualPlanStatus: ["是", "否"],
developmentMode: ["买采产品", "全新开发产品", "达人、重点款产品"],
developmentCategory: [
"全新开发",
"小改动产品",
"直接选款产品",
"选款改动产品",
"特殊产品",
],
legacyIterationNote: ["是", "否"],
productManager: ["静泉", "玉堂"],
warehousingStatus: ["是", "否"],
activityStyleStatus: ["常规款", "活动款", "直播款", "测款"],
} as const;

View File

@ -0,0 +1,242 @@
import {
buildBrandFilingPlanningFormDefaults,
buildBrandFilingPlanningInitialForm,
buildBrandFilingPlanningInitialState,
upsertBrandFilingPlanningRecord,
} from "@/app/intake/brand-filing/brandFilingProductData";
import {
createEmptyBrandFilingPlanningForm,
type BrandFilingPlanningLineItem,
} from "@/app/intake/brand-filing/brandFilingTypes";
import {
createEmptyNonIntegratedOpeningReference,
type NonIntegratedProductRecord,
} from "@/app/intake/non-integrated/nonIntegratedStorage";
function createBrandFilingProduct(
overrides: Partial<NonIntegratedProductRecord> = {},
): NonIntegratedProductRecord {
return {
id: "product-1",
spu: "GBGJ-CJ0266",
productCode: "GBGJ-CJ0266",
previewImage: "",
salesChannel: "",
salesPlatform: "",
designer: "",
productPlanning: "",
purchaseOwner: "buyer-a",
purchaser: "",
tagMaterial: "",
color: "",
size: "",
openingReference: {
...createEmptyNonIntegratedOpeningReference(),
temporaryCode: "GBGJ-CJ0266",
designer: "appearance-a",
planningOwner: "planner-a",
reason: "demand-note",
size: "1800*800*750",
material: "ash-wood",
},
skuItems: [
{
id: "sku-1",
skuCode: "GBGJ-CJ0266-A",
barcode: "",
model: "",
specName: "variant-a",
size: "",
color: "",
material: "",
tagMaterial: "",
},
{
id: "sku-2",
skuCode: "GBGJ-CJ0266-B",
barcode: "",
model: "",
specName: "variant-b",
size: "",
color: "",
material: "",
tagMaterial: "",
},
] as NonIntegratedProductRecord["skuItems"],
factoryRows: [
{
skuId: "sku-1",
supplierName: "supplier-a",
taxIncludedUnitPrice: "120",
},
{
skuId: "sku-2",
supplierName: "supplier-a",
taxIncludedUnitPrice: "130",
},
] as NonIntegratedProductRecord["factoryRows"],
...overrides,
} as unknown as NonIntegratedProductRecord;
}
describe("brandFilingProductData", () => {
it("uses the matched tax included unit price as purchase price demand", () => {
const product = createBrandFilingProduct();
const form = buildBrandFilingPlanningFormDefaults(
product,
"GBGJ-CJ0266-B",
);
expect(form.purchasePriceDemand).toBe("130");
});
it("backfills an empty legacy purchase price demand from product factory data", () => {
const product = createBrandFilingProduct();
const form = buildBrandFilingPlanningInitialForm(product, {
completed: false,
updatedAt: "2026-07-09T00:00:00.000Z",
form: {
...createEmptyBrandFilingPlanningForm(),
itemCode: "GBGJ-CJ0266-B",
purchasePriceDemand: "",
},
});
expect(form.purchasePriceDemand).toBe("130");
});
it("builds per-item line items with matched purchase prices", () => {
const product = createBrandFilingProduct();
const state = buildBrandFilingPlanningInitialState(product);
expect(state.lineItems).toEqual([
expect.objectContaining({
itemCode: "GBGJ-CJ0266-A",
purchasePriceDemand: "120",
}),
expect.objectContaining({
itemCode: "GBGJ-CJ0266-B",
purchasePriceDemand: "130",
}),
]);
});
it("preserves row-level planning values and derives the annual total from monthly sales", () => {
const product = createBrandFilingProduct();
const initialState = buildBrandFilingPlanningInitialState(product);
const lineItems: BrandFilingPlanningLineItem[] = initialState.lineItems.map(
(item, index) => ({
...item,
retailPriceDemand: index === 0 ? "1999" : "2999",
purchasePriceDemand: index === 0 ? "120" : "130",
seriesName: index === 0 ? "系列A" : "系列B",
channel: index === 0 ? "顾家门店" : "电商",
month1Sales: index === 0 ? "1" : "2",
month2Sales: index === 0 ? "2" : "3",
month3Sales: index === 0 ? "3" : "4",
month4To6Sales: index === 0 ? "4" : "5",
month7To9Sales: index === 0 ? "5" : "6",
month10To12Sales: index === 0 ? "6" : "7",
}),
);
const { record } = upsertBrandFilingPlanningRecord({
product,
products: [product],
brand: "gujia",
form: initialState.form,
lineItems,
completed: false,
});
expect(record.lineItems?.[0]).toEqual(
expect.objectContaining({
itemCode: "GBGJ-CJ0266-A",
retailPriceDemand: "1999",
seriesName: "系列A",
channel: "顾家门店",
}),
);
expect(record.lineItems?.[1]).toEqual(
expect.objectContaining({
itemCode: "GBGJ-CJ0266-B",
retailPriceDemand: "2999",
seriesName: "系列B",
channel: "电商",
}),
);
expect(record.form.channel).toBe("顾家门店 / 电商");
expect(record.form.annualSalesTarget).toBe("21");
});
it("backfills row-level values from a legacy shared form when line items are missing", () => {
const product = createBrandFilingProduct();
const state = buildBrandFilingPlanningInitialState(product, {
completed: false,
updatedAt: "2026-07-09T00:00:00.000Z",
form: {
...createEmptyBrandFilingPlanningForm(),
itemCode: "GBGJ-CJ0266-A",
retailPriceDemand: "1888",
month1Sales: "8",
month2Sales: "6",
month3Sales: "4",
month4To6Sales: "12",
month7To9Sales: "10",
month10To12Sales: "9",
},
});
expect(state.lineItems[0]).toEqual(
expect.objectContaining({
itemCode: "GBGJ-CJ0266-A",
retailPriceDemand: "1888",
month1Sales: "8",
month10To12Sales: "9",
}),
);
});
it("clears legacy auto-filled channels so users can fill real sales channels manually", () => {
const product = createBrandFilingProduct({
openingReference: {
...createEmptyNonIntegratedOpeningReference(),
temporaryCode: "GBGJ-CJ0266",
salesCategory: "架类",
},
});
const state = buildBrandFilingPlanningInitialState(product, {
completed: false,
updatedAt: "2026-07-09T00:00:00.000Z",
form: {
...createEmptyBrandFilingPlanningForm(),
channel: "架类",
},
lineItems: [
{
itemCode: "GBGJ-CJ0266-A",
retailPriceDemand: "",
purchasePriceDemand: "",
sizeRequirement: "",
materialAndColor: "",
seriesName: "",
channel: "架类",
month1Sales: "",
month2Sales: "",
month3Sales: "",
month4To6Sales: "",
month7To9Sales: "",
month10To12Sales: "",
},
],
});
expect(state.form.channel).toBe("");
expect(state.lineItems[0]?.channel).toBe("");
});
});

View File

@ -0,0 +1,345 @@
import type { BrandFilingBrand } from "@/app/intake/brand-filing/brandFilingConfig";
import {
type BrandFilingPlanningLineItem,
createEmptyBrandFilingPlanningForm,
normalizeBrandFilingPlanningForm,
normalizeBrandFilingPlanningLineItems,
type BrandFilingPlanningFieldKey,
type BrandFilingPlanningForm,
type BrandFilingPlanningRecord,
type BrandFilingProductData,
} from "@/app/intake/brand-filing/brandFilingTypes";
import {
calculateBrandFilingAnnualSalesTarget,
buildBrandFilingLineItemChannelSummary,
buildBrandFilingPlanningLineItems,
} from "@/app/intake/brand-filing/brandFilingPlanningLineItems";
import {
buildBrandFilingPlanningMaterialAndColor,
buildBrandFilingPlanningSizeRequirement,
getBrandFilingMatchedSkuItemsByItemCode,
resolveBrandFilingPlanningItemCode,
} from "@/app/intake/brand-filing/brandFilingPlanningSkuRows";
import type { NonIntegratedProductRecord } from "@/app/intake/non-integrated/nonIntegratedStorage";
import { getNonIntegratedListPeople } from "@/app/intake/product-development/nonIntegratedProductListLogic";
const DEFAULT_BRAND_FILING_SUPPLIER_NAME = "欧伯娜";
const DEFAULT_BRAND_FILING_PRODUCT_MANAGER = "静泉";
const BRAND_FILING_BACKFILL_FIELD_KEYS: readonly BrandFilingPlanningFieldKey[] = [
"supplierName",
"purchasePriceDemand",
"sizeRequirement",
"materialAndColor",
];
function safeTrim(value: string | null | undefined) {
return value?.trim() ?? "";
}
function buildLegacyAutoChannel(product: NonIntegratedProductRecord) {
return (
safeTrim(product.salesChannel) ||
safeTrim(product.salesPlatform) ||
safeTrim(product.openingReference?.salesCategory)
);
}
function clearLegacyAutoChannelValue(
value: string,
legacyAutoChannel: string,
) {
const normalizedValue = safeTrim(value);
if (!legacyAutoChannel) {
return normalizedValue;
}
return normalizedValue === legacyAutoChannel ? "" : normalizedValue;
}
export function normalizeLegacyBrandFilingPlanningRecord(
product: NonIntegratedProductRecord,
record?: BrandFilingPlanningRecord,
) {
if (!record) return undefined;
const legacyAutoChannel = buildLegacyAutoChannel(product);
if (!legacyAutoChannel) {
return record;
}
return {
...record,
form: {
...record.form,
channel: clearLegacyAutoChannelValue(
record.form.channel,
legacyAutoChannel,
),
},
lineItems: (record.lineItems ?? []).map((item) => ({
...item,
channel: clearLegacyAutoChannelValue(item.channel, legacyAutoChannel),
})),
} satisfies BrandFilingPlanningRecord;
}
export type BrandFilingPlanningEditorState = {
form: BrandFilingPlanningForm;
lineItems: BrandFilingPlanningLineItem[];
};
function syncPlanningSeriesFields(form: BrandFilingPlanningForm) {
const productSeries = safeTrim(form.productSeries);
const seriesName = safeTrim(form.seriesName);
if (productSeries && !seriesName) {
form.seriesName = productSeries;
} else if (!productSeries && seriesName) {
form.productSeries = seriesName;
}
return form;
}
function buildPurchasePriceDemand(
product: NonIntegratedProductRecord,
itemCode?: string,
) {
const matchedSkuIds = new Set(
getBrandFilingMatchedSkuItemsByItemCode(product, itemCode)
.map((skuItem) => safeTrim(skuItem.id))
.filter(Boolean),
);
const matchedFactoryRowValue = product.factoryRows.find(
(row) =>
matchedSkuIds.has(safeTrim(row.skuId)) &&
safeTrim(row.taxIncludedUnitPrice),
)?.taxIncludedUnitPrice;
if (safeTrim(matchedFactoryRowValue)) {
return safeTrim(matchedFactoryRowValue);
}
return (
safeTrim(
product.factoryRows.find((row) => safeTrim(row.taxIncludedUnitPrice))
?.taxIncludedUnitPrice,
) || ""
);
}
export function getBrandFilingPlanningRecord(
product: NonIntegratedProductRecord,
brand: BrandFilingBrand,
) {
return product.brandFilingData?.[brand]?.planningTable;
}
function normalizeLegacyPlanningForm(
product: NonIntegratedProductRecord,
form: Partial<BrandFilingPlanningForm> | undefined,
defaultForm: BrandFilingPlanningForm,
) {
if (!form) return undefined;
const nextForm = normalizeBrandFilingPlanningForm(form);
const workflowSupplierName = safeTrim(product.factoryRows[0]?.supplierName);
if (
workflowSupplierName &&
safeTrim(nextForm.supplierName) === workflowSupplierName
) {
nextForm.supplierName = DEFAULT_BRAND_FILING_SUPPLIER_NAME;
}
BRAND_FILING_BACKFILL_FIELD_KEYS.forEach((fieldKey) => {
if (!safeTrim(nextForm[fieldKey])) {
nextForm[fieldKey] = defaultForm[fieldKey];
}
});
return nextForm;
}
export function buildBrandFilingPlanningFormDefaults(
product: NonIntegratedProductRecord,
itemCode?: string,
): BrandFilingPlanningForm {
const people = getNonIntegratedListPeople(product);
const resolvedItemCode = resolveBrandFilingPlanningItemCode(product, itemCode);
return {
...createEmptyBrandFilingPlanningForm(),
supplierName: DEFAULT_BRAND_FILING_SUPPLIER_NAME,
productManager: DEFAULT_BRAND_FILING_PRODUCT_MANAGER,
launchDate: "",
productDemandAnalysis: safeTrim(product.openingReference?.reason),
itemCode: resolvedItemCode,
purchasePriceDemand: buildPurchasePriceDemand(product, resolvedItemCode),
sizeRequirement: buildBrandFilingPlanningSizeRequirement(
product,
resolvedItemCode,
),
materialAndColor: buildBrandFilingPlanningMaterialAndColor(
product,
resolvedItemCode,
),
channel: "",
planningOwner: people.appearanceDesigner,
purchaseOwner: people.purchaseOwner,
imageUrl:
safeTrim(product.openingReference?.productImage) ||
safeTrim(product.previewImage),
};
}
export function buildBrandFilingPlanningInitialForm(
product: NonIntegratedProductRecord,
currentRecord?: BrandFilingPlanningRecord,
) {
const normalizedRecord = normalizeLegacyBrandFilingPlanningRecord(
product,
currentRecord,
);
const defaultForm = buildBrandFilingPlanningFormDefaults(
product,
normalizedRecord?.form.itemCode,
);
return syncPlanningSeriesFields(
normalizeBrandFilingPlanningForm({
...defaultForm,
...(normalizeLegacyPlanningForm(
product,
normalizedRecord?.form,
defaultForm,
) ?? {}),
}),
);
}
export function buildBrandFilingPlanningInitialState(
product: NonIntegratedProductRecord,
currentRecord?: BrandFilingPlanningRecord,
): BrandFilingPlanningEditorState {
const normalizedRecord = normalizeLegacyBrandFilingPlanningRecord(
product,
currentRecord,
);
const form = buildBrandFilingPlanningInitialForm(product, normalizedRecord);
const lineItems = buildBrandFilingPlanningLineItems({
product,
form,
currentRecord: normalizedRecord,
defaultPurchasePriceDemand: (itemCode) =>
buildPurchasePriceDemand(product, itemCode),
defaultChannel: "",
});
return { form, lineItems };
}
export function getNextBrandFilingSequenceNumber(
products: NonIntegratedProductRecord[],
brand: BrandFilingBrand,
) {
return (
products.reduce((maxValue, product) => {
const sequenceNumber = getBrandFilingPlanningRecord(product, brand)?.sequenceNumber;
return sequenceNumber && sequenceNumber > maxValue
? sequenceNumber
: maxValue;
}, 0) + 1
);
}
export function upsertBrandFilingPlanningRecord(params: {
product: NonIntegratedProductRecord;
products: NonIntegratedProductRecord[];
brand: BrandFilingBrand;
form?: Partial<BrandFilingPlanningForm>;
lineItems?: BrandFilingPlanningLineItem[];
completed?: boolean;
}) {
const { product, products, brand } = params;
const currentRecord = getBrandFilingPlanningRecord(product, brand);
const defaultState = buildBrandFilingPlanningInitialState(product, currentRecord);
const nextForm = normalizeBrandFilingPlanningForm({
...defaultState.form,
...(params.form ?? {}),
});
const nextLineItems =
params.lineItems && params.lineItems.length > 0
? normalizeBrandFilingPlanningLineItems(params.lineItems)
: defaultState.lineItems;
const normalizedChannelSummary = buildBrandFilingLineItemChannelSummary(
nextLineItems,
);
const firstLineItem = nextLineItems[0];
nextForm.itemCode = firstLineItem?.itemCode ?? nextForm.itemCode;
nextForm.retailPriceDemand =
firstLineItem?.retailPriceDemand ?? nextForm.retailPriceDemand;
nextForm.purchasePriceDemand =
firstLineItem?.purchasePriceDemand ?? nextForm.purchasePriceDemand;
nextForm.sizeRequirement =
firstLineItem?.sizeRequirement ?? nextForm.sizeRequirement;
nextForm.materialAndColor =
firstLineItem?.materialAndColor ?? nextForm.materialAndColor;
nextForm.seriesName = firstLineItem?.seriesName ?? nextForm.seriesName;
nextForm.channel = normalizedChannelSummary;
nextForm.month1Sales = firstLineItem?.month1Sales ?? nextForm.month1Sales;
nextForm.month2Sales = firstLineItem?.month2Sales ?? nextForm.month2Sales;
nextForm.month3Sales = firstLineItem?.month3Sales ?? nextForm.month3Sales;
nextForm.month4To6Sales =
firstLineItem?.month4To6Sales ?? nextForm.month4To6Sales;
nextForm.month7To9Sales =
firstLineItem?.month7To9Sales ?? nextForm.month7To9Sales;
nextForm.month10To12Sales =
firstLineItem?.month10To12Sales ?? nextForm.month10To12Sales;
nextForm.annualSalesTarget = firstLineItem
? calculateBrandFilingAnnualSalesTarget(firstLineItem)
: "";
const nextRecord: BrandFilingPlanningRecord = {
sequenceNumber:
currentRecord?.sequenceNumber ??
getNextBrandFilingSequenceNumber(products, brand),
completed:
params.completed ??
currentRecord?.completed ??
false,
updatedAt: new Date().toISOString(),
form: nextForm,
lineItems: nextLineItems,
};
const nextBrandFilingData: BrandFilingProductData = {
...(product.brandFilingData ?? {}),
[brand]: {
...(product.brandFilingData?.[brand] ?? {}),
planningTable: nextRecord,
},
};
return {
record: nextRecord,
product: {
...product,
brandFilingData: nextBrandFilingData,
},
};
}
export function ensureBrandFilingPlanningRecord(params: {
product: NonIntegratedProductRecord;
products: NonIntegratedProductRecord[];
brand: BrandFilingBrand;
}) {
return upsertBrandFilingPlanningRecord({
...params,
completed:
getBrandFilingPlanningRecord(params.product, params.brand)?.completed ??
false,
});
}

View File

@ -0,0 +1,236 @@
export type BrandFilingPlanningFieldKey =
| "planningProductLine"
| "productLine"
| "annualPlanStatus"
| "developmentMode"
| "productSeries"
| "developmentCategory"
| "supplierName"
| "warehousingStatus"
| "launchDate"
| "activityStyleStatus"
| "productDemandAnalysis"
| "itemCode"
| "retailPriceDemand"
| "purchasePriceDemand"
| "sizeRequirement"
| "materialAndColor"
| "legacyIterationNote"
| "seriesName"
| "annualSalesTarget"
| "channel"
| "month1Sales"
| "month2Sales"
| "month3Sales"
| "month4To6Sales"
| "month7To9Sales"
| "month10To12Sales"
| "productManager"
| "planningOwner"
| "purchaseOwner"
| "imageUrl";
export type BrandFilingPlanningLineItemFieldKey =
| "itemCode"
| "retailPriceDemand"
| "purchasePriceDemand"
| "sizeRequirement"
| "materialAndColor"
| "seriesName"
| "channel"
| "month1Sales"
| "month2Sales"
| "month3Sales"
| "month4To6Sales"
| "month7To9Sales"
| "month10To12Sales";
export type BrandFilingPlanningLineItem = Record<
BrandFilingPlanningLineItemFieldKey,
string
>;
export type BrandFilingPlanningForm = Record<
BrandFilingPlanningFieldKey,
string
>;
export type BrandFilingPlanningRecord = {
sequenceNumber?: number;
completed: boolean;
updatedAt: string;
form: BrandFilingPlanningForm;
lineItems?: BrandFilingPlanningLineItem[];
};
export type BrandFilingBrandData = {
planningTable?: BrandFilingPlanningRecord;
};
export type BrandFilingProductData = {
gujia?: BrandFilingBrandData;
suofeiya?: BrandFilingBrandData;
};
export const BRAND_FILING_PLANNING_FIELD_KEYS = [
"planningProductLine",
"productLine",
"annualPlanStatus",
"developmentMode",
"productSeries",
"developmentCategory",
"supplierName",
"warehousingStatus",
"launchDate",
"activityStyleStatus",
"productDemandAnalysis",
"itemCode",
"retailPriceDemand",
"purchasePriceDemand",
"sizeRequirement",
"materialAndColor",
"legacyIterationNote",
"seriesName",
"annualSalesTarget",
"channel",
"month1Sales",
"month2Sales",
"month3Sales",
"month4To6Sales",
"month7To9Sales",
"month10To12Sales",
"productManager",
"planningOwner",
"purchaseOwner",
"imageUrl",
] as const satisfies readonly BrandFilingPlanningFieldKey[];
export const BRAND_FILING_PLANNING_LINE_ITEM_FIELD_KEYS = [
"itemCode",
"retailPriceDemand",
"purchasePriceDemand",
"sizeRequirement",
"materialAndColor",
"seriesName",
"channel",
"month1Sales",
"month2Sales",
"month3Sales",
"month4To6Sales",
"month7To9Sales",
"month10To12Sales",
] as const satisfies readonly BrandFilingPlanningLineItemFieldKey[];
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readText(value: unknown) {
if (typeof value === "string") {
return value;
}
if (typeof value === "number" && Number.isFinite(value)) {
return String(value);
}
return "";
}
function readOptionalSequenceNumber(value: unknown) {
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
return Math.floor(value);
}
if (typeof value === "string") {
const parsed = Number.parseInt(value.trim(), 10);
if (Number.isFinite(parsed) && parsed > 0) {
return parsed;
}
}
return undefined;
}
export function createEmptyBrandFilingPlanningForm(): BrandFilingPlanningForm {
return Object.fromEntries(
BRAND_FILING_PLANNING_FIELD_KEYS.map((key) => [key, ""]),
) as BrandFilingPlanningForm;
}
export function createEmptyBrandFilingPlanningLineItem(): BrandFilingPlanningLineItem {
return Object.fromEntries(
BRAND_FILING_PLANNING_LINE_ITEM_FIELD_KEYS.map((key) => [key, ""]),
) as BrandFilingPlanningLineItem;
}
export function normalizeBrandFilingPlanningForm(
value: unknown,
): BrandFilingPlanningForm {
const nextForm = createEmptyBrandFilingPlanningForm();
if (!isRecord(value)) return nextForm;
BRAND_FILING_PLANNING_FIELD_KEYS.forEach((key) => {
nextForm[key] = readText(value[key]);
});
return nextForm;
}
export function normalizeBrandFilingPlanningLineItem(
value: unknown,
): BrandFilingPlanningLineItem {
const nextItem = createEmptyBrandFilingPlanningLineItem();
if (!isRecord(value)) return nextItem;
BRAND_FILING_PLANNING_LINE_ITEM_FIELD_KEYS.forEach((key) => {
nextItem[key] = readText(value[key]);
});
return nextItem;
}
export function normalizeBrandFilingPlanningLineItems(
value: unknown,
): BrandFilingPlanningLineItem[] {
if (!Array.isArray(value)) return [];
return value.map((entry) => normalizeBrandFilingPlanningLineItem(entry));
}
export function normalizeBrandFilingPlanningRecord(
value: unknown,
): BrandFilingPlanningRecord | undefined {
if (!isRecord(value)) return undefined;
return {
sequenceNumber: readOptionalSequenceNumber(value.sequenceNumber),
completed: value.completed === true,
updatedAt: readText(value.updatedAt),
form: normalizeBrandFilingPlanningForm(value.form),
lineItems: normalizeBrandFilingPlanningLineItems(value.lineItems),
};
}
function normalizeBrandFilingBrandData(
value: unknown,
): BrandFilingBrandData | undefined {
if (!isRecord(value)) return undefined;
const planningTable = normalizeBrandFilingPlanningRecord(value.planningTable);
if (!planningTable) return undefined;
return { planningTable };
}
export function normalizeBrandFilingData(
value: unknown,
): BrandFilingProductData | undefined {
if (!isRecord(value)) return undefined;
const gujia = normalizeBrandFilingBrandData(value.gujia);
const suofeiya = normalizeBrandFilingBrandData(value.suofeiya);
if (!gujia && !suofeiya) return undefined;
return {
...(gujia ? { gujia } : {}),
...(suofeiya ? { suofeiya } : {}),
};
}

View File

@ -2,6 +2,8 @@
import { useEffect, useRef } from "react";
import { BarcodePrintWorkbenchButton } from "@/app/intake/barcode-print/BarcodePrintWorkbenchButton";
import { BrandFilingWorkbenchSection } from "@/app/intake/brand-filing/BrandFilingWorkbenchSection";
import type { BrandFilingBrand } from "@/app/intake/brand-filing/brandFilingConfig";
import { BomLibraryWorkbenchSection } from "@/app/intake/bom-library/BomLibraryWorkbenchSection";
import type {
IntakeEntryTab,
@ -16,6 +18,7 @@ import { ReferenceEstimatorWorkbenchSection } from "@/app/intake/reference-estim
export function FormalIntakeWorkbench({
activeSection,
activeBrandFilingBrand,
onToggle,
pendingEntryTab,
onPendingEntryTabChange,
@ -29,6 +32,7 @@ export function FormalIntakeWorkbench({
onOpenProductPatentLedger,
onOpenProductPatentDashboard,
onOpenBarcodePrintWorkspace,
onOpenBrandFilingWorkspace,
onOpenSpuRuleLibraryWorkspace,
onOpenAdminNodePermissionWorkspace,
canViewProductDevelopmentList,
@ -41,9 +45,11 @@ export function FormalIntakeWorkbench({
canViewProductPatentLedger,
canViewProductPatentDashboard,
canViewBarcodePrint,
canViewBrandFiling,
showNodePermissionSettings,
}: {
activeSection: IntakeSection;
activeBrandFilingBrand: BrandFilingBrand | null;
onToggle: (section: Exclude<IntakeSection, null>) => void;
pendingEntryTab: IntakeEntryTab;
onPendingEntryTabChange: (tab: IntakeEntryTab) => void;
@ -57,6 +63,7 @@ export function FormalIntakeWorkbench({
onOpenProductPatentLedger: () => void;
onOpenProductPatentDashboard: () => void;
onOpenBarcodePrintWorkspace: () => void;
onOpenBrandFilingWorkspace: (brand: BrandFilingBrand) => void;
onOpenSpuRuleLibraryWorkspace?: () => void;
onOpenAdminNodePermissionWorkspace?: () => void;
canViewProductDevelopmentList: boolean;
@ -69,6 +76,7 @@ export function FormalIntakeWorkbench({
canViewProductPatentLedger: boolean;
canViewProductPatentDashboard: boolean;
canViewBarcodePrint: boolean;
canViewBrandFiling: boolean;
showNodePermissionSettings: boolean;
}) {
const menuRef = useRef<HTMLDivElement>(null);
@ -131,6 +139,13 @@ export function FormalIntakeWorkbench({
canViewBarcodePrint={canViewBarcodePrint}
onOpenBarcodePrintWorkspace={onOpenBarcodePrintWorkspace}
/>
<BrandFilingWorkbenchSection
activeSection={activeSection}
activeBrand={activeBrandFilingBrand}
canViewBrandFiling={canViewBrandFiling}
onToggle={onToggle}
onOpenBrandFilingWorkspace={onOpenBrandFilingWorkspace}
/>
</div>
);
}

View File

@ -1,5 +1,6 @@
"use client";
import type { BrandFilingBrand } from "@/app/intake/brand-filing/brandFilingConfig";
import type { BomLibraryKey } from "@/app/intake/bom-library/BomLibraryDomain";
import { IntakeEmptyDetail } from "@/app/intake/IntakeMainLayout";
import type { IntakePrimaryDomainContentProps } from "@/app/intake/IntakePrimaryDomainContent";
@ -155,6 +156,7 @@ export function buildIntakePrimaryDomainProps(params: {
canViewProductPatentLedger: boolean;
canEditProductPatentLedger: boolean;
canViewBarcodePrint: boolean;
canViewBrandFiling: boolean;
}): IntakePrimaryDomainContentProps {
return {
activeSection: params.activeSection,
@ -212,11 +214,13 @@ export function buildIntakePrimaryDomainProps(params: {
products: params.nonIntegratedProducts,
user: params.user,
},
canViewBrandFiling: params.canViewBrandFiling,
};
}
export function buildIntakeRightWorkbenchProps(params: {
activeWorkspaceTabKind: IntakeWorkspaceTab["kind"] | null;
activeBrandFilingBrand: BrandFilingBrand | null;
activeSection: IntakeSection;
onToggle: (section: Exclude<IntakeSection, null>) => void;
pendingEntryTab: IntakeEntryTab;
@ -234,6 +238,7 @@ export function buildIntakeRightWorkbenchProps(params: {
onOpenProductPatentLedger: () => void;
onOpenProductPatentDashboard: () => void;
onOpenBarcodePrintWorkspace: () => void;
onOpenBrandFilingWorkspace: (brand: BrandFilingBrand) => void;
onOpenSpuRuleLibraryWorkspace?: () => void;
onOpenAdminNodePermissionWorkspace?: () => void;
showNodePermissionSettings: boolean;
@ -247,11 +252,13 @@ export function buildIntakeRightWorkbenchProps(params: {
canViewProductPatentLedger: boolean;
canViewProductPatentDashboard: boolean;
canViewBarcodePrint: boolean;
canViewBrandFiling: boolean;
}): IntakeRightWorkbenchPanelProps {
return {
activeWorkspaceTabKind: params.activeWorkspaceTabKind,
workbench: {
activeSection: params.activeSection,
activeBrandFilingBrand: params.activeBrandFilingBrand,
onToggle: params.onToggle,
pendingEntryTab: params.pendingEntryTab,
onPendingEntryTabChange: params.onPendingEntryTabChange,
@ -267,6 +274,7 @@ export function buildIntakeRightWorkbenchProps(params: {
onOpenProductPatentLedger: params.onOpenProductPatentLedger,
onOpenProductPatentDashboard: params.onOpenProductPatentDashboard,
onOpenBarcodePrintWorkspace: params.onOpenBarcodePrintWorkspace,
onOpenBrandFilingWorkspace: params.onOpenBrandFilingWorkspace,
onOpenSpuRuleLibraryWorkspace: params.onOpenSpuRuleLibraryWorkspace,
onOpenAdminNodePermissionWorkspace:
params.onOpenAdminNodePermissionWorkspace,
@ -282,6 +290,7 @@ export function buildIntakeRightWorkbenchProps(params: {
canViewProductPatentLedger: params.canViewProductPatentLedger,
canViewProductPatentDashboard: params.canViewProductPatentDashboard,
canViewBarcodePrint: params.canViewBarcodePrint,
canViewBrandFiling: params.canViewBrandFiling,
},
};
}

View File

@ -139,6 +139,9 @@ export function serializeIntakeWorkspaceTab(
if (tab.kind === "productPatentLedger") {
return { id: tab.id, kind: tab.kind, target: tab.target };
}
if (tab.kind === "brandFiling") {
return { id: tab.id, kind: tab.kind, brand: tab.brand };
}
return { id: tab.id, kind: tab.kind };
}

View File

@ -1,3 +1,7 @@
import {
getBrandFilingWorkspaceLabel,
type BrandFilingBrand,
} from "@/app/intake/brand-filing/brandFilingConfig";
import type { BomLibraryKey } from "@/app/intake/bom-library/BomLibraryDomain";
import { BOM_LIBRARY_OPTIONS } from "@/app/intake/bom-library/bomLibraryConfig";
import { BARCODE_PRINT_LABEL } from "@/app/intake/barcode-print/barcodePrintConfig";
@ -351,6 +355,21 @@ export function createBarcodePrintWorkspaceTab(
};
}
export function createBrandFilingWorkspaceTab({
id,
brand,
}: {
id: string;
brand: BrandFilingBrand;
}): IntakeWorkspaceTab {
return {
id,
kind: "brandFiling",
label: getBrandFilingWorkspaceLabel(brand),
brand,
};
}
export function createDefaultSupplierBarcodePrintWorkspaceState(): InitialIntakeWorkspaceState {
const defaultTab = createBarcodePrintWorkspaceTab("barcode-print:supplier:default");
@ -474,6 +493,12 @@ function restoreIntakeWorkspaceTab(
label: BARCODE_PRINT_LABEL,
};
}
if (tab.kind === "brandFiling") {
return createBrandFilingWorkspaceTab({
id: tab.id,
brand: tab.brand,
});
}
if (tab.kind === "nonIntegratedProduct") {
const product =
productMap.get(tab.productId) ??
@ -663,6 +688,7 @@ export function getWorkspaceTabInitialSection(tab: IntakeWorkspaceTab | null) {
if (tab.kind === "productPatentLedger") return "patents";
if (tab.kind === "productPatentDashboard") return "patents";
if (tab.kind === "barcodePrint") return "barcodePrint";
if (tab.kind === "brandFiling") return "brandFiling";
if (tab.kind === "bomLibrary") return "bom";
if (tab.kind === "rawMaterialLibrary") return "rawMaterialLibrary";
return "referenceEstimator";
@ -749,6 +775,7 @@ export function getFormalWorkspaceTargetKeyForTab(tab: IntakeWorkspaceTab) {
}
if (tab.kind === "productPatentDashboard") return "productPatentDashboard";
if (tab.kind === "barcodePrint") return "barcodePrint";
if (tab.kind === "brandFiling") return "barcodePrint";
return null;
}

View File

@ -22,6 +22,7 @@ export function getWorkspaceTabPageKey(tab: IntakeWorkspaceTab) {
if (tab.kind === "adminNodePermission") return "node-permission";
if (tab.kind === "productPatentDashboard") return "product-patent:dashboard";
if (tab.kind === "barcodePrint") return "barcode-print";
if (tab.kind === "brandFiling") return `brand-filing:${tab.brand}`;
if (tab.kind === "productPatentLedger") {
const target = tab.target;
return [

View File

@ -1,3 +1,4 @@
import type { BrandFilingBrand } from "@/app/intake/brand-filing/brandFilingConfig";
import type { BomLibraryKey } from "@/app/intake/bom-library/BomLibraryDomain";
import type { ProductPatentLedgerTarget } from "@/app/intake/product-patent/ProductPatentDomain";
import type { RawMaterialLibraryKey } from "@/app/intake/raw-material-library/RawMaterialLibraryDomain";
@ -17,6 +18,7 @@ export type IntakeSection =
| "referenceEstimator"
| "patents"
| "barcodePrint"
| "brandFiling"
| "adminNodePermission"
| null;
@ -73,6 +75,12 @@ export type IntakeWorkspaceTab =
kind: "barcodePrint";
label: string;
}
| {
id: string;
kind: "brandFiling";
label: string;
brand: BrandFilingBrand;
}
| {
id: string;
kind: "nonIntegratedProduct";
@ -141,6 +149,11 @@ export type PersistedIntakeWorkspaceTab =
id: string;
kind: "barcodePrint";
}
| {
id: string;
kind: "brandFiling";
brand: BrandFilingBrand;
}
| {
id: string;
kind: "nonIntegratedProduct";

View File

@ -19,6 +19,10 @@ import {
normalizeAssetVariantUvConfig,
type AssetVariantUvConfig,
} from "@/lib/asset-variant-render-config";
import {
normalizeBrandFilingData,
type BrandFilingProductData,
} from "@/app/intake/brand-filing/brandFilingTypes";
import { normalizeNonIntegratedAssetUrl } from "./nonIntegratedAssetUrl";
import {
normalizeAiWhiteBackgroundPartGroups,
@ -766,6 +770,7 @@ export type NonIntegratedProductRecord = {
id: string;
productDevelopmentKind?: "product" | "series";
metadata?: Record<string, unknown>;
brandFilingData?: BrandFilingProductData;
productTag?: string;
spu: string;
projectStatus: NonIntegratedProjectStatus;
@ -2430,6 +2435,7 @@ function toProductRecord(
normalizedPreviewImageName,
);
const metadata = normalizeMetadataRecord(item.metadata);
const brandFilingData = normalizeBrandFilingData(item.brandFilingData);
const productTag = resolveStoredProductTag(item, metadata);
return {
@ -2437,6 +2443,7 @@ function toProductRecord(
productDevelopmentKind:
item.productDevelopmentKind === "series" ? "series" : "product",
metadata,
brandFilingData,
...(productTag !== undefined ? { productTag } : {}),
spu: item.spu,
projectStatus: normalizeNonIntegratedProjectStatus(item.projectStatus),
@ -2829,6 +2836,7 @@ export function createCopiedNonIntegratedProduct(
return {
...sourceProduct,
brandFilingData: undefined,
id: buildNonIntegratedProductId(nextSpu),
spu: nextSpu,
projectStatus: normalizeNonIntegratedProjectStatus(

View File

@ -0,0 +1,84 @@
import type { ComponentProps } from "react";
import { fireEvent, render, screen } from "@testing-library/react";
import { createEmptyNonIntegratedOpeningReference } from "../../nonIntegratedStorage";
import { BasicInfoCreateSection } from "./BasicInfoCreateSection";
import { createFormValuesFromProduct } from "./productFormValueHelpers";
const STRUCTURE_DESIGNER_LABEL = "\u7ed3\u6784\u8bbe\u8ba1\u5e08";
function renderBasicInfoCreateSection(
overrides: Partial<ComponentProps<typeof BasicInfoCreateSection>> = {},
) {
const props: ComponentProps<typeof BasicInfoCreateSection> = {
availableSeriesNameOptions: [],
basicInfoDesignerOptions: ["appearance-a", "structure-a", "structure-b"],
basicInfoPlanningOwnerOptions: ["planner-a"],
basicInfoProductCategoryOptions: [],
basicInfoSecondaryCategoryOptions: [],
developmentCreateTitle: "create-product",
developmentNameLabel: "name",
developmentNamePlaceholder: "enter-name",
formValues: createFormValuesFromProduct(null),
isReadOnly: false,
isSeriesComponentCreate: false,
isSeriesDevelopment: false,
isStockProductCreate: false,
openingReference: {
...createEmptyNonIntegratedOpeningReference(),
structureDesigner: "structure-a",
},
previewFileInputRef: { current: null },
canUseExistingSpuCreateMode: false,
createSpuDisplayLabel: "",
existingSpuSelectOptions: [],
isExistingSpuCreateMode: false,
resolvedExistingSpuValue: "",
selectedExistingSpu: "",
selectedExistingSpuOption: false,
seriesCreateEstimatedSpuLabel: "",
spuCreateMode: "auto",
stockProductSpu: "",
onAdaptableBrandsChange: jest.fn(),
onExistingSpuSelection: jest.fn(),
onOpeningReferenceFieldChange: jest.fn(),
onPreviewImageChange: jest.fn(),
onPreviewProductImage: jest.fn(),
onSpuCreateModeChange: jest.fn(),
onStockProductSpuChange: jest.fn(),
onUpdateField: jest.fn(),
onUpdateManualSeriesProductSpu: jest.fn(),
...overrides,
};
render(<BasicInfoCreateSection {...props} />);
return props;
}
describe("BasicInfoCreateSection", () => {
it("allows editing structure designer in basic info create mode", () => {
const props = renderBasicInfoCreateSection();
const structureDesignerInput = screen.getByLabelText(
STRUCTURE_DESIGNER_LABEL,
);
fireEvent.focus(structureDesignerInput);
fireEvent.mouseDown(screen.getByRole("option", { name: "structure-b" }));
expect(props.onOpeningReferenceFieldChange).toHaveBeenCalledWith(
"structureDesigner",
"structure-b",
);
});
it("shows structure designer for stock product creation too", () => {
renderBasicInfoCreateSection({
isStockProductCreate: true,
stockProductSpu: "CJ0250",
});
expect(
screen.getByLabelText(STRUCTURE_DESIGNER_LABEL),
).toBeInTheDocument();
});
});

View File

@ -150,7 +150,7 @@ export function BasicInfoCreateSection({
compact
readOnly={isReadOnly}
/>
<div className="grid content-start gap-3 md:grid-cols-2 xl:grid-cols-4">
<div className="grid content-start gap-3 md:grid-cols-2 xl:grid-cols-5">
<BasicField
label="SPU"
required
@ -195,6 +195,19 @@ export function BasicInfoCreateSection({
onOpeningReferenceFieldChange("designer", value)
}
/>
<BasicField
label="结构设计师"
type="select"
searchableSelect
placeholder="请输入结构设计师"
options={designerOptions}
value={openingReference.structureDesigner}
readOnly={isReadOnly}
compact
onChange={(value) =>
onOpeningReferenceFieldChange("structureDesigner", value)
}
/>
<BasicField
label="品牌"
required
@ -288,6 +301,19 @@ export function BasicInfoCreateSection({
onOpeningReferenceFieldChange("designer", value)
}
/>
<BasicField
label="结构设计师"
type="select"
searchableSelect
placeholder="请输入结构设计师"
options={designerOptions}
value={openingReference.structureDesigner}
readOnly={isReadOnly}
compact
onChange={(value) =>
onOpeningReferenceFieldChange("structureDesigner", value)
}
/>
<BasicField
label="品牌"
required

View File

@ -69,6 +69,7 @@ export function useIntakeClientViewModel(
initialNodePermissionOverrides: props.initialNodePermissionOverrides,
user: props.user,
});
const canViewBrandFiling = canViewBarcodePrint;
const { initialActiveWorkspaceTab, initialWorkspaceState } =
useIntakeInitialWorkspaceState(props);
const { materialLibraryFinishes, setMaterialLibraryFinishes } =
@ -163,6 +164,7 @@ export function useIntakeClientViewModel(
closeNewNonIntegratedProductWorkspace,
openAdminNodePermissionWorkspace,
openBarcodePrintWorkspace,
openBrandFilingWorkspace,
openBomLibraryWorkspace,
openNewNonIntegratedProductWorkspace,
openPendingEntryWorkspace,
@ -183,6 +185,7 @@ export function useIntakeClientViewModel(
canManageSpuRuleLibrary: canManageSpuRules,
canUseDesignControlledProductData,
canViewBarcodePrint,
canViewBrandFiling,
canViewBomLibrary,
canViewFormalWorkspaceByTarget,
canViewProductPatentDashboard,
@ -244,6 +247,7 @@ export function useIntakeClientViewModel(
canViewProductPatentLedger,
canEditProductPatentLedger,
canViewBarcodePrint,
canViewBrandFiling,
}),
workspace: buildIntakeWorkspacePanelProps({
workspaceTabs,
@ -284,6 +288,10 @@ export function useIntakeClientViewModel(
}),
rightWorkbench: buildIntakeRightWorkbenchProps({
activeWorkspaceTabKind: activeWorkspaceTab?.kind ?? null,
activeBrandFilingBrand:
activeWorkspaceTab?.kind === "brandFiling"
? activeWorkspaceTab.brand
: null,
activeSection,
onToggle: (section) => {
setActiveWorkspaceTabId(null);
@ -301,6 +309,7 @@ export function useIntakeClientViewModel(
onOpenProductPatentLedger: openProductPatentLedgerWorkspace,
onOpenProductPatentDashboard: openProductPatentDashboardWorkspace,
onOpenBarcodePrintWorkspace: openBarcodePrintWorkspace,
onOpenBrandFilingWorkspace: openBrandFilingWorkspace,
onOpenSpuRuleLibraryWorkspace: canManageSpuRules
? openSpuRuleLibraryWorkspace
: undefined,
@ -318,6 +327,7 @@ export function useIntakeClientViewModel(
canViewProductPatentLedger,
canViewProductPatentDashboard,
canViewBarcodePrint,
canViewBrandFiling,
}),
};
}

View File

@ -1,6 +1,7 @@
"use client";
import { useCallback, type Dispatch, type SetStateAction } from "react";
import type { BrandFilingBrand } from "@/app/intake/brand-filing/brandFilingConfig";
import type { BomLibraryKey } from "@/app/intake/bom-library/BomLibraryDomain";
import type {
IntakeEntryTab,
@ -13,6 +14,7 @@ import type {
import {
createAdminNodePermissionWorkspaceTab,
createBarcodePrintWorkspaceTab,
createBrandFilingWorkspaceTab,
createBomLibraryWorkspaceTab,
createNewNonIntegratedProductWorkspaceTab,
createNonIntegratedProductWorkspaceTab,
@ -43,6 +45,7 @@ type WorkspaceOpenersParams = {
canManageSpuRuleLibrary: boolean;
canUseDesignControlledProductData: boolean;
canViewBarcodePrint: boolean;
canViewBrandFiling: boolean;
canViewBomLibrary: boolean;
canViewFormalWorkspaceByTarget: (
targetKey: IntakeNodePermissionTargetKey,
@ -71,6 +74,7 @@ export function useIntakeWorkspaceOpeners({
canManageSpuRuleLibrary,
canUseDesignControlledProductData,
canViewBarcodePrint,
canViewBrandFiling,
canViewBomLibrary,
canViewFormalWorkspaceByTarget,
canViewProductPatentDashboard,
@ -466,11 +470,28 @@ export function useIntakeWorkspaceOpeners({
);
}, [canViewBarcodePrint, createWorkspaceTabId, openWorkspaceTab]);
const openBrandFilingWorkspace = useCallback(
(brand: BrandFilingBrand) => {
if (!canViewBrandFiling) {
return;
}
openWorkspaceTab(
createBrandFilingWorkspaceTab({
id: createWorkspaceTabId("brand-filing", brand),
brand,
}),
);
},
[canViewBrandFiling, createWorkspaceTabId, openWorkspaceTab],
);
return {
activatePendingEntryListWorkspace,
closeNewNonIntegratedProductWorkspace,
openAdminNodePermissionWorkspace,
openBarcodePrintWorkspace,
openBrandFilingWorkspace,
openBomLibraryWorkspace,
openNewNonIntegratedProductWorkspace,
openPendingEntryWorkspace,

View File

@ -0,0 +1,828 @@
import fs from "node:fs/promises";
import path from "node:path";
import ExcelJS from "exceljs";
import JSZip from "jszip";
import sharp from "sharp";
import {
getBrandFilingBrandLabel,
type BrandFilingBrand,
} from "@/app/intake/brand-filing/brandFilingConfig";
import {
BRAND_FILING_PLANNING_CELL_MAP,
BRAND_FILING_PLANNING_ROW_LAYOUT,
} from "@/app/intake/brand-filing/brandFilingPlanningTableSchema";
import {
buildBrandFilingLineItemChannelSummary,
buildBrandFilingPlanningDisplayRows,
} from "@/app/intake/brand-filing/brandFilingPlanningLineItems";
import { normalizeLegacyBrandFilingPlanningRecord } from "@/app/intake/brand-filing/brandFilingProductData";
import type {
BrandFilingPlanningFieldKey,
BrandFilingPlanningRecord,
} from "@/app/intake/brand-filing/brandFilingTypes";
import type { NonIntegratedProductRecord } from "@/app/intake/non-integrated/nonIntegratedStorage";
import { resolveObjectStorageUrlForServerAccess } from "@/lib/storage";
import { AppError, ErrorCode } from "@/server/core/errors";
const TEMPLATE_FILE_NAME_BY_BRAND: Record<BrandFilingBrand, string> = {
gujia: "顾家企划表.xlsx",
suofeiya: "索菲亚企划表.xlsx",
};
const NUMERIC_FIELD_KEYS = new Set<BrandFilingPlanningFieldKey>([
"retailPriceDemand",
"purchasePriceDemand",
"annualSalesTarget",
"month1Sales",
"month2Sales",
"month3Sales",
"month4To6Sales",
"month7To9Sales",
"month10To12Sales",
]);
const ROW_DRIVEN_FIELD_KEYS = new Set<BrandFilingPlanningFieldKey>([
"itemCode",
"retailPriceDemand",
"purchasePriceDemand",
"sizeRequirement",
"materialAndColor",
"legacyIterationNote",
"seriesName",
"annualSalesTarget",
"channel",
"month1Sales",
"month2Sales",
"month3Sales",
"month4To6Sales",
"month7To9Sales",
"month10To12Sales",
"productManager",
"planningOwner",
"purchaseOwner",
]);
type PlanningTableDownloadResult = {
fileName: string;
contentType: string;
bytes: Uint8Array;
};
type LoadedPlanningImage = {
buffer: Buffer;
extension: "png";
width: number;
height: number;
};
const PLANNING_IMAGE_AREA = {
startColumn: 2,
endColumn: 10,
startRow: 7,
endRow: 8,
} as const;
function getTemplatePath(brand: BrandFilingBrand) {
return path.join(
process.cwd(),
"public",
"brand-filing",
"templates",
TEMPLATE_FILE_NAME_BY_BRAND[brand],
);
}
function getPlanningRecord(
product: NonIntegratedProductRecord,
brand: BrandFilingBrand,
) {
const record = product.brandFilingData?.[brand]?.planningTable;
if (!record) {
throw new AppError(
ErrorCode.VALIDATION_FAILED,
`${product.spu || product.id} 还没有企划表数据`,
);
}
return normalizeLegacyBrandFilingPlanningRecord(product, record) ?? record;
}
function buildPlanningFileName(params: {
brand: BrandFilingBrand;
product: NonIntegratedProductRecord;
record: BrandFilingPlanningRecord;
}) {
const brandLabel = getBrandFilingBrandLabel(params.brand);
const safeSpu = params.product.spu.trim() || params.product.id;
const sequenceText = params.record.sequenceNumber
? String(params.record.sequenceNumber)
: "0";
return `${sequenceText}-${safeSpu}-${brandLabel}企划表.xlsx`;
}
function coerceCellValue(key: BrandFilingPlanningFieldKey, rawValue: string) {
const trimmedValue = rawValue.trim();
if (!trimmedValue) return "";
if (key === "launchDate") {
const date = new Date(trimmedValue);
return Number.isNaN(date.getTime()) ? trimmedValue : date;
}
if (NUMERIC_FIELD_KEYS.has(key)) {
const numericValue = Number(trimmedValue.replace(/,/g, ""));
return Number.isFinite(numericValue) ? numericValue : trimmedValue;
}
return trimmedValue;
}
async function loadPlanningImage(imageUrl: string) {
const trimmedUrl = imageUrl.trim();
if (!trimmedUrl) return null;
try {
const response = await fetch(resolvePlanningImageUrl(trimmedUrl));
if (!response.ok) return null;
const imageBuffer = Buffer.from(await response.arrayBuffer());
const { data: pngBuffer, info } = await sharp(imageBuffer, {
animated: true,
})
.rotate()
.trim({
background: {
r: 255,
g: 255,
b: 255,
alpha: 1,
},
})
.png()
.toBuffer({ resolveWithObject: true });
if (!info.width || !info.height) return null;
return {
buffer: pngBuffer,
extension: "png",
width: info.width,
height: info.height,
} satisfies LoadedPlanningImage;
} catch {
return null;
}
}
function resolvePlanningImageUrl(imageUrl: string) {
const storageUrl = resolveObjectStorageUrlForServerAccess(imageUrl);
if (storageUrl !== imageUrl) {
return storageUrl;
}
if (/^https?:\/\//i.test(imageUrl)) {
return imageUrl;
}
const appBaseUrl =
process.env.NEXT_PUBLIC_APP_URL ||
process.env.NEXTAUTH_URL ||
"http://127.0.0.1:3001";
try {
return new URL(imageUrl, appBaseUrl).toString();
} catch {
return imageUrl;
}
}
function clearPlanningImageArea(worksheet: ExcelJS.Worksheet) {
const columns = ["B", "C", "D", "E", "F", "G", "H", "I", "J"];
for (let row = 7; row <= 8; row += 1) {
columns.forEach((column) => {
worksheet.getCell(`${column}${row}`).value = "";
});
}
}
function getWorksheetColumnPixelWidth(
worksheet: ExcelJS.Worksheet,
columnNumber: number,
) {
const width = worksheet.getColumn(columnNumber).width ?? 8.43;
return Math.max(1, Math.round(width * 7 + 5));
}
function getWorksheetRowPixelHeight(
worksheet: ExcelJS.Worksheet,
rowNumber: number,
) {
const height = worksheet.getRow(rowNumber).height ?? 15;
return Math.max(1, Math.round((height * 96) / 72));
}
function getPlanningImageAreaPixels(worksheet: ExcelJS.Worksheet) {
let width = 0;
for (
let columnNumber = PLANNING_IMAGE_AREA.startColumn;
columnNumber <= PLANNING_IMAGE_AREA.endColumn;
columnNumber += 1
) {
width += getWorksheetColumnPixelWidth(worksheet, columnNumber);
}
let height = 0;
for (
let rowNumber = PLANNING_IMAGE_AREA.startRow;
rowNumber <= PLANNING_IMAGE_AREA.endRow;
rowNumber += 1
) {
height += getWorksheetRowPixelHeight(worksheet, rowNumber);
}
return { width, height };
}
function resolveImageAnchorCoordinate(params: {
worksheet: ExcelJS.Worksheet;
axis: "column" | "row";
start: number;
end: number;
offset: number;
}) {
let coordinate = params.start - 1;
let remainingOffset = Math.max(0, params.offset);
for (let index = params.start; index <= params.end; index += 1) {
const size =
params.axis === "column"
? getWorksheetColumnPixelWidth(params.worksheet, index)
: getWorksheetRowPixelHeight(params.worksheet, index);
if (remainingOffset >= size) {
coordinate += 1;
remainingOffset -= size;
continue;
}
coordinate += remainingOffset / size;
return coordinate;
}
return coordinate;
}
function placePlanningImage(params: {
worksheet: ExcelJS.Worksheet;
workbook: ExcelJS.Workbook;
image: LoadedPlanningImage;
}) {
const { worksheet, workbook, image } = params;
const area = getPlanningImageAreaPixels(worksheet);
const scale = Math.min(area.width / image.width, area.height / image.height);
const renderWidth = Math.max(1, Math.round(image.width * scale));
const renderHeight = Math.max(1, Math.round(image.height * scale));
const offsetX = Math.max(0, Math.round((area.width - renderWidth) / 2));
const offsetY = Math.max(0, Math.round((area.height - renderHeight) / 2));
const imageId = workbook.addImage({
buffer: image.buffer as never,
extension: image.extension,
} as never);
worksheet.addImage(imageId, {
tl: {
col: resolveImageAnchorCoordinate({
worksheet,
axis: "column",
start: PLANNING_IMAGE_AREA.startColumn,
end: PLANNING_IMAGE_AREA.endColumn,
offset: offsetX,
}),
row: resolveImageAnchorCoordinate({
worksheet,
axis: "row",
start: PLANNING_IMAGE_AREA.startRow,
end: PLANNING_IMAGE_AREA.endRow,
offset: offsetY,
}),
},
ext: {
width: renderWidth,
height: renderHeight,
},
});
}
function insertStyledRows(
worksheet: ExcelJS.Worksheet,
sourceRow: number,
rowCount: number,
) {
if (rowCount <= 0) return;
worksheet.duplicateRow(sourceRow, rowCount, true);
}
const DYNAMIC_TEMPLATE_MERGE_REFS = [
"B11:C11",
"D11:E11",
"F11:G11",
"H11:I11",
"J11:L11",
"A12:L12",
"A13:A14",
"B13:B14",
"C13:D13",
"E13:L13",
"I14:J14",
"K14:L14",
"A15:B15",
"I15:J15",
"K15:L15",
"A16:L16",
"B17:D17",
"F17:H17",
"J17:L17",
"B18:L18",
] as const;
type CellStyleSnapshot = {
numFmt: ExcelJS.Cell["numFmt"];
font: ExcelJS.Cell["font"];
alignment: ExcelJS.Cell["alignment"];
fill: ExcelJS.Cell["fill"];
border: ExcelJS.Cell["border"];
protection: ExcelJS.Cell["protection"];
};
function cloneCellStylePart<T>(value: T): T {
if (value == null) {
return value;
}
return JSON.parse(JSON.stringify(value)) as T;
}
function captureCellStyle(cell: ExcelJS.Cell): CellStyleSnapshot {
return {
numFmt: cell.numFmt,
font: cloneCellStylePart(cell.font),
alignment: cloneCellStylePart(cell.alignment),
fill: cloneCellStylePart(cell.fill),
border: cloneCellStylePart(cell.border),
protection: cloneCellStylePart(cell.protection),
};
}
function restoreCellStyle(cell: ExcelJS.Cell, snapshot: CellStyleSnapshot) {
cell.numFmt = snapshot.numFmt;
cell.font = cloneCellStylePart(snapshot.font);
cell.alignment = cloneCellStylePart(snapshot.alignment);
cell.fill = cloneCellStylePart(snapshot.fill);
cell.border = cloneCellStylePart(snapshot.border);
cell.protection = cloneCellStylePart(snapshot.protection);
}
function parseCellAddress(address: string) {
const match = /^([A-Z]+)(\d+)$/i.exec(address.trim());
if (!match) {
throw new Error(`Unsupported cell address: ${address}`);
}
return {
column: match[1]!.toUpperCase(),
row: Number.parseInt(match[2]!, 10),
};
}
function columnNameToNumber(columnName: string) {
let value = 0;
for (const character of columnName) {
value = value * 26 + (character.charCodeAt(0) - 64);
}
return value;
}
function columnNumberToName(columnNumber: number) {
let value = columnNumber;
let columnName = "";
while (value > 0) {
const remainder = (value - 1) % 26;
columnName = String.fromCharCode(65 + remainder) + columnName;
value = Math.floor((value - 1) / 26);
}
return columnName;
}
function expandRangeAddresses(ref: string) {
const [startAddress, endAddress = startAddress] = ref.split(":");
const start = parseCellAddress(startAddress);
const end = parseCellAddress(endAddress);
const addresses: string[] = [];
for (let row = start.row; row <= end.row; row += 1) {
for (
let columnNumber = columnNameToNumber(start.column);
columnNumber <= columnNameToNumber(end.column);
columnNumber += 1
) {
addresses.push(`${columnNumberToName(columnNumber)}${row}`);
}
}
return addresses;
}
function clearDynamicTemplateMerges(worksheet: ExcelJS.Worksheet) {
DYNAMIC_TEMPLATE_MERGE_REFS.forEach((ref) => {
const styleSnapshots = expandRangeAddresses(ref).map((address) => ({
address,
style: captureCellStyle(worksheet.getCell(address)),
}));
try {
worksheet.unMergeCells(ref);
styleSnapshots.forEach(({ address, style }) => {
restoreCellStyle(worksheet.getCell(address), style);
});
} catch {
// Skip refs that are already unmerged after row duplication.
}
});
}
function applyMergeRefs(
worksheet: ExcelJS.Worksheet,
mergeRefs: readonly string[],
) {
mergeRefs.forEach((ref) => {
const styleSnapshots = expandRangeAddresses(ref).map((address) => ({
address,
style: captureCellStyle(worksheet.getCell(address)),
}));
worksheet.mergeCells(ref);
styleSnapshots.forEach(({ address, style }) => {
restoreCellStyle(worksheet.getCell(address), style);
});
});
}
function setCellValue(
worksheet: ExcelJS.Worksheet,
address: string,
key: BrandFilingPlanningFieldKey,
rawValue: string,
) {
worksheet.getCell(address).value = coerceCellValue(key, rawValue);
}
function setCellCenterAlignment(
worksheet: ExcelJS.Worksheet,
address: string,
) {
const cell = worksheet.getCell(address);
cell.alignment = {
...cell.alignment,
horizontal: "center",
vertical: "middle",
};
}
function applyCenterAcrossRange(
worksheet: ExcelJS.Worksheet,
ref: string,
) {
const addresses = expandRangeAddresses(ref);
if (addresses.length === 0) return;
const firstCell = worksheet.getCell(addresses[0]!);
firstCell.alignment = {
...firstCell.alignment,
horizontal: "centerContinuous",
vertical: "middle",
};
}
function writePlanningSectionRows(params: {
worksheet: ExcelJS.Worksheet;
product: NonIntegratedProductRecord;
record: BrandFilingPlanningRecord;
}) {
const { worksheet, product, record } = params;
const displayRows = buildBrandFilingPlanningDisplayRows({
product,
form: record.form,
lineItems: record.lineItems ?? [],
});
const rowCount = Math.max(displayRows.length, 1);
const additionalPlanRows = rowCount - 1;
const planDataStartRow = BRAND_FILING_PLANNING_ROW_LAYOUT.plan.dataStartRow;
clearDynamicTemplateMerges(worksheet);
insertStyledRows(worksheet, planDataStartRow, additionalPlanRows);
const promotionSectionTitleRow =
BRAND_FILING_PLANNING_ROW_LAYOUT.promotion.sectionTitleRow + additionalPlanRows;
const promotionHeaderTopRow =
BRAND_FILING_PLANNING_ROW_LAYOUT.promotion.headerTopRow + additionalPlanRows;
const promotionHeaderBottomRow =
BRAND_FILING_PLANNING_ROW_LAYOUT.promotion.headerBottomRow + additionalPlanRows;
const promotionDataStartRow =
BRAND_FILING_PLANNING_ROW_LAYOUT.promotion.dataStartRow + additionalPlanRows;
const additionalPromotionRows = rowCount - 1;
insertStyledRows(
worksheet,
promotionDataStartRow,
additionalPromotionRows,
);
const signSectionTitleRow =
BRAND_FILING_PLANNING_ROW_LAYOUT.sign.sectionTitleRow +
additionalPlanRows +
additionalPromotionRows;
const signDataRow =
BRAND_FILING_PLANNING_ROW_LAYOUT.sign.dataRow +
additionalPlanRows +
additionalPromotionRows;
const channelFooterRow =
BRAND_FILING_PLANNING_ROW_LAYOUT.sign.channelFooterRow +
additionalPlanRows +
additionalPromotionRows;
for (let offset = 0; offset < rowCount; offset += 1) {
const rowNumber = planDataStartRow + offset;
applyMergeRefs(
worksheet,
BRAND_FILING_PLANNING_ROW_LAYOUT.plan.mergedRanges.map(
([startColumn, endColumn]) =>
`${startColumn}${rowNumber}:${endColumn}${rowNumber}`,
),
);
}
applyMergeRefs(worksheet, [
`A${promotionSectionTitleRow}:L${promotionSectionTitleRow}`,
`A${promotionHeaderTopRow}:A${promotionHeaderBottomRow}`,
`B${promotionHeaderTopRow}:B${promotionHeaderBottomRow}`,
`C${promotionHeaderTopRow}:D${promotionHeaderTopRow}`,
]);
applyCenterAcrossRange(
worksheet,
`E${promotionHeaderTopRow}:L${promotionHeaderTopRow}`,
);
applyCenterAcrossRange(
worksheet,
`I${promotionHeaderBottomRow}:J${promotionHeaderBottomRow}`,
);
applyCenterAcrossRange(
worksheet,
`K${promotionHeaderBottomRow}:L${promotionHeaderBottomRow}`,
);
for (let offset = 0; offset < rowCount; offset += 1) {
const rowNumber = promotionDataStartRow + offset;
applyMergeRefs(
worksheet,
BRAND_FILING_PLANNING_ROW_LAYOUT.promotion.mergedRanges.map(
([startColumn, endColumn]) =>
`${startColumn}${rowNumber}:${endColumn}${rowNumber}`,
),
);
}
applyMergeRefs(worksheet, [
`A${signSectionTitleRow}:L${signSectionTitleRow}`,
`B${signDataRow}:D${signDataRow}`,
`F${signDataRow}:H${signDataRow}`,
`J${signDataRow}:L${signDataRow}`,
`B${channelFooterRow}:L${channelFooterRow}`,
]);
displayRows.forEach((displayRow, index) => {
const planRowNumber = planDataStartRow + index;
setCellValue(
worksheet,
`A${planRowNumber}`,
"itemCode",
displayRow.itemCode,
);
setCellValue(
worksheet,
`B${planRowNumber}`,
"retailPriceDemand",
displayRow.retailPriceDemand,
);
setCellValue(
worksheet,
`D${planRowNumber}`,
"purchasePriceDemand",
displayRow.purchasePriceDemand,
);
setCellValue(
worksheet,
`F${planRowNumber}`,
"sizeRequirement",
displayRow.sizeRequirement,
);
setCellValue(
worksheet,
`H${planRowNumber}`,
"materialAndColor",
displayRow.materialAndColor,
);
setCellValue(
worksheet,
`J${planRowNumber}`,
"legacyIterationNote",
record.form.legacyIterationNote,
);
["A", "B", "D", "F", "H", "J"].forEach((column) => {
setCellCenterAlignment(worksheet, `${column}${planRowNumber}`);
});
const promotionRowNumber = promotionDataStartRow + index;
setCellValue(
worksheet,
`A${promotionRowNumber}`,
"seriesName",
displayRow.seriesName,
);
setCellValue(
worksheet,
`B${promotionRowNumber}`,
"itemCode",
displayRow.itemCode,
);
setCellValue(
worksheet,
`C${promotionRowNumber}`,
"annualSalesTarget",
displayRow.annualSalesTarget,
);
setCellValue(
worksheet,
`D${promotionRowNumber}`,
"channel",
displayRow.channel,
);
setCellValue(
worksheet,
`E${promotionRowNumber}`,
"month1Sales",
displayRow.month1Sales,
);
setCellValue(
worksheet,
`F${promotionRowNumber}`,
"month2Sales",
displayRow.month2Sales,
);
setCellValue(
worksheet,
`G${promotionRowNumber}`,
"month3Sales",
displayRow.month3Sales,
);
setCellValue(
worksheet,
`H${promotionRowNumber}`,
"month4To6Sales",
displayRow.month4To6Sales,
);
setCellValue(
worksheet,
`I${promotionRowNumber}`,
"month7To9Sales",
displayRow.month7To9Sales,
);
setCellValue(
worksheet,
`K${promotionRowNumber}`,
"month10To12Sales",
displayRow.month10To12Sales,
);
["A", "B", "C", "D", "E", "F", "G", "H", "I", "K"].forEach((column) => {
setCellCenterAlignment(worksheet, `${column}${promotionRowNumber}`);
});
});
setCellValue(
worksheet,
`B${signDataRow}`,
"productManager",
record.form.productManager,
);
setCellValue(
worksheet,
`F${signDataRow}`,
"planningOwner",
record.form.planningOwner,
);
setCellValue(
worksheet,
`J${signDataRow}`,
"purchaseOwner",
record.form.purchaseOwner,
);
["B", "F", "J"].forEach((column) => {
setCellCenterAlignment(worksheet, `${column}${signDataRow}`);
});
worksheet.getCell(`B${channelFooterRow}`).value =
buildBrandFilingLineItemChannelSummary(record.lineItems ?? []) ||
record.form.channel.trim();
setCellCenterAlignment(worksheet, `B${channelFooterRow}`);
}
async function buildPlanningWorkbookBytes(params: {
brand: BrandFilingBrand;
product: NonIntegratedProductRecord;
record: BrandFilingPlanningRecord;
}) {
const workbook = new ExcelJS.Workbook();
const templateBytes = await fs.readFile(getTemplatePath(params.brand));
await workbook.xlsx.load(templateBytes as never);
const worksheet = workbook.getWorksheet("Sheet1") ?? workbook.worksheets[0];
if (!worksheet) {
throw new AppError(ErrorCode.SYSTEM_INTERNAL, "企划表模板读取失败");
}
clearPlanningImageArea(worksheet);
Object.entries(BRAND_FILING_PLANNING_CELL_MAP).forEach(([key, address]) => {
const fieldKey = key as Exclude<BrandFilingPlanningFieldKey, "imageUrl">;
if (ROW_DRIVEN_FIELD_KEYS.has(fieldKey)) return;
worksheet.getCell(address).value = coerceCellValue(
fieldKey,
params.record.form[fieldKey],
);
});
writePlanningSectionRows({
worksheet,
product: params.product,
record: params.record,
});
const planningImage = await loadPlanningImage(params.record.form.imageUrl);
if (planningImage) {
placePlanningImage({
worksheet,
workbook,
image: planningImage,
});
}
const output = await workbook.xlsx.writeBuffer();
return output instanceof Uint8Array ? output : new Uint8Array(output);
}
export async function buildBrandFilingPlanningTableDownload(params: {
brand: BrandFilingBrand;
products: NonIntegratedProductRecord[];
}): Promise<PlanningTableDownloadResult> {
if (params.products.length === 0) {
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, "请先选择产品");
}
const files = await Promise.all(
params.products.map(async (product) => {
const record = getPlanningRecord(product, params.brand);
return {
fileName: buildPlanningFileName({
brand: params.brand,
product,
record,
}),
bytes: await buildPlanningWorkbookBytes({
brand: params.brand,
product,
record,
}),
};
}),
);
if (files.length === 1) {
return {
fileName: files[0]!.fileName,
contentType:
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
bytes: files[0]!.bytes,
};
}
const brandLabel = getBrandFilingBrandLabel(params.brand);
const zip = new JSZip();
const folder = zip.folder(`${brandLabel}企划表`) ?? zip;
files.forEach((file) => {
folder.file(file.fileName, file.bytes);
});
return {
fileName: `${brandLabel}企划表.zip`,
contentType: "application/zip",
bytes: await zip.generateAsync({ type: "uint8array" }),
};
}

View File

@ -1125,6 +1125,7 @@ function preserveWorkspaceCategoryFromProduct(
return {
...preservedProduct,
planningLayoutRows: sourceProduct.planningLayoutRows,
brandFilingData: sourceProduct.brandFilingData,
} as SharedNonIntegratedProductRecord
case FACTORY_QUALITY_WORKSPACE_CATEGORY:
return {
@ -1273,6 +1274,11 @@ function preserveWorkspaceCategoryFromPayload(
fallbackProduct,
'planningLayoutRows',
),
brandFilingData: getSourcePayloadValue(
sourcePayload,
fallbackProduct,
'brandFilingData',
),
} as SharedNonIntegratedProductRecord
case FACTORY_QUALITY_WORKSPACE_CATEGORY:
return {
@ -1562,8 +1568,14 @@ function getChangedWorkspaceCategories(
addChangedWorkspaceCategory(
categories,
PLANNING_LAYOUT_WORKSPACE_CATEGORY,
existingProduct.planningLayoutRows,
nextProduct.planningLayoutRows,
{
planningLayoutRows: existingProduct.planningLayoutRows,
brandFilingData: existingProduct.brandFilingData,
},
{
planningLayoutRows: nextProduct.planningLayoutRows,
brandFilingData: nextProduct.brandFilingData,
},
)
addChangedWorkspaceCategory(
categories,