写这篇文章的目的很简单:很多企业做门店业绩上报系统时,商品数据板块常被当成“表格+导入导出”处理,结果后端混乱、数据不一致、店员上报困难、报表统计不准。本文从落地可用的角度出发,讲清楚为什么要重视商品数据板块、它包含哪些内容(商品类别、商品信息、商品档案),如何设计架构、业务流程、实现细节和开发技巧,并把所有代码集中放在第12部分,方便工程化落地。
本文你将了解
- 为什么要讲门店业绩上报管理中的商品数据板块?
- 什么是门店业绩上报管理
- 商品数据板块的功能清单
- 技术架构(含架构图)
- 业务流程(含流程图)
- 数据库设计
- 后端设计
- 前端设计
- 开发技巧与落地注意事项(干货)
- 实现效果(验收要点、示例场景)
- 部署、运维与数据迁移建议
- 全部代码集中区(SQL / 后端 / 前端 / 工具脚本)
注:本文示例所用方案模板:简道云门店业绩上报管理系统,给大家示例的是一些通用的功能和模块,都是支持自定义修改的,你可以根据自己的需求修改里面的功能。
一、为什么要讲商品数据板块?
一句话:商品数据是门店业绩上报的“基石”。 如果商品基础数据混乱,上报的销量、毛利、促销效果都无法可信。很多问题的根源都是商品维度设计不合理或数据管理缺位。重视商品数据,不是为了系统好看,而是为了让后续的统计、联动促销、库存预警、补货建议都能“说得通”。
常见痛点:
- 店员上报商品名称不统一(“可乐500ml” vs “可口可乐500ml”)
- SKU 没有唯一标识,导入错行、重复创建
- 类目不标准,报表拆解困难
- 图片、条码、规格等字段缺失或维护困难
本文目标是把这些痛点用工程化方式解决,让商品数据既方便前端操作,又能支撑统计分析和上游 ERP/OMS 对接。
二、什么是门店业绩上报管理?商品数据的角色
门店业绩上报管理系统是门店与总部之间的“数据上链”系统,包含门店的日常销售数据、业绩 KPI、任务完成情况等。商品数据板块负责:
- 提供标准化、可检索的商品主数据(Master Data)
- 支撑门店上报时的商品选择、校验和补全
- 向报表系统输出准确的商品维度数据(品类、品牌、规格)
- 与采购/库存/价格系统做数据同步(双向或单向)
设计原则:数据质量、稳定标识、可扩展属性优先。
三、商品数据板块的功能清单(必备 + 推荐)
必备功能
- 商品类别管理(树型类目:一级/二级/三级)
- 商品信息管理(名称、条码、SKU、品牌、规格、单位、主图)
- 商品档案(价格历史、成本、上架状态、有效期)
- 批量导入/导出(CSV/Excel,带验重与校验报错)
- 商品检索(模糊、条码、SKU、类目筛选)
- 商品上报校验规则(字段必填、条码格式、上下线时间)
- 审批/变更记录(谁在什么时候改了什么)
- API 接口供其他系统调用(门店上报时实时校验)
推荐功能
- 商品图片管理(支持多图、缩略图、CDN)
- 全文检索/高亮(ElasticSearch)
- 相似商品检测(防止重复录入)
- 价格与促销维度(支持门店定价)
- 数据质量看板(缺失字段、重复条码)
- 版本化(当商品属性变化时保留历史记录)
四、技术架构(简单版)
推荐技术栈(兼顾速度与扩展):
- 前端:React + TypeScript + Ant Design
- 后端:Node.js + TypeScript + NestJS 或 Express
- ORM:TypeORM 或 Sequelize(示例采用 TypeORM)
- DB:PostgreSQL(支持 JSONB)
- 缓存:Redis
- 搜索:ElasticSearch(或 PostgreSQL fulltext)
- 文件存储:S3 / 对象存储(商品图片)
- MQ:RabbitMQ / Kafka(异步同步、索引更新)
简化架构图(文字说明): 前端 ↔ API 网关/后端 ↔ PostgreSQL(Master Data) 后端 ↔ Redis(缓存) 后端 ↔ S3(图片) 后端 → MQ → ES(搜索)/其他系统(ERP/OMS)
五、业务流程(商品新增 / 导入 / 上报)
新增商品(典型流程)
- 前端填写商品信息表单(含条码、SKU、类目、图片)
- 后端做字段校验(必填、条码格式、类目存在)
- 去重检查(条码/SKU/名称相似)
- 写入主表并记录变更历史
- 图片上传到对象存储并保存 URL
- 异步发送消息:更新搜索索引、同步到 ERP、通知门店
- 返回创建成功
批量导入(两阶段)
- 预检:解析文件、逐行校验,返回行级错误与建议(覆盖/跳过/合并)
- 确认写入:用户确认后正式写入主表,写入历史与记录映射(老系统 ID → 新 SKU)
门店上报
- 前端扫码或检索商品(按 SKU/条码/名称)并返回 product_id,不允许自由文本作为主键标识
- 若临时商品,进入临时商品审批流程,审核后生成正式商品数据
六、数据库设计(核心表与设计说明 — 概念)
关键表(概念说明):
- product_categories(类目树)
- products(商品主表:sku, barcode, name, category_id, attributes(JSONB), price, cost, status)
- product_images(多图)
- product_history(变更审计)
- import_jobs / import_rows(导入临时表,用于预检)
设计说明:
- SKU 作为系统主唯一键,barcode 作为辅助唯一键(带冲突处理策略)
- attributes 使用 JSONB 扩展属性
- 为 name 建立全文索引或接入 ES 做模糊/相似搜索
- 每次重要变更写入 product_history(before/after)
注:具体 SQL 与实体代码我已集中放到第 12 部分,便于复制使用。
七、后端设计(接口、服务、异步策略)
建议接口:
- GET /api/categories:类目树
- POST /api/products:新增商品(支持事务)
- PUT /api/products/:id:更新商品(写变更历史)
- GET /api/products:分页搜索(支持 SKU/barcode/全文)
- POST /api/products/import:上传导入文件 -> 触发预检任务
- POST /api/products/import/confirm:确认导入写入主表
要点:
- 校验层与服务层分离,业务逻辑放服务层
- 对写操作使用 DB 事务,复杂流程使用 Saga 模式或 MQ 做补偿
- 写入后通过 MQ 异步触发索引更新与外部同步,保证主流程响应速度
八、前端设计(表单与导入 UX)
前端关键点:
- 表单要做充分校验(字段必填、数值范围、条码格式)
- 提供扫码功能(扫码直接填入 barcode 并触发后端查找)
- 批量导入:上传 -> 预检结果表格展示(成功/失败/警告) -> 用户确认 -> 正式写入
- 商品列表支持服务端分页、列筛选、导出 CSV/Excel
- 门店上报界面不要自由文本输入商品字段,必须通过商品选择下拉或扫码映射 product_id,没有匹配则走临时商品流程
(具体 React/TSX 代码我已放到第12部分的前端代码区)
九、开发技巧与落地注意事项(干货)
下面列出实战中非常有用的技巧与注意点,贴合企业落地需求。
9.1 SKU 与 Barcode 策略
- 把 SKU 当作内部唯一标识并强制索引;条码是门店扫码的主输入,但可能缺失或冲突。导入/新增时按优先级处理:barcode -> sku -> 名称相似度。
- 导入提供“覆盖/跳过/人工确认”选项,并保留操作日志。
9.2 相似度检测与去重
- 使用 ElasticSearch fuzzy/phonetic 或 PostgreSQL trigram/Levenshtein 做名称相似检测。相似度高的建议人工确认,不要自动合并。
- 高并发导入可以先写临时表再合并主表,或使用 Redis 作为并发去重缓存键。
9.3 类目编码与迁移策略
- 类目维护要有稳定 code,便于与 ERP 做映射。类目变更提供“迁移子类目”功能并记录历史,避免直接修改导致报表混乱。
9.4 图片管理
- 图片上传走对象存储(S3),保存 URL,配合 CDN。保存原图与缩略图,前端按需展示,后端提供图片尺寸校验与审核流程。
9.5 缓存策略
- 热门商品、类目树缓存 Redis,列表分页按需缓存,搜索走 ES。避免把商品列表全部拉到前端分页。
9.6 审计与回滚
- change history 必不可少:记录 before/after 与 change_by。支持按时间点回滚(慎重,需要审批),并保留回滚记录。
9.7 同步与幂等
- 同步 ERP/OMS 时使用幂等设计:携带外部系统 ID 或使用幂等 token,记录同步状态、错误信息与重试策略。
9.8 数据质量监控
- 设数据质量看板:缺失条码、重复 SKU、无价格、无类目等,定期触发邮件/工作流让负责人补齐。
十、实现效果(验收要点与示例场景)
实现后的验收点(可用于验收清单):
- 新增商品耗时(表单提交到成功) < 30 秒(含图片上传)
- 批量导入 1000 行:预检在 1-2 分钟返回校验结果(异步),确认写入可在后台任务完成并记录日志
- 门店上报实时校验:扫码/搜索能在 300ms 内返回商品标准信息(缓存 & 索引)
- 报表能按类目/品牌/规格准确统计销量与毛利(cost 字段可用于毛利计算)
- 数据质量看板显示缺失/重复问题并可触发角色分配的处理流程
示例场景:促销活动统计某类目门店销量
- 通过类目 code 精准定位商品集合(支持多级类目)
- 关联销售表的 product_id 做聚合,保证聚合准确性的前提是 product_id 的稳定与一致性
十一、部署、运维与数据迁移建议
- 初期可单体部署(API + DB),后期拆微服务(Product Service、Search Service、Sync Service)
- 数据迁移:先导入临时表做预检,生成老系统 ID -> 新 SKU 的映射表,人工或半自动确认后批量写入主表并生成映射关系
- 灾备:定期导出 products 表 CSV,DB 做物理备份与 WAL 备份
- 监控:接口延迟、MQ 堆积、同步失败率、数据质量异常,设置告警阈值与自动重试机制
十二、代码展示
下面把文章中所有的代码集中放在这里,按文件/用途分类——包含 SQL(建表)、后端(TypeORM 实体、Service、Controller、MQ 示例)、前端(React + Antd 表单、导入思路)、工具与示例脚本。把这些文件放入示例仓库即可快速上手。
说明:代码为 示例/模板,实际使用时请按项目规范(日志、异常处理、安全、配置管理)完善。
12.1 SQL:建表(PostgreSQL 精简版)
-- 商品类别(树形)
CREATE TABLE product_categories (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
parent_id INTEGER REFERENCES product_categories(id) ON DELETE SET NULL,
code VARCHAR(100),
created_at TIMESTAMP DEFAULT now(),
updated_at TIMESTAMP DEFAULT now()
);
-- 商品主表
CREATE TABLE products (
id SERIAL PRIMARY KEY,
sku VARCHAR(100) UNIQUE NOT NULL,
barcode VARCHAR(64) UNIQUE,
name VARCHAR(500) NOT NULL,
category_id INTEGER REFERENCES product_categories(id),
brand VARCHAR(200),
spec VARCHAR(200),
unit VARCHAR(50) DEFAULT '件',
price NUMERIC(12,2),
cost NUMERIC(12,2),
status VARCHAR(20) DEFAULT 'active',
attributes JSONB,
main_image_url TEXT,
created_by INTEGER,
updated_by INTEGER,
created_at TIMESTAMP DEFAULT now(),
updated_at TIMESTAMP DEFAULT now()
);
-- 商品图片
CREATE TABLE product_images (
id SERIAL PRIMARY KEY,
product_id INTEGER REFERENCES products(id) ON DELETE CASCADE,
url TEXT NOT NULL,
is_main BOOLEAN DEFAULT FALSE,
sort_order INTEGER DEFAULT 0
);
-- 历史记录(变更审计)
CREATE TABLE product_history (
id SERIAL PRIMARY KEY,
product_id INTEGER,
change_type VARCHAR(50),
change_by INTEGER,
change_at TIMESTAMP DEFAULT now(),
before JSONB,
after JSONB
);
-- 临时导入表(预检)
CREATE TABLE product_import_jobs (
id SERIAL PRIMARY KEY,
filename VARCHAR(255),
total_rows INTEGER,
success_rows INTEGER,
failed_rows INTEGER,
status VARCHAR(50) DEFAULT 'pending',
created_by INTEGER,
created_at TIMESTAMP DEFAULT now(),
updated_at TIMESTAMP DEFAULT now()
);
CREATE TABLE product_import_rows (
id SERIAL PRIMARY KEY,
job_id INTEGER REFERENCES product_import_jobs(id) ON DELETE CASCADE,
row_index INTEGER,
raw_data JSONB,
status VARCHAR(50), -- pending/ok/error
error_msg TEXT
);
-- 索引建议
CREATE INDEX idx_products_category ON products(category_id);
CREATE INDEX idx_products_name_gin ON products USING gin (to_tsvector('simple', name));
12.2 后端(Node.js + TypeScript + TypeORM 示例)
12.2.1 Entity:Product(product.entity.ts)
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
@Entity('products')
export class Product {
@PrimaryGeneratedColumn()
id: number;
@Column({ unique: true })
sku: string;
@Column({ unique: true, nullable: true })
barcode: string;
@Column()
name: string;
@Column({ nullable: true })
brand: string;
@Column({ nullable: true })
spec: string;
@Column('numeric', { nullable: true })
price: number;
@Column('numeric', { nullable: true })
cost: number;
@Column({ type: 'jsonb', nullable: true })
attributes: any;
@Column({ nullable: true })
main_image_url: string;
@CreateDateColumn()
created_at: Date;
@UpdateDateColumn()
updated_at: Date;
}
12.2.2 Service:ProductService(product.service.ts)
import { getRepository } from 'typeorm';
import { Product } from './product.entity';
import { publishToMQ } from './mq';
import { saveHistory } from './history.service';
export class ProductService {
private repo = getRepository(Product);
async createProduct(payload: any, userId: number) {
if (!payload.sku && !payload.barcode) {
throw new Error('SKU 或 条码 必填其一');
}
if (payload.barcode) {
const exists = await this.repo.findOne({ where: { barcode: payload.barcode } });
if (exists) {
throw new Error(`条码 ${payload.barcode} 已存在(ID=${exists.id})`);
}
}
if (payload.sku) {
const existsSku = await this.repo.findOne({ where: { sku: payload.sku } });
if (existsSku) {
throw new Error(`SKU ${payload.sku} 已存在(ID=${existsSku.id})`);
}
}
const product = this.repo.create(payload);
const saved = await this.repo.save(product);
await saveHistory({
product_id: saved.id,
change_type: 'create',
change_by: userId,
before: null,
after: saved
});
publishToMQ('product.created', { productId: saved.id });
return saved;
}
async updateProduct(id: number, payload: any, userId: number) {
const existing = await this.repo.findOneOrFail(id);
const before = { ...existing };
if (payload.barcode && payload.barcode !== existing.barcode) {
const e = await this.repo.findOne({ where: { barcode: payload.barcode } });
if (e) throw new Error('条码冲突');
}
Object.assign(existing, payload);
const saved = await this.repo.save(existing);
await saveHistory({
product_id: saved.id,
change_type: 'update',
change_by: userId,
before,
after: saved
});
publishToMQ('product.updated', { productId: saved.id });
return saved;
}
async findByBarcodeOrSku(code: string) {
const p = await this.repo.findOne({ where: [{ barcode: code }, { sku: code }] });
return p;
}
}
12.2.3 Controller(product.route.ts)
import express from 'express';
import { ProductService } from './product.service';
const router = express.Router();
const svc = new ProductService();
router.post('/', async (req, res) => {
try {
const userId = req.user?.id || 0;
const product = await svc.createProduct(req.body, userId);
res.status(201).json(product);
} catch (err) {
res.status(400).json({ message: err.message });
}
});
router.put('/:id', async (req, res) => {
try {
const userId = req.user?.id || 0;
const product = await svc.updateProduct(Number(req.params.id), req.body, userId);
res.json(product);
} catch (err) {
res.status(400).json({ message: err.message });
}
});
router.get('/search', async (req, res) => {
try {
const q = req.query.q as string;
// 简单模糊搜索示例(实际用 ES)
const result = await svc.findByBarcodeOrSku(q);
res.json(result);
} catch (err) {
res.status(500).json({ message: err.message });
}
});
export default router;
12.2.4 MQ(publishToMQ)示例(mq.ts)
export async function publishToMQ(topic: string, payload: any) {
// 示例:实际接入 RabbitMQ/Kafka
// const channel = await mq.getChannel();
// channel.publish(exchange, routingKey, Buffer.from(JSON.stringify(payload)));
console.log('MQ Publish ->', topic, payload);
}
12.2.5 History Service(history.service.ts)
import { getManager } from 'typeorm';
export async function saveHistory(entry: {
product_id: number;
change_type: string;
change_by: number;
before: any;
after: any;
}) {
const em = getManager();
await em.query(
`INSERT INTO product_history (product_id, change_type, change_by, before, after) VALUES ($1, $2, $3, $4, $5)`,
[entry.product_id, entry.change_type, entry.change_by, JSON.stringify(entry.before), JSON.stringify(entry.after)]
);
}
12.3 前端(React + TypeScript + Ant Design 示例)
import React from 'react';
import { Form, Input, Button, Upload, TreeSelect, InputNumber } from 'antd';
import { UploadOutlined } from '@ant-design/icons';
import axios from 'axios';
const ProductForm = ({ initialValues = {}, onSaved }) => {
const [form] = Form.useForm();
const onFinish = async (values: any) => {
try {
// 处理图片 URL 等(这里假设上传返回 url)
const res = await axios.post('/api/products', values);
onSaved && onSaved(res.data);
} catch (e) {
console.error(e);
}
};
return (
}>上传主图
保存
);
};
export default ProductForm;
12.4 导入解析后端示例(简化)
// 使用 exceljs 或 xlsx 解析文件,生成 product_import_rows
// 伪代码:
async function handleFileUpload(file, userId) {
const rows = parseExcel(file);
const job = await createImportJob({ filename: file.name, total_rows: rows.length, created_by: userId });
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const errors = validateRow(row); // 检查必填、条码格式、类目是否存在等
await insertImportRow(job.id, i+1, row, errors.length ? 'error' : 'ok', errors.join(';'));
}
// 触发异步任务进行预检(或实时同步处理)
return job;
}
12.5 前后端交互(扫码/上报示例)
// 门店扫码或输入条码,前端调用:
axios.get('/api/products/search?q=BARCODE_OR_SKU')
.then(res => {
if (res.data) {
// 使用 res.data.product_id 进行上报
} else {
// 提示临时商品流程
}
});
12.6 示例:导出商品为 CSV(Node.js)
import { getRepository } from 'typeorm';
import { Product } from './product.entity';
import { Parser } from 'json2csv';
import fs from 'fs';
export async function exportProductsCSV(filePath: string) {
const repo = getRepository(Product);
const products = await repo.find();
const fields = ['id','sku','barcode','name','brand','spec','price','cost','category_id'];
const parser = new Parser({ fields });
const csv = parser.parse(products);
fs.writeFileSync(filePath, csv);
}
十三、常见问题
FAQ1:条码和 SKU 哪个更重要?如果条码重复怎么办?
条码(barcode)和 SKU 在实践中各有用途:条码通常由供应商或生产厂家分配,适合门店扫码使用;SKU 多由企业内部定义,承担业务唯一标识的责任。因此建议把 SKU 作为系统的主唯一标识(必须唯一索引),同时把条码作为辅助唯一标识并加索引。对于条码重复(比如不同供应商使用相同条码或条码录错),系统应在导入/创建时做冲突校验:如果发现已有条码,提示“条码已存在,是否关联到该商品或创建独立 SKU?”。导入流程要提供“覆盖/跳过/人工合并”选项,并记录所有操作的历史,以便回溯。总体原则是:不盲目覆盖,保留人工判断路径,并通过相似度检测(名称+规格)降低误合并风险。
FAQ2:如何做批量导入时的数据质量控制,避免导入脏数据?
批量导入要分两步走:预检 和 正式写入。预检阶段把上传的 CSV/Excel 解析到临时表或内存中,对每一行做字段校验(必填项、数值范围、条码格式、类目是否存在)、唯一性检测(sku/barcode)和相似度检测(名称与现有商品比对),将结果返回给前端让用户确认。预检结果要给出详细错误信息与行号,并提供行级操作(修改、忽略、合并)。只有用户确认后才写入主表。对于一次性大规模导入,建议先在测试环境跑一遍并做人工核查。同时记录导入日志,支持回滚或补偿操作。增量导入应确保幂等(以 SKU 为键进行 upsert)。
FAQ3:门店上报如何保证商品选择的标准化,避免门店用自由文本?
要保证上报标准化,前端上报界面必须把商品选择从“自由文本输入”改为“选择稳定主数据”的模式:门店上报时提供按 SKU/条码/名称搜索的下拉选择,扫码直接填入条码并在后台映射到 product_id,不允许店员仅输入文本作为商品标识。对于确实找不到的商品(如临时售卖品),提供“临时商品”流程:临时商品先在客户端以临时记录的方式提交,后台触发管理员审批,审批通过后生成正式 SKU 并通知门店,否则被回退修改。系统应支持离线补报(门店断网)但在同步时做严格校验并把“待同步”项标注清楚。总体目标是从入口端把自由文本空间压缩,依靠主数据保证上报一致性。