ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Web ERP进销存基础档案模块:客户与商品信息管理实战

Web ERP进销存基础档案模块:客户与商品信息管理实战 各业务系统在数据源头各维护一套客户和商品资料往往会导致名称不一致、编码重复、价格信息混乱。尤其在跨部门协作时销售下单用的客户名称与财务回款看到的不完全一样仓库出库时商品单位也与采购入库时对不上整套ERP进销存的单据流就会变得不可信。本篇是 Web ERP 进销存系列教程的第二篇重点讲解“客户信息”和“商品信息”这两个基础档案模块。你可以把这两个模块理解为整个系统的地基后面所有的采购订单、销售订单、库存台账、应收应付都要引用这两份基础数据。本文会从表结构设计开始逐步展开后端接口、前端页面和常见坑点尽量让没有完整做过Web ERP的新手也能跟着一步步落地。1. 客户信息与商品信息在ERP进销存中的定位1.1 为什么必须先做基础档案ERP进销存的核心是“物流、资金流、单据流”三流合一。但三流都是围绕业务对象运行的业务对象主要有两类一类是“人/组织”比如客户、供应商另一类是“物”比如商品、原材料。如果客户建档不规范后续销售订单、发货单、对账单都会跟着出错。比如同一家公司被录成“北京华信科技”和“华信科技北京”系统会认为是两个客户统计应收款时就会分家财务对账会非常痛苦。商品信息则直接影响采购、库存和成本核算。商品编码、规格型号、计量单位、参考进价、参考售价、库存上下限这些字段不仅承担查询作用更会被后续的采购入库、销售出库、库存盘点大批量引用。商品档案一旦建错修改成本会非常高因为很多历史单据已经和这条商品记录产生了关联。1.2 两个模块的管理边界在Web ERP中客户信息管理和商品信息管理通常是两个独立菜单但两者常常在同一个业务页面中配合使用。客户信息管理关注的是“客户是谁”客户的基本属性名称、简称、类型、所在地区。联系信息联系人、电话、邮箱、地址。合作属性信用额度、结算方式、默认税率。状态控制启用、停用。商品信息管理关注的是“商品是什么、怎么卖、怎么买”商品身份编码、名称、条码、类别。销售属性销售价、会员价、最低售价。采购属性参考进价、默认供应商。库存属性计量单位、库存上下限、启用批次/序列号。状态控制在售、停售。整个ERP进销存的数据流转中客户资料通常只被“销售出库单、销售退货单、收款单”引用而商品资料几乎被所有库存和进销单据引用。所以商品档案的严谨性要求往往高于客户档案。1.3 本篇实战目标本章的实战效果是你能在自己的Web ERP项目里跑通“客户列表查询、新增客户、编辑客户、停用客户”以及“商品列表查询、新增商品、编辑商品、上下架”这样一组基础功能。技术实现上会采用比较轻量的方案后端使用 Spring Boot MyBatis-Plus前端使用一个简单的 HTML Bootstrap Axios 页面。不引入复杂的前端工程化构建方便读者把注意力集中在基础档案的业务逻辑和交互流程上。2. 环境准备与项目基础结构2.1 开发环境由于不同公司的技术栈差异较大这里不把版本写死。以下是我推荐的一套最低要求环境说明JDK8 或 11 及以上Spring Boot2.x 或 3.x 均可思路一致数据库MySQL 5.7 或 8.0也可以替换为其他关系型数据库ORMMyBatis-Plus 3.5.x前端原生 HTML Bootstrap 5 Axios不需要 Node 环境工具IDEA 或 Eclipse配合 Navicat / DBeaver示例中所有 SQL 都以 MySQL 方言为准。如果你使用 Oracle 或 PostgreSQL只需修改数据类型和序列/自增策略即可。2.2 创建 Spring Boot 工程如果你是从零创建项目可以使用 Spring Initializr 生成基础工程。这里给出建议的依赖坐标dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies这里有一个容易踩坑的版本问题Spring Boot 3.x 要求 JDK 17 及以上MyBatis-Plus 需要 3.5.3 以上版本旧版mybatis-plus-boot-starter中捆绑的 mybatis 与 Spring Boot 3 不兼容。如果启动时报ClassNotFoundException首先检查 JDK 版本和 MyBatis-Plus 版本是否匹配。2.3 基础目录结构建议按“客户模块”和“商品模块”拆包后续扩展供应商、仓库等模块时不会互相干扰。src/main/java/com/example/erp ├── common // 通用返回结果、异常处理 ├── customer // 客户模块 │ ├── controller │ ├── entity │ ├── mapper │ └── service ├── product // 商品模块 │ ├── controller │ ├── entity │ ├── mapper │ └── service └── config // 配置类3. 数据库表结构设计3.1 客户表设计客户表是进销存系统的基础资料表之一核心要求是“一个客户一条记录”并通过唯一索引保证不重复建档。CREATE TABLE customer ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 主键, customer_no varchar(32) NOT NULL COMMENT 客户编码, customer_name varchar(128) NOT NULL COMMENT 客户名称, short_name varchar(64) DEFAULT NULL COMMENT 客户简称, customer_type tinyint(4) DEFAULT 1 COMMENT 类型1 经销2 零售3 项目客户, contact varchar(64) DEFAULT NULL COMMENT 联系人, phone varchar(32) DEFAULT NULL COMMENT 联系电话, email varchar(128) DEFAULT NULL COMMENT 邮箱, region varchar(64) DEFAULT NULL COMMENT 所属地区, address varchar(255) DEFAULT NULL COMMENT 详细地址, credit_limit decimal(18,2) DEFAULT 0.00 COMMENT 信用额度, status tinyint(4) DEFAULT 1 COMMENT 状态1 启用0 停用, remark varchar(500) DEFAULT NULL COMMENT 备注, create_time datetime DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, PRIMARY KEY (id), UNIQUE KEY uk_customer_no (customer_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT客户档案表;客户编码建议业务上唯一。一个比较实用的规则是“客户类型前缀 四位流水号”例如KH001、XB001。这样可以保证编码可读性强也方便财务对账时快速辨别客户类型。status字段用于控制客户在业务单据中是否可选。停用的客户通常不允许新建销售订单但历史单据仍然可以正常查询。这种设计比物理删除更符合 ERP 的业务规则。3.2 商品表设计商品表的设计跨度比较大因为商品既要满足采购录入、又要满足销售开单还要支撑库存统计。一个相对通用但不过度设计的方式是先把最常用的字段铺好复杂维度可以放到后续的扩展表。CREATE TABLE product ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 主键, product_no varchar(64) NOT NULL COMMENT 商品编码, product_name varchar(128) NOT NULL COMMENT 商品名称, category_id bigint(20) DEFAULT NULL COMMENT 商品分类ID, spec varchar(128) DEFAULT NULL COMMENT 规格型号, unit varchar(16) DEFAULT 件 COMMENT 计量单位, bar_code varchar(64) DEFAULT NULL COMMENT 条码, purchase_price decimal(18,2) DEFAULT 0.00 COMMENT 参考进价, sale_price decimal(18,2) DEFAULT 0.00 COMMENT 参考售价, warning_stock decimal(18,2) DEFAULT 0.00 COMMENT 库存预警值, status tinyint(4) DEFAULT 1 COMMENT 状态1 在售0 停售, create_time datetime DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, PRIMARY KEY (id), UNIQUE KEY uk_product_no (product_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品档案表;商品分类建议单独建一张product_category表而不是直接存分类名称。因为分类往往有层级结构比如“食品-饮料-碳酸饮料”如果直接红字存字符串后续做报表筛选和库存汇总会非常麻烦。CREATE TABLE product_category ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 分类ID, parent_id bigint(20) NOT NULL DEFAULT 0 COMMENT 父分类ID0为顶级, category_name varchar(64) NOT NULL COMMENT 分类名称, sort_no int(11) DEFAULT 0 COMMENT 排序号, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品分类表;3.3 逻辑删除 vs 物理删除在客户和商品档案中不建议使用物理删除。原因有两个第一客户或商品可能已经被采购单、销售单、库存单据引用物理删除会导致历史单据关联失效报表数据缺失。第二业务上误删除后很难找回且无法追溯是谁在什么时间删除了这条数据。推荐的做法有两种增加deleted标记字段配合 MyBatis-Plus 的逻辑删除功能。使用status状态实现“停用”替代删除操作。实操中客户和商品模块通常采用第二种方案不做删除按钮只做“停用/启用”。如果客户或商品已被单据引用停用后不允许新增引用但历史数据不受影响。这样既简单又安全。4. 后端接口实现4.1 通用返回结构为了统一前端判断逻辑先定义一个简单的返回结果类。放在common包里。package com.example.erp.common; import lombok.Data; Data public class ResultT { private Integer code; private String msg; private T data; public static T ResultT ok(T data) { ResultT result new Result(); result.setCode(200); result.setMsg(success); result.setData(data); return result; } public static T ResultT error(String msg) { ResultT result new Result(); result.setCode(500); result.setMsg(msg); return result; } }4.2 客户实体类使用 MyBatis-Plus 注解做映射表名和实体类可以保持一致减少配置量。package com.example.erp.customer.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.math.BigDecimal; import java.time.LocalDateTime; Data TableName(customer) public class Customer { TableId(type IdType.AUTO) private Long id; private String customerNo; private String customerName; private String shortName; private Integer customerType; private String contact; private String phone; private String email; private String region; private String address; private BigDecimal creditLimit; private Integer status; private String remark; private LocalDateTime createTime; private LocalDateTime updateTime; }4.3 客户控制器基础档案的接口相对直接主要就是分页查询、新增、修改、启停用。这里不提供删除接口。package com.example.erp.customer.controller; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.example.erp.common.Result; import com.example.erp.customer.entity.Customer; import com.example.erp.customer.mapper.CustomerMapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.*; import java.time.LocalDateTime; RestController RequestMapping(/api/customer) public class CustomerController { Autowired private CustomerMapper customerMapper; GetMapping(/page) public ResultIPageCustomer page(RequestParam(defaultValue 1) Integer pageNum, RequestParam(defaultValue 10) Integer pageSize, RequestParam(required false) String keyword, RequestParam(required false) Integer status) { LambdaQueryWrapperCustomer wrapper new LambdaQueryWrapper(); if (StringUtils.hasText(keyword)) { wrapper.and(w - w.like(Customer::getCustomerName, keyword) .or().like(Customer::getCustomerNo, keyword) .or().like(Customer::getContact, keyword)); } if (status ! null) { wrapper.eq(Customer::getStatus, status); } wrapper.orderByDesc(Customer::getCreateTime); return Result.ok(customerMapper.selectPage(new Page(pageNum, pageSize), wrapper)); } PostMapping(/add) public ResultString add(RequestBody Customer customer) { if (!StringUtils.hasText(customer.getCustomerNo()) || !StringUtils.hasText(customer.getCustomerName())) { return Result.error(客户编码和客户名称不能为空); } Long count customerMapper.selectCount( new LambdaQueryWrapperCustomer().eq(Customer::getCustomerNo, customer.getCustomerNo())); if (count 0) { return Result.error(客户编码已存在); } customer.setCreateTime(LocalDateTime.now()); customer.setUpdateTime(LocalDateTime.now()); if (customer.getStatus() null) { customer.setStatus(1); } customerMapper.insert(customer); return Result.ok(新增成功); } PostMapping(/update) public ResultString update(RequestBody Customer customer) { if (customer.getId() null) { return Result.error(缺少客户ID); } customer.setUpdateTime(LocalDateTime.now()); customerMapper.updateById(customer); return Result.ok(修改成功); } PostMapping(/status/{id}/{status}) public ResultString changeStatus(PathVariable Long id, PathVariable Integer status) { Customer customer new Customer(); customer.setId(id); customer.setStatus(status); customer.setUpdateTime(LocalDateTime.now()); customerMapper.updateById(customer); return Result.ok(状态更新成功); } }这段代码里最需要留意的就是keyword的模糊搜索条件。我用and(w - w.like(...).or().like(...).or().like(...))将名称、编码、联系人三个字段的模糊查询包在一起再和status条件做 and 连接避免 OR 条件把状态过滤逻辑破坏掉。很多新手在写多条件查询时容易在这里出错导致status0的记录也被非预期地查出来。4.4 商品模块接口商品模块的接口和客户模块结构相似但增加了分类字段和价格字段。商品编码唯一性校验仍然是重点。package com.example.erp.product.controller; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.example.erp.common.Result; import com.example.erp.product.entity.Product; import com.example.erp.product.mapper.ProductMapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.*; import java.math.BigDecimal; import java.time.LocalDateTime; RestController RequestMapping(/api/product) public class ProductController { Autowired private ProductMapper productMapper; GetMapping(/page) public ResultIPageProduct page(RequestParam(defaultValue 1) Integer pageNum, RequestParam(defaultValue 10) Integer pageSize, RequestParam(required false) String keyword, RequestParam(required false) Long categoryId, RequestParam(required false) Integer status) { LambdaQueryWrapperProduct wrapper new LambdaQueryWrapper(); if (StringUtils.hasText(keyword)) { wrapper.and(w - w.like(Product::getProductName, keyword) .or().like(Product::getProductNo, keyword) .or().like(Product::getBarCode, keyword)); } if (categoryId ! null) { wrapper.eq(Product::getCategoryId, categoryId); } if (status ! null) { wrapper.eq(Product::getStatus, status); } wrapper.orderByDesc(Product::getCreateTime); return Result.ok(productMapper.selectPage(new Page(pageNum, pageSize), wrapper)); } PostMapping(/add) public ResultString add(RequestBody Product product) { if (!StringUtils.hasText(product.getProductNo()) || !StringUtils.hasText(product.getProductName())) { return Result.error(商品编码和商品名称不能为空); } Long count productMapper.selectCount( new LambdaQueryWrapperProduct().eq(Product::getProductNo, product.getProductNo())); if (count 0) { return Result.error(商品编码已存在); } // 价格字段不能为 null否则前端展示会出现空值 if (product.getPurchasePrice() null) { product.setPurchasePrice(BigDecimal.ZERO); } if (product.getSalePrice() null) { product.setSalePrice(BigDecimal.ZERO); } product.setCreateTime(LocalDateTime.now()); product.setUpdateTime(LocalDateTime.now()); if (product.getStatus() null) { product.setStatus(1); } productMapper.insert(product); return Result.ok(新增成功); } PostMapping(/update) public ResultString update(RequestBody Product product) { if (product.getId() null) { return Result.error(缺少商品ID); } product.setUpdateTime(LocalDateTime.now()); productMapper.updateById(product); return Result.ok(修改成功); } PostMapping(/status/{id}/{status}) public ResultString changeStatus(PathVariable Long id, PathVariable Integer status) { Product product new Product(); product.setId(id); product.setStatus(status); product.setUpdateTime(LocalDateTime.now()); productMapper.updateById(product); return Result.ok(状态更新成功); } }4.5 MyBatis-Plus 分页配置上面的接口使用了selectPage但 MyBatis-Plus 默认情况下分页插件没有启用需要手动配置一个分页拦截器。package com.example.erp.config; import com.baomidou.mybatisplus.annotation.DbType; import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor paginationInterceptor new PaginationInnerInterceptor(DbType.MYSQL); // 超过最大页数时返回第一页避免恶意查询导致全表扫描 paginationInterceptor.setOverflow(true); interceptor.addInnerInterceptor(paginationInterceptor); return interceptor; } }这里我把setOverflow(true)设置上了。不做这个设置时如果前端传了一个超大页码数据库依然可能执行深分页查询影响接口性能。开启溢出后超出总页数时自动回到第一页。5. 前端页面实战5.1 项目结构说明前端我们不用 Vue、React而是直接使用静态 HTML 页面。页面放在src/main/resources/static目录下Spring Boot 会自动托管这些静态资源。src/main/resources/static ├── customer.html ├── product.html └── js └── common.js这种方式对已经使用纯后端技术栈的团队非常友好不需要额外搭建前端构建环境也能快速把管理后台跑起来。5.2 客户管理页面下面是一个完整的客户管理页面示例包含查询表单、客户列表、新增/编辑弹窗、启停用操作。!DOCTYPE html html langzh-CN head meta charsetUTF-8 title客户信息管理/title link hrefhttps://cdn.bootcdn.net/ajax/libs/twitter-bootstrap/5.1.3/css/bootstrap.min.css relstylesheet /head body classbg-light div classcontainer mt-4 h4 classmb-3客户信息管理/h4 div classcard mb-3 div classcard-body div classrow g-2 div classcol-md-4 input typetext classform-control idkeyword placeholder客户名称 / 编码 / 联系人 /div div classcol-md-2 select classform-select idstatus option value全部状态/option option value1启用/option option value0停用/option /select /div div classcol-md-2 button classbtn btn-primary onclickloadData()查询/button button classbtn btn-success onclickopenEdit(null)新增客户/button /div /div /div /div div classcard div classcard-body table classtable table-hover align-middle thead tr th客户编码/th th客户名称/th th联系人/th th电话/th th信用额度/th th状态/th th操作/th /tr /thead tbody idcustomerTable /tbody /table nav ul classpagination idpagination/ul /nav /div /div /div !-- 编辑弹窗 -- div classmodal fade ideditModal tabindex-1 div classmodal-dialog div classmodal-content div classmodal-header h5 classmodal-title idmodalTitle新增客户/h5 button typebutton classbtn-close>div classmb-2 label商品编码/label input typetext classform-control idproductNo /div div classmb-2 label商品名称/label input typetext classform-control idproductName /div div classmb-2 label规格型号/label input typetext classform-control idspec /div div classmb-2 label计量单位/label input typetext classform-control idunit value件 /div div classmb-2 label参考进价/label input typenumber step0.01 classform-control idpurchasePrice value0 /div div classmb-2 label参考售价/label input typenumber step0.01 classform-control idsalePrice value0 /div div classmb-2 label库存预警值/label input typenumber step0.01 classform-control idwarningStock value0 /div商品表单中的关键点在于所有价格和库存字段都不能用空字符串提交后端也要做默认值处理。很多进销存系统报数字类型转换异常都是因为前端把空值提交给了 BigDecimal 字段。6. 运行验证6.1 配置 application.ymlserver: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/erp_demo?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: automap-underscore-to-camel-case这个配置建议打开它会自动把数据库的customer_no映射成实体类的customerNo省去大量手写 resultMap 的工作。6.2 启动并测试启动 Spring Boot 应用后浏览器访问http://localhost:8080/customer.html可以看到客户管理页面。控制台会打印 MyBatis-Plus 执行的 SQL 日志方便观察查询条件是否正确。可以按下面顺序手动测试一遍新增一条客户记录输入客户编码KH001、客户名称“深圳启程科技有限公司”保存后列表显示新记录。再新增一条客户记录客户编码仍然输入KH001观察是否提示“客户编码已存在”。点击“停用”按钮客户状态变为“停用”。在查询条件中输入客户名称关键字或联系人关键字确认列表能正确过滤。打开商品页面新增几个商品重复测试商品编码唯一性。6.3 验证结果说明如果第2步提示编码已存在说明唯一性校验生效。这是基础档案模块最重要的保护机制。如果第4步查询结果异常比如关键字为空时查不出任何数据需要检查keyword参数是否为null。在 Axios 的params里传keyword: 时后端StringUtils.hasText会返回 false因此不会拼接模糊查询条件这是正常现象。还有一种常见情况状态查询全部失效。这通常是因为前端传入的status是字符串1而后端实体字段是IntegerMyBatis-Plus 在特定版本下可能类型转换异常或匹配不上。解决方案是在前端把status转为数字或者在后端使用Integer.valueOf(status)。7. 常见问题与排查清单问题现象常见原因解决思路项目启动报错找不到数据源数据库连接参数或驱动依赖版本错误检查 application.yml 地址、账号、密码确认 mysql-connector-j 版本可用分页查询无效返回全量数据没有配置 MybatisPlusInterceptor 分页插件添加分页拦截器配置类新增客户提示编码已存在编码唯一索引触发 DuplicateKeyException插入前做唯一性校验或在异常处理中捕获 DuplicateKeyException编辑客户后名称没变化主键 id 为空或更新时覆盖了旧数据检查前端是否把 id 放入请求体状态查询不出停用数据前端传 status 为字符串类型转换出错使用 parseInt 转换或后端做类型兼容商品价格保存后变成 0前端空字符串导致 BigDecimal 转换异常前端空值转 0后端也做默认值兜底中文条件查询乱码JDBC URL 未设置 characterEncodingURL 增加 useUnicodetruecharacterEncodingutf8表单回显数据错乱编辑时通过列表接口取当前页数据回显提供主键查询详情接口/api/customer/{id}排查时建议按“前端参数 - 后端入参 - SQL 日志 - 数据库数据”的顺序定位。先看浏览器 Network 里的请求参数再看后端控制台打印的 SQL最后核对数据库记录。不要一上来就怀疑数据库索引或框架源码。8. 最佳实践与工程优化建议8.1 编码规则要提前定死客户编码和商品编码是整个系统的引用标识。编码规则一旦上线后再变更影响面会非常大。建议在需求评审阶段就和业务方确认好客户编码建议使用KH 四位流水号或者按区域分组如SZ KH 四位流水号。商品编码建议使用类别前缀 流水号如SP-Food-0001或者完全使用条码作为商品编码。不允许手工录入无规则编码比如“001”“商品1”这种后续排序和导入都容易错。如果系统已经上线但现有编码混乱可以考虑增加“辅助编码”或“旧编码”字段而不是直接修改已有编码。8.2 价格字段区分参考价与执行价商品表里的purchase_price和sale_price应该是“参考价”或“默认价”而不是业务单据中的强制价格。实际采购单和销售单中价格可能会根据供应商、客户等级、订单数量变化。因此在单据设计时要把商品档案中的默认价带入单据行项目但允许用户在单据上修改。这样既能提高开单效率又不限制业务灵活性。如果是更复杂的 ERP价格还会分成“客户等级价”“阶梯价”“促销价”多套价格体系。这时候建议单独设计价格表不要在商品档案表里加几十个价格字段。8.3 唯一性校验要放在数据库层虽然后端接口已经做了编码唯一性校验但真正的安全防线还是数据库唯一索引。并发情况下两个请求同时提交相同的编码应用层校验可能都判断为“不存在”然后同时插入最终只有一个请求能成功。所以表结构中的UNIQUE KEY必须保留并且后端要对DuplicateKeyException做统一捕获返回友好的业务提示而不是把堆栈信息直接抛给前端。8.4 停用比删除更安全客户和商品的删除操作在基础档案中应默认禁止。正确的操作模式是新增一条客户或商品后默认启用。如果不再合作或不再销售改为“停用”状态。停用后的基础数据不允许被新单据引用。对历史单据不影响查询和统计。需要重新合作时直接“启用”即可。如果后续真的有数据清理需求也应先确认没有业务单据引用再在运维环境中谨慎处理且必须提前备份。8.5 列表查询避免深分页基础档案数据量可能不大但客户和商品一旦发展起来记录数很容易达到几十万条。此时深分页页码过大会导致LIMIT 100000, 10这种慢查询。可以做的优化有三种限制最大页码超过一定范围后提示用户使用筛选条件。使用“上次游标”方式代替页码翻页。增加create_time或id排序索引并配合索引下推优化。最简单的方式是前端只显示前 100 页强制用户通过关键字搜索缩小范围。8.6 表单校验要前后端双重做前端校验可以提升用户体验比如必填项即时提醒、数字格式校验。但真正的数据完整性必须依赖后端校验。后端至少要做到必填字段为空时返回明确错误。字符串长度限制避免超长数据写入。数字字段范围校验价格不能为负数。编码、条码等唯一字段先查重。状态字段只允许特定枚举值。商品价格尤其要注意负数和超大数。一台设备如果录入成999999999999后续报表计算会出现难以发现的数据异常。8.7 字段扩展性考虑业务在发展过程中客户和商品可能需要更多自定义属性。比如客户可能要区分“是否允许赊销”“账期天数”商品可能要区分“是否临期管理”“是否称重商品”。为了避免每次加字段都改表结构可以在基础档案表上预留扩展字段比如ext_json字段用来存储业务自定义属性。但这个方案不适合做频繁查询的条件字段只能做展示和冗余存储。如果自定义字段需要参与查询过滤或报表统计建议做成独立的“扩展属性表”这样更规范但实现成本也更高。小团队起步阶段建议先用ext_json顶着后续有明确需求再拆表。8.8 操作日志不可少客户资料和商品资料属于核心主数据谁在什么时间修改了什么内容应该留痕。特别是价格调整和状态变更直接影响后续单据价格和历史查询。建议在后续迭代中加入简单的操作日志表CREATE TABLE data_operation_log ( id bigint(20) NOT NULL AUTO_INCREMENT, biz_type varchar(32) NOT NULL COMMENT 业务类型customer/product, biz_id bigint(20) NOT NULL COMMENT 业务记录ID, operation_type varchar(16) NOT NULL COMMENT 操作类型add/update/status, operator varchar(64) DEFAULT NULL COMMENT 操作人, change_content text COMMENT 变更内容JSON, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT数据操作日志表;在新增、修改、启停用的接口中写日志时尽量记录变更前后的差异。不要只记录“修改了客户名称”而是记录“客户名称由 A 改为 B”这样的日志才具有审计价值。9. 后续学习建议客户和商品信息管理做完以后下一步可以按以下顺序推进供应商信息管理结构上可以复用客户模块。商品分类管理解决分类树形结构和层级查询。库存期初余额录入为后续库存台账做数据准备。采购入库单、销售出库单把基础档案真正接入业务流转。从实际项目经验看基础档案模块决定了整个进销存系统的数据质量上限。很多后期难以修复的报表问题本质上都是基础档案不规范造成的。建议在上线前期投入足够精力梳理业务流程和字段规则而不是等单据跑起来以后再回头调整。开发者在实现功能之外也应该站在业务角度多问几句这条数据录入以后会在哪些单据哪些报表中出现录入规则是否足够简单清晰只有把这些问题想清楚才能真正做好一套可落地的 Web ERP 进销存系统。
返回列表