项目实战:出入库管理系统
前言这一篇进入项目实战一个完整的出入库管理系统。这个项目把前面学的 Spring Boot、MyBatis、Vue 3、Element Plus、Axios 全部串起来“请做好准备全军出击”一、出入库业务理解完整业务流程1.1 什么是出入库管理出入库管理就是记录货物的进入库和出出库并跟踪库存变化。1.2 核心业务实体实体说明商品Goods仓库里的货物仓库Warehouse存放商品的地方出入库记录StockRecord一次入库或出库的操作记录审核状态Status记录是否已审核1.3 完整业务流程text1. 新增出入库记录草稿/待审核 ↓ 2. 编辑记录未审核前可修改 ↓ 3. 批量审核审核通过/驳回 ↓ 4. 审核通过后更新库存 ↓ 5. 查询出入库记录多条件 分页1.4 业务规则规则说明未审核可编辑已审核的记录不能改审核后改库存入库加库存出库减库存批量审核可以一次审核多条出库不能超库存出库数量不能大于当前库存1.5 出入库记录字段设计字段类型说明idInteger主键recordNoString记录编号goodsIdInteger商品 IDgoodsNameString商品名称warehouseIdInteger仓库 IDtypeInteger类型1 入库2 出库quantityInteger数量statusInteger状态0 待审核1 已通过2 已驳回remarkString备注createTimeLocalDateTime创建时间auditTimeLocalDateTime审核时间二、后端接口分析业务需求对应接口2.1 接口清单业务需求请求方法接口路径说明分页查询记录GET/api/records多条件 分页查询单条记录GET/api/records/{id}编辑前回显新增记录POST/api/records新增出入库记录编辑记录PUT/api/records/{id}修改未审核记录删除记录DELETE/api/records/{id}删除未审核记录批量审核PUT/api/records/audit批量通过/驳回2.2 接口详细设计2.2.1 分页查询textGET /api/records?recordNogoodsNametypestatuspage1size10响应json{ code: 200, message: 成功, data: { list: [ ... ], total: 100 } }2.2.2 新增记录textPOST /api/records Body: { goodsId: 1, warehouseId: 1, type: 1, quantity: 100, remark: 首次入库 }2.2.3 批量审核textPUT /api/records/audit Body: { ids: [1, 2, 3], status: 1 }2.3 对应关系业务Controller 方法Service 方法Mapper 方法分页查询page()page()selectByCondition()查询单条getById()getById()selectById()新增add()add()insert()编辑update()update()update()删除delete()delete()deleteById()批量审核audit()audit()updateStatusBatch()三、后端接口开发3.1 EntityjavaData public class StockRecord { private Integer id; private String recordNo; private Integer goodsId; private String goodsName; private Integer warehouseId; private Integer type; // 1 入库2 出库 private Integer quantity; private Integer status; // 0 待审核1 通过2 驳回 private String remark; private LocalDateTime createTime; private LocalDateTime auditTime; }3.2 Query 对象javaData public class StockRecordQuery { private String recordNo; private String goodsName; private Integer type; private Integer status; private Integer page 1; private Integer size 10; }3.3 MapperjavaMapper public interface StockRecordMapper { ListStockRecord selectByCondition(StockRecordQuery query); StockRecord selectById(Integer id); int insert(StockRecord record); int update(StockRecord record); int deleteById(Integer id); int updateStatusBatch(Param(ids) ListInteger ids, Param(status) Integer status); }3.4 Mapper XMLxmlselect idselectByCondition resultTypeStockRecord SELECT * FROM stock_record where if testrecordNo ! null and recordNo ! AND record_no LIKE CONCAT(%, #{recordNo}, %) /if if testgoodsName ! null and goodsName ! AND goods_name LIKE CONCAT(%, #{goodsName}, %) /if if testtype ! null AND type #{type} /if if teststatus ! null AND status #{status} /if /where ORDER BY create_time DESC /select select idselectById resultTypeStockRecord SELECT * FROM stock_record WHERE id #{id} /select insert idinsert useGeneratedKeystrue keyPropertyid INSERT INTO stock_record (record_no, goods_id, goods_name, warehouse_id, type, quantity, status, remark, create_time) VALUES (#{recordNo}, #{goodsId}, #{goodsName}, #{warehouseId}, #{type}, #{quantity}, 0, #{remark}, NOW()) /insert update idupdate UPDATE stock_record set if testgoodsId ! nullgoods_id #{goodsId},/if if testwarehouseId ! nullwarehouse_id #{warehouseId},/if if testtype ! nulltype #{type},/if if testquantity ! nullquantity #{quantity},/if if testremark ! nullremark #{remark},/if /set WHERE id #{id} AND status 0 /update delete iddeleteById DELETE FROM stock_record WHERE id #{id} AND status 0 /delete update idupdateStatusBatch UPDATE stock_record SET status #{status}, audit_time NOW() WHERE id IN foreach collectionids itemid open( separator, close) #{id} /foreach AND status 0 /update3.5 Servicejavapublic interface StockRecordService { PageResultStockRecord page(StockRecordQuery query); StockRecord getById(Integer id); void add(StockRecord record); void update(StockRecord record); void delete(Integer id); void audit(ListInteger ids, Integer status); }javaService public class StockRecordServiceImpl implements StockRecordService { Autowired private StockRecordMapper recordMapper; Autowired private GoodsMapper goodsMapper; Override public PageResultStockRecord page(StockRecordQuery query) { PageHelper.startPage(query.getPage(), query.getSize()); ListStockRecord list recordMapper.selectByCondition(query); PageInfoStockRecord pageInfo new PageInfo(list); PageResultStockRecord result new PageResult(); result.setList(list); result.setTotal(pageInfo.getTotal()); return result; } Override public StockRecord getById(Integer id) { StockRecord record recordMapper.selectById(id); if (record null) { throw new BusinessException(404, 记录不存在); } return record; } Override public void add(StockRecord record) { // 生成记录编号 record.setRecordNo(RK System.currentTimeMillis()); recordMapper.insert(record); } Override public void update(StockRecord record) { StockRecord old recordMapper.selectById(record.getId()); if (old null) { throw new BusinessException(404, 记录不存在); } if (old.getStatus() ! 0) { throw new BusinessException(已审核的记录不能修改); } recordMapper.update(record); } Override public void delete(Integer id) { StockRecord old recordMapper.selectById(id); if (old null) { throw new BusinessException(404, 记录不存在); } if (old.getStatus() ! 0) { throw new BusinessException(已审核的记录不能删除); } recordMapper.deleteById(id); } Transactional Override public void audit(ListInteger ids, Integer status) { // 1. 批量更新状态 recordMapper.updateStatusBatch(ids, status); // 2. 审核通过时更新库存 if (status 1) { for (Integer id : ids) { StockRecord record recordMapper.selectById(id); if (record.getType() 1) { goodsMapper.increaseStock(record.getGoodsId(), record.getQuantity()); } else { goodsMapper.decreaseStock(record.getGoodsId(), record.getQuantity()); } } } } }3.6 ControllerjavaRestController RequestMapping(/api/records) public class StockRecordController { Autowired private StockRecordService recordService; GetMapping public ResultPageResultStockRecord page(StockRecordQuery query) { return Result.success(recordService.page(query)); } GetMapping(/{id}) public ResultStockRecord getById(PathVariable Integer id) { return Result.success(recordService.getById(id)); } PostMapping public ResultVoid add(RequestBody StockRecord record) { recordService.add(record); return Result.success(); } PutMapping(/{id}) public ResultVoid update(PathVariable Integer id, RequestBody StockRecord record) { record.setId(id); recordService.update(record); return Result.success(); } DeleteMapping(/{id}) public ResultVoid delete(PathVariable Integer id) { recordService.delete(id); return Result.success(); } PutMapping(/audit) public ResultVoid audit(RequestBody AuditRequest request) { recordService.audit(request.getIds(), request.getStatus()); return Result.success(); } }3.7 批量审核请求对象javaData public class AuditRequest { private ListInteger ids; private Integer status; }四、前端界面搭建4.1 页面结构text------------------------------------------ | 搜索栏记录编号 | 商品名称 | 类型 | 状态 | 查询 | 重置 | ------------------------------------------ | 操作栏新增 | 批量审核 | ------------------------------------------ | 表格编号 | 商品 | 类型 | 数量 | 状态 | 操作 | ------------------------------------------ | 分页 | ------------------------------------------4.2 页面组件vuetemplate div classrecord-page !-- 搜索栏 -- el-form :inlinetrue :modelquery classsearch-form el-form-item label记录编号 el-input v-modelquery.recordNo placeholder请输入 clearable / /el-form-item el-form-item label商品名称 el-input v-modelquery.goodsName placeholder请输入 clearable / /el-form-item el-form-item label类型 el-select v-modelquery.type placeholder全部 clearable el-option label入库 :value1 / el-option label出库 :value2 / /el-select /el-form-item el-form-item label状态 el-select v-modelquery.status placeholder全部 clearable el-option label待审核 :value0 / el-option label已通过 :value1 / el-option label已驳回 :value2 / /el-select /el-form-item el-form-item el-button typeprimary clickhandleSearch查询/el-button el-button clickhandleReset重置/el-button /el-form-item /el-form !-- 操作栏 -- div classtoolbar el-button typeprimary clickhandleAdd新增/el-button el-button typesuccess :disabledselectedIds.length 0 clickhandleAudit 批量审核 /el-button /div !-- 表格 -- el-table :datatableData border stripe selection-changehandleSelectionChange el-table-column typeselection width50 / el-table-column proprecordNo label记录编号 width160 / el-table-column propgoodsName label商品名称 / el-table-column label类型 width100 template #default{ row } el-tag :typerow.type 1 ? success : warning {{ row.type 1 ? 入库 : 出库 }} /el-tag /template /el-table-column el-table-column propquantity label数量 width100 / el-table-column label状态 width120 template #default{ row } el-tag :typestatusType(row.status) {{ statusText(row.status) }} /el-tag /template /el-table-column el-table-column propcreateTime label创建时间 width180 / el-table-column label操作 width200 template #default{ row } el-button sizesmall :disabledrow.status ! 0 clickhandleEdit(row) 编辑 /el-button el-button sizesmall typedanger :disabledrow.status ! 0 clickhandleDelete(row) 删除 /el-button /template /el-table-column /el-table !-- 分页 -- el-pagination v-model:current-pagequery.page v-model:page-sizequery.size :totaltotal :page-sizes[10, 20, 50] layouttotal, sizes, prev, pager, next, jumper size-changefetchData current-changefetchData / !-- 新增/编辑弹窗 -- RecordForm v-modelformVisible :edit-dataeditData successfetchData / /div /template script setup import { ref, reactive, onMounted } from vue import { ElMessage, ElMessageBox } from element-plus import { getRecordList, deleteRecord, auditRecords } from /api/record import RecordForm from ./RecordForm.vue const query reactive({ recordNo: , goodsName: , type: null, status: null, page: 1, size: 10 }) const tableData ref([]) const total ref(0) const selectedIds ref([]) const formVisible ref(false) const editData ref(null) async function fetchData() { const res await getRecordList(query) tableData.value res.list total.value res.total } function handleSearch() { query.page 1 fetchData() } function handleReset() { query.recordNo query.goodsName query.type null query.status null query.page 1 fetchData() } function handleSelectionChange(rows) { selectedIds.value rows.filter(r r.status 0).map(r r.id) } function handleAdd() { editData.value null formVisible.value true } function handleEdit(row) { editData.value row formVisible.value true } async function handleDelete(row) { try { await ElMessageBox.confirm(确定要删除吗, 提示, { type: warning }) await deleteRecord(row.id) ElMessage.success(删除成功) fetchData() } catch {} } async function handleAudit() { if (selectedIds.value.length 0) { ElMessage.warning(请选择待审核的记录) return } try { await ElMessageBox.confirm(确定审核通过选中的 ${selectedIds.value.length} 条记录吗, 提示, { type: warning }) await auditRecords({ ids: selectedIds.value, status: 1 }) ElMessage.success(审核成功) fetchData() } catch {} } function statusText(status) { return { 0: 待审核, 1: 已通过, 2: 已驳回 }[status] } function statusType(status) { return { 0: warning, 1: success, 2: danger }[status] } onMounted(fetchData) /script五、多条件分页查询出入库记录的前端开发5.1 API 封装javascript// api/record.js import request from /utils/request export function getRecordList(params) { return request.get(/records, { params }) } export function getRecordById(id) { return request.get(/records/${id}) } export function addRecord(data) { return request.post(/records, data) } export function updateRecord(id, data) { return request.put(/records/${id}, data) } export function deleteRecord(id) { return request.delete(/records/${id}) } export function auditRecords(data) { return request.put(/records/audit, data) }5.2 查询参数处理javascriptconst query reactive({ recordNo: , goodsName: , type: null, status: null, page: 1, size: 10 })5.3 查询与重置javascriptasync function fetchData() { const res await getRecordList(query) tableData.value res.list total.value res.total } function handleSearch() { query.page 1 // 查询时重置到第一页 fetchData() } function handleReset() { Object.assign(query, { recordNo: , goodsName: , type: null, status: null, page: 1, size: 10 }) fetchData() }5.4 分页切换vueel-pagination v-model:current-pagequery.page v-model:page-sizequery.size :totaltotal size-changefetchData current-changefetchData /六、新增出入库记录后端接口开发流程6.1 流程text1. Controller 接收 RequestBody 2. Service 生成记录编号 3. Service 设置默认状态为待审核 4. Mapper 插入数据库 5. 返回成功6.2 ControllerjavaPostMapping public ResultVoid add(RequestBody StockRecord record) { recordService.add(record); return Result.success(); }6.3 ServicejavaOverride public void add(StockRecord record) { // 校验 if (record.getGoodsId() null || record.getQuantity() null) { throw new BusinessException(商品和数量不能为空); } if (record.getQuantity() 0) { throw new BusinessException(数量必须大于 0); } // 生成记录编号 record.setRecordNo(RK System.currentTimeMillis()); // 默认待审核 record.setStatus(0); record.setCreateTime(LocalDateTime.now()); recordMapper.insert(record); }6.4 Mapperxmlinsert idinsert useGeneratedKeystrue keyPropertyid INSERT INTO stock_record (record_no, goods_id, goods_name, warehouse_id, type, quantity, status, remark, create_time) VALUES (#{recordNo}, #{goodsId}, #{goodsName}, #{warehouseId}, #{type}, #{quantity}, #{status}, #{remark}, #{createTime}) /insert七、新增出入库记录前端开发流程7.1 表单组件vuetemplate el-dialog v-modelvisible :titleisEdit ? 编辑记录 : 新增记录 width600px closehandleClose el-form :modelform :rulesrules refformRef label-width100px el-form-item label商品 propgoodsId el-select v-modelform.goodsId placeholder请选择商品 filterable el-option v-forg in goodsList :keyg.id :labelg.name :valueg.id / /el-select /el-form-item el-form-item label仓库 propwarehouseId el-select v-modelform.warehouseId placeholder请选择仓库 el-option v-forw in warehouseList :keyw.id :labelw.name :valuew.id / /el-select /el-form-item el-form-item label类型 proptype el-radio-group v-modelform.type el-radio :value1入库/el-radio el-radio :value2出库/el-radio /el-radio-group /el-form-item el-form-item label数量 propquantity el-input-number v-modelform.quantity :min1 / /el-form-item el-form-item label备注 el-input v-modelform.remark typetextarea :rows3 / /el-form-item /el-form template #footer el-button clickhandleClose取消/el-button el-button typeprimary clickhandleSubmit确定/el-button /template /el-dialog /template script setup import { ref, reactive, computed, watch } from vue import { ElMessage } from element-plus import { addRecord, updateRecord } from /api/record import { getGoodsList } from /api/goods import { getWarehouseList } from /api/warehouse const props defineProps({ modelValue: Boolean, editData: Object }) const emit defineEmits([update:modelValue, success]) const visible computed({ get: () props.modelValue, set: (val) emit(update:modelValue, val) }) const isEdit computed(() !!props.editData) const formRef ref(null) const goodsList ref([]) const warehouseList ref([]) const form reactive({ goodsId: null, warehouseId: null, type: 1, quantity: 1, remark: }) const rules { goodsId: [{ required: true, message: 请选择商品, trigger: change }], warehouseId: [{ required: true, message: 请选择仓库, trigger: change }], type: [{ required: true, message: 请选择类型, trigger: change }], quantity: [{ required: true, message: 请输入数量, trigger: blur }] } watch(() props.modelValue, async (val) { if (val) { // 加载下拉数据 goodsList.value await getGoodsList() warehouseList.value await getWarehouseList() // 编辑时回显 if (props.editData) { Object.assign(form, props.editData) } else { Object.assign(form, { goodsId: null, warehouseId: null, type: 1, quantity: 1, remark: }) } } }) async function handleSubmit() { await formRef.value.validate() if (isEdit.value) { await updateRecord(props.editData.id, form) ElMessage.success(修改成功) } else { await addRecord(form) ElMessage.success(新增成功) } emit(success) handleClose() } function handleClose() { visible.value false formRef.value?.resetFields() } /script7.2 提交逻辑场景调用接口新增POST /records编辑PUT /records/{id}八、编辑出入库记录功能开发【数据回显和编辑】8.1 数据回显流程text1. 点击编辑按钮 → 拿到 row 数据 2. 打开弹窗把 row 数据传给子组件 3. 子组件 watch 监听到 editData回显到表单 4. 用户修改后提交8.2 回显实现javascriptwatch(() props.modelValue, async (val) { if (val) { goodsList.value await getGoodsList() warehouseList.value await getWarehouseList() if (props.editData) { // 回显 Object.assign(form, { goodsId: props.editData.goodsId, warehouseId: props.editData.warehouseId, type: props.editData.type, quantity: props.editData.quantity, remark: props.editData.remark }) } } })8.3 编辑提交javaPutMapping(/{id}) public ResultVoid update(PathVariable Integer id, RequestBody StockRecord record) { record.setId(id); recordService.update(record); return Result.success(); }javaOverride public void update(StockRecord record) { StockRecord old recordMapper.selectById(record.getId()); if (old null) { throw new BusinessException(404, 记录不存在); } if (old.getStatus() ! 0) { throw new BusinessException(已审核的记录不能修改); } recordMapper.update(record); }8.4 编辑的注意事项注意点说明只有待审核可编辑已审核记录禁用编辑按钮回显要完整商品、仓库、类型、数量、备注都要回显提交带上 id路径参数传 id后端二次校验防止前端绕过后端再查状态九、批量审核出入库记录功能开发流程9.1 流程text1. 前端勾选多条待审核记录 2. 点击批量审核选择通过/驳回 3. 后端批量更新状态 4. 审核通过时更新库存 5. 返回成功刷新列表9.2 前端实现javascriptconst selectedIds ref([]) function handleSelectionChange(rows) { // 只保留待审核的记录 selectedIds.value rows.filter(r r.status 0).map(r r.id) } async function handleAudit() { if (selectedIds.value.length 0) { ElMessage.warning(请选择待审核的记录) return } try { await ElMessageBox.confirm( 确定审核通过选中的 ${selectedIds.value.length} 条记录吗, 提示, { type: warning } ) await auditRecords({ ids: selectedIds.value, status: 1 }) ElMessage.success(审核成功) fetchData() } catch {} }9.3 后端实现javaPutMapping(/audit) public ResultVoid audit(RequestBody AuditRequest request) { recordService.audit(request.getIds(), request.getStatus()); return Result.success(); }javaTransactional Override public void audit(ListInteger ids, Integer status) { if (ids null || ids.isEmpty()) { throw new BusinessException(请选择要审核的记录); } // 1. 批量更新状态 int rows recordMapper.updateStatusBatch(ids, status); if (rows 0) { throw new BusinessException(没有可审核的记录); } // 2. 审核通过时更新库存 if (status 1) { for (Integer id : ids) { StockRecord record recordMapper.selectById(id); if (record.getType() 1) { goodsMapper.increaseStock(record.getGoodsId(), record.getQuantity()); } else { // 出库前检查库存 int stock goodsMapper.getStock(record.getGoodsId()); if (stock record.getQuantity()) { throw new BusinessException(商品 record.getGoodsName() 库存不足); } goodsMapper.decreaseStock(record.getGoodsId(), record.getQuantity()); } } } }9.4 Mapper 批量更新xmlupdate idupdateStatusBatch UPDATE stock_record SET status #{status}, audit_time NOW() WHERE id IN foreach collectionids itemid open( separator, close) #{id} /foreach AND status 0 /update9.5 注意事项注意点说明只审核待审核记录WHERE status 0防止重复审核事务保证状态更新 库存更新必须在一个事务里库存校验出库前检查库存不足则抛异常回滚批量操作用foreach拼接 IN 语句审核后不可改已审核记录禁用编辑和删除十、核心口诀知识点口诀业务理解新增 → 编辑 → 审核 → 更新库存 → 查询接口分析业务需求对应 Controller/Service/Mapper 方法后端开发Entity → Query → Mapper → Service → Controller前端搭建搜索栏 操作栏 表格 分页 弹窗多条件分页查询参数 PageHelper el-pagination新增后端生成编号、默认待审核、插入数据库新增前端表单校验 提交 刷新列表编辑回显拿到 row 数据 → 传给子组件 → 回显表单批量审核勾选待审核 → 批量更新状态 → 事务更新库存十一、常见易错点易错点说明已审核记录还能编辑前端禁用按钮后端二次校验状态批量审核重复审核SQL 加WHERE status 0审核更新库存没加事务状态和库存必须在一个事务里出库不检查库存出库前查库存不足抛异常编辑回显不完整所有字段都要回显分页查询后没重置页码搜索时page 1批量审核勾选已审核记录前端过滤只保留status 0新增编号重复用时间戳或 UUID 生成唯一编号删除已审核记录后端校验状态只允许删待审核库存更新并发问题用UPDATE ... SET stock stock - ?原子操作十二、总结这篇覆盖了出入库业务理解、后端接口分析、后端接口开发、前端界面搭建、多条件分页查询、新增后端/前端流程、编辑数据回显、批量审核