写这篇文章的目的很简单:很多企业做门店业绩上报系统时,商品数据板块常被当成“表格+导入导出”处理,结果后端混乱、数据不一致、店员上报困难、报表统计不准。本文从落地可用的角度出发,讲清楚为什么要重视商品数据板块、它包含哪些内容(商品类别、商品信息、商品档案),如何设计架构、业务流程、实现细节和开发技巧,并把所有代码集中放在第12部分,方便工程化落地。
本文你将了解
注:本文示例所用方案模板:简道云门店业绩上报管理系统,给大家示例的是一些通用的功能和模块,都是支持自定义修改的,你可以根据自己的需求修改里面的功能。
一句话:商品数据是门店业绩上报的“基石”。 如果商品基础数据混乱,上报的销量、毛利、促销效果都无法可信。很多问题的根源都是商品维度设计不合理或数据管理缺位。重视商品数据,不是为了系统好看,而是为了让后续的统计、联动促销、库存预警、补货建议都能“说得通”。
常见痛点:
本文目标是把这些痛点用工程化方式解决,让商品数据既方便前端操作,又能支撑统计分析和上游 ERP/OMS 对接。
门店业绩上报管理系统是门店与总部之间的“数据上链”系统,包含门店的日常销售数据、业绩 KPI、任务完成情况等。商品数据板块负责:
设计原则:数据质量、稳定标识、可扩展属性优先。
必备功能
推荐功能
推荐技术栈(兼顾速度与扩展):
简化架构图(文字说明): 前端 ↔ API 网关/后端 ↔ PostgreSQL(Master Data) 后端 ↔ Redis(缓存) 后端 ↔ S3(图片) 后端 → MQ → ES(搜索)/其他系统(ERP/OMS)
新增商品(典型流程)
批量导入(两阶段)
门店上报
关键表(概念说明):
设计说明:
注:具体 SQL 与实体代码我已集中放到第 12 部分,便于复制使用。
建议接口:
要点:
前端关键点:
(具体 React/TSX 代码我已放到第12部分的前端代码区)
下面列出实战中非常有用的技巧与注意点,贴合企业落地需求。
实现后的验收点(可用于验收清单):
示例场景:促销活动统计某类目门店销量
下面把文章中所有的代码集中放在这里,按文件/用途分类——包含 SQL(建表)、后端(TypeORM 实体、Service、Controller、MQ 示例)、前端(React + Antd 表单、导入思路)、工具与示例脚本。把这些文件放入示例仓库即可快速上手。
说明:代码为 示例/模板,实际使用时请按项目规范(日志、异常处理、安全、配置管理)完善。
-- 商品类别(树形)
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));
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;
}
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;
}
}
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;
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);
}
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)]
);
}
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 (
<Form form={form} initialValues={initialValues} onFinish={onFinish} layout="vertical">
<Form.Item name="sku" label="SKU" rules={[{ required: true, message: '请输入 SKU' }]}>
<Input />
</Form.Item>
<Form.Item name="barcode" label="条码">
<Input />
</Form.Item>
<Form.Item name="name" label="商品名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="category_id" label="类目" rules={[{ required: true }]}>
<TreeSelect treeData={/* 从接口获取类目树 */[]} />
</Form.Item>
<Form.Item name="price" label="价格">
<InputNumber min={0} />
</Form.Item>
<Form.Item name="main_image" label="主图">
<Upload name="file" action="/api/upload" listType="picture">
<Button icon={<UploadOutlined />}>上传主图</Button>
</Upload>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">保存</Button>
</Form.Item>
</Form>
);
};
export default ProductForm;
// 使用 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;
}
// 门店扫码或输入条码,前端调用:
axios.get('/api/products/search?q=BARCODE_OR_SKU')
.then(res => {
if (res.data) {
// 使用 res.data.product_id 进行上报
} else {
// 提示临时商品流程
}
});
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);
}
条码(barcode)和 SKU 在实践中各有用途:条码通常由供应商或生产厂家分配,适合门店扫码使用;SKU 多由企业内部定义,承担业务唯一标识的责任。因此建议把 SKU 作为系统的主唯一标识(必须唯一索引),同时把条码作为辅助唯一标识并加索引。对于条码重复(比如不同供应商使用相同条码或条码录错),系统应在导入/创建时做冲突校验:如果发现已有条码,提示“条码已存在,是否关联到该商品或创建独立 SKU?”。导入流程要提供“覆盖/跳过/人工合并”选项,并记录所有操作的历史,以便回溯。总体原则是:不盲目覆盖,保留人工判断路径,并通过相似度检测(名称+规格)降低误合并风险。
批量导入要分两步走:预检 和 正式写入。预检阶段把上传的 CSV/Excel 解析到临时表或内存中,对每一行做字段校验(必填项、数值范围、条码格式、类目是否存在)、唯一性检测(sku/barcode)和相似度检测(名称与现有商品比对),将结果返回给前端让用户确认。预检结果要给出详细错误信息与行号,并提供行级操作(修改、忽略、合并)。只有用户确认后才写入主表。对于一次性大规模导入,建议先在测试环境跑一遍并做人工核查。同时记录导入日志,支持回滚或补偿操作。增量导入应确保幂等(以 SKU 为键进行 upsert)。
要保证上报标准化,前端上报界面必须把商品选择从“自由文本输入”改为“选择稳定主数据”的模式:门店上报时提供按 SKU/条码/名称搜索的下拉选择,扫码直接填入条码并在后台映射到 product_id,不允许店员仅输入文本作为商品标识。对于确实找不到的商品(如临时售卖品),提供“临时商品”流程:临时商品先在客户端以临时记录的方式提交,后台触发管理员审批,审批通过后生成正式 SKU 并通知门店,否则被回退修改。系统应支持离线补报(门店断网)但在同步时做严格校验并把“待同步”项标注清楚。总体目标是从入口端把自由文本空间压缩,依靠主数据保证上报一致性。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。