feat(intake): 完善品牌报备模板与已报备流程

This commit is contained in:
WuYongJie 2026-07-10 14:22:30 +08:00
parent f178e9cceb
commit be49070962
20 changed files with 1071 additions and 209 deletions

View File

@ -16,6 +16,10 @@ import {
type BrandFilingPlanningLineItem,
type BrandFilingPlanningRecord,
} from "@/app/intake/brand-filing/brandFilingTypes";
import {
buildRequiredFieldsMessage,
validateBrandFilingPlanningRequiredFields,
} from "@/app/intake/brand-filing/brandFilingRequiredValidation";
import type { NonIntegratedProductRecord } from "@/app/intake/non-integrated/nonIntegratedStorage";
export function BrandFilingPlanningTableDialog({
@ -156,6 +160,23 @@ function BrandFilingPlanningTableDialogContent({
);
};
const getMissingRequiredFields = () =>
validateBrandFilingPlanningRequiredFields(form, lineItems);
const handleSaveDraft = () => {
onSave(form, lineItems, completed && getMissingRequiredFields().length === 0);
};
const handleSaveAndConfirm = () => {
const missingFields = getMissingRequiredFields();
if (missingFields.length > 0) {
window.alert(buildRequiredFieldsMessage("企划表", missingFields));
return;
}
onSave(form, lineItems, true);
};
return (
<div
role="dialog"
@ -221,7 +242,7 @@ function BrandFilingPlanningTableDialogContent({
<button
type="button"
disabled={saving}
onClick={() => onSave(form, lineItems, completed)}
onClick={handleSaveDraft}
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"
>
保存
@ -229,7 +250,7 @@ function BrandFilingPlanningTableDialogContent({
<button
type="button"
disabled={saving}
onClick={() => onSave(form, lineItems, true)}
onClick={handleSaveAndConfirm}
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"
>
保存并确认

View File

@ -33,6 +33,15 @@ function SectionBand({ title }: { title: string }) {
return <div className={SECTION_BAND_CLASS}>{title}</div>;
}
function RequiredFieldLabel({ children }: { children: ReactNode }) {
return (
<span className="inline-flex items-center justify-center gap-0.5">
<span>{children}</span>
<span className="text-red-500">*</span>
</span>
);
}
function LabelCell({
children,
className = "",
@ -159,6 +168,16 @@ function PlanSectionRow({
}) {
return (
<>
<InputCell
value={displayRow.brandItemCode}
onChange={(event) =>
onLineItemChange(
displayRow.itemCode,
"brandItemCode",
event.target.value,
)
}
/>
{productHasSkuRows ? (
<ReadonlyValueCell value={displayRow.itemCode} />
) : (
@ -252,6 +271,16 @@ function PromotionSectionRow({
onLineItemChange(displayRow.itemCode, "seriesName", event.target.value)
}
/>
<InputCell
value={displayRow.brandItemCode}
onChange={(event) =>
onLineItemChange(
displayRow.itemCode,
"brandItemCode",
event.target.value,
)
}
/>
<ReadonlyValueCell value={displayRow.itemCode} />
<ReadonlyValueCell value={displayRow.annualSalesTarget} />
<InputCell
@ -364,7 +393,9 @@ export function BrandFilingPlanningTemplateSheet({
<SectionBand title="基本信息" />
<div className="grid grid-cols-[1.5fr_1.1fr_1.35fr_1.15fr_1.45fr_1.05fr]">
<LabelCell>企划产品线</LabelCell>
<LabelCell>
<RequiredFieldLabel>企划产品线</RequiredFieldLabel>
</LabelCell>
<InputCell
value={form.planningProductLine}
onChange={(event) =>
@ -373,14 +404,18 @@ export function BrandFilingPlanningTemplateSheet({
listId="planning-product-line-options"
options={planningProductLineOptions}
/>
<LabelCell>产品线</LabelCell>
<LabelCell>
<RequiredFieldLabel>产品线</RequiredFieldLabel>
</LabelCell>
<InputCell
value={form.productLine}
onChange={(event) => onChange("productLine", event.target.value)}
listId="product-line-options"
options={productLineOptions}
/>
<LabelCell>是否年度规划内</LabelCell>
<LabelCell>
<RequiredFieldLabel>是否年度规划内</RequiredFieldLabel>
</LabelCell>
<InputCell
value={form.annualPlanStatus}
onChange={(event) =>
@ -391,7 +426,9 @@ export function BrandFilingPlanningTemplateSheet({
/>
</div>
<div className="grid grid-cols-[1.5fr_1.1fr_1.35fr_1.15fr_1.45fr_1.05fr]">
<LabelCell>开发方式</LabelCell>
<LabelCell>
<RequiredFieldLabel>开发方式</RequiredFieldLabel>
</LabelCell>
<InputCell
value={form.developmentMode}
onChange={(event) =>
@ -400,12 +437,16 @@ export function BrandFilingPlanningTemplateSheet({
listId="development-mode-options"
options={developmentModeOptions}
/>
<LabelCell>产品系列</LabelCell>
<LabelCell>
<RequiredFieldLabel>产品系列</RequiredFieldLabel>
</LabelCell>
<InputCell
value={form.productSeries}
onChange={(event) => onChange("productSeries", event.target.value)}
/>
<LabelCell>开发类别</LabelCell>
<LabelCell>
<RequiredFieldLabel>开发类别</RequiredFieldLabel>
</LabelCell>
<InputCell
value={form.developmentCategory}
onChange={(event) =>
@ -416,13 +457,17 @@ export function BrandFilingPlanningTemplateSheet({
/>
</div>
<div className="grid grid-cols-[1.55fr_1.55fr_1fr_0.95fr_1.55fr_1.55fr_1.45fr_1.45fr]">
<LabelCell>买采供应商名称</LabelCell>
<LabelCell>
<RequiredFieldLabel>买采供应商名称</RequiredFieldLabel>
</LabelCell>
<InputCell
value={form.supplierName}
onChange={(event) => onChange("supplierName", event.target.value)}
inputClassName={INPUT_LEFT_CLASS}
/>
<LabelCell>是否入仓</LabelCell>
<LabelCell>
<RequiredFieldLabel>是否入仓</RequiredFieldLabel>
</LabelCell>
<InputCell
value={form.warehousingStatus}
onChange={(event) =>
@ -431,12 +476,16 @@ export function BrandFilingPlanningTemplateSheet({
listId="warehousing-status-options"
options={warehousingStatusOptions}
/>
<LabelCell>需求上市时间</LabelCell>
<LabelCell>
<RequiredFieldLabel>需求上市时间</RequiredFieldLabel>
</LabelCell>
<InputCell
value={form.launchDate}
onChange={(event) => onChange("launchDate", event.target.value)}
/>
<LabelCell>是否活动款</LabelCell>
<LabelCell>
<RequiredFieldLabel>是否活动款</RequiredFieldLabel>
</LabelCell>
<InputCell
value={form.activityStyleStatus}
onChange={(event) =>
@ -449,10 +498,12 @@ export function BrandFilingPlanningTemplateSheet({
<SectionBand title="企划背景介绍" />
<div className="grid grid-cols-[190px_minmax(0,1fr)]">
<LabelCell className="min-h-[200px] text-[22px]">图片</LabelCell>
<LabelCell className="min-h-[200px] text-[22px]">
<RequiredFieldLabel>图片</RequiredFieldLabel>
</LabelCell>
<ImageCell imageUrl={previewImage} />
<LabelCell className="min-h-[200px] text-[18px]">
产品需求分析
<RequiredFieldLabel>产品需求分析</RequiredFieldLabel>
</LabelCell>
<TextareaCell
value={form.productDemandAnalysis}
@ -464,13 +515,26 @@ export function BrandFilingPlanningTemplateSheet({
</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>
<div className="grid grid-cols-[1.05fr_1.05fr_1.55fr_1.45fr_1.55fr_1.7fr_2fr]">
<LabelCell>品牌货号</LabelCell>
<LabelCell>
<RequiredFieldLabel>货号</RequiredFieldLabel>
</LabelCell>
<LabelCell>
<RequiredFieldLabel>零售价需求</RequiredFieldLabel>
</LabelCell>
<LabelCell>
<RequiredFieldLabel>采购价需求</RequiredFieldLabel>
</LabelCell>
<LabelCell>
<RequiredFieldLabel>尺寸要求</RequiredFieldLabel>
</LabelCell>
<LabelCell>
<RequiredFieldLabel>材质/颜色</RequiredFieldLabel>
</LabelCell>
<LabelCell>
<RequiredFieldLabel>特殊说明(是否老款迭代)</RequiredFieldLabel>
</LabelCell>
{displayRows.map((displayRow, index) => (
<Fragment key={`plan-${displayRow.itemCode || index}`}>
<PlanSectionRow
@ -485,20 +549,41 @@ export function BrandFilingPlanningTemplateSheet({
</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>
<div className="grid grid-cols-[1.25fr_1.2fr_1.25fr_1.1fr_0.9fr_0.85fr_0.85fr_0.85fr_0.95fr_1fr_1.25fr] auto-rows-[58px]">
<LabelCell className="row-span-2">
<RequiredFieldLabel>系列</RequiredFieldLabel>
</LabelCell>
<LabelCell className="row-span-2">品牌货号</LabelCell>
<LabelCell className="row-span-2">
<RequiredFieldLabel>货号</RequiredFieldLabel>
</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>
<LabelCell className="whitespace-nowrap">
<RequiredFieldLabel>全年销售总量</RequiredFieldLabel>
</LabelCell>
<LabelCell>
<RequiredFieldLabel>渠道</RequiredFieldLabel>
</LabelCell>
<LabelCell>
<RequiredFieldLabel>第1个月</RequiredFieldLabel>
</LabelCell>
<LabelCell>
<RequiredFieldLabel>第2个月</RequiredFieldLabel>
</LabelCell>
<LabelCell>
<RequiredFieldLabel>第3个月</RequiredFieldLabel>
</LabelCell>
<LabelCell>
<RequiredFieldLabel>第4-6月</RequiredFieldLabel>
</LabelCell>
<LabelCell>
<RequiredFieldLabel>第7-9月</RequiredFieldLabel>
</LabelCell>
<LabelCell>
<RequiredFieldLabel>第10-12月</RequiredFieldLabel>
</LabelCell>
{displayRows.map((displayRow, index) => (
<Fragment key={`promotion-${displayRow.itemCode || index}`}>
@ -512,26 +597,34 @@ export function BrandFilingPlanningTemplateSheet({
<SectionBand title="会签" />
<div className="grid grid-cols-[1.15fr_2.2fr_1fr_2.2fr_1fr_2.25fr]">
<LabelCell>产品经理</LabelCell>
<LabelCell>
<RequiredFieldLabel>产品经理</RequiredFieldLabel>
</LabelCell>
<InputCell
value={form.productManager}
onChange={(event) => onChange("productManager", event.target.value)}
listId="product-manager-options"
options={productManagerOptions}
/>
<LabelCell>企划负责人</LabelCell>
<LabelCell>
<RequiredFieldLabel>企划负责人</RequiredFieldLabel>
</LabelCell>
<InputCell
value={form.planningOwner}
onChange={(event) => onChange("planningOwner", event.target.value)}
/>
<LabelCell>采购负责人</LabelCell>
<LabelCell>
<RequiredFieldLabel>采购负责人</RequiredFieldLabel>
</LabelCell>
<InputCell
value={form.purchaseOwner}
onChange={(event) => onChange("purchaseOwner", event.target.value)}
/>
</div>
<div className="grid grid-cols-[1.15fr_5.45fr]">
<LabelCell>渠道</LabelCell>
<LabelCell>
<RequiredFieldLabel>渠道</RequiredFieldLabel>
</LabelCell>
<ReadonlyValueCell
value={channelSummary}
emptyPlaceholder=""

View File

@ -10,6 +10,7 @@ import { type BrandFilingBrand } from "@/app/intake/brand-filing/brandFilingConf
import { getBrandFilingPlanningRecord } from "@/app/intake/brand-filing/brandFilingProductData";
import {
buildBrandFilingSequenceMap,
getBrandFilingEnteredAt,
getBrandFilingProducts,
isBrandFilingBasicInfoCurrentlyConfirmed,
} from "@/app/intake/brand-filing/brandFilingSequence";
@ -20,11 +21,14 @@ export type BrandFilingRow = {
product: NonIntegratedProductRecord;
planningRecord?: BrandFilingPlanningRecord;
sequenceNumber?: number;
enteredAt: string;
quotationCompleted: boolean;
infoConfirmationCompleted: boolean;
planningCompleted: boolean;
brandFilingCompleted: boolean;
basicInfoConfirmed: boolean;
reportedAt: string;
reported: boolean;
displayName: string;
spu: string;
productName: string;
@ -41,6 +45,7 @@ export function buildBrandFilingRows(
const sequenceMap = buildBrandFilingSequenceMap(products, brand);
return getBrandFilingProducts(products, brand).map((product) => {
const brandData = product.brandFilingData?.[brand];
const people = getNonIntegratedListPeople(product);
const previewImage =
product.openingReference?.productImage?.trim() || product.previewImage;
@ -59,10 +64,13 @@ export function buildBrandFilingRows(
product,
planningRecord,
sequenceNumber: sequenceMap.get(product.id),
enteredAt: getBrandFilingEnteredAt(product, brand),
quotationCompleted,
infoConfirmationCompleted,
planningCompleted,
basicInfoConfirmed,
reportedAt: brandData?.reportedAt?.trim() ?? "",
reported: Boolean(brandData?.reportedAt?.trim()),
brandFilingCompleted:
brand === "gujia"
? quotationCompleted && infoConfirmationCompleted && planningCompleted
@ -112,6 +120,15 @@ export function formatBrandFilingSequenceNumber(value: number | undefined) {
return value && value > 0 ? String(value) : "-";
}
export function formatBrandFilingEntryDate(value: string | undefined) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "-";
return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`;
}
export function getBrandFilingPlanningStatusLabel(completed: boolean) {
return completed ? "已完成" : "待填写";
}

View File

@ -12,12 +12,17 @@ import {
import {
BrandFilingPlanningTableDialog,
} from "@/app/intake/brand-filing/BrandFilingPlanningTableDialog";
import {
BrandFilingReportConfirmationDialog,
} from "@/app/intake/brand-filing/BrandFilingReportConfirmationDialog";
import {
BrandFilingProductTableToolbar,
type BrandFilingReportView,
} from "@/app/intake/brand-filing/BrandFilingProductTableToolbar";
import { downloadGujiaBrandFilingPackage } from "@/app/intake/brand-filing/gujiaBrandFilingPackageDownload";
import { downloadBrandFilingPlanningTables } from "@/app/intake/brand-filing/brandFilingPlanningTableDownload";
import type { BrandFilingDownloadProgress } from "@/app/intake/brand-filing/brandFilingDownloadProgress";
import { markBrandFilingReported } from "@/app/intake/brand-filing/brandFilingReportStatus";
import {
ensureBrandFilingPlanningRecord,
getBrandFilingPlanningRecord,
@ -26,6 +31,7 @@ import {
} from "@/app/intake/brand-filing/brandFilingProductData";
import {
buildBrandFilingRows,
formatBrandFilingEntryDate,
formatBrandFilingSequenceNumber,
getBrandFilingProductsByIds,
getBrandFilingTemplateStatusLabel,
@ -66,6 +72,7 @@ const TEXT = {
productCountUnit: "个产品",
rowUnit: "条",
index: "序号",
entryDate: "日期",
quotationStatus: "报价表状态",
infoConfirmationStatus: "信息确认函状态",
planningStatus: "企划表状态",
@ -90,6 +97,11 @@ type ActiveDownloadProgress = BrandFilingDownloadProgress & {
title: string;
};
type PendingReportConfirmation = {
productIds: string[];
count: number;
};
function getDownloadProgressDescription(progress: ActiveDownloadProgress) {
if (progress.phase === "preparing") return "正在生成文件";
if (progress.phase === "saving") return "正在保存文件";
@ -217,6 +229,8 @@ export function BrandFilingProductTable({
const [currentPage, setCurrentPage] = useState(1);
const [pageJumpInput, setPageJumpInput] = useState("1");
const [isPageJumpEditing, setIsPageJumpEditing] = useState(false);
const [reportView, setReportView] =
useState<BrandFilingReportView>("unreported");
const [selectedRowIds, setSelectedRowIds] = useState<string[]>([]);
const [activePlanningProductId, setActivePlanningProductId] = useState<
string | null
@ -240,6 +254,9 @@ export function BrandFilingProductTable({
isDownloadingGujiaTemplatePackage,
setIsDownloadingGujiaTemplatePackage,
] = useState(false);
const [isConfirmingReport, setIsConfirmingReport] = useState(false);
const [pendingReportConfirmation, setPendingReportConfirmation] =
useState<PendingReportConfirmation | null>(null);
const [downloadProgress, setDownloadProgress] =
useState<ActiveDownloadProgress | null>(null);
const selectAllRef = useRef<HTMLInputElement | null>(null);
@ -301,12 +318,23 @@ export function BrandFilingProductTable({
.map(({ sourceIndex: _sourceIndex, ...row }) => row),
[rows],
);
const unreportedRows = useMemo(
() => orderedRows.filter((row) => !row.reported),
[orderedRows],
);
const reportedRows = useMemo(
() => orderedRows.filter((row) => row.reported),
[orderedRows],
);
const visibleRows = reportView === "reported" ? reportedRows : unreportedRows;
useEffect(() => {
const rowsNeedingSequencePersistence = orderedRows.filter(
(row) =>
row.basicInfoConfirmed &&
row.sequenceNumber &&
!row.product.brandFilingData?.[brand]?.sequenceNumber,
(!row.product.brandFilingData?.[brand]?.sequenceNumber ||
!row.product.brandFilingData?.[brand]?.enteredAt),
);
if (rowsNeedingSequencePersistence.length === 0) return;
@ -347,21 +375,18 @@ export function BrandFilingProductTable({
};
}, [applySavedProduct, brand, orderedRows]);
const validRowIdSet = useMemo(
() => new Set(orderedRows.map((row) => row.id)),
[orderedRows],
() => new Set(visibleRows.map((row) => row.id)),
[visibleRows],
);
const rowMap = useMemo(
() => new Map(orderedRows.map((row) => [row.id, row])),
[orderedRows],
);
const totalRows = orderedRows.length;
const completedRows = orderedRows.filter(
(row) => row.brandFilingCompleted,
).length;
const totalRows = visibleRows.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 pagedRows = visibleRows.slice(pageStartIndex, pageStartIndex + PAGE_SIZE);
const pageRowIdSet = useMemo(
() => new Set(pagedRows.map((row) => row.id)),
[pagedRows],
@ -379,13 +404,17 @@ export function BrandFilingProductTable({
[normalizedSelectedRowIds, pageRowIdSet],
);
const selectedRows = useMemo(
() => orderedRows.filter((row) => selectedRowSet.has(row.id)),
[orderedRows, selectedRowSet],
() => visibleRows.filter((row) => selectedRowSet.has(row.id)),
[visibleRows, selectedRowSet],
);
const selectedRowsNeedingBasicInfoConfirmation = useMemo(
() => selectedRows.filter((row) => !row.basicInfoConfirmed),
[selectedRows],
);
const selectedRowsNeedingTemplateCompletion = useMemo(
() => selectedRows.filter((row) => !row.brandFilingCompleted),
[selectedRows],
);
const hasSelectedRowsNeedingBasicInfoConfirmation =
selectedRowsNeedingBasicInfoConfirmation.length > 0;
const brandLabel = getBrandFilingBrandLabel(brand);
@ -406,7 +435,8 @@ export function BrandFilingProductTable({
isSavingGujiaQuotationTable ||
isSavingGujiaProductInfoConfirmation ||
isDownloadingPlanningTable ||
isDownloadingGujiaTemplatePackage;
isDownloadingGujiaTemplatePackage ||
isConfirmingReport;
useEffect(() => {
if (selectAllRef.current) {
@ -435,6 +465,13 @@ export function BrandFilingProductTable({
);
};
const handleReportViewChange = (view: BrandFilingReportView) => {
setReportView(view);
setCurrentPage(1);
setPageJumpInput("1");
setSelectedRowIds([]);
};
const createDownloadProgressHandler = (title: string) => {
return (progress: BrandFilingDownloadProgress) => {
setDownloadProgress({
@ -661,6 +698,10 @@ export function BrandFilingProductTable({
warnBasicInfoConfirmationRequired();
return;
}
if (selectedRowsNeedingTemplateCompletion.length > 0) {
toast.warning("请先完成并确认企划表、信息确认函和产品报价表后再下载全套表");
return;
}
setIsDownloadingGujiaTemplatePackage(true);
try {
@ -673,6 +714,10 @@ export function BrandFilingProductTable({
onProgress: createDownloadProgressHandler("顾家全套报备表"),
});
toast.success("顾家全套报备表压缩包已开始下载");
setPendingReportConfirmation({
productIds: latestSelectedProducts.map((product) => product.id),
count: latestSelectedProducts.length,
});
} catch (error) {
toast.error(
error instanceof Error ? error.message : "顾家全套报备表下载失败,请稍后重试",
@ -683,6 +728,48 @@ export function BrandFilingProductTable({
}
};
const handleConfirmGujiaTemplatePackageReported = async () => {
if (!pendingReportConfirmation) return;
const productsToReport = getBrandFilingProductsByIds(
tableProductsRef.current,
pendingReportConfirmation.productIds,
);
if (productsToReport.length === 0) {
setPendingReportConfirmation(null);
toast.warning("没有找到需要转入已报备的明细");
return;
}
setIsConfirmingReport(true);
try {
const reportedAt = new Date().toISOString();
for (const product of productsToReport) {
const nextProduct = markBrandFilingReported({
product,
brand,
reportedAt,
});
const savedProduct = await saveSharedNonIntegratedProduct(nextProduct, {
workspaceCategory: NON_INTEGRATED_PLANNING_LAYOUT_WORKSPACE_CATEGORY,
});
applySavedProduct(savedProduct);
}
setPendingReportConfirmation(null);
setSelectedRowIds([]);
setReportView("reported");
toast.success("已转入已报备");
} catch (error) {
toast.error(
error instanceof Error ? error.message : "转入已报备失败,请稍后重试",
);
} finally {
setIsConfirmingReport(false);
}
};
const handleSaveGujiaQuotationTable = async (
headerForm: GujiaQuotationHeaderForm,
lineItems: GujiaQuotationLineItem[],
@ -789,11 +876,12 @@ export function BrandFilingProductTable({
return (
<>
<BrandFilingProductTableToolbar
brandLabel={brandLabel}
totalRows={totalRows}
completedRows={completedRows}
reportView={reportView}
unreportedRows={unreportedRows.length}
reportedRows={reportedRows.length}
selectedCount={selectedRows.length}
busy={busy}
onReportViewChange={handleReportViewChange}
onOpenPlanningTable={handleOpenPlanningTable}
onOpenGujiaQuotationTable={handleOpenGujiaQuotationTable}
onOpenGujiaProductInfoConfirmation={
@ -814,7 +902,7 @@ export function BrandFilingProductTable({
<div className="min-h-0 flex-1 overflow-x-auto overflow-y-auto pb-16">
<table
className={`w-full border-collapse ${
showGujiaTemplateStatusColumns ? "min-w-[1460px]" : "min-w-[1220px]"
showGujiaTemplateStatusColumns ? "min-w-[1570px]" : "min-w-[1330px]"
}`}
>
<thead>
@ -830,19 +918,22 @@ export function BrandFilingProductTable({
/>
</th>
<th className={`${HEADER_CELL_CLASS} w-[80px]`}>{TEXT.index}</th>
{showGujiaTemplateStatusColumns ? (
<>
<th className={`${HEADER_CELL_CLASS} w-[120px]`}>
{TEXT.quotationStatus}
</th>
<th className={`${HEADER_CELL_CLASS} w-[136px]`}>
{TEXT.infoConfirmationStatus}
</th>
</>
) : null}
<th className={`${HEADER_CELL_CLASS} w-[112px]`}>
{TEXT.entryDate}
</th>
<th className={`${HEADER_CELL_CLASS} w-[120px]`}>
{TEXT.planningStatus}
</th>
{showGujiaTemplateStatusColumns ? (
<>
<th className={`${HEADER_CELL_CLASS} w-[136px]`}>
{TEXT.infoConfirmationStatus}
</th>
<th className={`${HEADER_CELL_CLASS} w-[120px]`}>
{TEXT.quotationStatus}
</th>
</>
) : null}
<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>
@ -864,7 +955,7 @@ export function BrandFilingProductTable({
{pagedRows.length === 0 ? (
<tr>
<td
colSpan={showGujiaTemplateStatusColumns ? 12 : 10}
colSpan={showGujiaTemplateStatusColumns ? 13 : 11}
className="px-4 py-12 text-center text-sm text-[var(--obs-shell-muted)]"
>
{TEXT.empty}
@ -888,25 +979,28 @@ export function BrandFilingProductTable({
<td className="px-4 py-3 text-sm font-medium text-[var(--obs-shell-title)]">
{formatBrandFilingSequenceNumber(row.sequenceNumber)}
</td>
{showGujiaTemplateStatusColumns ? (
<>
<td className="px-4 py-3">
<BrandFilingStatusBadge
completed={row.quotationCompleted}
/>
</td>
<td className="px-4 py-3">
<BrandFilingStatusBadge
completed={row.infoConfirmationCompleted}
/>
</td>
</>
) : null}
<td className="px-4 py-3 text-sm tabular-nums text-[var(--obs-shell-title)]">
{formatBrandFilingEntryDate(row.enteredAt)}
</td>
<td className="px-4 py-3">
<BrandFilingStatusBadge
completed={row.planningCompleted}
/>
</td>
{showGujiaTemplateStatusColumns ? (
<>
<td className="px-4 py-3">
<BrandFilingStatusBadge
completed={row.infoConfirmationCompleted}
/>
</td>
<td className="px-4 py-3">
<BrandFilingStatusBadge
completed={row.quotationCompleted}
/>
</td>
</>
) : null}
<td className="px-4 py-3">
{row.previewImage ? (
<button
@ -1062,6 +1156,14 @@ export function BrandFilingProductTable({
/>
) : null}
<BrandFilingReportConfirmationDialog
count={pendingReportConfirmation?.count ?? 0}
isOpen={Boolean(pendingReportConfirmation)}
saving={isConfirmingReport}
onCancel={() => setPendingReportConfirmation(null)}
onConfirm={handleConfirmGujiaTemplatePackageReported}
/>
<ToastContainer
toasts={toast.toasts}
onClose={toast.removeToast}

View File

@ -3,6 +3,41 @@
import type { ReactNode } from "react";
import { Download, SquarePen } from "lucide-react";
export type BrandFilingReportView = "unreported" | "reported";
function ReportViewButton({
active,
count,
label,
onClick,
}: {
active: boolean;
count: number;
label: string;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={`inline-flex h-8 items-center gap-1.5 rounded-[6px] border px-3 text-[12px] font-apple-semibold transition ${
active
? "border-[var(--obs-shell-action-border)] bg-[var(--obs-shell-action-bg)] text-[var(--obs-shell-action-text)]"
: "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)]"
}`}
>
<span>{label}</span>
<span
className={`rounded-full px-1.5 py-0.5 text-[10px] ${
active ? "bg-white/20" : "bg-[var(--obs-shell-soft-bg)]"
}`}
>
{count}
</span>
</button>
);
}
function ToolbarButton({
disabled,
icon,
@ -34,11 +69,12 @@ function ToolbarButton({
}
export function BrandFilingProductTableToolbar({
brandLabel,
totalRows,
completedRows,
reportView,
unreportedRows,
reportedRows,
selectedCount,
busy,
onReportViewChange,
onOpenPlanningTable,
onOpenGujiaQuotationTable,
onOpenGujiaProductInfoConfirmation,
@ -47,11 +83,12 @@ export function BrandFilingProductTableToolbar({
showGujiaQuotationTable = false,
showGujiaProductInfoConfirmation = false,
}: {
brandLabel: string;
totalRows: number;
completedRows: number;
reportView: BrandFilingReportView;
unreportedRows: number;
reportedRows: number;
selectedCount: number;
busy: boolean;
onReportViewChange: (view: BrandFilingReportView) => void;
onOpenPlanningTable: () => void;
onOpenGujiaQuotationTable?: () => void;
onOpenGujiaProductInfoConfirmation?: () => void;
@ -64,24 +101,31 @@ export function BrandFilingProductTableToolbar({
<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 className="flex flex-wrap items-center gap-2">
<ReportViewButton
active={reportView === "unreported"}
count={unreportedRows}
label="未报备"
onClick={() => onReportViewChange("unreported")}
/>
<ReportViewButton
active={reportView === "reported"}
count={reportedRows}
label="已报备"
onClick={() => onReportViewChange("reported")}
/>
</div>
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
{showGujiaQuotationTable && onOpenGujiaQuotationTable ? (
<ToolbarButton
disabled={busy || selectedCount === 0}
icon={<SquarePen className="h-3.5 w-3.5" aria-hidden="true" />}
label="填写产品报价表"
onClick={onOpenGujiaQuotationTable}
/>
) : null}
<span className="mr-1 text-xs text-[var(--obs-shell-muted)]">
已选中 {selectedCount}
</span>
<ToolbarButton
disabled={busy || selectedCount !== 1}
icon={<SquarePen className="h-3.5 w-3.5" aria-hidden="true" />}
label="填写企划表"
onClick={onOpenPlanningTable}
/>
{showGujiaProductInfoConfirmation &&
onOpenGujiaProductInfoConfirmation ? (
<ToolbarButton
@ -91,12 +135,14 @@ export function BrandFilingProductTableToolbar({
onClick={onOpenGujiaProductInfoConfirmation}
/>
) : null}
<ToolbarButton
disabled={busy || selectedCount !== 1}
icon={<SquarePen className="h-3.5 w-3.5" aria-hidden="true" />}
label="填写企划表"
onClick={onOpenPlanningTable}
/>
{showGujiaQuotationTable && onOpenGujiaQuotationTable ? (
<ToolbarButton
disabled={busy || selectedCount === 0}
icon={<SquarePen className="h-3.5 w-3.5" aria-hidden="true" />}
label="填写产品报价表"
onClick={onOpenGujiaQuotationTable}
/>
) : null}
{showGujiaQuotationTable && onDownloadGujiaTemplatePackage ? (
<ToolbarButton
disabled={busy || selectedCount === 0}

View File

@ -0,0 +1,51 @@
"use client";
import Button from "@/components/ui/Button";
export function BrandFilingReportConfirmationDialog({
count,
isOpen,
saving,
onCancel,
onConfirm,
}: {
count: number;
isOpen: boolean;
saving: boolean;
onCancel: () => void;
onConfirm: () => void;
}) {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/24 px-4">
<div className="w-full max-w-[420px] rounded-[10px] border border-[var(--obs-shell-border)] bg-white p-5 shadow-[0_20px_60px_rgba(15,23,42,0.18)]">
<div className="text-sm font-medium text-[var(--obs-shell-title)]">
确认进入已报备
</div>
<div className="mt-2 text-sm leading-6 text-[var(--obs-shell-muted)]">
全套表已经开始下载。确认后,当前选中的 {count} 条明细会移动到“已报备”。
</div>
<div className="mt-5 flex justify-end gap-2">
<Button
type="button"
variant="plain"
size="small"
disabled={saving}
onClick={onCancel}
>
暂不确认
</Button>
<Button
type="button"
size="small"
loading={saving}
onClick={onConfirm}
>
确认进入已报备
</Button>
</div>
</div>
</div>
);
}

View File

@ -9,6 +9,10 @@ import {
type GujiaProductInfoConfirmationEditorState,
} from "@/app/intake/brand-filing/gujiaProductInfoConfirmationData";
import type { GujiaProductInfoConfirmationLineItem } from "@/app/intake/brand-filing/brandFilingTypes";
import {
buildRequiredFieldsMessage,
validateGujiaProductInfoConfirmationRequiredFields,
} from "@/app/intake/brand-filing/brandFilingRequiredValidation";
import type { NonIntegratedProductRecord } from "@/app/intake/non-integrated/nonIntegratedStorage";
export function GujiaProductInfoConfirmationDialog({
@ -132,6 +136,23 @@ function GujiaProductInfoConfirmationDialogContent({
);
};
const getMissingRequiredFields = () =>
validateGujiaProductInfoConfirmationRequiredFields(lineItems);
const handleSaveDraft = () => {
onSave(lineItems, completed && getMissingRequiredFields().length === 0);
};
const handleSaveAndConfirm = () => {
const missingFields = getMissingRequiredFields();
if (missingFields.length > 0) {
window.alert(buildRequiredFieldsMessage("产品信息确认函", missingFields));
return;
}
onSave(lineItems, true);
};
return (
<div
role="dialog"
@ -189,7 +210,7 @@ function GujiaProductInfoConfirmationDialogContent({
<button
type="button"
disabled={saving}
onClick={() => onSave(lineItems, completed)}
onClick={handleSaveDraft}
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"
>
保存
@ -197,7 +218,7 @@ function GujiaProductInfoConfirmationDialogContent({
<button
type="button"
disabled={saving}
onClick={() => onSave(lineItems, true)}
onClick={handleSaveAndConfirm}
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"
>
保存并确认

View File

@ -1,6 +1,6 @@
"use client";
import type { ChangeEvent } from "react";
import type { ChangeEvent, ReactNode } from "react";
import { buildGujiaProductInfoConfirmationDisplayRows } from "@/app/intake/brand-filing/gujiaProductInfoConfirmationDisplayRows";
import {
calculateNetWeightKg,
@ -35,6 +35,12 @@ const TABLE_COLUMNS = [
{ key: "colorBoardImageUrl", label: "色板图片", width: 132 },
] as const;
const OPTIONAL_COLUMN_KEYS = new Set<keyof GujiaProductInfoConfirmationLineItem>([
"materialCode",
"brandItemCode",
"colorBoardImageUrl",
]);
const SHEET_WIDTH = TABLE_COLUMNS.reduce(
(total, column) => total + column.width,
0,
@ -66,6 +72,15 @@ function TextInput({
);
}
function RequiredFieldLabel({ children }: { children: ReactNode }) {
return (
<span className="inline-flex items-center justify-center gap-0.5">
<span>{children}</span>
<span className="text-red-600">*</span>
</span>
);
}
function ImageInputCell({
alt,
value,
@ -179,7 +194,11 @@ export function GujiaProductInfoConfirmationTemplateSheet({
key={column.key}
className={`${BORDER_CLASS} h-[62px] whitespace-pre-line px-2 text-center align-middle`}
>
{column.label}
{OPTIONAL_COLUMN_KEYS.has(column.key) ? (
column.label
) : (
<RequiredFieldLabel>{column.label}</RequiredFieldLabel>
)}
</td>
))}
</tr>

View File

@ -12,6 +12,10 @@ import type {
GujiaQuotationHeaderForm,
GujiaQuotationLineItem,
} from "@/app/intake/brand-filing/brandFilingTypes";
import {
buildRequiredFieldsMessage,
validateGujiaQuotationRequiredFields,
} from "@/app/intake/brand-filing/brandFilingRequiredValidation";
import type { NonIntegratedProductRecord } from "@/app/intake/non-integrated/nonIntegratedStorage";
export function GujiaQuotationTableDialog({
@ -150,6 +154,27 @@ function GujiaQuotationTableDialogContent({
);
};
const getMissingRequiredFields = () =>
validateGujiaQuotationRequiredFields(headerForm, lineItems);
const handleSaveDraft = () => {
onSave(
headerForm,
lineItems,
completed && getMissingRequiredFields().length === 0,
);
};
const handleSaveAndConfirm = () => {
const missingFields = getMissingRequiredFields();
if (missingFields.length > 0) {
window.alert(buildRequiredFieldsMessage("产品报价表", missingFields));
return;
}
onSave(headerForm, lineItems, true);
};
return (
<div
role="dialog"
@ -209,7 +234,7 @@ function GujiaQuotationTableDialogContent({
<button
type="button"
disabled={saving}
onClick={() => onSave(headerForm, lineItems, completed)}
onClick={handleSaveDraft}
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"
>
保存
@ -217,7 +242,7 @@ function GujiaQuotationTableDialogContent({
<button
type="button"
disabled={saving}
onClick={() => onSave(headerForm, lineItems, true)}
onClick={handleSaveAndConfirm}
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"
>
保存并确认

View File

@ -1,6 +1,6 @@
"use client";
import type { ChangeEvent } from "react";
import type { ChangeEvent, ReactNode } from "react";
import { buildGujiaQuotationDisplayRows } from "@/app/intake/brand-filing/gujiaQuotationDisplayRows";
import type {
GujiaQuotationHeaderForm,
@ -11,6 +11,7 @@ const GUJIA_KUKA_LOGO_SRC = "/brand-filing/gujia-kuka-logo.png";
const TABLE_COLUMNS = [
{ key: "sequence", width: 95 },
{ key: "brandItemCode", width: 121 },
{ key: "itemCode", width: 121 },
{ key: "specification", width: 160 },
{ key: "image", width: 164 },
@ -56,6 +57,15 @@ function QuotationInput({
return <input value={value} onChange={onChange} className={inputClassName} />;
}
function RequiredFieldLabel({ children }: { children: ReactNode }) {
return (
<span className="inline-flex items-center justify-center gap-0.5">
<span>{children}</span>
<span className="text-red-600">*</span>
</span>
);
}
export function GujiaQuotationTemplateSheet({
headerForm,
lineItems,
@ -93,7 +103,7 @@ export function GujiaQuotationTemplateSheet({
<tbody>
<tr>
<td
colSpan={13}
colSpan={14}
className={`${BORDER_CLASS} h-[58px] px-4 text-center text-[28px] font-semibold`}
>
<div className="relative flex min-h-[58px] items-center justify-center px-[180px]">
@ -109,11 +119,13 @@ export function GujiaQuotationTemplateSheet({
</tr>
<tr>
<td
colSpan={13}
colSpan={14}
className={`${BORDER_CLASS} h-[32px] px-3 text-left align-middle`}
>
<div className="flex items-center gap-2">
<span className="shrink-0 text-[15px]">编号:</span>
<span className="shrink-0 text-[15px]">
<RequiredFieldLabel>编号:</RequiredFieldLabel>
</span>
<input
value={headerForm.contactName}
onChange={(event) =>
@ -126,11 +138,13 @@ export function GujiaQuotationTemplateSheet({
</tr>
<tr>
<td
colSpan={6}
colSpan={7}
className={`${BORDER_CLASS} h-[32px] px-3 text-left align-middle`}
>
<div className="flex items-center gap-2">
<span className="shrink-0 text-[15px]">TO(需求方):</span>
<span className="shrink-0 text-[15px]">
<RequiredFieldLabel>TO(需求方):</RequiredFieldLabel>
</span>
<input
value={headerForm.demandCompany}
onChange={(event) =>
@ -143,7 +157,7 @@ export function GujiaQuotationTemplateSheet({
<td
className={`${BORDER_CLASS} px-3 text-left align-middle text-[15px]`}
>
FROM(供应方):
<RequiredFieldLabel>FROM(供应方):</RequiredFieldLabel>
</td>
<td
colSpan={6}
@ -160,11 +174,13 @@ export function GujiaQuotationTemplateSheet({
</tr>
<tr>
<td
colSpan={6}
colSpan={7}
className={`${BORDER_CLASS} h-[32px] px-3 text-left align-middle`}
>
<div className="flex items-center gap-2">
<span className="shrink-0 text-[15px]">MOB(手机):</span>
<span className="shrink-0 text-[15px]">
<RequiredFieldLabel>MOB(手机):</RequiredFieldLabel>
</span>
<input
value={headerForm.demandMobile}
onChange={(event) =>
@ -177,7 +193,7 @@ export function GujiaQuotationTemplateSheet({
<td
className={`${BORDER_CLASS} px-3 text-left align-middle text-[15px]`}
>
MOB(手机):
<RequiredFieldLabel>MOB(手机):</RequiredFieldLabel>
</td>
<td
colSpan={6}
@ -194,42 +210,47 @@ export function GujiaQuotationTemplateSheet({
</tr>
<tr className="text-[15px] font-medium leading-tight">
<td className={`${BORDER_CLASS} h-[62px] px-2 text-center align-middle`}>
序号
<RequiredFieldLabel>序号</RequiredFieldLabel>
</td>
<td className={`${BORDER_CLASS} px-2 text-center align-middle`}>
产品货号
品牌货号
</td>
<td className={`${BORDER_CLASS} px-2 text-center align-middle`}>
规格
<RequiredFieldLabel>产品货号</RequiredFieldLabel>
</td>
<td className={`${BORDER_CLASS} px-2 text-center align-middle`}>
图片
<RequiredFieldLabel>规格</RequiredFieldLabel>
</td>
<td className={`${BORDER_CLASS} px-2 text-center align-middle`}>
采购价(含税)
<RequiredFieldLabel>图片</RequiredFieldLabel>
</td>
<td className={`${BORDER_CLASS} px-2 text-center align-middle`}>
售价
<RequiredFieldLabel>采购价(含税)</RequiredFieldLabel>
</td>
<td className={`${BORDER_CLASS} px-2 text-center align-middle`}>
材质
<RequiredFieldLabel>售价</RequiredFieldLabel>
</td>
<td className={`${BORDER_CLASS} px-2 text-center align-middle`}>
规格(CM)
<RequiredFieldLabel>材质</RequiredFieldLabel>
</td>
<td className={`${BORDER_CLASS} px-2 text-center align-middle`}>
起订量
<RequiredFieldLabel>规格(CM)</RequiredFieldLabel>
</td>
<td className={`${BORDER_CLASS} px-2 text-center align-middle`}>
<RequiredFieldLabel>起订量</RequiredFieldLabel>
</td>
<td
className={`${BORDER_CLASS} px-2 text-center align-middle whitespace-pre-line`}
>
采购周期{"\n"}(天)
<RequiredFieldLabel>
采购周期{"\n"}(天)
</RequiredFieldLabel>
</td>
<td className={`${BORDER_CLASS} px-2 text-center align-middle`}>
包装件数
<RequiredFieldLabel>包装件数</RequiredFieldLabel>
</td>
<td className={`${BORDER_CLASS} px-2 text-center align-middle`}>
包装方式
<RequiredFieldLabel>包装方式</RequiredFieldLabel>
</td>
<td className={`${BORDER_CLASS} px-2 text-center align-middle`}>
备注
@ -245,6 +266,18 @@ export function GujiaQuotationTemplateSheet({
{row.sequenceNumber}
</td>
) : null}
<td className={`${BORDER_CLASS} h-[58px] px-2 align-middle text-[16px]`}>
<QuotationInput
value={row.brandItemCode}
onChange={(event) =>
onLineItemChange(
row.lineId,
"brandItemCode",
event.target.value,
)
}
/>
</td>
<td className={`${BORDER_CLASS} h-[58px] px-2 align-middle text-[16px]`}>
<QuotationInput
value={row.itemCode}
@ -373,7 +406,7 @@ export function GujiaQuotationTemplateSheet({
))}
<tr>
<td
colSpan={13}
colSpan={14}
className={`${BORDER_CLASS} px-4 pt-5 text-left align-top text-[14px] leading-8 text-[#ff0000] whitespace-pre-line`}
style={{ height: "168px" }}
>
@ -381,14 +414,14 @@ export function GujiaQuotationTemplateSheet({
</td>
</tr>
<tr>
<td colSpan={13} className={`${BORDER_CLASS} h-[28px] bg-white`} />
<td colSpan={14} className={`${BORDER_CLASS} h-[28px] bg-white`} />
</tr>
<tr className="text-[15px]">
<td className={`${BORDER_CLASS} h-[56px] px-2 text-center align-middle`}>
基地名称
</td>
<td
colSpan={12}
colSpan={13}
className={`${BORDER_CLASS} px-2 text-center align-middle`}
>
仓库详细地址
@ -399,7 +432,7 @@ export function GujiaQuotationTemplateSheet({
电商办公仓
</td>
<td
colSpan={12}
colSpan={13}
className={`${BORDER_CLASS} px-3 text-center align-middle`}
>
湖州市安吉县阳光二区北庄路曼龙工业链园区3号厂房3楼

View File

@ -94,6 +94,7 @@ export function buildBrandFilingPlanningLineItems(params: {
return normalizeBrandFilingPlanningLineItem({
...createEmptyBrandFilingPlanningLineItem(),
brandItemCode: pickNonEmptyText(matchedItem?.brandItemCode),
itemCode: pickNonEmptyText(matchedItem?.itemCode, row.itemCode),
sizeRequirement: pickNonEmptyText(
matchedItem?.sizeRequirement,
@ -158,6 +159,7 @@ export function buildBrandFilingPlanningDisplayRows(params: {
const normalizedItem = normalizeBrandFilingPlanningLineItem({
...createEmptyBrandFilingPlanningLineItem(),
...matchedItem,
brandItemCode: pickNonEmptyText(matchedItem?.brandItemCode),
itemCode: pickNonEmptyText(matchedItem?.itemCode, row.itemCode),
sizeRequirement: pickNonEmptyText(
matchedItem?.sizeRequirement,

View File

@ -125,11 +125,11 @@ export const BRAND_FILING_PLANNING_ROW_LAYOUT = {
plan: {
dataStartRow: 11,
mergedRanges: [
["B", "C"],
["D", "E"],
["F", "G"],
["H", "I"],
["J", "L"],
["C", "D"],
["E", "F"],
["G", "H"],
["I", "J"],
["K", "L"],
] as const,
},
promotion: {
@ -138,7 +138,6 @@ export const BRAND_FILING_PLANNING_ROW_LAYOUT = {
headerBottomRow: 14,
dataStartRow: 15,
mergedRanges: [
["I", "J"],
["K", "L"],
] as const,
},

View File

@ -20,7 +20,10 @@ import {
getBrandFilingMatchedSkuItemsByItemCode,
resolveBrandFilingPlanningItemCode,
} from "@/app/intake/brand-filing/brandFilingPlanningSkuRows";
import { resolveBrandFilingSequenceNumber } from "@/app/intake/brand-filing/brandFilingSequence";
import {
getBrandFilingBasicInfoApprovedAt,
resolveBrandFilingSequenceNumber,
} from "@/app/intake/brand-filing/brandFilingSequence";
import type { NonIntegratedProductRecord } from "@/app/intake/non-integrated/nonIntegratedStorage";
import { getNonIntegratedListPeople } from "@/app/intake/product-development/nonIntegratedProductListLogic";
@ -271,17 +274,25 @@ export function upsertBrandFilingSequenceNumber(params: {
if (!normalizedSequenceNumber) {
return product;
}
if (product.brandFilingData?.[brand]?.sequenceNumber) {
const currentBrandData = product.brandFilingData?.[brand];
if (currentBrandData?.sequenceNumber && currentBrandData.enteredAt) {
return product;
}
const enteredAt =
currentBrandData?.enteredAt ||
getBrandFilingBasicInfoApprovedAt(product) ||
new Date().toISOString();
return {
...product,
brandFilingData: {
...(product.brandFilingData ?? {}),
[brand]: {
...(product.brandFilingData?.[brand] ?? {}),
sequenceNumber: normalizedSequenceNumber,
...(currentBrandData ?? {}),
sequenceNumber:
currentBrandData?.sequenceNumber ?? normalizedSequenceNumber,
enteredAt,
},
},
};

View File

@ -0,0 +1,21 @@
import type { BrandFilingBrand } from "@/app/intake/brand-filing/brandFilingConfig";
import type { NonIntegratedProductRecord } from "@/app/intake/non-integrated/nonIntegratedStorage";
export function markBrandFilingReported(params: {
product: NonIntegratedProductRecord;
brand: BrandFilingBrand;
reportedAt?: string;
}) {
const reportedAt = params.reportedAt ?? new Date().toISOString();
return {
...params.product,
brandFilingData: {
...(params.product.brandFilingData ?? {}),
[params.brand]: {
...(params.product.brandFilingData?.[params.brand] ?? {}),
reportedAt,
},
},
} satisfies NonIntegratedProductRecord;
}

View File

@ -0,0 +1,179 @@
import type {
BrandFilingPlanningFieldKey,
BrandFilingPlanningForm,
BrandFilingPlanningLineItem,
BrandFilingPlanningLineItemFieldKey,
GujiaProductInfoConfirmationLineItem,
GujiaQuotationHeaderFieldKey,
GujiaQuotationHeaderForm,
GujiaQuotationLineItem,
} from "@/app/intake/brand-filing/brandFilingTypes";
function hasText(value: string | null | undefined) {
return Boolean(value?.trim());
}
function pushMissingField(
missingFields: string[],
value: string | null | undefined,
label: string,
) {
if (!hasText(value)) {
missingFields.push(label);
}
}
const PLANNING_REQUIRED_FORM_FIELDS = [
["planningProductLine", "企划产品线"],
["productLine", "产品线"],
["annualPlanStatus", "是否年度规划内"],
["developmentMode", "开发方式"],
["productSeries", "产品系列"],
["developmentCategory", "开发类别"],
["supplierName", "买采供应商名称"],
["warehousingStatus", "是否入仓"],
["launchDate", "需求上市时间"],
["activityStyleStatus", "是否活动款"],
["imageUrl", "图片"],
["productDemandAnalysis", "产品需求分析"],
["legacyIterationNote", "特殊说明(是否老款迭代)"],
["productManager", "产品经理"],
["planningOwner", "企划负责人"],
["purchaseOwner", "采购负责人"],
] as const satisfies readonly [BrandFilingPlanningFieldKey, string][];
const PLANNING_REQUIRED_LINE_FIELDS = [
["itemCode", "货号"],
["retailPriceDemand", "零售价需求"],
["purchasePriceDemand", "采购价需求"],
["sizeRequirement", "尺寸要求"],
["materialAndColor", "材质/颜色"],
["seriesName", "系列"],
["channel", "渠道"],
["month1Sales", "第1个月"],
["month2Sales", "第2个月"],
["month3Sales", "第3个月"],
["month4To6Sales", "第4-6月"],
["month7To9Sales", "第7-9月"],
["month10To12Sales", "第10-12月"],
] as const satisfies readonly [BrandFilingPlanningLineItemFieldKey, string][];
const QUOTATION_REQUIRED_HEADER_FIELDS = [
["contactName", "编号"],
["demandCompany", "TO(需求方)"],
["demandMobile", "需求方手机"],
["supplyCompany", "FROM(供应方)"],
["supplyMobile", "供应方手机"],
] as const satisfies readonly [GujiaQuotationHeaderFieldKey, string][];
const QUOTATION_REQUIRED_LINE_FIELDS = [
["sequenceNumber", "序号"],
["itemCode", "产品货号"],
["specification", "规格"],
["imageUrl", "图片"],
["purchasePrice", "采购价(含税)"],
["salesPrice", "售价"],
["material", "材质"],
["sizeCm", "规格(CM)"],
["minimumOrderQuantity", "起订量"],
["leadTimeDays", "采购周期(天)"],
["packageCount", "包装件数"],
["packageMethod", "包装方式"],
] as const satisfies readonly [keyof GujiaQuotationLineItem, string][];
const PRODUCT_INFO_REQUIRED_LINE_FIELDS = [
["sequenceNumber", "序号"],
["productImageUrl", "产品图片"],
["productLine", "产品线"],
["itemCode", "货号"],
["productName", "品名"],
["sizeCm", "规格"],
["certificateMaterial", "合格证的主材/基材"],
["accessoryMaterial", "辅材"],
["packageCount", "包装件数"],
["packageSizeCm", "外包装尺寸"],
["packageVolumeM3", "外包装体积"],
["grossWeightKg", "包装毛重"],
["netWeightKg", "产品净重"],
["assemblyMethod", "拆装方式"],
["manufacturer", "合格证上的制造商"],
["colorBoardCode", "色板编号"],
] as const satisfies readonly [
keyof GujiaProductInfoConfirmationLineItem,
string,
][];
export function validateBrandFilingPlanningRequiredFields(
form: BrandFilingPlanningForm,
lineItems: BrandFilingPlanningLineItem[],
) {
const missingFields: string[] = [];
PLANNING_REQUIRED_FORM_FIELDS.forEach(([key, label]) => {
pushMissingField(missingFields, form[key], label);
});
if (lineItems.length === 0) {
missingFields.push("企划方案明细");
}
lineItems.forEach((lineItem, index) => {
PLANNING_REQUIRED_LINE_FIELDS.forEach(([key, label]) => {
pushMissingField(missingFields, lineItem[key], `第 ${index + 1} 行 ${label}`);
});
});
return missingFields;
}
export function validateGujiaQuotationRequiredFields(
headerForm: GujiaQuotationHeaderForm,
lineItems: GujiaQuotationLineItem[],
) {
const missingFields: string[] = [];
QUOTATION_REQUIRED_HEADER_FIELDS.forEach(([key, label]) => {
pushMissingField(missingFields, headerForm[key], label);
});
if (lineItems.length === 0) {
missingFields.push("报价明细");
}
lineItems.forEach((lineItem, index) => {
QUOTATION_REQUIRED_LINE_FIELDS.forEach(([key, label]) => {
pushMissingField(missingFields, lineItem[key], `第 ${index + 1} 行 ${label}`);
});
});
return missingFields;
}
export function validateGujiaProductInfoConfirmationRequiredFields(
lineItems: GujiaProductInfoConfirmationLineItem[],
) {
const missingFields: string[] = [];
if (lineItems.length === 0) {
missingFields.push("信息确认函明细");
}
lineItems.forEach((lineItem, index) => {
PRODUCT_INFO_REQUIRED_LINE_FIELDS.forEach(([key, label]) => {
pushMissingField(missingFields, lineItem[key], `第 ${index + 1} 行 ${label}`);
});
});
return missingFields;
}
export function buildRequiredFieldsMessage(
title: string,
missingFields: string[],
) {
const previewFields = missingFields.slice(0, 8).join("、");
const suffix =
missingFields.length > 8 ? ` 等 ${missingFields.length} 个字段` : "";
return `${title}还有必填内容未填写:${previewFields}${suffix}。填完后才能确认。`;
}

View File

@ -109,6 +109,16 @@ export function getBrandFilingBasicInfoApprovedAt(
);
}
export function getBrandFilingEnteredAt(
product: NonIntegratedProductRecord,
brand: BrandFilingBrand,
) {
return (
safeTrim(product.brandFilingData?.[brand]?.enteredAt) ||
getBrandFilingBasicInfoApprovedAt(product)
);
}
function compareProductsByBrandFilingSequenceAnchor(
left: { product: NonIntegratedProductRecord; sourceIndex: number },
right: { product: NonIntegratedProductRecord; sourceIndex: number },

View File

@ -31,6 +31,7 @@ export type BrandFilingPlanningFieldKey =
| "imageUrl";
export type BrandFilingPlanningLineItemFieldKey =
| "brandItemCode"
| "itemCode"
| "retailPriceDemand"
| "purchasePriceDemand"
@ -80,6 +81,7 @@ export type GujiaQuotationLineItem = {
productId: string;
skuId: string;
sequenceNumber: string;
brandItemCode: string;
itemCode: string;
specification: string;
imageUrl: string;
@ -134,6 +136,8 @@ export type GujiaProductInfoConfirmationRecord = {
export type BrandFilingBrandData = {
sequenceNumber?: number;
enteredAt?: string;
reportedAt?: string;
planningTable?: BrandFilingPlanningRecord;
gujiaQuotationTable?: GujiaQuotationRecord;
gujiaProductInfoConfirmation?: GujiaProductInfoConfirmationRecord;
@ -178,6 +182,7 @@ export const BRAND_FILING_PLANNING_FIELD_KEYS = [
] as const satisfies readonly BrandFilingPlanningFieldKey[];
export const BRAND_FILING_PLANNING_LINE_ITEM_FIELD_KEYS = [
"brandItemCode",
"itemCode",
"retailPriceDemand",
"purchasePriceDemand",
@ -316,6 +321,7 @@ export function normalizeGujiaQuotationLineItem(
productId: "",
skuId: "",
sequenceNumber: "",
brandItemCode: "",
itemCode: "",
specification: "",
imageUrl: "",
@ -336,6 +342,7 @@ export function normalizeGujiaQuotationLineItem(
productId: readText(value.productId),
skuId: readText(value.skuId),
sequenceNumber: readText(value.sequenceNumber),
brandItemCode: readText(value.brandItemCode),
itemCode: readText(value.itemCode),
specification: readText(value.specification),
imageUrl: readText(value.imageUrl),
@ -440,6 +447,8 @@ function normalizeBrandFilingBrandData(
if (!isRecord(value)) return undefined;
const sequenceNumber = readOptionalSequenceNumber(value.sequenceNumber);
const enteredAt = readText(value.enteredAt);
const reportedAt = readText(value.reportedAt);
const planningTable = normalizeBrandFilingPlanningRecord(value.planningTable);
const gujiaQuotationTable = normalizeGujiaQuotationRecord(
value.gujiaQuotationTable,
@ -450,6 +459,8 @@ function normalizeBrandFilingBrandData(
);
if (
!sequenceNumber &&
!enteredAt &&
!reportedAt &&
!planningTable &&
!gujiaQuotationTable &&
!gujiaProductInfoConfirmation
@ -459,6 +470,8 @@ function normalizeBrandFilingBrandData(
return {
...(sequenceNumber ? { sequenceNumber } : {}),
...(enteredAt ? { enteredAt } : {}),
...(reportedAt ? { reportedAt } : {}),
...(planningTable ? { planningTable } : {}),
...(gujiaQuotationTable ? { gujiaQuotationTable } : {}),
...(gujiaProductInfoConfirmation ? { gujiaProductInfoConfirmation } : {}),

View File

@ -292,6 +292,7 @@ function buildDefaultLineItem(params: {
sequenceNumber && sequenceNumber > 0
? String(sequenceNumber)
: String(productIndex + 1),
brandItemCode: "",
itemCode,
specification: buildSpecification(product, measurementRow, skuItem),
imageUrl: previewImage,

View File

@ -16,8 +16,8 @@ const SHEET_NAME = "报价单指导模板";
const DATA_START_ROW = 6;
const DATA_END_ROW = 279;
const FOOTER_START_ROW = 280;
const DATA_COLUMN_COUNT = 13;
const IMAGE_COLUMN = 4;
const DATA_COLUMN_COUNT = 14;
const IMAGE_COLUMN = 5;
const FOOTER_NOTE_ROW_COUNT = 5;
const TEMPLATE_NOTE_TEXT = [
"1、上述报价含增值税13%;",
@ -160,6 +160,105 @@ function clearRowValues(
}
}
function cloneCellStylePart<T>(value: T): T {
if (value == null) {
return value;
}
return JSON.parse(JSON.stringify(value)) as T;
}
function copyCellStyle(sourceCell: ExcelJS.Cell, targetCell: ExcelJS.Cell) {
targetCell.numFmt = sourceCell.numFmt;
targetCell.font = cloneCellStylePart(sourceCell.font);
targetCell.alignment = cloneCellStylePart(sourceCell.alignment);
targetCell.fill = cloneCellStylePart(sourceCell.fill);
targetCell.border = cloneCellStylePart(sourceCell.border);
targetCell.protection = cloneCellStylePart(sourceCell.protection);
}
function unmergeIfExists(worksheet: ExcelJS.Worksheet, ref: string) {
try {
worksheet.unMergeCells(ref);
} catch {
// Some refs may already be unmerged after workbook normalization.
}
}
function mergeCellsWithStyle(worksheet: ExcelJS.Worksheet, ref: string) {
const sourceCell = worksheet.getCell(ref.split(":")[0]!);
const styleSnapshot = {
numFmt: sourceCell.numFmt,
font: cloneCellStylePart(sourceCell.font),
alignment: cloneCellStylePart(sourceCell.alignment),
fill: cloneCellStylePart(sourceCell.fill),
border: cloneCellStylePart(sourceCell.border),
protection: cloneCellStylePart(sourceCell.protection),
};
worksheet.mergeCells(ref);
const mergedCell = worksheet.getCell(ref.split(":")[0]!);
mergedCell.numFmt = styleSnapshot.numFmt;
mergedCell.font = cloneCellStylePart(styleSnapshot.font);
mergedCell.alignment = cloneCellStylePart(styleSnapshot.alignment);
mergedCell.fill = cloneCellStylePart(styleSnapshot.fill);
mergedCell.border = cloneCellStylePart(styleSnapshot.border);
mergedCell.protection = cloneCellStylePart(styleSnapshot.protection);
}
function prepareQuotationTemplateColumns(worksheet: ExcelJS.Worksheet) {
worksheet.getColumn(14).width = worksheet.getColumn(13).width ?? 9.65;
for (let rowNumber = 1; rowNumber <= FOOTER_START_ROW + 10; rowNumber += 1) {
copyCellStyle(
worksheet.getRow(rowNumber).getCell(13),
worksheet.getRow(rowNumber).getCell(14),
);
}
[
"A1:M1",
"A2:M2",
"A3:F3",
"H3:M3",
"A4:F4",
"H4:M4",
].forEach((ref) => unmergeIfExists(worksheet, ref));
mergeCellsWithStyle(worksheet, "A1:N1");
mergeCellsWithStyle(worksheet, "A2:N2");
mergeCellsWithStyle(worksheet, "A3:G3");
mergeCellsWithStyle(worksheet, "I3:N3");
mergeCellsWithStyle(worksheet, "A4:G4");
mergeCellsWithStyle(worksheet, "I4:N4");
const headerLabels = [
"序号",
"品牌货号",
"产品货号",
"规格",
"图片",
"采购价(含税)",
"售价",
"材质",
"规格(CM)",
"起订量",
"采购周期\n(天)",
"包装件数",
"包装方式",
"备注",
];
headerLabels.forEach((label, index) => {
const cell = worksheet.getRow(DATA_START_ROW - 1).getCell(index + 1);
cell.value = label;
cell.alignment = {
...cell.alignment,
horizontal: "center",
vertical: "middle",
wrapText: true,
};
});
}
function restoreFooterLayout(worksheet: ExcelJS.Worksheet, rowCount: number) {
const noteStartRow = DATA_START_ROW + rowCount;
const noteEndRow = noteStartRow + FOOTER_NOTE_ROW_COUNT - 1;
@ -173,7 +272,7 @@ function restoreFooterLayout(worksheet: ExcelJS.Worksheet, rowCount: number) {
clearRowValues(worksheet, rowNumber);
}
worksheet.mergeCells(`A${noteStartRow}:M${noteEndRow}`);
worksheet.mergeCells(`A${noteStartRow}:N${noteEndRow}`);
const noteCell = worksheet.getCell(`A${noteStartRow}`);
noteCell.value = TEMPLATE_NOTE_TEXT;
noteCell.font = {
@ -187,9 +286,9 @@ function restoreFooterLayout(worksheet: ExcelJS.Worksheet, rowCount: number) {
wrapText: true,
};
worksheet.mergeCells(`A${spacerRow}:M${spacerRow}`);
worksheet.mergeCells(`B${warehouseHeaderRow}:M${warehouseHeaderRow}`);
worksheet.mergeCells(`B${warehouseAddressRow}:M${warehouseAddressRow}`);
worksheet.mergeCells(`A${spacerRow}:N${spacerRow}`);
worksheet.mergeCells(`B${warehouseHeaderRow}:N${warehouseHeaderRow}`);
worksheet.mergeCells(`B${warehouseAddressRow}:N${warehouseAddressRow}`);
worksheet.getCell(`A${warehouseHeaderRow}`).value = "基地名称";
worksheet.getCell(`B${warehouseHeaderRow}`).value = "仓库详细地址";
@ -329,16 +428,17 @@ async function buildWorkbookBytes(products: NonIntegratedProductRecord[]) {
throw new AppError(ErrorCode.SYSTEM_INTERNAL, "顾家产品报价表模板读取失败");
}
prepareQuotationTemplateColumns(worksheet);
prepareWorksheetRows(worksheet, displayRows.length);
restoreFooterLayout(worksheet, displayRows.length);
worksheet.getCell("A2").value = `编号:${initialState.headerForm.contactName.trim()}`;
worksheet.getCell("A3").value = `TO(需求方): ${initialState.headerForm.demandCompany.trim()}`;
worksheet.getCell("G3").value = "FROM(供应方):";
worksheet.getCell("H3").value = initialState.headerForm.supplyCompany.trim();
worksheet.getCell("H3").value = "FROM(供应方):";
worksheet.getCell("I3").value = initialState.headerForm.supplyCompany.trim();
worksheet.getCell("A4").value = `MOB(手机):${initialState.headerForm.demandMobile.trim()}`;
worksheet.getCell("G4").value = "MOB(手机):";
worksheet.getCell("H4").value = initialState.headerForm.supplyMobile.trim();
worksheet.getCell("H4").value = "MOB(手机):";
worksheet.getCell("I4").value = initialState.headerForm.supplyMobile.trim();
const imageTasks = displayRows.map(async (row, index) => {
const rowNumber = DATA_START_ROW + index;
@ -349,18 +449,19 @@ async function buildWorkbookBytes(products: NonIntegratedProductRecord[]) {
}
}
worksheet.getCell(`B${rowNumber}`).value = row.itemCode.trim();
worksheet.getCell(`C${rowNumber}`).value = row.specification.trim();
worksheet.getCell(`D${rowNumber}`).value = "";
worksheet.getCell(`E${rowNumber}`).value = row.purchasePrice.trim();
worksheet.getCell(`F${rowNumber}`).value = row.salesPrice.trim();
worksheet.getCell(`G${rowNumber}`).value = row.material.trim();
worksheet.getCell(`H${rowNumber}`).value = row.sizeCm.trim();
worksheet.getCell(`I${rowNumber}`).value = row.minimumOrderQuantity.trim();
worksheet.getCell(`J${rowNumber}`).value = row.leadTimeDays.trim();
worksheet.getCell(`K${rowNumber}`).value = row.packageCount.trim();
worksheet.getCell(`L${rowNumber}`).value = row.packageMethod.trim();
worksheet.getCell(`M${rowNumber}`).value = row.remark.trim();
worksheet.getCell(`B${rowNumber}`).value = row.brandItemCode.trim();
worksheet.getCell(`C${rowNumber}`).value = row.itemCode.trim();
worksheet.getCell(`D${rowNumber}`).value = row.specification.trim();
worksheet.getCell(`E${rowNumber}`).value = "";
worksheet.getCell(`F${rowNumber}`).value = row.purchasePrice.trim();
worksheet.getCell(`G${rowNumber}`).value = row.salesPrice.trim();
worksheet.getCell(`H${rowNumber}`).value = row.material.trim();
worksheet.getCell(`I${rowNumber}`).value = row.sizeCm.trim();
worksheet.getCell(`J${rowNumber}`).value = row.minimumOrderQuantity.trim();
worksheet.getCell(`K${rowNumber}`).value = row.leadTimeDays.trim();
worksheet.getCell(`L${rowNumber}`).value = row.packageCount.trim();
worksheet.getCell(`M${rowNumber}`).value = row.packageMethod.trim();
worksheet.getCell(`N${rowNumber}`).value = row.remark.trim();
if (!row.imageUrl.trim()) return;

View File

@ -328,16 +328,34 @@ function insertStyledRows(
}
const DYNAMIC_TEMPLATE_MERGE_REFS = [
"B10:C10",
"C10:D10",
"D10:E10",
"E10:F10",
"F10:G10",
"G10:H10",
"H10:I10",
"I10:J10",
"J10:L10",
"K10:L10",
"B11:C11",
"C11:D11",
"D11:E11",
"E11:F11",
"F11:G11",
"G11:H11",
"H11:I11",
"I11:J11",
"J11:L11",
"K11:L11",
"A12:L12",
"A13:A14",
"B13:B14",
"C13:C14",
"C13:D13",
"D13:E13",
"E13:L13",
"F13:L13",
"I14:J14",
"K14:L14",
"A15:B15",
@ -491,19 +509,95 @@ function setCellCenterAlignment(
};
}
function applyCenterAcrossRange(
function clearRowValues(
worksheet: ExcelJS.Worksheet,
ref: string,
rowNumber: number,
startColumnNumber = 1,
endColumnNumber = 12,
) {
const addresses = expandRangeAddresses(ref);
if (addresses.length === 0) return;
for (
let columnNumber = startColumnNumber;
columnNumber <= endColumnNumber;
columnNumber += 1
) {
worksheet.getCell(`${columnNumberToName(columnNumber)}${rowNumber}`).value =
"";
}
}
const firstCell = worksheet.getCell(addresses[0]!);
firstCell.alignment = {
...firstCell.alignment,
horizontal: "centerContinuous",
vertical: "middle",
};
function setTextCellValue(
worksheet: ExcelJS.Worksheet,
address: string,
value: string,
) {
worksheet.getCell(address).value = value.trim();
setCellCenterAlignment(worksheet, address);
}
function writePlanningHeaderRows(params: {
worksheet: ExcelJS.Worksheet;
planHeaderRow: number;
promotionHeaderTopRow: number;
promotionHeaderBottomRow: number;
}) {
const {
worksheet,
planHeaderRow,
promotionHeaderTopRow,
promotionHeaderBottomRow,
} = params;
clearRowValues(worksheet, planHeaderRow);
applyMergeRefs(worksheet, [
`C${planHeaderRow}:D${planHeaderRow}`,
`E${planHeaderRow}:F${planHeaderRow}`,
`G${planHeaderRow}:H${planHeaderRow}`,
`I${planHeaderRow}:J${planHeaderRow}`,
`K${planHeaderRow}:L${planHeaderRow}`,
]);
[
["A", "品牌货号"],
["B", "货号"],
["C", "零售价需求"],
["E", "采购价需求"],
["G", "尺寸要求"],
["I", "材质/颜色"],
["K", "特殊说明(是否老款迭代)"],
].forEach(([column, label]) => {
setTextCellValue(worksheet, `${column}${planHeaderRow}`, label);
});
clearRowValues(worksheet, promotionHeaderTopRow);
clearRowValues(worksheet, promotionHeaderBottomRow);
applyMergeRefs(worksheet, [
`A${promotionHeaderTopRow}:A${promotionHeaderBottomRow}`,
`B${promotionHeaderTopRow}:B${promotionHeaderBottomRow}`,
`C${promotionHeaderTopRow}:C${promotionHeaderBottomRow}`,
`D${promotionHeaderTopRow}:E${promotionHeaderTopRow}`,
`F${promotionHeaderTopRow}:L${promotionHeaderTopRow}`,
`K${promotionHeaderBottomRow}:L${promotionHeaderBottomRow}`,
]);
[
["A", "系列"],
["B", "品牌货号"],
["C", "货号"],
["D", "上市计划"],
["F", "上市季度销量"],
].forEach(([column, label]) => {
setTextCellValue(worksheet, `${column}${promotionHeaderTopRow}`, label);
});
[
["D", "全年销售总量"],
["E", "渠道"],
["F", "第1个月"],
["G", "第2个月"],
["H", "第3个月"],
["I", "第4-6月"],
["J", "第7-9月"],
["K", "第10-12月"],
].forEach(([column, label]) => {
setTextCellValue(worksheet, `${column}${promotionHeaderBottomRow}`, label);
});
}
function writePlanningSectionRows(params: {
@ -566,22 +660,13 @@ function writePlanningSectionRows(params: {
applyMergeRefs(worksheet, [
`A${promotionSectionTitleRow}:L${promotionSectionTitleRow}`,
`A${promotionHeaderTopRow}:A${promotionHeaderBottomRow}`,
`B${promotionHeaderTopRow}:B${promotionHeaderBottomRow}`,
`C${promotionHeaderTopRow}:D${promotionHeaderTopRow}`,
]);
applyCenterAcrossRange(
writePlanningHeaderRows({
worksheet,
`E${promotionHeaderTopRow}:L${promotionHeaderTopRow}`,
);
applyCenterAcrossRange(
worksheet,
`I${promotionHeaderBottomRow}:J${promotionHeaderBottomRow}`,
);
applyCenterAcrossRange(
worksheet,
`K${promotionHeaderBottomRow}:L${promotionHeaderBottomRow}`,
);
planHeaderRow: planDataStartRow - 1,
promotionHeaderTopRow,
promotionHeaderBottomRow,
});
for (let offset = 0; offset < rowCount; offset += 1) {
const rowNumber = promotionDataStartRow + offset;
@ -604,43 +689,48 @@ function writePlanningSectionRows(params: {
displayRows.forEach((displayRow, index) => {
const planRowNumber = planDataStartRow + index;
setCellValue(
setTextCellValue(
worksheet,
`A${planRowNumber}`,
displayRow.brandItemCode,
);
setCellValue(
worksheet,
`B${planRowNumber}`,
"itemCode",
displayRow.itemCode,
);
setCellValue(
worksheet,
`B${planRowNumber}`,
`C${planRowNumber}`,
"retailPriceDemand",
displayRow.retailPriceDemand,
);
setCellValue(
worksheet,
`D${planRowNumber}`,
`E${planRowNumber}`,
"purchasePriceDemand",
displayRow.purchasePriceDemand,
);
setCellValue(
worksheet,
`F${planRowNumber}`,
`G${planRowNumber}`,
"sizeRequirement",
displayRow.sizeRequirement,
);
setCellValue(
worksheet,
`H${planRowNumber}`,
`I${planRowNumber}`,
"materialAndColor",
displayRow.materialAndColor,
);
setCellValue(
worksheet,
`J${planRowNumber}`,
`K${planRowNumber}`,
"legacyIterationNote",
record.form.legacyIterationNote,
);
["A", "B", "D", "F", "H", "J"].forEach((column) => {
["A", "B", "C", "E", "G", "I", "K"].forEach((column) => {
setCellCenterAlignment(worksheet, `${column}${planRowNumber}`);
});
@ -651,51 +741,56 @@ function writePlanningSectionRows(params: {
"seriesName",
displayRow.seriesName,
);
setCellValue(
setTextCellValue(
worksheet,
`B${promotionRowNumber}`,
displayRow.brandItemCode,
);
setCellValue(
worksheet,
`C${promotionRowNumber}`,
"itemCode",
displayRow.itemCode,
);
setCellValue(
worksheet,
`C${promotionRowNumber}`,
`D${promotionRowNumber}`,
"annualSalesTarget",
displayRow.annualSalesTarget,
);
setCellValue(
worksheet,
`D${promotionRowNumber}`,
`E${promotionRowNumber}`,
"channel",
displayRow.channel,
);
setCellValue(
worksheet,
`E${promotionRowNumber}`,
`F${promotionRowNumber}`,
"month1Sales",
displayRow.month1Sales,
);
setCellValue(
worksheet,
`F${promotionRowNumber}`,
`G${promotionRowNumber}`,
"month2Sales",
displayRow.month2Sales,
);
setCellValue(
worksheet,
`G${promotionRowNumber}`,
`H${promotionRowNumber}`,
"month3Sales",
displayRow.month3Sales,
);
setCellValue(
worksheet,
`H${promotionRowNumber}`,
`I${promotionRowNumber}`,
"month4To6Sales",
displayRow.month4To6Sales,
);
setCellValue(
worksheet,
`I${promotionRowNumber}`,
`J${promotionRowNumber}`,
"month7To9Sales",
displayRow.month7To9Sales,
);
@ -705,9 +800,11 @@ function writePlanningSectionRows(params: {
"month10To12Sales",
displayRow.month10To12Sales,
);
["A", "B", "C", "D", "E", "F", "G", "H", "I", "K"].forEach((column) => {
setCellCenterAlignment(worksheet, `${column}${promotionRowNumber}`);
});
["A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K"].forEach(
(column) => {
setCellCenterAlignment(worksheet, `${column}${promotionRowNumber}`);
},
);
});
setCellValue(