diff --git a/.gitignore b/.gitignore index 07f7787..1db319f 100644 --- a/.gitignore +++ b/.gitignore @@ -70,6 +70,9 @@ ruoyi-ui/yarn-error.log* /profile/ *.pid +# Local environment overrides may contain database credentials. +/ruoyi-admin/src/main/resources/application-test.yml + !*/build/*.java !*/build/*.html !*/build/*.xml diff --git a/README.md b/README.md index fcc9a1c..64a9fed 100644 --- a/README.md +++ b/README.md @@ -24,3 +24,12 @@ npm run build:prod ``` 后端启动包输出为 `ruoyi-admin/target/cmvr-wms.jar`。 + +## 数据库升级 + +已有数据库升级前必须先备份目标库,并执行 `select database();` 确认连接库名。按以下顺序执行: + +1. `sql/wms_optimization_20260916.sql`:补充查询索引,并将可唯一匹配的历史人员昵称规范为账号。 +2. `sql/wms_project_management_20260922.sql`:增加项目、经费预算与流水、采购金额、库存来源批次及跨项目使用能力。 + +两个脚本均可重复执行。项目经费脚本会把无法还原真实项目归属的历史采购和库存放入 `LEGACY` 项目,不会虚构原始归属。升级后应按 [采购与项目经费改造说明](docs/purchase-project-refactor.md) 完成业务回归,再发布应用。 diff --git a/docs/purchase-project-refactor.md b/docs/purchase-project-refactor.md new file mode 100644 index 0000000..1edf577 --- /dev/null +++ b/docs/purchase-project-refactor.md @@ -0,0 +1,58 @@ +# 采购与项目经费改造说明 + +## 目标 + +本次改造将采购从独立申请单升级为项目经费闭环: + +`项目分科目预算 -> 采购申请占用 -> 分批到货验收 -> 实际支出 -> 项目来源库存批次 -> 项目领用` + +## 采购主线边界 + +- 项目:项目基本信息、负责人、成员及访问范围。 +- 经费:经费科目、项目分科目预算、占用金额、支出金额和不可变流水。 +- 采购申请:项目、经费科目、预计含税单价、预计金额及采购状态。 +- 采购导入:普通 Excel、带下拉选项的模板、表格粘贴和智能识别结果均须补齐项目、科目和预计单价。 +- 到货验收:支持分批到货、实际含税单价、实际金额、拒收和未入库前撤销验收。 +- 入库:验收通过生成待入库单;确认入库后生成带来源项目、经费科目和实际成本的库存批次。 +- 出库:按库存批次扣减,记录来源项目与使用项目;跨项目使用须有权限和原因。 + +AI 指令审计、批量导入性能、移动端和仪表盘调整与采购主线共享同一未提交工作区,但不属于采购闭环的最小发布范围,提交时应单独审查。 + +## 经费规则 + +- 新增采购申请:按 `需求数量 * 预计含税单价` 增加占用金额。 +- 修改有效申请:按新旧预计金额差额调整占用;更换项目或科目时先释放原占用,再占用新预算。 +- 取消或删除申请:释放尚未结算的预计占用。 +- 验收通过:按本批数量对应的预计金额释放占用,并按实际金额增加支出。 +- 撤销已通过验收:仅允许在对应入库单尚未确认时执行,同时恢复占用并冲回实际支出。 + +## 数据库升级 + +1. 备份目标数据库。 +2. 执行 `select database();`,确认连接的是预期数据库。 +3. 执行 `sql/wms_optimization_20260916.sql`。 +4. 执行 `sql/wms_project_management_20260922.sql`。 +5. 再次执行第 3、4 步,确认脚本可安全重放。 +6. 检查 `project_info`、`expense_category`、`project_budget`、`project_fund_ledger`、`stock_lot` 和 `out_record_lot`。 + +## 最小回归清单 + +1. 创建进行中的项目,将测试用户加入项目并配置一个经费科目预算。 +2. 新建采购申请,确认预计金额和项目预算占用同步增加。 +3. 修改数量或预计单价,确认只产生差额占用流水。 +4. 登记部分到货并验收通过,确认采购状态为部分到货、占用减少、实际支出增加,并生成待入库单。 +5. 确认入库,检查库存汇总和 `stock_lot` 的来源项目、科目、采购申请、到货记录及单位成本。 +6. 对第二批到货验收,确认采购状态变为已到齐。 +7. 对尚未确认入库的验收执行撤销,确认待入库单删除、经费恢复且采购状态回退。 +8. 验证预算不足、超量到货、非项目成员、已归档项目和重复提交均被拒绝。 + +## 当前验证状态 + +- 2026-09-28 在 `wms-test`(MySQL 8.0.36)执行升级前 50 张基础表的数据与结构快照,备份库为 `wms_test_backup_20260928_103302`。 +- `wms_optimization_20260916.sql` 和 `wms_project_management_20260922.sql` 均连续执行两遍成功,重放后业务表行数保持不变。 +- 真实 Spring 服务与测试库事务回归通过,测试数据已全部回滚。覆盖:项目预算、采购占用、预计金额调整、部分到货、验收结算、撤销验收、再次验收、确认入库、库存批次成本和采购到齐。 +- 回归发现并修复了“撤销后再次验收复用旧幂等键,导致经费不重新结算”的问题;验收流水幂等键现按验收周期生成。 +- 后端 `mvn clean test`:通过,但仓库暂时没有常驻自动化测试源文件。 +- 前端 `npm run build:prod`:通过,存在包体积告警。 +- 2026-09-28 发布前已备份 `wms-dev`,备份库为 `wms_dev_backup_20260928_105015`;升级脚本连续执行两遍成功,基础表由 42 张增至 50 张,原业务表行数保持不变。 +- `wms-dev` 真实 Spring 服务事务回归通过并完成回滚校验,未保留测试项目、采购、到货、入库或库存批次数据。 diff --git a/pom.xml b/pom.xml index c54dee3..0355f7a 100644 --- a/pom.xml +++ b/pom.xml @@ -191,8 +191,7 @@ 3.13.0 true - ${java.version} - ${java.version} + ${java.version} ${project.build.sourceEncoding} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/project/ProjectInfoController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/project/ProjectInfoController.java new file mode 100644 index 0000000..acecf8f --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/project/ProjectInfoController.java @@ -0,0 +1,147 @@ +package com.ruoyi.web.controller.project; + +import java.util.List; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import com.ruoyi.common.annotation.Log; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.project.domain.ExpenseCategory; +import com.ruoyi.project.domain.ProjectBudget; +import com.ruoyi.project.domain.ProjectFundLedger; +import com.ruoyi.project.domain.ProjectInfo; +import com.ruoyi.project.service.IProjectBudgetService; +import com.ruoyi.project.service.IProjectInfoService; + +@RestController +@RequestMapping("/project/info") +public class ProjectInfoController extends BaseController +{ + @Autowired + private IProjectInfoService projectInfoService; + + @Autowired + private IProjectBudgetService projectBudgetService; + + @PreAuthorize("@ss.hasAnyPermi('project:project:list,project:project:manage')") + @GetMapping("/list") + public TableDataInfo list(ProjectInfo project) + { + startPage(); + return getDataTable(projectInfoService.selectProjectInfoList(project)); + } + + @PreAuthorize("@ss.hasAnyPermi('project:project:query,project:project:list,project:project:manage')") + @GetMapping("/{id}") + public AjaxResult getInfo(@PathVariable Long id) + { + return success(projectInfoService.selectProjectInfoById(id)); + } + + @PreAuthorize("@ss.hasAnyPermi('project:project:list,project:project:query,project:project:manage,project:inventory:use,warehouse:purchase:list,warehouse:purchase:add,warehouse:purchase:edit,warehouse:purchase:import,warehouse:purchase:manage,warehouse:in:list,warehouse:in:add,warehouse:in:edit,warehouse:out:list,warehouse:out:add,warehouse:stock:out')") + @GetMapping("/options") + public AjaxResult options(String keyword, + @RequestParam(defaultValue = "false") boolean includeArchived) + { + return success(projectInfoService.selectProjectOptions(keyword, includeArchived)); + } + + @PreAuthorize("@ss.hasPermi('project:project:add')") + @Log(title = "项目管理", businessType = BusinessType.INSERT) + @PostMapping + public AjaxResult add(@RequestBody ProjectInfo project) + { + return toAjax(projectInfoService.insertProjectInfo(project)); + } + + @PreAuthorize("@ss.hasPermi('project:project:edit')") + @Log(title = "项目管理", businessType = BusinessType.UPDATE) + @PutMapping + public AjaxResult edit(@RequestBody ProjectInfo project) + { + return toAjax(projectInfoService.updateProjectInfo(project)); + } + + @PreAuthorize("@ss.hasPermi('project:project:remove')") + @Log(title = "项目管理", businessType = BusinessType.DELETE) + @DeleteMapping("/{id}") + public AjaxResult remove(@PathVariable Long id) + { + return toAjax(projectInfoService.deleteProjectInfo(id)); + } + + @PreAuthorize("@ss.hasAnyPermi('project:budget:list,project:budget:edit,project:project:manage')") + @GetMapping("/{projectId}/budgets") + public AjaxResult budgets(@PathVariable Long projectId) + { + return success(projectBudgetService.selectProjectBudgetList(projectId)); + } + + @PreAuthorize("@ss.hasAnyPermi('project:budget:edit,project:project:manage')") + @Log(title = "项目预算", businessType = BusinessType.UPDATE) + @PostMapping("/budget") + public AjaxResult saveBudget(@RequestBody ProjectBudget budget) + { + return toAjax(projectBudgetService.saveProjectBudget(budget)); + } + + @PreAuthorize("@ss.hasAnyPermi('project:budget:list,project:project:manage')") + @GetMapping("/ledger/list") + public TableDataInfo ledgerList(ProjectFundLedger ledger) + { + startPage(); + return getDataTable(projectBudgetService.selectFundLedgerList(ledger)); + } + + @PreAuthorize("@ss.hasAnyPermi('project:budget:list,project:budget:edit,project:category:edit,warehouse:purchase:list,warehouse:purchase:add,warehouse:purchase:edit,warehouse:purchase:import,warehouse:purchase:manage,warehouse:in:list,warehouse:in:add,warehouse:in:edit')") + @GetMapping("/category/options") + public AjaxResult categoryOptions() + { + ExpenseCategory query = new ExpenseCategory(); + query.setStatus(0); + return success(projectBudgetService.selectExpenseCategoryList(query)); + } + + @PreAuthorize("@ss.hasAnyPermi('project:category:edit,project:project:manage')") + @GetMapping("/category/list") + public AjaxResult categoryList(ExpenseCategory category) + { + List list = projectBudgetService.selectExpenseCategoryList(category); + return success(list); + } + + @PreAuthorize("@ss.hasPermi('project:category:edit')") + @Log(title = "经费科目", businessType = BusinessType.INSERT) + @PostMapping("/category") + public AjaxResult addCategory(@RequestBody ExpenseCategory category) + { + return toAjax(projectBudgetService.insertExpenseCategory(category)); + } + + @PreAuthorize("@ss.hasPermi('project:category:edit')") + @Log(title = "经费科目", businessType = BusinessType.UPDATE) + @PutMapping("/category") + public AjaxResult editCategory(@RequestBody ExpenseCategory category) + { + return toAjax(projectBudgetService.updateExpenseCategory(category)); + } + + @PreAuthorize("@ss.hasPermi('project:category:edit')") + @Log(title = "经费科目", businessType = BusinessType.DELETE) + @DeleteMapping("/category/{id}") + public AjaxResult removeCategory(@PathVariable Long id) + { + return toAjax(projectBudgetService.deleteExpenseCategory(id)); + } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/warehouse/AgentController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/warehouse/AgentController.java index d7eb058..26a7b90 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/warehouse/AgentController.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/warehouse/AgentController.java @@ -1,12 +1,16 @@ package com.ruoyi.web.controller.warehouse; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import com.ruoyi.common.core.controller.BaseController; import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.annotation.RepeatSubmit; +import com.ruoyi.common.annotation.RateLimiter; +import com.ruoyi.common.enums.LimitType; import com.ruoyi.warehouse.domain.agent.AgentAnalyzeRequest; import com.ruoyi.warehouse.domain.agent.AgentExecuteRequest; import com.ruoyi.warehouse.service.IAgentService; @@ -19,12 +23,16 @@ public class AgentController extends BaseController private IAgentService agentService; @PostMapping("/analyze") + @PreAuthorize("@ss.hasPermi('ai:assistant:use')") + @RateLimiter(time = 60, count = 20, limitType = LimitType.IP) public AjaxResult analyze(@RequestBody AgentAnalyzeRequest request) { return success(agentService.analyze(request.getText(), request.getContextResults(), getUsername())); } @PostMapping("/execute") + @PreAuthorize("@ss.hasPermi('ai:assistant:use')") + @RepeatSubmit(interval = 3000, message = "操作正在执行,请勿重复提交") public AjaxResult execute(@RequestBody AgentExecuteRequest request) { return success(agentService.execute(request.getPlan(), getUsername())); diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/warehouse/PurchaseRequestController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/warehouse/PurchaseRequestController.java index 45b8e7e..3b83cf8 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/warehouse/PurchaseRequestController.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/warehouse/PurchaseRequestController.java @@ -1,6 +1,7 @@ package com.ruoyi.web.controller.warehouse; import java.util.List; +import java.util.Set; import jakarta.servlet.http.HttpServletResponse; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.access.prepost.PreAuthorize; @@ -15,10 +16,12 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; import com.ruoyi.common.annotation.Log; +import com.ruoyi.common.annotation.RateLimiter; import com.ruoyi.common.core.controller.BaseController; import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.common.core.page.TableDataInfo; import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.common.enums.LimitType; import com.ruoyi.common.utils.poi.ExcelUtil; import com.ruoyi.warehouse.domain.PurchaseBatch; import com.ruoyi.warehouse.domain.PurchaseBatchAssignRequest; @@ -32,6 +35,10 @@ import com.ruoyi.warehouse.service.IPurchaseBatchService; import com.ruoyi.warehouse.service.IProductInfoService; import com.ruoyi.warehouse.service.IPurchaseRequestService; import com.ruoyi.warehouse.service.IPurchaseSmartImportService; +import com.ruoyi.warehouse.util.WarehouseFileUtils; +import com.ruoyi.project.domain.ExpenseCategory; +import com.ruoyi.project.service.IProjectBudgetService; +import com.ruoyi.project.service.IProjectInfoService; @RestController @RequestMapping("/warehouse/purchase") @@ -52,6 +59,12 @@ public class PurchaseRequestController extends BaseController @Autowired private IPurchaseSmartImportService purchaseSmartImportService; + @Autowired + private IProjectInfoService projectInfoService; + + @Autowired + private IProjectBudgetService projectBudgetService; + @PreAuthorize("@ss.hasAnyPermi('warehouse:purchase:list,warehouse:purchase:manage')") @GetMapping("/list") public TableDataInfo list(PurchaseRequest purchaseRequest) @@ -74,6 +87,7 @@ public class PurchaseRequestController extends BaseController @PostMapping("/importData") public AjaxResult importData(MultipartFile file) throws Exception { + WarehouseFileUtils.validateUpload(file, 10L * 1024 * 1024, Set.of("xls", "xlsx")); ExcelUtil util = new ExcelUtil(PurchaseRequest.class); List requestList = util.importExcel(file.getInputStream()); return success(purchaseRequestService.importPurchaseRequest(requestList, getUsername())); @@ -82,6 +96,7 @@ public class PurchaseRequestController extends BaseController @PreAuthorize("@ss.hasAnyPermi('warehouse:purchase:add,warehouse:purchase:manage')") @Log(title = "采购申请智能导入", businessType = BusinessType.IMPORT) @PostMapping("/smart-import") + @RateLimiter(time = 60, count = 10, limitType = LimitType.IP) public AjaxResult smartImport(MultipartFile file) throws Exception { return success(purchaseSmartImportService.analyze(file)); @@ -91,10 +106,14 @@ public class PurchaseRequestController extends BaseController @PostMapping("/importTemplate") public void importTemplate(HttpServletResponse response) throws Exception { - PurchaseTemplateExcelUtil.write(response, productInfoService.selectProductInfoAll()); + ExpenseCategory query = new ExpenseCategory(); + query.setStatus(0); + PurchaseTemplateExcelUtil.write(response, productInfoService.selectProductInfoAll(), + projectInfoService.selectProjectOptions(null, false), + projectBudgetService.selectExpenseCategoryList(query)); } - @PreAuthorize("@ss.hasAnyPermi('warehouse:purchase:query,warehouse:purchase:manage')") + @PreAuthorize("@ss.hasAnyPermi('warehouse:purchase:list,warehouse:purchase:query,warehouse:purchase:manage')") @GetMapping("/{id}") public AjaxResult getInfo(@PathVariable Long id) { diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/warehouse/PurchaseTemplateExcelUtil.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/warehouse/PurchaseTemplateExcelUtil.java index ab09efb..1461a8f 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/warehouse/PurchaseTemplateExcelUtil.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/warehouse/PurchaseTemplateExcelUtil.java @@ -26,17 +26,19 @@ import com.ruoyi.common.utils.StringUtils; import com.ruoyi.common.utils.file.FileUtils; import com.ruoyi.warehouse.domain.ProductInfo; import com.ruoyi.warehouse.domain.PurchaseRequest; +import com.ruoyi.project.domain.ExpenseCategory; +import com.ruoyi.project.domain.ProjectInfo; public final class PurchaseTemplateExcelUtil { private static final String[] HEADERS = { - "关联物料编码", "物料名称", "品牌", "类别", "参数规格", "采购链接", - "需求数量", "单位", "期望到货日期", "产品备注", "申请备注" + "项目编码", "经费科目编码", "关联物料编码", "物料名称", "品牌", "类别", "参数规格", "采购链接", + "需求数量", "预计含税单价", "单位", "期望到货日期", "产品备注", "申请备注" }; private static final String[] EXPORT_HEADERS = { - "关联物料编码", "物料名称", "品牌", "类别", "参数规格", "采购链接", - "需求数量", "单位", "期望到货日期", "申请人", "采购状态", "采购批次", "进度说明", + "项目编码", "项目名称", "经费科目编码", "经费科目", "关联物料编码", "物料名称", "品牌", "类别", "参数规格", "采购链接", + "需求数量", "预计含税单价", "预计总金额", "单位", "期望到货日期", "申请人", "采购状态", "采购批次", "进度说明", "产品备注", "申请备注", "已验收数量", "待验收数量" }; @@ -44,19 +46,21 @@ public final class PurchaseTemplateExcelUtil { } - public static void write(HttpServletResponse response, List products) throws IOException + public static void write(HttpServletResponse response, List products, + List projects, List categories) throws IOException { response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setCharacterEncoding("utf-8"); FileUtils.setAttachmentResponseHeader(response, "采购申请导入模板.xlsx"); - try (XSSFWorkbook workbook = createTemplateWorkbook(products)) + try (XSSFWorkbook workbook = createTemplateWorkbook(products, projects, categories)) { workbook.write(response.getOutputStream()); } } - static XSSFWorkbook createTemplateWorkbook(List products) + static XSSFWorkbook createTemplateWorkbook(List products, + List projects, List categories) { XSSFWorkbook workbook = new XSSFWorkbook(); Sheet sheet = workbook.createSheet("采购申请数据"); @@ -72,12 +76,14 @@ public final class PurchaseTemplateExcelUtil } sheet.createFreezePane(0, 1); - addOptionColumn(workbook, sheet, options, 0, "物料编码", values(products, "code")); - addOptionColumn(workbook, sheet, options, 1, "物料名称", values(products, "name")); - addOptionColumn(workbook, sheet, options, 2, "品牌", values(products, "brand")); - addOptionColumn(workbook, sheet, options, 3, "类别", values(products, "category")); - addOptionColumn(workbook, sheet, options, 4, "参数规格", values(products, "spec")); - addOptionColumn(workbook, sheet, options, 7, "单位", values(products, "unit")); + addOptionColumn(workbook, sheet, options, 0, "项目编码", projectValues(projects)); + addOptionColumn(workbook, sheet, options, 1, "经费科目编码", categoryValues(categories)); + addOptionColumn(workbook, sheet, options, 2, "物料编码", values(products, "code")); + addOptionColumn(workbook, sheet, options, 3, "物料名称", values(products, "name")); + addOptionColumn(workbook, sheet, options, 4, "品牌", values(products, "brand")); + addOptionColumn(workbook, sheet, options, 5, "类别", values(products, "category")); + addOptionColumn(workbook, sheet, options, 6, "参数规格", values(products, "spec")); + addOptionColumn(workbook, sheet, options, 10, "单位", values(products, "unit")); workbook.setSheetVisibility(workbook.getSheetIndex(options), SheetVisibility.HIDDEN); return workbook; } @@ -110,9 +116,10 @@ public final class PurchaseTemplateExcelUtil PurchaseRequest request = requests.get(rowIndex); Row row = sheet.createRow(rowIndex + 1); Object[] values = { - request.getProductCode(), request.getMaterialName(), request.getMaterialBrand(), + request.getProjectCode(), request.getProjectName(), request.getExpenseCategoryCode(), + request.getExpenseCategoryName(), request.getProductCode(), request.getMaterialName(), request.getMaterialBrand(), request.getCategory(), request.getMaterialParams(), - request.getPurchaseLink(), request.getQuantity(), request.getUnit(), + request.getPurchaseLink(), request.getQuantity(), request.getEstimatedUnitPrice(), request.getEstimatedAmount(), request.getUnit(), request.getExpectedDate() == null ? null : dateFormat.format(request.getExpectedDate()), request.getApplicantName(), purchaseStatusLabel(request.getPurchaseStatus()), request.getPurchaseBatchNo(), request.getProgressRemark(), request.getProductRemark(), @@ -124,12 +131,12 @@ public final class PurchaseTemplateExcelUtil } } - addOptionColumn(workbook, sheet, options, 0, "物料编码", values(products, "code")); - addOptionColumn(workbook, sheet, options, 1, "物料名称", values(products, "name")); - addOptionColumn(workbook, sheet, options, 2, "品牌", values(products, "brand")); - addOptionColumn(workbook, sheet, options, 3, "类别", values(products, "category")); - addOptionColumn(workbook, sheet, options, 4, "参数规格", values(products, "spec")); - addOptionColumn(workbook, sheet, options, 7, "单位", values(products, "unit")); + addOptionColumn(workbook, sheet, options, 4, "物料编码", values(products, "code")); + addOptionColumn(workbook, sheet, options, 5, "物料名称", values(products, "name")); + addOptionColumn(workbook, sheet, options, 6, "品牌", values(products, "brand")); + addOptionColumn(workbook, sheet, options, 7, "类别", values(products, "category")); + addOptionColumn(workbook, sheet, options, 8, "参数规格", values(products, "spec")); + addOptionColumn(workbook, sheet, options, 13, "单位", values(products, "unit")); workbook.setSheetVisibility(workbook.getSheetIndex(options), SheetVisibility.HIDDEN); workbook.write(response.getOutputStream()); } @@ -203,6 +210,26 @@ public final class PurchaseTemplateExcelUtil return new ArrayList<>(values); } + private static List projectValues(List projects) + { + List values = new ArrayList<>(); + for (ProjectInfo project : projects) + { + if (StringUtils.isNotBlank(project.getProjectCode())) values.add(project.getProjectCode()); + } + return values; + } + + private static List categoryValues(List categories) + { + List values = new ArrayList<>(); + for (ExpenseCategory category : categories) + { + if (StringUtils.isNotBlank(category.getCategoryCode())) values.add(category.getCategoryCode()); + } + return values; + } + private static CellStyle createHeaderStyle(XSSFWorkbook workbook) { CellStyle style = workbook.createCellStyle(); diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/warehouse/StockInfoController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/warehouse/StockInfoController.java index bc91555..4e846af 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/warehouse/StockInfoController.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/warehouse/StockInfoController.java @@ -23,7 +23,9 @@ import com.ruoyi.common.utils.StringUtils; import com.ruoyi.common.utils.poi.ExcelUtil; import com.ruoyi.warehouse.domain.OutRecord; import com.ruoyi.warehouse.domain.StockInfo; +import com.ruoyi.warehouse.domain.StockLot; import com.ruoyi.warehouse.service.IStockInfoService; +import com.ruoyi.warehouse.service.IStockLotService; @RestController @RequestMapping("/warehouse/stock") @@ -32,6 +34,9 @@ public class StockInfoController extends BaseController @Autowired private IStockInfoService stockInfoService; + @Autowired + private IStockLotService stockLotService; + @PreAuthorize("@ss.hasPermi('warehouse:stock:list')") @GetMapping("/list") public TableDataInfo list(StockInfo stockInfo) @@ -71,6 +76,13 @@ public class StockInfoController extends BaseController util.exportExcel(response, list, "库存数据"); } + @PreAuthorize("@ss.hasPermi('warehouse:stock:query')") + @GetMapping("/lots") + public AjaxResult lots(StockLot lot) + { + return success(stockLotService.selectStockLotList(lot)); + } + @PreAuthorize("@ss.hasPermi('warehouse:stock:query')") @GetMapping("/{id}") public AjaxResult getInfo(@PathVariable Long id) diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/warehouse/WarehouseUserController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/warehouse/WarehouseUserController.java index bc18e42..c319014 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/warehouse/WarehouseUserController.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/warehouse/WarehouseUserController.java @@ -2,6 +2,7 @@ package com.ruoyi.web.controller.warehouse; import java.util.List; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -19,11 +20,18 @@ public class WarehouseUserController extends BaseController private ISysUserService userService; @GetMapping("/optionselect") + @PreAuthorize("@ss.hasAnyPermi('warehouse:in:list,warehouse:in:add,warehouse:in:edit,warehouse:out:list,warehouse:out:add,warehouse:stock:list,warehouse:stock:out,warehouse:purchase:list,warehouse:purchase:manage')") public AjaxResult optionselect() { SysUser user = new SysUser(); user.setStatus(UserConstants.NORMAL); - List users = userService.selectUserList(user); + List users = userService.selectUserList(user).stream() + .map(item -> new WarehouseUserOption(item.getUserId(), item.getUserName(), item.getNickName())) + .toList(); return success(users); } + + public record WarehouseUserOption(Long userId, String userName, String nickName) + { + } } diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/warehouse/WmsBatchImportController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/warehouse/WmsBatchImportController.java index b15f544..4dc7743 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/warehouse/WmsBatchImportController.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/warehouse/WmsBatchImportController.java @@ -1,6 +1,7 @@ package com.ruoyi.web.controller.warehouse; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -11,10 +12,13 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; import com.ruoyi.common.annotation.Log; +import com.ruoyi.common.annotation.RepeatSubmit; +import com.ruoyi.common.annotation.RateLimiter; import com.ruoyi.common.core.controller.BaseController; import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.common.core.page.TableDataInfo; import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.common.enums.LimitType; import com.ruoyi.warehouse.domain.WmsBatchActionUpdateRequest; import com.ruoyi.warehouse.domain.WmsBatchItem; import com.ruoyi.warehouse.domain.WmsBatchJob; @@ -28,6 +32,7 @@ public class WmsBatchImportController extends BaseController private IWmsBatchImportService batchImportService; @GetMapping("/list") + @PreAuthorize("@ss.hasAnyPermi('warehouse:stock:list,warehouse:in:list,warehouse:out:list,warehouse:check:list,warehouse:product:list,warehouse:area:list')") public TableDataInfo list(WmsBatchJob job) { startPage(); @@ -35,6 +40,8 @@ public class WmsBatchImportController extends BaseController } @PostMapping("/upload") + @PreAuthorize("@ss.hasAnyPermi('warehouse:in:add,warehouse:stock:out,warehouse:out:add,warehouse:check:add,warehouse:product:add,warehouse:area:add')") + @RateLimiter(time = 60, count = 10, limitType = LimitType.IP) @Log(title = "WMS批量导入", businessType = BusinessType.IMPORT) public AjaxResult upload(MultipartFile file) throws Exception { @@ -42,36 +49,43 @@ public class WmsBatchImportController extends BaseController } @GetMapping("/{id}") + @PreAuthorize("@ss.hasAnyPermi('warehouse:stock:list,warehouse:in:list,warehouse:out:list,warehouse:check:list,warehouse:product:list,warehouse:area:list')") public AjaxResult getInfo(@PathVariable Long id) { return success(batchImportService.selectJobById(id)); } @GetMapping("/{jobId}/items") + @PreAuthorize("@ss.hasAnyPermi('warehouse:stock:list,warehouse:in:list,warehouse:out:list,warehouse:check:list,warehouse:product:list,warehouse:area:list')") public AjaxResult items(@PathVariable Long jobId) { return success(batchImportService.selectItemList(jobId)); } @PutMapping("/item") + @PreAuthorize("@ss.hasAnyPermi('warehouse:in:add,warehouse:in:edit,warehouse:stock:out,warehouse:out:add,warehouse:check:add,warehouse:check:edit,warehouse:product:add,warehouse:area:add')") public AjaxResult updateItem(@RequestBody WmsBatchItem item) { return success(batchImportService.updateItem(item)); } @PutMapping("/{jobId}/items/action") + @PreAuthorize("@ss.hasAnyPermi('warehouse:in:add,warehouse:in:edit,warehouse:stock:out,warehouse:out:add,warehouse:check:add,warehouse:check:edit,warehouse:product:add,warehouse:area:add')") public AjaxResult updateItemAction(@PathVariable Long jobId, @RequestBody WmsBatchActionUpdateRequest request) { return toAjax(batchImportService.updateItemAction(jobId, request.getActionType(), request.getItemIds())); } @DeleteMapping("/item/{id}") + @PreAuthorize("@ss.hasAnyPermi('warehouse:in:add,warehouse:in:edit,warehouse:stock:out,warehouse:out:add,warehouse:check:add,warehouse:check:edit,warehouse:product:add,warehouse:area:add')") public AjaxResult deleteItem(@PathVariable Long id) { return toAjax(batchImportService.deleteItem(id)); } @PostMapping("/{jobId}/confirm") + @PreAuthorize("@ss.hasAnyPermi('warehouse:in:add,warehouse:stock:out,warehouse:out:add,warehouse:check:add,warehouse:product:add,warehouse:area:add')") + @RepeatSubmit(interval = 5000, message = "批量任务正在执行,请勿重复提交") @Log(title = "WMS批量确认执行", businessType = BusinessType.INSERT) public AjaxResult confirm(@PathVariable Long jobId) { diff --git a/ruoyi-system/src/main/java/com/ruoyi/project/domain/ExpenseCategory.java b/ruoyi-system/src/main/java/com/ruoyi/project/domain/ExpenseCategory.java new file mode 100644 index 0000000..2275990 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/project/domain/ExpenseCategory.java @@ -0,0 +1,25 @@ +package com.ruoyi.project.domain; + +import com.ruoyi.common.core.domain.BaseEntity; + +public class ExpenseCategory extends BaseEntity +{ + private static final long serialVersionUID = 1L; + + private Long id; + private String categoryCode; + private String categoryName; + private Integer sortOrder; + private Integer status; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public String getCategoryCode() { return categoryCode; } + public void setCategoryCode(String categoryCode) { this.categoryCode = categoryCode; } + public String getCategoryName() { return categoryName; } + public void setCategoryName(String categoryName) { this.categoryName = categoryName; } + public Integer getSortOrder() { return sortOrder; } + public void setSortOrder(Integer sortOrder) { this.sortOrder = sortOrder; } + public Integer getStatus() { return status; } + public void setStatus(Integer status) { this.status = status; } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/project/domain/ProjectBudget.java b/ruoyi-system/src/main/java/com/ruoyi/project/domain/ProjectBudget.java new file mode 100644 index 0000000..a4661a9 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/project/domain/ProjectBudget.java @@ -0,0 +1,50 @@ +package com.ruoyi.project.domain; + +import java.math.BigDecimal; +import com.ruoyi.common.core.domain.BaseEntity; + +public class ProjectBudget extends BaseEntity +{ + private static final long serialVersionUID = 1L; + + private Long id; + private Long projectId; + private String projectCode; + private String projectName; + private Long expenseCategoryId; + private String categoryCode; + private String categoryName; + private BigDecimal budgetAmount; + private BigDecimal committedAmount; + private BigDecimal spentAmount; + private BigDecimal availableAmount; + private BigDecimal alertPercent; + private Integer version; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public Long getProjectId() { return projectId; } + public void setProjectId(Long projectId) { this.projectId = projectId; } + public String getProjectCode() { return projectCode; } + public void setProjectCode(String projectCode) { this.projectCode = projectCode; } + public String getProjectName() { return projectName; } + public void setProjectName(String projectName) { this.projectName = projectName; } + public Long getExpenseCategoryId() { return expenseCategoryId; } + public void setExpenseCategoryId(Long expenseCategoryId) { this.expenseCategoryId = expenseCategoryId; } + public String getCategoryCode() { return categoryCode; } + public void setCategoryCode(String categoryCode) { this.categoryCode = categoryCode; } + public String getCategoryName() { return categoryName; } + public void setCategoryName(String categoryName) { this.categoryName = categoryName; } + public BigDecimal getBudgetAmount() { return budgetAmount; } + public void setBudgetAmount(BigDecimal budgetAmount) { this.budgetAmount = budgetAmount; } + public BigDecimal getCommittedAmount() { return committedAmount; } + public void setCommittedAmount(BigDecimal committedAmount) { this.committedAmount = committedAmount; } + public BigDecimal getSpentAmount() { return spentAmount; } + public void setSpentAmount(BigDecimal spentAmount) { this.spentAmount = spentAmount; } + public BigDecimal getAvailableAmount() { return availableAmount; } + public void setAvailableAmount(BigDecimal availableAmount) { this.availableAmount = availableAmount; } + public BigDecimal getAlertPercent() { return alertPercent; } + public void setAlertPercent(BigDecimal alertPercent) { this.alertPercent = alertPercent; } + public Integer getVersion() { return version; } + public void setVersion(Integer version) { this.version = version; } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/project/domain/ProjectFundLedger.java b/ruoyi-system/src/main/java/com/ruoyi/project/domain/ProjectFundLedger.java new file mode 100644 index 0000000..c445161 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/project/domain/ProjectFundLedger.java @@ -0,0 +1,62 @@ +package com.ruoyi.project.domain; + +import java.math.BigDecimal; +import com.ruoyi.common.core.domain.BaseEntity; + +public class ProjectFundLedger extends BaseEntity +{ + private static final long serialVersionUID = 1L; + + private Long id; + private String ledgerNo; + private Long projectId; + private String projectName; + private Long expenseCategoryId; + private String categoryName; + private String bizType; + private Long bizId; + private String bizNo; + private BigDecimal budgetDelta; + private BigDecimal committedDelta; + private BigDecimal spentDelta; + private BigDecimal balanceBudget; + private BigDecimal balanceCommitted; + private BigDecimal balanceSpent; + private String idempotencyKey; + private String operator; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public String getLedgerNo() { return ledgerNo; } + public void setLedgerNo(String ledgerNo) { this.ledgerNo = ledgerNo; } + public Long getProjectId() { return projectId; } + public void setProjectId(Long projectId) { this.projectId = projectId; } + public String getProjectName() { return projectName; } + public void setProjectName(String projectName) { this.projectName = projectName; } + public Long getExpenseCategoryId() { return expenseCategoryId; } + public void setExpenseCategoryId(Long expenseCategoryId) { this.expenseCategoryId = expenseCategoryId; } + public String getCategoryName() { return categoryName; } + public void setCategoryName(String categoryName) { this.categoryName = categoryName; } + public String getBizType() { return bizType; } + public void setBizType(String bizType) { this.bizType = bizType; } + public Long getBizId() { return bizId; } + public void setBizId(Long bizId) { this.bizId = bizId; } + public String getBizNo() { return bizNo; } + public void setBizNo(String bizNo) { this.bizNo = bizNo; } + public BigDecimal getBudgetDelta() { return budgetDelta; } + public void setBudgetDelta(BigDecimal budgetDelta) { this.budgetDelta = budgetDelta; } + public BigDecimal getCommittedDelta() { return committedDelta; } + public void setCommittedDelta(BigDecimal committedDelta) { this.committedDelta = committedDelta; } + public BigDecimal getSpentDelta() { return spentDelta; } + public void setSpentDelta(BigDecimal spentDelta) { this.spentDelta = spentDelta; } + public BigDecimal getBalanceBudget() { return balanceBudget; } + public void setBalanceBudget(BigDecimal balanceBudget) { this.balanceBudget = balanceBudget; } + public BigDecimal getBalanceCommitted() { return balanceCommitted; } + public void setBalanceCommitted(BigDecimal balanceCommitted) { this.balanceCommitted = balanceCommitted; } + public BigDecimal getBalanceSpent() { return balanceSpent; } + public void setBalanceSpent(BigDecimal balanceSpent) { this.balanceSpent = balanceSpent; } + public String getIdempotencyKey() { return idempotencyKey; } + public void setIdempotencyKey(String idempotencyKey) { this.idempotencyKey = idempotencyKey; } + public String getOperator() { return operator; } + public void setOperator(String operator) { this.operator = operator; } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/project/domain/ProjectInfo.java b/ruoyi-system/src/main/java/com/ruoyi/project/domain/ProjectInfo.java new file mode 100644 index 0000000..f093782 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/project/domain/ProjectInfo.java @@ -0,0 +1,63 @@ +package com.ruoyi.project.domain; + +import java.util.Date; +import java.util.List; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.ruoyi.common.core.domain.BaseEntity; + +public class ProjectInfo extends BaseEntity +{ + private static final long serialVersionUID = 1L; + + private Long id; + private String projectCode; + private String projectName; + private Long managerId; + private String managerName; + private Long deptId; + private String deptName; + @JsonFormat(pattern = "yyyy-MM-dd") + private Date startDate; + @JsonFormat(pattern = "yyyy-MM-dd") + private Date endDate; + private Integer projectStatus; + private String currency; + private List memberIds; + private java.math.BigDecimal budgetAmount; + private java.math.BigDecimal committedAmount; + private java.math.BigDecimal spentAmount; + private java.math.BigDecimal availableAmount; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public String getProjectCode() { return projectCode; } + public void setProjectCode(String projectCode) { this.projectCode = projectCode; } + public String getProjectName() { return projectName; } + public void setProjectName(String projectName) { this.projectName = projectName; } + public Long getManagerId() { return managerId; } + public void setManagerId(Long managerId) { this.managerId = managerId; } + public String getManagerName() { return managerName; } + public void setManagerName(String managerName) { this.managerName = managerName; } + public Long getDeptId() { return deptId; } + public void setDeptId(Long deptId) { this.deptId = deptId; } + public String getDeptName() { return deptName; } + public void setDeptName(String deptName) { this.deptName = deptName; } + public Date getStartDate() { return startDate; } + public void setStartDate(Date startDate) { this.startDate = startDate; } + public Date getEndDate() { return endDate; } + public void setEndDate(Date endDate) { this.endDate = endDate; } + public Integer getProjectStatus() { return projectStatus; } + public void setProjectStatus(Integer projectStatus) { this.projectStatus = projectStatus; } + public String getCurrency() { return currency; } + public void setCurrency(String currency) { this.currency = currency; } + public List getMemberIds() { return memberIds; } + public void setMemberIds(List memberIds) { this.memberIds = memberIds; } + public java.math.BigDecimal getBudgetAmount() { return budgetAmount; } + public void setBudgetAmount(java.math.BigDecimal budgetAmount) { this.budgetAmount = budgetAmount; } + public java.math.BigDecimal getCommittedAmount() { return committedAmount; } + public void setCommittedAmount(java.math.BigDecimal committedAmount) { this.committedAmount = committedAmount; } + public java.math.BigDecimal getSpentAmount() { return spentAmount; } + public void setSpentAmount(java.math.BigDecimal spentAmount) { this.spentAmount = spentAmount; } + public java.math.BigDecimal getAvailableAmount() { return availableAmount; } + public void setAvailableAmount(java.math.BigDecimal availableAmount) { this.availableAmount = availableAmount; } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/project/mapper/ProjectBudgetMapper.java b/ruoyi-system/src/main/java/com/ruoyi/project/mapper/ProjectBudgetMapper.java new file mode 100644 index 0000000..af5d351 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/project/mapper/ProjectBudgetMapper.java @@ -0,0 +1,43 @@ +package com.ruoyi.project.mapper; + +import java.util.List; +import org.apache.ibatis.annotations.Param; +import com.ruoyi.project.domain.ExpenseCategory; +import com.ruoyi.project.domain.ProjectBudget; +import com.ruoyi.project.domain.ProjectFundLedger; + +public interface ProjectBudgetMapper +{ + List selectExpenseCategoryList(ExpenseCategory category); + + ExpenseCategory selectExpenseCategoryById(Long id); + + ExpenseCategory selectExpenseCategoryByCode(String categoryCode); + + int insertExpenseCategory(ExpenseCategory category); + + int updateExpenseCategory(ExpenseCategory category); + + int deleteExpenseCategory(Long id); + + int countCategoryBusiness(Long id); + + List selectProjectBudgetList(@Param("projectId") Long projectId, + @Param("userId") Long userId); + + ProjectBudget selectProjectBudgetById(Long id); + + ProjectBudget selectProjectBudgetForUpdate(@Param("projectId") Long projectId, + @Param("expenseCategoryId") Long expenseCategoryId); + + int insertProjectBudget(ProjectBudget budget); + + int updateProjectBudgetBalance(ProjectBudget budget); + + List selectFundLedgerList(@Param("ledger") ProjectFundLedger ledger, + @Param("userId") Long userId); + + ProjectFundLedger selectLedgerByIdempotencyKey(String idempotencyKey); + + int insertFundLedger(ProjectFundLedger ledger); +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/project/mapper/ProjectInfoMapper.java b/ruoyi-system/src/main/java/com/ruoyi/project/mapper/ProjectInfoMapper.java new file mode 100644 index 0000000..e7334cd --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/project/mapper/ProjectInfoMapper.java @@ -0,0 +1,36 @@ +package com.ruoyi.project.mapper; + +import java.util.List; +import org.apache.ibatis.annotations.Param; +import com.ruoyi.project.domain.ProjectInfo; + +public interface ProjectInfoMapper +{ + List selectProjectInfoList(@Param("project") ProjectInfo project, @Param("userId") Long userId); + + ProjectInfo selectProjectInfoById(@Param("id") Long id, @Param("userId") Long userId); + + ProjectInfo selectProjectInfoByCode(String projectCode); + + List selectProjectOptions(@Param("keyword") String keyword, @Param("userId") Long userId, + @Param("includeArchived") boolean includeArchived); + + int insertProjectInfo(ProjectInfo project); + + int updateProjectInfo(ProjectInfo project); + + int deleteProjectInfo(Long id); + + int countProjectBusiness(Long projectId); + + int deleteProjectMembers(Long projectId); + + int insertProjectMember(@Param("projectId") Long projectId, @Param("userId") Long userId, + @Param("memberRole") String memberRole, @Param("createBy") String createBy); + + List selectProjectMemberIds(Long projectId); + + int countProjectAccess(@Param("projectId") Long projectId, @Param("userId") Long userId); + + Long selectLegacyProjectId(); +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/project/service/IProjectBudgetService.java b/ruoyi-system/src/main/java/com/ruoyi/project/service/IProjectBudgetService.java new file mode 100644 index 0000000..97ab93c --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/project/service/IProjectBudgetService.java @@ -0,0 +1,33 @@ +package com.ruoyi.project.service; + +import java.math.BigDecimal; +import java.util.List; +import com.ruoyi.project.domain.ExpenseCategory; +import com.ruoyi.project.domain.ProjectBudget; +import com.ruoyi.project.domain.ProjectFundLedger; + +public interface IProjectBudgetService +{ + List selectExpenseCategoryList(ExpenseCategory category); + + int insertExpenseCategory(ExpenseCategory category); + + int updateExpenseCategory(ExpenseCategory category); + + int deleteExpenseCategory(Long id); + + List selectProjectBudgetList(Long projectId); + + int saveProjectBudget(ProjectBudget budget); + + List selectFundLedgerList(ProjectFundLedger ledger); + + void changeCommitment(Long projectId, Long categoryId, BigDecimal delta, String bizType, + Long bizId, String bizNo, String idempotencyKey, String remark); + + void settleExpense(Long projectId, Long categoryId, BigDecimal committedRelease, BigDecimal actualAmount, + String bizType, Long bizId, String bizNo, String idempotencyKey, String remark); + + void reverseSettlement(Long projectId, Long categoryId, BigDecimal committedRestore, BigDecimal actualAmount, + String bizType, Long bizId, String bizNo, String idempotencyKey, String remark); +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/project/service/IProjectInfoService.java b/ruoyi-system/src/main/java/com/ruoyi/project/service/IProjectInfoService.java new file mode 100644 index 0000000..7b1c5fe --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/project/service/IProjectInfoService.java @@ -0,0 +1,25 @@ +package com.ruoyi.project.service; + +import java.util.List; +import com.ruoyi.project.domain.ProjectInfo; + +public interface IProjectInfoService +{ + List selectProjectInfoList(ProjectInfo project); + + ProjectInfo selectProjectInfoById(Long id); + + List selectProjectOptions(String keyword, boolean includeArchived); + + int insertProjectInfo(ProjectInfo project); + + int updateProjectInfo(ProjectInfo project); + + int deleteProjectInfo(Long id); + + void requireProjectAccess(Long projectId); + + void requireActiveProject(Long projectId); + + Long selectLegacyProjectId(); +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/project/service/impl/ProjectBudgetServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/project/service/impl/ProjectBudgetServiceImpl.java new file mode 100644 index 0000000..fe9f5e4 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/project/service/impl/ProjectBudgetServiceImpl.java @@ -0,0 +1,273 @@ +package com.ruoyi.project.service.impl; + +import java.math.BigDecimal; +import java.util.List; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.SecurityUtils; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.project.domain.ExpenseCategory; +import com.ruoyi.project.domain.ProjectBudget; +import com.ruoyi.project.domain.ProjectFundLedger; +import com.ruoyi.project.mapper.ProjectBudgetMapper; +import com.ruoyi.project.service.IProjectBudgetService; +import com.ruoyi.project.service.IProjectInfoService; +import com.ruoyi.warehouse.util.WarehouseNoUtils; + +@Service +public class ProjectBudgetServiceImpl implements IProjectBudgetService +{ + private static final BigDecimal ZERO = BigDecimal.ZERO.setScale(2); + + @Autowired + private ProjectBudgetMapper projectBudgetMapper; + + @Autowired + private IProjectInfoService projectInfoService; + + @Override + public List selectExpenseCategoryList(ExpenseCategory category) + { + return projectBudgetMapper.selectExpenseCategoryList(category); + } + + @Override + public int insertExpenseCategory(ExpenseCategory category) + { + normalizeCategory(category); + if (projectBudgetMapper.selectExpenseCategoryByCode(category.getCategoryCode()) != null) + { + throw new ServiceException("经费科目编码已存在"); + } + category.setCreateBy(SecurityUtils.getUsername()); + return projectBudgetMapper.insertExpenseCategory(category); + } + + @Override + public int updateExpenseCategory(ExpenseCategory category) + { + if (category.getId() == null) + { + throw new ServiceException("经费科目ID不能为空"); + } + ExpenseCategory existing = projectBudgetMapper.selectExpenseCategoryById(category.getId()); + if (existing == null || "LEGACY".equals(existing.getCategoryCode())) + { + throw new ServiceException("经费科目不存在或不能修改"); + } + category.setCategoryCode(existing.getCategoryCode()); + normalizeCategory(category); + category.setUpdateBy(SecurityUtils.getUsername()); + return projectBudgetMapper.updateExpenseCategory(category); + } + + @Override + public int deleteExpenseCategory(Long id) + { + ExpenseCategory existing = projectBudgetMapper.selectExpenseCategoryById(id); + if (existing == null || "LEGACY".equals(existing.getCategoryCode())) + { + throw new ServiceException("经费科目不存在或不能删除"); + } + if (projectBudgetMapper.countCategoryBusiness(id) > 0) + { + throw new ServiceException("经费科目已被项目或采购使用,不能删除,可改为停用"); + } + return projectBudgetMapper.deleteExpenseCategory(id); + } + + @Override + public List selectProjectBudgetList(Long projectId) + { + projectInfoService.requireProjectAccess(projectId); + return projectBudgetMapper.selectProjectBudgetList(projectId, currentScopeUserId()); + } + + @Override + @Transactional + public int saveProjectBudget(ProjectBudget budget) + { + if (budget.getProjectId() == null || budget.getExpenseCategoryId() == null) + { + throw new ServiceException("项目和经费科目不能为空"); + } + projectInfoService.requireProjectAccess(budget.getProjectId()); + if (projectBudgetMapper.selectExpenseCategoryById(budget.getExpenseCategoryId()) == null) + { + throw new ServiceException("经费科目不存在"); + } + BigDecimal target = money(budget.getBudgetAmount()); + if (target.signum() < 0) + { + throw new ServiceException("预算金额不能小于0"); + } + ProjectBudget existing = projectBudgetMapper.selectProjectBudgetForUpdate( + budget.getProjectId(), budget.getExpenseCategoryId()); + if (existing == null) + { + budget.setBudgetAmount(target); + budget.setCommittedAmount(ZERO); + budget.setSpentAmount(ZERO); + budget.setAlertPercent(validAlertPercent(budget.getAlertPercent())); + budget.setCreateBy(SecurityUtils.getUsername()); + int rows = projectBudgetMapper.insertProjectBudget(budget); + insertLedger(budget, target, ZERO, ZERO, "BUDGET_SET", budget.getId(), null, + "BUDGET:" + budget.getId() + ":CREATE", "设置项目预算"); + return rows; + } + if (target.compareTo(money(existing.getCommittedAmount()).add(money(existing.getSpentAmount()))) < 0) + { + throw new ServiceException("预算金额不能小于已占用与已支出之和"); + } + BigDecimal delta = target.subtract(money(existing.getBudgetAmount())); + existing.setBudgetAmount(target); + existing.setAlertPercent(validAlertPercent(budget.getAlertPercent())); + existing.setRemark(budget.getRemark()); + existing.setUpdateBy(SecurityUtils.getUsername()); + int rows = projectBudgetMapper.updateProjectBudgetBalance(existing); + if (delta.signum() != 0) + { + insertLedger(existing, delta, ZERO, ZERO, "BUDGET_ADJUST", existing.getId(), null, + null, StringUtils.defaultIfBlank(budget.getRemark(), "调整项目预算")); + } + return rows; + } + + @Override + public List selectFundLedgerList(ProjectFundLedger ledger) + { + if (ledger.getProjectId() != null) + { + projectInfoService.requireProjectAccess(ledger.getProjectId()); + } + return projectBudgetMapper.selectFundLedgerList(ledger, currentScopeUserId()); + } + + @Override + @Transactional + public void changeCommitment(Long projectId, Long categoryId, BigDecimal delta, String bizType, + Long bizId, String bizNo, String idempotencyKey, String remark) + { + post(projectId, categoryId, ZERO, money(delta), ZERO, bizType, bizId, bizNo, idempotencyKey, remark); + } + + @Override + @Transactional + public void settleExpense(Long projectId, Long categoryId, BigDecimal committedRelease, BigDecimal actualAmount, + String bizType, Long bizId, String bizNo, String idempotencyKey, String remark) + { + post(projectId, categoryId, ZERO, money(committedRelease).negate(), money(actualAmount), + bizType, bizId, bizNo, idempotencyKey, remark); + } + + @Override + @Transactional + public void reverseSettlement(Long projectId, Long categoryId, BigDecimal committedRestore, BigDecimal actualAmount, + String bizType, Long bizId, String bizNo, String idempotencyKey, String remark) + { + post(projectId, categoryId, ZERO, money(committedRestore), money(actualAmount).negate(), + bizType, bizId, bizNo, idempotencyKey, remark); + } + + private void post(Long projectId, Long categoryId, BigDecimal budgetDelta, BigDecimal committedDelta, + BigDecimal spentDelta, String bizType, Long bizId, String bizNo, String idempotencyKey, String remark) + { + if (StringUtils.isNotBlank(idempotencyKey) + && projectBudgetMapper.selectLedgerByIdempotencyKey(idempotencyKey) != null) + { + return; + } + ProjectBudget budget = projectBudgetMapper.selectProjectBudgetForUpdate(projectId, categoryId); + if (budget == null) + { + throw new ServiceException("项目尚未配置该经费科目的预算"); + } + BigDecimal newBudget = money(budget.getBudgetAmount()).add(budgetDelta); + BigDecimal newCommitted = money(budget.getCommittedAmount()).add(committedDelta); + BigDecimal newSpent = money(budget.getSpentAmount()).add(spentDelta); + if (newBudget.signum() < 0 || newCommitted.signum() < 0 || newSpent.signum() < 0) + { + throw new ServiceException("经费流水会导致预算、占用或支出出现负数"); + } + if (newCommitted.add(newSpent).compareTo(newBudget) > 0) + { + throw new ServiceException("项目经费不足,可用金额:" + + money(budget.getBudgetAmount()).subtract(money(budget.getCommittedAmount())) + .subtract(money(budget.getSpentAmount()))); + } + budget.setBudgetAmount(newBudget); + budget.setCommittedAmount(newCommitted); + budget.setSpentAmount(newSpent); + budget.setUpdateBy(SecurityUtils.getUsername()); + projectBudgetMapper.updateProjectBudgetBalance(budget); + insertLedger(budget, budgetDelta, committedDelta, spentDelta, bizType, bizId, bizNo, + idempotencyKey, remark); + } + + private void insertLedger(ProjectBudget budget, BigDecimal budgetDelta, BigDecimal committedDelta, + BigDecimal spentDelta, String bizType, Long bizId, String bizNo, String idempotencyKey, String remark) + { + ProjectFundLedger ledger = new ProjectFundLedger(); + ledger.setLedgerNo(WarehouseNoUtils.next("FUND")); + ledger.setProjectId(budget.getProjectId()); + ledger.setExpenseCategoryId(budget.getExpenseCategoryId()); + ledger.setBizType(bizType); + ledger.setBizId(bizId); + ledger.setBizNo(bizNo); + ledger.setBudgetDelta(money(budgetDelta)); + ledger.setCommittedDelta(money(committedDelta)); + ledger.setSpentDelta(money(spentDelta)); + ledger.setBalanceBudget(money(budget.getBudgetAmount())); + ledger.setBalanceCommitted(money(budget.getCommittedAmount())); + ledger.setBalanceSpent(money(budget.getSpentAmount())); + ledger.setIdempotencyKey(idempotencyKey); + ledger.setOperator(SecurityUtils.getUsername()); + ledger.setRemark(remark); + projectBudgetMapper.insertFundLedger(ledger); + } + + private void normalizeCategory(ExpenseCategory category) + { + category.setCategoryCode(StringUtils.trim(category.getCategoryCode())); + category.setCategoryName(StringUtils.trim(category.getCategoryName())); + if (StringUtils.isBlank(category.getCategoryCode()) || StringUtils.isBlank(category.getCategoryName())) + { + throw new ServiceException("经费科目编码和名称不能为空"); + } + if (!category.getCategoryCode().matches("[A-Za-z0-9_-]{2,64}")) + { + throw new ServiceException("经费科目编码格式不正确"); + } + if (category.getSortOrder() == null) + { + category.setSortOrder(0); + } + if (category.getStatus() == null) + { + category.setStatus(0); + } + } + + private BigDecimal validAlertPercent(BigDecimal value) + { + BigDecimal result = value == null ? new BigDecimal("80.00") : value; + if (result.signum() < 0 || result.compareTo(new BigDecimal("100")) > 0) + { + throw new ServiceException("预算预警比例必须在0到100之间"); + } + return result; + } + + private BigDecimal money(BigDecimal value) + { + return value == null ? ZERO : value.setScale(2, java.math.RoundingMode.HALF_UP); + } + + private Long currentScopeUserId() + { + return SecurityUtils.isAdmin() || SecurityUtils.hasPermi("project:project:manage") + ? null : SecurityUtils.getUserId(); + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/project/service/impl/ProjectInfoServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/project/service/impl/ProjectInfoServiceImpl.java new file mode 100644 index 0000000..2fb1f0b --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/project/service/impl/ProjectInfoServiceImpl.java @@ -0,0 +1,197 @@ +package com.ruoyi.project.service.impl; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.SecurityUtils; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.project.domain.ProjectInfo; +import com.ruoyi.project.mapper.ProjectInfoMapper; +import com.ruoyi.project.service.IProjectInfoService; + +@Service +public class ProjectInfoServiceImpl implements IProjectInfoService +{ + @Autowired + private ProjectInfoMapper projectInfoMapper; + + @Override + public List selectProjectInfoList(ProjectInfo project) + { + return projectInfoMapper.selectProjectInfoList(project, currentScopeUserId()); + } + + @Override + public ProjectInfo selectProjectInfoById(Long id) + { + ProjectInfo project = projectInfoMapper.selectProjectInfoById(id, currentScopeUserId()); + if (project == null) + { + throw new ServiceException("项目不存在或无权访问"); + } + project.setMemberIds(projectInfoMapper.selectProjectMemberIds(id)); + return project; + } + + @Override + public List selectProjectOptions(String keyword, boolean includeArchived) + { + return projectInfoMapper.selectProjectOptions(StringUtils.trim(keyword), currentScopeUserId(), includeArchived); + } + + @Override + @Transactional + public int insertProjectInfo(ProjectInfo project) + { + normalizeAndValidate(project); + if (projectInfoMapper.selectProjectInfoByCode(project.getProjectCode()) != null) + { + throw new ServiceException("项目编码已存在"); + } + project.setCreateBy(SecurityUtils.getUsername()); + int rows = projectInfoMapper.insertProjectInfo(project); + replaceMembers(project); + return rows; + } + + @Override + @Transactional + public int updateProjectInfo(ProjectInfo project) + { + if (project.getId() == null) + { + throw new ServiceException("项目ID不能为空"); + } + ProjectInfo existing = projectInfoMapper.selectProjectInfoById(project.getId(), null); + if (existing == null || "LEGACY".equals(existing.getProjectCode())) + { + throw new ServiceException("项目不存在或历史待归属项目不能修改"); + } + normalizeAndValidate(project); + ProjectInfo sameCode = projectInfoMapper.selectProjectInfoByCode(project.getProjectCode()); + if (sameCode != null && !sameCode.getId().equals(project.getId())) + { + throw new ServiceException("项目编码已存在"); + } + project.setUpdateBy(SecurityUtils.getUsername()); + int rows = projectInfoMapper.updateProjectInfo(project); + replaceMembers(project); + return rows; + } + + @Override + @Transactional + public int deleteProjectInfo(Long id) + { + ProjectInfo project = projectInfoMapper.selectProjectInfoById(id, null); + if (project == null || "LEGACY".equals(project.getProjectCode())) + { + throw new ServiceException("项目不存在或不能删除"); + } + if (projectInfoMapper.countProjectBusiness(id) > 0) + { + throw new ServiceException("项目已有预算、采购、库存或出库业务,不能删除,可改为已归档"); + } + projectInfoMapper.deleteProjectMembers(id); + return projectInfoMapper.deleteProjectInfo(id); + } + + @Override + public void requireProjectAccess(Long projectId) + { + if (projectId == null) + { + throw new ServiceException("项目不能为空"); + } + if (!isManageAll() && projectInfoMapper.countProjectAccess(projectId, SecurityUtils.getUserId()) == 0) + { + throw new ServiceException("无权使用该项目"); + } + if (projectInfoMapper.selectProjectInfoById(projectId, null) == null) + { + throw new ServiceException("项目不存在"); + } + } + + @Override + public void requireActiveProject(Long projectId) + { + requireProjectAccess(projectId); + ProjectInfo project = projectInfoMapper.selectProjectInfoById(projectId, null); + if (project.getProjectStatus() == null || (project.getProjectStatus() != 0 && project.getProjectStatus() != 1)) + { + throw new ServiceException("项目已完成或归档,不能新增业务"); + } + } + + @Override + public Long selectLegacyProjectId() + { + return projectInfoMapper.selectLegacyProjectId(); + } + + private void replaceMembers(ProjectInfo project) + { + projectInfoMapper.deleteProjectMembers(project.getId()); + Set memberIds = new LinkedHashSet<>(); + if (project.getMemberIds() != null) + { + memberIds.addAll(project.getMemberIds()); + } + if (project.getManagerId() != null) + { + memberIds.add(project.getManagerId()); + } + for (Long userId : memberIds) + { + if (userId != null) + { + projectInfoMapper.insertProjectMember(project.getId(), userId, + userId.equals(project.getManagerId()) ? "MANAGER" : "MEMBER", SecurityUtils.getUsername()); + } + } + } + + private void normalizeAndValidate(ProjectInfo project) + { + project.setProjectCode(StringUtils.trim(project.getProjectCode())); + project.setProjectName(StringUtils.trim(project.getProjectName())); + if (StringUtils.isBlank(project.getProjectCode()) || StringUtils.isBlank(project.getProjectName())) + { + throw new ServiceException("项目编码和项目名称不能为空"); + } + if (!project.getProjectCode().matches("[A-Za-z0-9_-]{2,64}")) + { + throw new ServiceException("项目编码只能包含字母、数字、下划线和中划线"); + } + if (project.getStartDate() != null && project.getEndDate() != null + && project.getStartDate().after(project.getEndDate())) + { + throw new ServiceException("项目结束日期不能早于开始日期"); + } + if (project.getProjectStatus() == null) + { + project.setProjectStatus(1); + } + if (StringUtils.isBlank(project.getCurrency())) + { + project.setCurrency("CNY"); + } + } + + private Long currentScopeUserId() + { + return isManageAll() ? null : SecurityUtils.getUserId(); + } + + private boolean isManageAll() + { + return SecurityUtils.isAdmin() + || SecurityUtils.hasPermi("project:project:manage") + || SecurityUtils.hasPermi("warehouse:purchase:manage"); + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/warehouse/config/WarehouseAsyncConfig.java b/ruoyi-system/src/main/java/com/ruoyi/warehouse/config/WarehouseAsyncConfig.java new file mode 100644 index 0000000..b56e69c --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/warehouse/config/WarehouseAsyncConfig.java @@ -0,0 +1,25 @@ +package com.ruoyi.warehouse.config; + +import java.util.concurrent.ThreadPoolExecutor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +@Configuration +public class WarehouseAsyncConfig +{ + @Bean(name = "warehouseTaskExecutor") + public ThreadPoolTaskExecutor warehouseTaskExecutor() + { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(2); + executor.setMaxPoolSize(4); + executor.setQueueCapacity(20); + executor.setKeepAliveSeconds(60); + executor.setThreadNamePrefix("warehouse-task-"); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(30); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + return executor; + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/warehouse/domain/AiCommand.java b/ruoyi-system/src/main/java/com/ruoyi/warehouse/domain/AiCommand.java new file mode 100644 index 0000000..23ab3e5 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/warehouse/domain/AiCommand.java @@ -0,0 +1,53 @@ +package com.ruoyi.warehouse.domain; + +import java.util.Date; + +public class AiCommand +{ + private Long id; + private Long userId; + private String username; + private String action; + private String requiredPermission; + private String requestText; + private String planJson; + private String resultJson; + private String commandStatus; + private Date expiresAt; + private Date confirmedAt; + private Date executedAt; + private String errorMessage; + private Date createTime; + private Date updateTime; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public Long getUserId() { return userId; } + public void setUserId(Long userId) { this.userId = userId; } + public String getUsername() { return username; } + public void setUsername(String username) { this.username = username; } + public String getAction() { return action; } + public void setAction(String action) { this.action = action; } + public String getRequiredPermission() { return requiredPermission; } + public void setRequiredPermission(String requiredPermission) { this.requiredPermission = requiredPermission; } + public String getRequestText() { return requestText; } + public void setRequestText(String requestText) { this.requestText = requestText; } + public String getPlanJson() { return planJson; } + public void setPlanJson(String planJson) { this.planJson = planJson; } + public String getResultJson() { return resultJson; } + public void setResultJson(String resultJson) { this.resultJson = resultJson; } + public String getCommandStatus() { return commandStatus; } + public void setCommandStatus(String commandStatus) { this.commandStatus = commandStatus; } + public Date getExpiresAt() { return expiresAt; } + public void setExpiresAt(Date expiresAt) { this.expiresAt = expiresAt; } + public Date getConfirmedAt() { return confirmedAt; } + public void setConfirmedAt(Date confirmedAt) { this.confirmedAt = confirmedAt; } + public Date getExecutedAt() { return executedAt; } + public void setExecutedAt(Date executedAt) { this.executedAt = executedAt; } + public String getErrorMessage() { return errorMessage; } + public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; } + public Date getCreateTime() { return createTime; } + public void setCreateTime(Date createTime) { this.createTime = createTime; } + public Date getUpdateTime() { return updateTime; } + public void setUpdateTime(Date updateTime) { this.updateTime = updateTime; } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/warehouse/domain/InRecord.java b/ruoyi-system/src/main/java/com/ruoyi/warehouse/domain/InRecord.java index a23f349..b1d0c73 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/warehouse/domain/InRecord.java +++ b/ruoyi-system/src/main/java/com/ruoyi/warehouse/domain/InRecord.java @@ -26,6 +26,16 @@ public class InRecord extends BaseEntity private Long arrivalId; + private Long sourceProjectId; + + @Excel(name = "来源项目") + private String sourceProjectName; + + private Long expenseCategoryId; + + @Excel(name = "经费科目") + private String expenseCategoryName; + @Excel(name = "来源单号") private String sourceNo; @@ -41,9 +51,11 @@ public class InRecord extends BaseEntity @Excel(name = "系统操作人") private String operator; - @Excel(name = "入库人") private String inOperator; + @Excel(name = "入库人") + private String inOperatorName; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") @Excel(name = "确认入库时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss") private Date confirmTime; @@ -86,6 +98,14 @@ public class InRecord extends BaseEntity public void setPurchaseRequestId(Long purchaseRequestId) { this.purchaseRequestId = purchaseRequestId; } public Long getArrivalId() { return arrivalId; } public void setArrivalId(Long arrivalId) { this.arrivalId = arrivalId; } + public Long getSourceProjectId() { return sourceProjectId; } + public void setSourceProjectId(Long sourceProjectId) { this.sourceProjectId = sourceProjectId; } + public String getSourceProjectName() { return sourceProjectName; } + public void setSourceProjectName(String sourceProjectName) { this.sourceProjectName = sourceProjectName; } + public Long getExpenseCategoryId() { return expenseCategoryId; } + public void setExpenseCategoryId(Long expenseCategoryId) { this.expenseCategoryId = expenseCategoryId; } + public String getExpenseCategoryName() { return expenseCategoryName; } + public void setExpenseCategoryName(String expenseCategoryName) { this.expenseCategoryName = expenseCategoryName; } public String getSourceNo() { return sourceNo; } public void setSourceNo(String sourceNo) { this.sourceNo = sourceNo; } @@ -103,6 +123,8 @@ public class InRecord extends BaseEntity public void setOperator(String operator) { this.operator = operator; } public String getInOperator() { return inOperator; } public void setInOperator(String inOperator) { this.inOperator = inOperator; } + public String getInOperatorName() { return inOperatorName; } + public void setInOperatorName(String inOperatorName) { this.inOperatorName = inOperatorName; } public Date getConfirmTime() { return confirmTime; } public void setConfirmTime(Date confirmTime) { this.confirmTime = confirmTime; } public String getAreaCode() { return areaCode; } diff --git a/ruoyi-system/src/main/java/com/ruoyi/warehouse/domain/OutRecord.java b/ruoyi-system/src/main/java/com/ruoyi/warehouse/domain/OutRecord.java index a02437a..c97588e 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/warehouse/domain/OutRecord.java +++ b/ruoyi-system/src/main/java/com/ruoyi/warehouse/domain/OutRecord.java @@ -20,6 +20,10 @@ public class OutRecord extends BaseEntity private Long areaId; private Long productId; + private Long usageProjectId; + + @Excel(name = "使用项目") + private String usageProjectName; @Excel(name = "出库类型", readConverterExp = "1=生产出库,2=退货出库,3=销售出库") private Integer outType; @@ -30,12 +34,22 @@ public class OutRecord extends BaseEntity @Excel(name = "关联单据号") private String sourceNo; + private Integer crossProjectFlag; + + @Excel(name = "跨项目原因") + private String crossProjectReason; + + @Excel(name = "来源项目") + private String sourceProjectNames; + @Excel(name = "系统操作人") private String operator; - @Excel(name = "出库人") private String outOperator; + @Excel(name = "出库人") + private String outOperatorName; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") @Excel(name = "出库时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss") private Date outTime; @@ -74,6 +88,10 @@ public class OutRecord extends BaseEntity @NotNull(message = "产品不能为空") public Long getProductId() { return productId; } public void setProductId(Long productId) { this.productId = productId; } + public Long getUsageProjectId() { return usageProjectId; } + public void setUsageProjectId(Long usageProjectId) { this.usageProjectId = usageProjectId; } + public String getUsageProjectName() { return usageProjectName; } + public void setUsageProjectName(String usageProjectName) { this.usageProjectName = usageProjectName; } @NotNull(message = "出库类型不能为空") public Integer getOutType() { return outType; } @@ -85,10 +103,18 @@ public class OutRecord extends BaseEntity public void setOutNum(BigDecimal outNum) { this.outNum = outNum; } public String getSourceNo() { return sourceNo; } public void setSourceNo(String sourceNo) { this.sourceNo = sourceNo; } + public Integer getCrossProjectFlag() { return crossProjectFlag; } + public void setCrossProjectFlag(Integer crossProjectFlag) { this.crossProjectFlag = crossProjectFlag; } + public String getCrossProjectReason() { return crossProjectReason; } + public void setCrossProjectReason(String crossProjectReason) { this.crossProjectReason = crossProjectReason; } + public String getSourceProjectNames() { return sourceProjectNames; } + public void setSourceProjectNames(String sourceProjectNames) { this.sourceProjectNames = sourceProjectNames; } public String getOperator() { return operator; } public void setOperator(String operator) { this.operator = operator; } public String getOutOperator() { return outOperator; } public void setOutOperator(String outOperator) { this.outOperator = outOperator; } + public String getOutOperatorName() { return outOperatorName; } + public void setOutOperatorName(String outOperatorName) { this.outOperatorName = outOperatorName; } public Date getOutTime() { return outTime; } public void setOutTime(Date outTime) { this.outTime = outTime; } public String getAreaCode() { return areaCode; } diff --git a/ruoyi-system/src/main/java/com/ruoyi/warehouse/domain/PurchaseArrival.java b/ruoyi-system/src/main/java/com/ruoyi/warehouse/domain/PurchaseArrival.java index 26fba4f..0dcb408 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/warehouse/domain/PurchaseArrival.java +++ b/ruoyi-system/src/main/java/com/ruoyi/warehouse/domain/PurchaseArrival.java @@ -15,6 +15,8 @@ public class PurchaseArrival extends BaseEntity private String arrivalNo; private Long purchaseRequestId; private BigDecimal arrivalQuantity; + private BigDecimal actualUnitPrice; + private BigDecimal actualAmount; @JsonFormat(pattern = "yyyy-MM-dd") private Date arrivalDate; @@ -43,6 +45,11 @@ public class PurchaseArrival extends BaseEntity private String materialBrand; private String materialParams; private String unit; + private Long projectId; + private String projectName; + private Long expenseCategoryId; + private String expenseCategoryName; + private BigDecimal estimatedUnitPrice; public Long getId() { return id; } public void setId(Long id) { this.id = id; } @@ -55,6 +62,10 @@ public class PurchaseArrival extends BaseEntity @DecimalMin(value = "0.001", message = "到货数量必须大于0") public BigDecimal getArrivalQuantity() { return arrivalQuantity; } public void setArrivalQuantity(BigDecimal arrivalQuantity) { this.arrivalQuantity = arrivalQuantity; } + public BigDecimal getActualUnitPrice() { return actualUnitPrice; } + public void setActualUnitPrice(BigDecimal actualUnitPrice) { this.actualUnitPrice = actualUnitPrice; } + public BigDecimal getActualAmount() { return actualAmount; } + public void setActualAmount(BigDecimal actualAmount) { this.actualAmount = actualAmount; } @NotNull(message = "到货日期不能为空") public Date getArrivalDate() { return arrivalDate; } @@ -93,4 +104,14 @@ public class PurchaseArrival extends BaseEntity public void setMaterialParams(String materialParams) { this.materialParams = materialParams; } public String getUnit() { return unit; } public void setUnit(String unit) { this.unit = unit; } + public Long getProjectId() { return projectId; } + public void setProjectId(Long projectId) { this.projectId = projectId; } + public String getProjectName() { return projectName; } + public void setProjectName(String projectName) { this.projectName = projectName; } + public Long getExpenseCategoryId() { return expenseCategoryId; } + public void setExpenseCategoryId(Long expenseCategoryId) { this.expenseCategoryId = expenseCategoryId; } + public String getExpenseCategoryName() { return expenseCategoryName; } + public void setExpenseCategoryName(String expenseCategoryName) { this.expenseCategoryName = expenseCategoryName; } + public BigDecimal getEstimatedUnitPrice() { return estimatedUnitPrice; } + public void setEstimatedUnitPrice(BigDecimal estimatedUnitPrice) { this.estimatedUnitPrice = estimatedUnitPrice; } } diff --git a/ruoyi-system/src/main/java/com/ruoyi/warehouse/domain/PurchaseRequest.java b/ruoyi-system/src/main/java/com/ruoyi/warehouse/domain/PurchaseRequest.java index 7016114..a1f3e8d 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/warehouse/domain/PurchaseRequest.java +++ b/ruoyi-system/src/main/java/com/ruoyi/warehouse/domain/PurchaseRequest.java @@ -21,6 +21,22 @@ public class PurchaseRequest extends BaseEntity private Long purchaseBatchId; + private Long projectId; + + @Excel(name = "项目编码") + private String projectCode; + + @Excel(name = "项目名称", type = Excel.Type.EXPORT) + private String projectName; + + private Long expenseCategoryId; + + @Excel(name = "经费科目编码") + private String expenseCategoryCode; + + @Excel(name = "经费科目", type = Excel.Type.EXPORT) + private String expenseCategoryName; + @Excel(name = "关联物料编码", prompt = "已有物料建议填写准确编码;留空时按名称、品牌、类别、参数规格匹配") private String productCode; @@ -42,6 +58,12 @@ public class PurchaseRequest extends BaseEntity @Excel(name = "需求数量") private BigDecimal quantity; + @Excel(name = "预计含税单价") + private BigDecimal estimatedUnitPrice; + + @Excel(name = "预计总金额", type = Excel.Type.EXPORT) + private BigDecimal estimatedAmount; + @Excel(name = "单位") private String unit; @@ -90,6 +112,18 @@ public class PurchaseRequest extends BaseEntity public void setProductId(Long productId) { this.productId = productId; } public Long getPurchaseBatchId() { return purchaseBatchId; } public void setPurchaseBatchId(Long purchaseBatchId) { this.purchaseBatchId = purchaseBatchId; } + public Long getProjectId() { return projectId; } + public void setProjectId(Long projectId) { this.projectId = projectId; } + public String getProjectCode() { return projectCode; } + public void setProjectCode(String projectCode) { this.projectCode = projectCode; } + public String getProjectName() { return projectName; } + public void setProjectName(String projectName) { this.projectName = projectName; } + public Long getExpenseCategoryId() { return expenseCategoryId; } + public void setExpenseCategoryId(Long expenseCategoryId) { this.expenseCategoryId = expenseCategoryId; } + public String getExpenseCategoryCode() { return expenseCategoryCode; } + public void setExpenseCategoryCode(String expenseCategoryCode) { this.expenseCategoryCode = expenseCategoryCode; } + public String getExpenseCategoryName() { return expenseCategoryName; } + public void setExpenseCategoryName(String expenseCategoryName) { this.expenseCategoryName = expenseCategoryName; } public String getProductCode() { return productCode; } public void setProductCode(String productCode) { this.productCode = productCode; } @@ -109,6 +143,10 @@ public class PurchaseRequest extends BaseEntity @DecimalMin(value = "0.001", message = "需求数量必须大于0") public BigDecimal getQuantity() { return quantity; } public void setQuantity(BigDecimal quantity) { this.quantity = quantity; } + public BigDecimal getEstimatedUnitPrice() { return estimatedUnitPrice; } + public void setEstimatedUnitPrice(BigDecimal estimatedUnitPrice) { this.estimatedUnitPrice = estimatedUnitPrice; } + public BigDecimal getEstimatedAmount() { return estimatedAmount; } + public void setEstimatedAmount(BigDecimal estimatedAmount) { this.estimatedAmount = estimatedAmount; } public String getUnit() { return unit; } public void setUnit(String unit) { this.unit = unit; } public Date getExpectedDate() { return expectedDate; } diff --git a/ruoyi-system/src/main/java/com/ruoyi/warehouse/domain/StockLot.java b/ruoyi-system/src/main/java/com/ruoyi/warehouse/domain/StockLot.java new file mode 100644 index 0000000..7bf6f5a --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/warehouse/domain/StockLot.java @@ -0,0 +1,65 @@ +package com.ruoyi.warehouse.domain; + +import java.math.BigDecimal; +import com.ruoyi.common.core.domain.BaseEntity; + +public class StockLot extends BaseEntity +{ + private static final long serialVersionUID = 1L; + + private Long id; + private String lotNo; + private Long areaId; + private String areaName; + private Long productId; + private String productName; + private Long sourceProjectId; + private String sourceProjectName; + private Long expenseCategoryId; + private String expenseCategoryName; + private Long purchaseRequestId; + private String purchaseRequestNo; + private Long arrivalId; + private Long inRecordId; + private BigDecimal receivedQuantity; + private BigDecimal availableQuantity; + private BigDecimal unitCost; + private Integer lotStatus; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public String getLotNo() { return lotNo; } + public void setLotNo(String lotNo) { this.lotNo = lotNo; } + public Long getAreaId() { return areaId; } + public void setAreaId(Long areaId) { this.areaId = areaId; } + public String getAreaName() { return areaName; } + public void setAreaName(String areaName) { this.areaName = areaName; } + public Long getProductId() { return productId; } + public void setProductId(Long productId) { this.productId = productId; } + public String getProductName() { return productName; } + public void setProductName(String productName) { this.productName = productName; } + public Long getSourceProjectId() { return sourceProjectId; } + public void setSourceProjectId(Long sourceProjectId) { this.sourceProjectId = sourceProjectId; } + public String getSourceProjectName() { return sourceProjectName; } + public void setSourceProjectName(String sourceProjectName) { this.sourceProjectName = sourceProjectName; } + public Long getExpenseCategoryId() { return expenseCategoryId; } + public void setExpenseCategoryId(Long expenseCategoryId) { this.expenseCategoryId = expenseCategoryId; } + public String getExpenseCategoryName() { return expenseCategoryName; } + public void setExpenseCategoryName(String expenseCategoryName) { this.expenseCategoryName = expenseCategoryName; } + public Long getPurchaseRequestId() { return purchaseRequestId; } + public void setPurchaseRequestId(Long purchaseRequestId) { this.purchaseRequestId = purchaseRequestId; } + public String getPurchaseRequestNo() { return purchaseRequestNo; } + public void setPurchaseRequestNo(String purchaseRequestNo) { this.purchaseRequestNo = purchaseRequestNo; } + public Long getArrivalId() { return arrivalId; } + public void setArrivalId(Long arrivalId) { this.arrivalId = arrivalId; } + public Long getInRecordId() { return inRecordId; } + public void setInRecordId(Long inRecordId) { this.inRecordId = inRecordId; } + public BigDecimal getReceivedQuantity() { return receivedQuantity; } + public void setReceivedQuantity(BigDecimal receivedQuantity) { this.receivedQuantity = receivedQuantity; } + public BigDecimal getAvailableQuantity() { return availableQuantity; } + public void setAvailableQuantity(BigDecimal availableQuantity) { this.availableQuantity = availableQuantity; } + public BigDecimal getUnitCost() { return unitCost; } + public void setUnitCost(BigDecimal unitCost) { this.unitCost = unitCost; } + public Integer getLotStatus() { return lotStatus; } + public void setLotStatus(Integer lotStatus) { this.lotStatus = lotStatus; } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/warehouse/domain/WmsBatchJob.java b/ruoyi-system/src/main/java/com/ruoyi/warehouse/domain/WmsBatchJob.java index c9cfb12..89b9c71 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/warehouse/domain/WmsBatchJob.java +++ b/ruoyi-system/src/main/java/com/ruoyi/warehouse/domain/WmsBatchJob.java @@ -1,5 +1,6 @@ package com.ruoyi.warehouse.domain; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.ruoyi.common.core.domain.BaseEntity; public class WmsBatchJob extends BaseEntity @@ -69,6 +70,7 @@ public class WmsBatchJob extends BaseEntity this.fileName = fileName; } + @JsonIgnore public String getFilePath() { return filePath; diff --git a/ruoyi-system/src/main/java/com/ruoyi/warehouse/domain/agent/AgentPlan.java b/ruoyi-system/src/main/java/com/ruoyi/warehouse/domain/agent/AgentPlan.java index 3a95467..b0afacb 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/warehouse/domain/agent/AgentPlan.java +++ b/ruoyi-system/src/main/java/com/ruoyi/warehouse/domain/agent/AgentPlan.java @@ -6,6 +6,8 @@ import java.util.List; public class AgentPlan { + private Long commandId; + private String expiresAt; private String action; private String summary; private String message; @@ -15,6 +17,9 @@ public class AgentPlan private Long productId; private Long stockId; private Long recordId; + private Long sourceProjectId; + private Long expenseCategoryId; + private Long usageProjectId; private Integer referenceIndex; private String areaName; private String productName; @@ -27,6 +32,10 @@ public class AgentPlan private Integer outType; private String operatorName; private String recordNo; + private String sourceProjectName; + private String expenseCategoryName; + private String usageProjectName; + private String crossProjectReason; private String remark; private BigDecimal currentStock; private BigDecimal afterStock; @@ -35,6 +44,11 @@ public class AgentPlan private List results = new ArrayList<>(); private AgentPlan nextPlan; + public Long getCommandId() { return commandId; } + public void setCommandId(Long commandId) { this.commandId = commandId; } + public String getExpiresAt() { return expiresAt; } + public void setExpiresAt(String expiresAt) { this.expiresAt = expiresAt; } + public String getAction() { return action; @@ -125,6 +139,13 @@ public class AgentPlan this.recordId = recordId; } + public Long getSourceProjectId() { return sourceProjectId; } + public void setSourceProjectId(Long sourceProjectId) { this.sourceProjectId = sourceProjectId; } + public Long getExpenseCategoryId() { return expenseCategoryId; } + public void setExpenseCategoryId(Long expenseCategoryId) { this.expenseCategoryId = expenseCategoryId; } + public Long getUsageProjectId() { return usageProjectId; } + public void setUsageProjectId(Long usageProjectId) { this.usageProjectId = usageProjectId; } + public Integer getReferenceIndex() { return referenceIndex; @@ -245,6 +266,15 @@ public class AgentPlan this.recordNo = recordNo; } + public String getSourceProjectName() { return sourceProjectName; } + public void setSourceProjectName(String sourceProjectName) { this.sourceProjectName = sourceProjectName; } + public String getExpenseCategoryName() { return expenseCategoryName; } + public void setExpenseCategoryName(String expenseCategoryName) { this.expenseCategoryName = expenseCategoryName; } + public String getUsageProjectName() { return usageProjectName; } + public void setUsageProjectName(String usageProjectName) { this.usageProjectName = usageProjectName; } + public String getCrossProjectReason() { return crossProjectReason; } + public void setCrossProjectReason(String crossProjectReason) { this.crossProjectReason = crossProjectReason; } + public String getRemark() { return remark; diff --git a/ruoyi-system/src/main/java/com/ruoyi/warehouse/domain/agent/AgentResultItem.java b/ruoyi-system/src/main/java/com/ruoyi/warehouse/domain/agent/AgentResultItem.java index d5c5dc1..07a8324 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/warehouse/domain/agent/AgentResultItem.java +++ b/ruoyi-system/src/main/java/com/ruoyi/warehouse/domain/agent/AgentResultItem.java @@ -15,6 +15,15 @@ public class AgentResultItem private String unit; private BigDecimal stockNum; private String recordNo; + private String projectName; + private String expenseCategoryName; + private Integer purchaseStatus; + private String purchaseStatusName; + private BigDecimal quantity; + private BigDecimal acceptedQuantity; + private BigDecimal pendingArrivalQuantity; + private BigDecimal estimatedAmount; + private String expectedDate; public Long getId() { @@ -125,4 +134,23 @@ public class AgentResultItem { this.recordNo = recordNo; } + + public String getProjectName() { return projectName; } + public void setProjectName(String projectName) { this.projectName = projectName; } + public String getExpenseCategoryName() { return expenseCategoryName; } + public void setExpenseCategoryName(String expenseCategoryName) { this.expenseCategoryName = expenseCategoryName; } + public Integer getPurchaseStatus() { return purchaseStatus; } + public void setPurchaseStatus(Integer purchaseStatus) { this.purchaseStatus = purchaseStatus; } + public String getPurchaseStatusName() { return purchaseStatusName; } + public void setPurchaseStatusName(String purchaseStatusName) { this.purchaseStatusName = purchaseStatusName; } + public BigDecimal getQuantity() { return quantity; } + public void setQuantity(BigDecimal quantity) { this.quantity = quantity; } + public BigDecimal getAcceptedQuantity() { return acceptedQuantity; } + public void setAcceptedQuantity(BigDecimal acceptedQuantity) { this.acceptedQuantity = acceptedQuantity; } + public BigDecimal getPendingArrivalQuantity() { return pendingArrivalQuantity; } + public void setPendingArrivalQuantity(BigDecimal pendingArrivalQuantity) { this.pendingArrivalQuantity = pendingArrivalQuantity; } + public BigDecimal getEstimatedAmount() { return estimatedAmount; } + public void setEstimatedAmount(BigDecimal estimatedAmount) { this.estimatedAmount = estimatedAmount; } + public String getExpectedDate() { return expectedDate; } + public void setExpectedDate(String expectedDate) { this.expectedDate = expectedDate; } } diff --git a/ruoyi-system/src/main/java/com/ruoyi/warehouse/mapper/AiCommandMapper.java b/ruoyi-system/src/main/java/com/ruoyi/warehouse/mapper/AiCommandMapper.java new file mode 100644 index 0000000..11c60fd --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/warehouse/mapper/AiCommandMapper.java @@ -0,0 +1,15 @@ +package com.ruoyi.warehouse.mapper; + +import org.apache.ibatis.annotations.Param; +import com.ruoyi.warehouse.domain.AiCommand; + +public interface AiCommandMapper +{ + int insertAiCommand(AiCommand command); + + AiCommand selectAiCommandForUpdate(@Param("id") Long id, @Param("userId") Long userId); + + int markExecuting(@Param("id") Long id, @Param("userId") Long userId); + + int markSucceeded(@Param("id") Long id, @Param("resultJson") String resultJson); +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/warehouse/mapper/OutRecordMapper.java b/ruoyi-system/src/main/java/com/ruoyi/warehouse/mapper/OutRecordMapper.java index ed5937f..3131a47 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/warehouse/mapper/OutRecordMapper.java +++ b/ruoyi-system/src/main/java/com/ruoyi/warehouse/mapper/OutRecordMapper.java @@ -12,4 +12,6 @@ public interface OutRecordMapper public OutRecord checkOutNoUnique(String outNo); public int insertOutRecord(OutRecord outRecord); + + public int updateOutRecordProjectFlags(OutRecord outRecord); } diff --git a/ruoyi-system/src/main/java/com/ruoyi/warehouse/mapper/StockLotMapper.java b/ruoyi-system/src/main/java/com/ruoyi/warehouse/mapper/StockLotMapper.java new file mode 100644 index 0000000..4282fb5 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/warehouse/mapper/StockLotMapper.java @@ -0,0 +1,25 @@ +package com.ruoyi.warehouse.mapper; + +import java.math.BigDecimal; +import java.util.List; +import org.apache.ibatis.annotations.Param; +import com.ruoyi.warehouse.domain.StockLot; + +public interface StockLotMapper +{ + List selectStockLotList(StockLot lot); + + List selectAvailableLotsForUpdate(@Param("areaId") Long areaId, + @Param("productId") Long productId); + + StockLot selectStockLotByInRecordId(Long inRecordId); + + int insertStockLot(StockLot lot); + + int deductStockLot(@Param("id") Long id, @Param("quantity") BigDecimal quantity); + + int insertOutRecordLot(@Param("outRecordId") Long outRecordId, @Param("stockLotId") Long stockLotId, + @Param("sourceProjectId") Long sourceProjectId, @Param("usageProjectId") Long usageProjectId, + @Param("quantity") BigDecimal quantity, @Param("unitCost") BigDecimal unitCost, + @Param("amount") BigDecimal amount); +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/warehouse/mapper/WarehouseAreaMapper.java b/ruoyi-system/src/main/java/com/ruoyi/warehouse/mapper/WarehouseAreaMapper.java index eb1f7c8..681257b 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/warehouse/mapper/WarehouseAreaMapper.java +++ b/ruoyi-system/src/main/java/com/ruoyi/warehouse/mapper/WarehouseAreaMapper.java @@ -1,6 +1,7 @@ package com.ruoyi.warehouse.mapper; import java.util.List; +import org.apache.ibatis.annotations.Param; import com.ruoyi.warehouse.domain.WarehouseArea; public interface WarehouseAreaMapper @@ -11,6 +12,8 @@ public interface WarehouseAreaMapper public WarehouseArea selectWarehouseAreaById(Long id); + public WarehouseArea selectWarehouseAreaByNameOrCode(@Param("value") String value); + public WarehouseArea checkAreaCodeUnique(String areaCode); public int insertWarehouseArea(WarehouseArea warehouseArea); diff --git a/ruoyi-system/src/main/java/com/ruoyi/warehouse/mapper/WmsBatchItemMapper.java b/ruoyi-system/src/main/java/com/ruoyi/warehouse/mapper/WmsBatchItemMapper.java index 4dbffda..ea5e5c7 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/warehouse/mapper/WmsBatchItemMapper.java +++ b/ruoyi-system/src/main/java/com/ruoyi/warehouse/mapper/WmsBatchItemMapper.java @@ -1,15 +1,19 @@ package com.ruoyi.warehouse.mapper; import java.util.List; +import org.apache.ibatis.annotations.Param; import com.ruoyi.warehouse.domain.WmsBatchItem; +import com.ruoyi.warehouse.domain.WmsBatchJob; public interface WmsBatchItemMapper { public List selectWmsBatchItemList(WmsBatchItem item); public WmsBatchItem selectWmsBatchItemById(Long id); public int insertWmsBatchItem(WmsBatchItem item); + public int insertWmsBatchItemBatch(@Param("items") List items); public int updateWmsBatchItem(WmsBatchItem item); public int deleteWmsBatchItemById(Long id); public int deleteWmsBatchItemByJobId(Long jobId); public int countByJobIdAndStatus(WmsBatchItem item); + public WmsBatchJob selectWmsBatchItemStats(Long jobId); } diff --git a/ruoyi-system/src/main/java/com/ruoyi/warehouse/mapper/WmsBatchJobMapper.java b/ruoyi-system/src/main/java/com/ruoyi/warehouse/mapper/WmsBatchJobMapper.java index 6847fb8..4f19e75 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/warehouse/mapper/WmsBatchJobMapper.java +++ b/ruoyi-system/src/main/java/com/ruoyi/warehouse/mapper/WmsBatchJobMapper.java @@ -1,6 +1,7 @@ package com.ruoyi.warehouse.mapper; import java.util.List; +import org.apache.ibatis.annotations.Param; import com.ruoyi.warehouse.domain.WmsBatchJob; public interface WmsBatchJobMapper @@ -9,4 +10,5 @@ public interface WmsBatchJobMapper public WmsBatchJob selectWmsBatchJobById(Long id); public int insertWmsBatchJob(WmsBatchJob job); public int updateWmsBatchJob(WmsBatchJob job); + public int claimWmsBatchJob(@Param("id") Long id); } diff --git a/ruoyi-system/src/main/java/com/ruoyi/warehouse/service/IStockLotService.java b/ruoyi-system/src/main/java/com/ruoyi/warehouse/service/IStockLotService.java new file mode 100644 index 0000000..cf7055c --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/warehouse/service/IStockLotService.java @@ -0,0 +1,18 @@ +package com.ruoyi.warehouse.service; + +import java.math.BigDecimal; +import java.util.List; +import com.ruoyi.warehouse.domain.InRecord; +import com.ruoyi.warehouse.domain.OutRecord; +import com.ruoyi.warehouse.domain.StockLot; + +public interface IStockLotService +{ + List selectStockLotList(StockLot lot); + + void createInboundLot(InRecord inRecord, String username); + + void allocateOutboundLots(OutRecord outRecord); + + void adjustLots(Long areaId, Long productId, BigDecimal difference, Long checkId, String username); +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/warehouse/service/impl/AgentServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/warehouse/service/impl/AgentServiceImpl.java index e5b3a12..a3ca79f 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/warehouse/service/impl/AgentServiceImpl.java +++ b/ruoyi-system/src/main/java/com/ruoyi/warehouse/service/impl/AgentServiceImpl.java @@ -10,6 +10,7 @@ import java.text.Normalizer; import java.time.Duration; import java.util.ArrayList; import java.util.Comparator; +import java.util.Date; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; @@ -25,21 +26,31 @@ import com.alibaba.fastjson2.JSONArray; import com.alibaba.fastjson2.JSONObject; import com.ruoyi.common.core.domain.entity.SysUser; import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.common.utils.DateUtils; +import com.ruoyi.project.domain.ExpenseCategory; +import com.ruoyi.project.domain.ProjectInfo; +import com.ruoyi.project.service.IProjectBudgetService; +import com.ruoyi.project.service.IProjectInfoService; import com.ruoyi.system.service.ISysUserService; +import com.ruoyi.warehouse.domain.AiCommand; import com.ruoyi.warehouse.domain.InRecord; import com.ruoyi.warehouse.domain.OutRecord; import com.ruoyi.warehouse.domain.ProductInfo; +import com.ruoyi.warehouse.domain.PurchaseRequest; import com.ruoyi.warehouse.domain.StockCheckRecord; import com.ruoyi.warehouse.domain.StockInfo; import com.ruoyi.warehouse.domain.WarehouseArea; import com.ruoyi.warehouse.domain.agent.AgentCandidate; import com.ruoyi.warehouse.domain.agent.AgentPlan; import com.ruoyi.warehouse.domain.agent.AgentResultItem; +import com.ruoyi.warehouse.mapper.AiCommandMapper; import com.ruoyi.warehouse.service.IAgentService; import com.ruoyi.warehouse.service.IInRecordService; import com.ruoyi.warehouse.service.IOutRecordService; import com.ruoyi.warehouse.service.IProductInfoService; +import com.ruoyi.warehouse.service.IPurchaseRequestService; import com.ruoyi.warehouse.service.IStockCheckRecordService; import com.ruoyi.warehouse.service.IStockInfoService; import com.ruoyi.warehouse.service.IWarehouseAreaService; @@ -53,6 +64,7 @@ public class AgentServiceImpl implements IAgentService .build(); private static final String ACTION_QUERY_STOCK = "query_stock"; + private static final String ACTION_QUERY_PURCHASE = "query_purchase"; private static final String ACTION_CREATE_IN = "create_in_record"; private static final String ACTION_CONFIRM_IN = "confirm_in_record"; private static final String ACTION_CREATE_OUT = "create_out_record"; @@ -97,6 +109,9 @@ public class AgentServiceImpl implements IAgentService @Autowired private IStockInfoService stockInfoService; + @Autowired + private IPurchaseRequestService purchaseRequestService; + @Autowired private IInRecordService inRecordService; @@ -109,6 +124,15 @@ public class AgentServiceImpl implements IAgentService @Autowired private ISysUserService userService; + @Autowired + private IProjectInfoService projectInfoService; + + @Autowired + private IProjectBudgetService projectBudgetService; + + @Autowired + private AiCommandMapper aiCommandMapper; + @Override public AgentPlan analyze(String text, List contextResults, String username) { @@ -139,27 +163,56 @@ public class AgentServiceImpl implements IAgentService plan = fallbackParse(text); } plan.getWarnings().addAll(warnings); - return normalizePlan(plan, text, username, contextResults); + AgentPlan normalized = normalizePlan(plan, text, username, contextResults); + if (normalized.isNeedConfirm() && normalized.isExecutable()) + { + savePendingCommand(normalized, text, username); + } + return normalized; } @Override @Transactional public AgentPlan execute(AgentPlan plan, String username) { - if (plan == null || StringUtils.isBlank(plan.getAction())) + if (plan == null || plan.getCommandId() == null) { - throw new ServiceException("缺少可执行的操作草稿"); + throw new ServiceException("该操作草稿未在服务器登记,请重新分析后再确认"); } - AgentPlan checkedPlan = normalizePlan(plan, null, username, null); + AiCommand command = aiCommandMapper.selectAiCommandForUpdate(plan.getCommandId(), SecurityUtils.getUserId()); + if (command == null) + { + throw new ServiceException("操作草稿不存在或不属于当前用户"); + } + if (!"WAIT_CONFIRM".equals(command.getCommandStatus())) + { + throw new ServiceException("操作草稿已处理,不能重复执行"); + } + if (command.getExpiresAt() == null || command.getExpiresAt().before(new Date())) + { + throw new ServiceException("操作草稿已过期,请重新分析"); + } + AgentPlan checkedPlan = JSON.parseObject(command.getPlanJson(), AgentPlan.class); + if (checkedPlan == null || StringUtils.isBlank(checkedPlan.getAction())) + { + throw new ServiceException("服务器操作草稿无效"); + } + requireActionPermission(checkedPlan.getAction()); if (!checkedPlan.isExecutable()) { throw new ServiceException(StringUtils.defaultIfBlank(checkedPlan.getMessage(), "当前草稿不能执行")); } + if (aiCommandMapper.markExecuting(command.getId(), SecurityUtils.getUserId()) != 1) + { + throw new ServiceException("操作草稿已过期或正在执行"); + } + checkedPlan.setCommandId(command.getId()); if (ACTION_QUERY_STOCK.equals(checkedPlan.getAction())) { checkedPlan.setMessage("查询完成"); checkedPlan.setNeedConfirm(false); + finishCommand(command.getId(), checkedPlan); return checkedPlan; } if (ACTION_CREATE_IN.equals(checkedPlan.getAction())) @@ -169,6 +222,8 @@ public class AgentServiceImpl implements IAgentService record.setProductId(checkedPlan.getProductId()); record.setInType(checkedPlan.getInType()); record.setInNum(checkedPlan.getQuantity()); + record.setSourceProjectId(checkedPlan.getSourceProjectId()); + record.setExpenseCategoryId(checkedPlan.getExpenseCategoryId()); record.setInOperator(checkedPlan.getOperatorName()); record.setRemark(checkedPlan.getRemark()); record.setOperator(username); @@ -177,6 +232,7 @@ public class AgentServiceImpl implements IAgentService checkedPlan.setRecordNo(record.getInNo()); checkedPlan.setMessage("已生成待入库记录:" + record.getInNo()); checkedPlan.setNeedConfirm(false); + finishCommand(command.getId(), checkedPlan); return checkedPlan; } if (ACTION_CONFIRM_IN.equals(checkedPlan.getAction())) @@ -184,6 +240,7 @@ public class AgentServiceImpl implements IAgentService inRecordService.confirmInRecord(checkedPlan.getRecordId(), username); checkedPlan.setMessage("已确认入库:" + checkedPlan.getRecordNo()); checkedPlan.setNeedConfirm(false); + finishCommand(command.getId(), checkedPlan); return checkedPlan; } if (ACTION_CREATE_OUT.equals(checkedPlan.getAction())) @@ -193,6 +250,8 @@ public class AgentServiceImpl implements IAgentService record.setProductId(checkedPlan.getProductId()); record.setOutType(checkedPlan.getOutType()); record.setOutNum(checkedPlan.getQuantity()); + record.setUsageProjectId(checkedPlan.getUsageProjectId()); + record.setCrossProjectReason(checkedPlan.getCrossProjectReason()); record.setOutOperator(checkedPlan.getOperatorName()); record.setRemark(checkedPlan.getRemark()); record.setOperator(username); @@ -201,6 +260,7 @@ public class AgentServiceImpl implements IAgentService checkedPlan.setRecordNo(record.getOutNo()); checkedPlan.setMessage("已完成出库并生成记录:" + record.getOutNo()); checkedPlan.setNeedConfirm(false); + finishCommand(command.getId(), checkedPlan); return checkedPlan; } if (ACTION_CREATE_CHECK.equals(checkedPlan.getAction())) @@ -216,6 +276,7 @@ public class AgentServiceImpl implements IAgentService checkedPlan.setRecordNo(record.getCheckNo()); checkedPlan.setMessage("已生成待确认盘库记录:" + record.getCheckNo()); checkedPlan.setNeedConfirm(false); + finishCommand(command.getId(), checkedPlan); return checkedPlan; } if (ACTION_CREATE_PRODUCT.equals(checkedPlan.getAction())) @@ -238,6 +299,7 @@ public class AgentServiceImpl implements IAgentService normalizePlan(checkedPlan.getNextPlan(), null, username, null); checkedPlan.setMessage("已添加产品,并生成下一步草稿"); } + finishCommand(command.getId(), checkedPlan); return checkedPlan; } if (ACTION_CREATE_AREA.equals(checkedPlan.getAction())) @@ -256,11 +318,96 @@ public class AgentServiceImpl implements IAgentService normalizePlan(checkedPlan.getNextPlan(), null, username, null); checkedPlan.setMessage("已添加库区,并生成下一步草稿"); } + finishCommand(command.getId(), checkedPlan); return checkedPlan; } throw new ServiceException("暂不支持该操作"); } + private void requireActionPermission(String action) + { + boolean allowed; + if (ACTION_QUERY_STOCK.equals(action)) + { + allowed = SecurityUtils.hasPermi("warehouse:stock:list"); + } + else if (ACTION_QUERY_PURCHASE.equals(action)) + { + allowed = SecurityUtils.hasPermi("warehouse:purchase:list") || SecurityUtils.hasPermi("warehouse:purchase:manage"); + } + else if (ACTION_CREATE_IN.equals(action)) + { + allowed = SecurityUtils.hasPermi("warehouse:in:add"); + } + else if (ACTION_CONFIRM_IN.equals(action)) + { + allowed = SecurityUtils.hasPermi("warehouse:in:confirm"); + } + else if (ACTION_CREATE_OUT.equals(action)) + { + allowed = (SecurityUtils.hasPermi("warehouse:stock:out") || SecurityUtils.hasPermi("warehouse:out:add")) + && (SecurityUtils.isAdmin() || SecurityUtils.hasPermi("project:inventory:use")); + } + else if (ACTION_CREATE_CHECK.equals(action)) + { + allowed = SecurityUtils.hasPermi("warehouse:check:add"); + } + else if (ACTION_CREATE_PRODUCT.equals(action)) + { + allowed = SecurityUtils.hasPermi("warehouse:product:add"); + } + else if (ACTION_CREATE_AREA.equals(action)) + { + allowed = SecurityUtils.hasPermi("warehouse:area:add"); + } + else + { + allowed = false; + } + if (!allowed) + { + throw new ServiceException("无权执行该仓库操作"); + } + } + + private void savePendingCommand(AgentPlan plan, String requestText, String username) + { + Date expiresAt = new Date(System.currentTimeMillis() + 10 * 60 * 1000L); + AiCommand command = new AiCommand(); + command.setUserId(SecurityUtils.getUserId()); + command.setUsername(username); + command.setAction(plan.getAction()); + command.setRequiredPermission(requiredPermission(plan.getAction())); + command.setRequestText(StringUtils.substring(requestText, 0, 1000)); + command.setPlanJson(JSON.toJSONString(plan)); + command.setCommandStatus("WAIT_CONFIRM"); + command.setExpiresAt(expiresAt); + aiCommandMapper.insertAiCommand(command); + plan.setCommandId(command.getId()); + plan.setExpiresAt(DateUtils.parseDateToStr("yyyy-MM-dd HH:mm:ss", expiresAt)); + } + + private void finishCommand(Long commandId, AgentPlan result) + { + if (aiCommandMapper.markSucceeded(commandId, JSON.toJSONString(result)) != 1) + { + throw new ServiceException("AI 指令执行结果保存失败"); + } + } + + private String requiredPermission(String action) + { + if (ACTION_QUERY_STOCK.equals(action)) return "warehouse:stock:list"; + if (ACTION_QUERY_PURCHASE.equals(action)) return "warehouse:purchase:list|warehouse:purchase:manage"; + if (ACTION_CREATE_IN.equals(action)) return "warehouse:in:add"; + if (ACTION_CONFIRM_IN.equals(action)) return "warehouse:in:confirm"; + if (ACTION_CREATE_OUT.equals(action)) return "(warehouse:stock:out|warehouse:out:add)&project:inventory:use"; + if (ACTION_CREATE_CHECK.equals(action)) return "warehouse:check:add"; + if (ACTION_CREATE_PRODUCT.equals(action)) return "warehouse:product:add"; + if (ACTION_CREATE_AREA.equals(action)) return "warehouse:area:add"; + return null; + } + private AgentPlan normalizePlan(AgentPlan plan, String originalText, String username, List contextResults) { if (StringUtils.isBlank(plan.getAction())) @@ -268,6 +415,10 @@ public class AgentServiceImpl implements IAgentService plan.setAction(ACTION_UNKNOWN); } resolveExplicitAction(plan, originalText); + if (!ACTION_UNKNOWN.equals(plan.getAction())) + { + requireActionPermission(plan.getAction()); + } resolveOperator(plan, originalText, username); if (requiresOperator(plan.getAction()) && originalText != null) @@ -306,12 +457,29 @@ public class AgentServiceImpl implements IAgentService } return plan; } + if (ACTION_QUERY_PURCHASE.equals(plan.getAction())) + { + fillPurchaseResults(plan, originalText); + plan.setNeedConfirm(false); + plan.setExecutable(true); + plan.setMessage(plan.getResults().isEmpty() ? "未查询到符合条件的采购申请" : "查询到 " + plan.getResults().size() + " 条采购申请"); + plan.setSummary("查询采购状态"); + return plan; + } if (ACTION_CREATE_IN.equals(plan.getAction()) || ACTION_CREATE_OUT.equals(plan.getAction()) || ACTION_CREATE_CHECK.equals(plan.getAction())) { resolveReferencedResult(plan, originalText, contextResults); resolveArea(plan, originalText, true); resolveProduct(plan, originalText, true); + if (ACTION_CREATE_IN.equals(plan.getAction())) + { + resolveInboundFunding(plan, originalText); + } + else if (ACTION_CREATE_OUT.equals(plan.getAction())) + { + resolveUsageProject(plan, originalText); + } if ((ACTION_CREATE_IN.equals(plan.getAction()) || ACTION_CREATE_CHECK.equals(plan.getAction())) && plan.getAreaId() == null && StringUtils.isNotBlank(plan.getAreaName()) && noAreaCandidates(plan)) { @@ -331,7 +499,11 @@ public class AgentServiceImpl implements IAgentService } validateQuantity(plan); plan.setNeedConfirm(true); - plan.setExecutable(plan.getAreaId() != null && plan.getProductId() != null && validQuantity(plan) && plan.getCandidates().isEmpty()); + boolean projectComplete = !ACTION_CREATE_IN.equals(plan.getAction()) + || (plan.getSourceProjectId() != null && plan.getExpenseCategoryId() != null); + projectComplete = projectComplete && (!ACTION_CREATE_OUT.equals(plan.getAction()) || plan.getUsageProjectId() != null); + plan.setExecutable(plan.getAreaId() != null && plan.getProductId() != null && validQuantity(plan) + && projectComplete && plan.getCandidates().isEmpty()); fillCurrentStock(plan); suggestAlternativeStockAreas(plan); fillSummary(plan); @@ -392,6 +564,138 @@ public class AgentServiceImpl implements IAgentService return plan; } + private void resolveInboundFunding(AgentPlan plan, String originalText) + { + List projects = projectInfoService.selectProjectOptions(null, false); + ProjectInfo project = findProject(projects, plan.getSourceProjectId(), + StringUtils.defaultString(originalText) + " " + StringUtils.defaultString(plan.getSourceProjectName())); + if (project == null && plan.getSourceProjectId() == null && projects.size() == 1) + { + project = projects.get(0); + plan.getWarnings().add("仅有一个可用项目,已自动选择:" + project.getProjectName()); + } + if (project != null) + { + projectInfoService.requireActiveProject(project.getId()); + plan.setSourceProjectId(project.getId()); + plan.setSourceProjectName(project.getProjectName()); + } + else + { + plan.setSourceProjectId(null); + addProjectCandidates(plan, projects, "source_project"); + } + + ExpenseCategory categoryQuery = new ExpenseCategory(); + categoryQuery.setStatus(0); + List categories = projectBudgetService.selectExpenseCategoryList(categoryQuery); + ExpenseCategory category = findExpenseCategory(categories, plan.getExpenseCategoryId(), + StringUtils.defaultString(originalText) + " " + StringUtils.defaultString(plan.getExpenseCategoryName())); + if (category != null) + { + plan.setExpenseCategoryId(category.getId()); + plan.setExpenseCategoryName(category.getCategoryName()); + } + else + { + plan.setExpenseCategoryId(null); + addExpenseCategoryCandidates(plan, categories); + } + } + + private void resolveUsageProject(AgentPlan plan, String originalText) + { + List projects = projectInfoService.selectProjectOptions(null, false); + ProjectInfo project = findProject(projects, plan.getUsageProjectId(), + StringUtils.defaultString(originalText) + " " + StringUtils.defaultString(plan.getUsageProjectName())); + if (project == null && plan.getUsageProjectId() == null && projects.size() == 1) + { + project = projects.get(0); + plan.getWarnings().add("仅有一个可用项目,已自动选择:" + project.getProjectName()); + } + if (project != null) + { + projectInfoService.requireActiveProject(project.getId()); + plan.setUsageProjectId(project.getId()); + plan.setUsageProjectName(project.getProjectName()); + } + else + { + plan.setUsageProjectId(null); + addProjectCandidates(plan, projects, "usage_project"); + } + } + + private ProjectInfo findProject(List projects, Long projectId, String clue) + { + if (projectId != null) + { + for (ProjectInfo project : projects) + { + if (projectId.equals(project.getId())) return project; + } + return null; + } + ProjectInfo best = null; + int bestLength = 0; + String normalizedClue = normalizeSearchText(clue); + for (ProjectInfo project : projects) + { + String code = normalizeSearchText(project.getProjectCode()); + String name = normalizeSearchText(project.getProjectName()); + int length = normalizedClue.contains(name) ? name.length() : (normalizedClue.contains(code) ? code.length() : 0); + if (length > bestLength) + { + best = project; + bestLength = length; + } + } + return best; + } + + private ExpenseCategory findExpenseCategory(List categories, Long categoryId, String clue) + { + String normalizedClue = normalizeSearchText(clue); + for (ExpenseCategory category : categories) + { + if (categoryId != null && categoryId.equals(category.getId())) return category; + if (categoryId == null && (normalizedClue.contains(normalizeSearchText(category.getCategoryName())) + || normalizedClue.contains(normalizeSearchText(category.getCategoryCode())))) + { + return category; + } + } + return null; + } + + private void addProjectCandidates(AgentPlan plan, List projects, String type) + { + if (hasCandidateType(plan, type)) return; + for (ProjectInfo project : first(projects, 10)) + { + plan.getCandidates().add(new AgentCandidate(project.getId(), type, project.getProjectName(), project.getProjectCode())); + } + } + + private void addExpenseCategoryCandidates(AgentPlan plan, List categories) + { + if (hasCandidateType(plan, "expense_category")) return; + for (ExpenseCategory category : first(categories, 10)) + { + plan.getCandidates().add(new AgentCandidate(category.getId(), "expense_category", + category.getCategoryName(), category.getCategoryCode())); + } + } + + private boolean hasCandidateType(AgentPlan plan, String type) + { + for (AgentCandidate candidate : plan.getCandidates()) + { + if (type.equals(candidate.getType())) return true; + } + return false; + } + private AgentPlan callModel(String text, List contextResults) throws Exception { JSONObject body = new JSONObject(); @@ -440,8 +744,8 @@ public class AgentServiceImpl implements IAgentService private String systemPrompt() { return "你是库存管理系统的意图解析器,只返回一个 JSON 对象,不要解释。" - + "action 只能是 query_stock, create_in_record, confirm_in_record, create_out_record, create_stock_check, create_product, create_area, unknown。" - + "字段包括 action, areaName, productName, brand, category, spec, unit, quantity, inType, outType, operatorName, recordNo, remark, summary, referenceIndex。" + + "action 只能是 query_stock, query_purchase, create_in_record, confirm_in_record, create_out_record, create_stock_check, create_product, create_area, unknown。" + + "字段包括 action, areaName, productName, brand, category, spec, unit, quantity, inType, outType, operatorName, recordNo, sourceProjectName, expenseCategoryName, usageProjectName, crossProjectReason, remark, summary, referenceIndex。" + "只有原文明说第几个、第2条、上一个、刚才那个、这个、那个或它时,referenceIndex 才能返回从 1 开始的序号;‘一个’、‘1个’表示数量,不是引用,没有明确指代必须返回 null。" + "quantity 只表示本次入库、出库或盘点的数量,规格型号里的数字不算数量,‘一个’表示数量 1。" + "类似‘2进6出’、‘8口’的描述优先放入 spec,不要把人员姓名、‘东西’、‘产品’放入 productName。" @@ -538,6 +842,68 @@ public class AgentServiceImpl implements IAgentService return text; } + private void fillPurchaseResults(AgentPlan plan, String originalText) + { + PurchaseRequest query = new PurchaseRequest(); + query.setMaterialName(firstNotBlank(plan.getProductName(), extractPurchaseKeyword(originalText))); + List requests = purchaseRequestService.selectPurchaseRequestList(query); + int count = 0; + for (PurchaseRequest request : requests) + { + if (count++ >= 20) + { + plan.getWarnings().add("采购结果较多,仅展示前 20 条"); + break; + } + AgentResultItem item = new AgentResultItem(); + item.setId(request.getId()); + item.setRecordNo(request.getRequestNo()); + item.setProductId(request.getProductId()); + item.setProductName(request.getMaterialName()); + item.setBrand(request.getMaterialBrand()); + item.setCategory(request.getCategory()); + item.setSpec(request.getMaterialParams()); + item.setUnit(request.getUnit()); + item.setProjectName(request.getProjectName()); + item.setExpenseCategoryName(request.getExpenseCategoryName()); + item.setPurchaseStatus(request.getPurchaseStatus()); + item.setPurchaseStatusName(purchaseStatusName(request.getPurchaseStatus())); + item.setQuantity(request.getQuantity()); + item.setAcceptedQuantity(request.getAcceptedQuantity()); + item.setPendingArrivalQuantity(request.getPendingArrivalQuantity()); + item.setEstimatedAmount(request.getEstimatedAmount()); + if (request.getExpectedDate() != null) + { + item.setExpectedDate(DateUtils.parseDateToStr("yyyy-MM-dd", request.getExpectedDate())); + } + plan.getResults().add(item); + } + } + + private String extractPurchaseKeyword(String text) + { + String keyword = StringUtils.defaultString(text) + .replaceAll("采购|申请|状态|进度|到货|验收|查询|查一下|查", " ") + .replaceAll("[,。!??]", " ") + .trim(); + return keyword.length() >= 2 ? keyword : null; + } + + private String purchaseStatusName(Integer status) + { + if (status == null) return "未知"; + return switch (status) + { + case 0 -> "待采购"; + case 1 -> "已下单"; + case 2 -> "采购中"; + case 3 -> "部分到货"; + case 4 -> "已到齐"; + case 5 -> "已取消"; + default -> "未知"; + }; + } + private AgentPlan fallbackParse(String text) { String source = StringUtils.defaultString(text); @@ -556,6 +922,11 @@ public class AgentServiceImpl implements IAgentService { plan.setAction(ACTION_CONFIRM_IN); } + else if (source.contains("采购") && (source.contains("查") || source.contains("状态") || source.contains("进度") || source.contains("到货"))) + { + plan.setAction(ACTION_QUERY_PURCHASE); + plan.setProductName(extractPurchaseKeyword(source)); + } else if (source.contains("出库")) { plan.setAction(ACTION_CREATE_OUT); @@ -1005,6 +1376,10 @@ public class AgentServiceImpl implements IAgentService plan.setAreaName(source.getAreaName()); plan.setQuantity(source.getQuantity()); plan.setInType(source.getInType()); + plan.setSourceProjectId(source.getSourceProjectId()); + plan.setSourceProjectName(source.getSourceProjectName()); + plan.setExpenseCategoryId(source.getExpenseCategoryId()); + plan.setExpenseCategoryName(source.getExpenseCategoryName()); plan.setOperatorName(source.getOperatorName()); plan.setRemark(source.getRemark()); return plan; @@ -1025,6 +1400,13 @@ public class AgentServiceImpl implements IAgentService plan.setQuantity(source.getQuantity()); plan.setInType(source.getInType()); plan.setOutType(source.getOutType()); + plan.setSourceProjectId(source.getSourceProjectId()); + plan.setSourceProjectName(source.getSourceProjectName()); + plan.setExpenseCategoryId(source.getExpenseCategoryId()); + plan.setExpenseCategoryName(source.getExpenseCategoryName()); + plan.setUsageProjectId(source.getUsageProjectId()); + plan.setUsageProjectName(source.getUsageProjectName()); + plan.setCrossProjectReason(source.getCrossProjectReason()); plan.setOperatorName(source.getOperatorName()); plan.setRemark(source.getRemark()); return plan; @@ -1137,6 +1519,11 @@ public class AgentServiceImpl implements IAgentService { plan.setAction(ACTION_CONFIRM_IN); } + else if (originalText.contains("采购") && (originalText.contains("查") || originalText.contains("状态") + || originalText.contains("进度") || originalText.contains("到货"))) + { + plan.setAction(ACTION_QUERY_PURCHASE); + } else if (originalText.contains("出库")) { plan.setAction(ACTION_CREATE_OUT); @@ -1174,13 +1561,13 @@ public class AgentServiceImpl implements IAgentService } if (matchedUser != null) { - plan.setOperatorName(StringUtils.defaultIfBlank(matchedUser.getNickName(), matchedUser.getUserName())); + plan.setOperatorName(matchedUser.getUserName()); return; } } if (StringUtils.isBlank(plan.getOperatorName())) { - plan.setOperatorName(resolveNickName(username)); + plan.setOperatorName(username); plan.getWarnings().add("未识别到人员,默认使用当前登录用户"); } } @@ -1289,6 +1676,7 @@ public class AgentServiceImpl implements IAgentService private void fillActionCandidates(AgentPlan plan) { plan.getCandidates().add(new AgentCandidate(1L, "action", "查库存", "例如:查一下路由器库存")); + plan.getCandidates().add(new AgentCandidate(7L, "action", "查采购状态", "例如:查询路由器采购进度")); plan.getCandidates().add(new AgentCandidate(2L, "action", "入库登记", "例如:采购入库路由器10个到一号库")); plan.getCandidates().add(new AgentCandidate(3L, "action", "出库", "例如:第二个出库1个")); plan.getCandidates().add(new AgentCandidate(4L, "action", "盘库", "例如:盘点路由器实际5个")); @@ -1661,11 +2049,14 @@ public class AgentServiceImpl implements IAgentService String quantity = plan.getQuantity() == null ? "未识别数量" : plan.getQuantity().stripTrailingZeros().toPlainString(); if (ACTION_CREATE_IN.equals(plan.getAction())) { - plan.setSummary("登记入库:" + area + "," + product + ",数量 " + quantity); + plan.setSummary("登记入库:" + area + "," + product + ",数量 " + quantity + + ",来源项目 " + StringUtils.defaultIfBlank(plan.getSourceProjectName(), "未识别") + + ",经费科目 " + StringUtils.defaultIfBlank(plan.getExpenseCategoryName(), "未识别")); } else if (ACTION_CREATE_OUT.equals(plan.getAction())) { - plan.setSummary("执行出库:" + area + "," + product + ",数量 " + quantity); + plan.setSummary("执行出库:" + area + "," + product + ",数量 " + quantity + + ",使用项目 " + StringUtils.defaultIfBlank(plan.getUsageProjectName(), "未识别")); } else if (ACTION_CREATE_CHECK.equals(plan.getAction())) { @@ -1722,20 +2113,6 @@ public class AgentServiceImpl implements IAgentService plan.setMessage(plan.isExecutable() ? "请确认是否执行入库" : "该入库单已经确认"); } - private String resolveNickName(String username) - { - if (StringUtils.isBlank(username)) - { - return username; - } - SysUser user = userService.selectUserByUserName(username); - if (user != null && StringUtils.isNotBlank(user.getNickName())) - { - return user.getNickName(); - } - return username; - } - private String firstNotBlank(String... values) { for (String value : values) diff --git a/ruoyi-system/src/main/java/com/ruoyi/warehouse/service/impl/InRecordServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/warehouse/service/impl/InRecordServiceImpl.java index 24e1b70..3f9ec1c 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/warehouse/service/impl/InRecordServiceImpl.java +++ b/ruoyi-system/src/main/java/com/ruoyi/warehouse/service/impl/InRecordServiceImpl.java @@ -6,14 +6,17 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.ruoyi.common.constant.UserConstants; -import com.ruoyi.common.core.domain.entity.SysUser; import com.ruoyi.common.exception.ServiceException; import com.ruoyi.common.utils.StringUtils; -import com.ruoyi.system.service.ISysUserService; +import com.ruoyi.project.service.IProjectInfoService; +import com.ruoyi.project.domain.ExpenseCategory; +import com.ruoyi.project.mapper.ProjectBudgetMapper; import com.ruoyi.warehouse.domain.InRecord; import com.ruoyi.warehouse.mapper.InRecordMapper; import com.ruoyi.warehouse.mapper.StockInfoMapper; import com.ruoyi.warehouse.service.IInRecordService; +import com.ruoyi.warehouse.service.IStockLotService; +import com.ruoyi.warehouse.util.WarehouseNoUtils; @Service public class InRecordServiceImpl implements IInRecordService @@ -25,7 +28,13 @@ public class InRecordServiceImpl implements IInRecordService private StockInfoMapper stockInfoMapper; @Autowired - private ISysUserService userService; + private IProjectInfoService projectInfoService; + + @Autowired + private ProjectBudgetMapper projectBudgetMapper; + + @Autowired + private IStockLotService stockLotService; @Override public List selectInRecordList(InRecord inRecord) @@ -50,11 +59,12 @@ public class InRecordServiceImpl implements IInRecordService @Override public int insertInRecord(InRecord inRecord) { - inRecord.setInOperator(resolveNickName(inRecord.getInOperator())); + inRecord.setInOperator(StringUtils.trim(inRecord.getInOperator())); + requireManualSourceProject(inRecord); validateInRecord(inRecord); if (StringUtils.isBlank(inRecord.getInNo())) { - inRecord.setInNo("IN" + System.currentTimeMillis()); + inRecord.setInNo(WarehouseNoUtils.next("IN")); } else if (!checkInNoUnique(inRecord)) { @@ -67,7 +77,8 @@ public class InRecordServiceImpl implements IInRecordService @Override public int updateInRecord(InRecord inRecord) { - inRecord.setInOperator(resolveNickName(inRecord.getInOperator())); + inRecord.setInOperator(StringUtils.trim(inRecord.getInOperator())); + requireManualSourceProject(inRecord); validateInRecord(inRecord); if (StringUtils.isNotBlank(inRecord.getInNo()) && !checkInNoUnique(inRecord)) { @@ -84,6 +95,8 @@ public class InRecordServiceImpl implements IInRecordService inRecord.setProductId(dbRecord.getProductId()); inRecord.setPurchaseRequestId(dbRecord.getPurchaseRequestId()); inRecord.setArrivalId(dbRecord.getArrivalId()); + inRecord.setSourceProjectId(dbRecord.getSourceProjectId()); + inRecord.setExpenseCategoryId(dbRecord.getExpenseCategoryId()); inRecord.setSourceNo(dbRecord.getSourceNo()); inRecord.setInType(dbRecord.getInType()); inRecord.setInNum(dbRecord.getInNum()); @@ -137,7 +150,13 @@ public class InRecordServiceImpl implements IInRecordService confirmRecord.setInStatus(1); confirmRecord.setOperator(username); confirmRecord.setConfirmTime(new Date()); - return inRecordMapper.confirmInRecord(confirmRecord); + int rows = inRecordMapper.confirmInRecord(confirmRecord); + if (rows != 1) + { + throw new ServiceException("入库记录状态已变化,请刷新后重试"); + } + stockLotService.createInboundLot(inRecord, username); + return rows; } private void validateInRecord(InRecord inRecord) @@ -152,17 +171,22 @@ public class InRecordServiceImpl implements IInRecordService } } - private String resolveNickName(String value) + private void requireManualSourceProject(InRecord inRecord) { - if (StringUtils.isBlank(value)) + if (inRecord.getArrivalId() != null) { - return value; + return; } - SysUser user = userService.selectUserByUserName(value); - if (user != null && StringUtils.isNotBlank(user.getNickName())) + if (inRecord.getSourceProjectId() == null || inRecord.getExpenseCategoryId() == null) { - return user.getNickName(); + throw new ServiceException("手工入库必须选择来源项目和经费科目"); + } + projectInfoService.requireActiveProject(inRecord.getSourceProjectId()); + ExpenseCategory category = projectBudgetMapper.selectExpenseCategoryById(inRecord.getExpenseCategoryId()); + if (category == null || Integer.valueOf(1).equals(category.getStatus())) + { + throw new ServiceException("经费科目不存在或已停用"); } - return value.trim(); } + } diff --git a/ruoyi-system/src/main/java/com/ruoyi/warehouse/service/impl/OutRecordServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/warehouse/service/impl/OutRecordServiceImpl.java index 8df09cf..77f1f30 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/warehouse/service/impl/OutRecordServiceImpl.java +++ b/ruoyi-system/src/main/java/com/ruoyi/warehouse/service/impl/OutRecordServiceImpl.java @@ -6,15 +6,17 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.ruoyi.common.constant.UserConstants; -import com.ruoyi.common.core.domain.entity.SysUser; import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.common.utils.StringUtils; -import com.ruoyi.system.service.ISysUserService; +import com.ruoyi.project.service.IProjectInfoService; import com.ruoyi.warehouse.domain.OutRecord; import com.ruoyi.warehouse.domain.StockInfo; import com.ruoyi.warehouse.mapper.OutRecordMapper; import com.ruoyi.warehouse.mapper.StockInfoMapper; import com.ruoyi.warehouse.service.IOutRecordService; +import com.ruoyi.warehouse.service.IStockLotService; +import com.ruoyi.warehouse.util.WarehouseNoUtils; @Service public class OutRecordServiceImpl implements IOutRecordService @@ -26,7 +28,10 @@ public class OutRecordServiceImpl implements IOutRecordService private StockInfoMapper stockInfoMapper; @Autowired - private ISysUserService userService; + private IProjectInfoService projectInfoService; + + @Autowired + private IStockLotService stockLotService; @Override public List selectOutRecordList(OutRecord outRecord) @@ -52,7 +57,7 @@ public class OutRecordServiceImpl implements IOutRecordService @Transactional public int insertOutRecord(OutRecord outRecord) { - outRecord.setOutOperator(resolveNickName(outRecord.getOutOperator())); + outRecord.setOutOperator(StringUtils.trim(outRecord.getOutOperator())); if (outRecord.getOutType() == null) { throw new ServiceException("出库类型不能为空"); @@ -65,9 +70,18 @@ public class OutRecordServiceImpl implements IOutRecordService { throw new ServiceException("出库人不能为空"); } + if (outRecord.getUsageProjectId() == null) + { + throw new ServiceException("使用项目不能为空"); + } + if (!SecurityUtils.isAdmin() && !SecurityUtils.hasPermi("project:inventory:use")) + { + throw new ServiceException("无项目物资使用权限"); + } + projectInfoService.requireActiveProject(outRecord.getUsageProjectId()); if (StringUtils.isBlank(outRecord.getOutNo())) { - outRecord.setOutNo("OUT" + System.currentTimeMillis()); + outRecord.setOutNo(WarehouseNoUtils.next("OUT")); } else if (!checkOutNoUnique(outRecord)) { @@ -83,25 +97,15 @@ public class OutRecordServiceImpl implements IOutRecordService throw new ServiceException("库存不足,当前库存:" + stockInfo.getStockNum()); } outRecord.setOutTime(new Date()); + outRecord.setCrossProjectFlag(0); int rows = stockInfoMapper.deductStock(outRecord.getAreaId(), outRecord.getProductId(), outRecord.getOutNum()); if (rows <= 0) { throw new ServiceException("库存扣减失败"); } - return outRecordMapper.insertOutRecord(outRecord); + int result = outRecordMapper.insertOutRecord(outRecord); + stockLotService.allocateOutboundLots(outRecord); + return result; } - private String resolveNickName(String value) - { - if (StringUtils.isBlank(value)) - { - return value; - } - SysUser user = userService.selectUserByUserName(value); - if (user != null && StringUtils.isNotBlank(user.getNickName())) - { - return user.getNickName(); - } - return value.trim(); - } } diff --git a/ruoyi-system/src/main/java/com/ruoyi/warehouse/service/impl/ProductInfoServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/warehouse/service/impl/ProductInfoServiceImpl.java index 74a08a4..c9625ad 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/warehouse/service/impl/ProductInfoServiceImpl.java +++ b/ruoyi-system/src/main/java/com/ruoyi/warehouse/service/impl/ProductInfoServiceImpl.java @@ -16,6 +16,9 @@ import com.ruoyi.warehouse.service.IProductInfoService; @Service public class ProductInfoServiceImpl implements IProductInfoService { + private volatile List categoryHistoryCache = List.of(); + private volatile long categoryHistoryCacheExpiresAt; + @Autowired private ProductInfoMapper productInfoMapper; @@ -77,13 +80,17 @@ public class ProductInfoServiceImpl implements IProductInfoService { normalizeProductInfo(productInfo); validateProductInfoUnique(productInfo); - return productInfoMapper.updateProductInfo(productInfo); + int rows = productInfoMapper.updateProductInfo(productInfo); + invalidateCategoryHistory(); + return rows; } @Override public int deleteProductInfoByIds(Long[] ids) { - return productInfoMapper.deleteProductInfoByIds(ids); + int rows = productInfoMapper.deleteProductInfoByIds(ids); + invalidateCategoryHistory(); + return rows; } private void normalizeProductInfo(ProductInfo productInfo) @@ -121,7 +128,7 @@ public class ProductInfoServiceImpl implements IProductInfoService private String inferCategoryFromHistory(ProductInfo productInfo) { Map scores = new HashMap<>(); - for (ProductInfo product : productInfoMapper.selectProductInfoAll()) + for (ProductInfo product : categoryHistory()) { if (StringUtils.isBlank(product.getCategory())) { @@ -158,6 +165,30 @@ public class ProductInfoServiceImpl implements IProductInfoService return null; } + private List categoryHistory() + { + long now = System.currentTimeMillis(); + if (now < categoryHistoryCacheExpiresAt) + { + return categoryHistoryCache; + } + synchronized (this) + { + if (now < categoryHistoryCacheExpiresAt) + { + return categoryHistoryCache; + } + categoryHistoryCache = List.copyOf(productInfoMapper.selectProductInfoAll()); + categoryHistoryCacheExpiresAt = now + 60_000L; + return categoryHistoryCache; + } + } + + private void invalidateCategoryHistory() + { + categoryHistoryCacheExpiresAt = 0L; + } + private int categoryProductScore(ProductInfo source, ProductInfo target) { int score = 0; diff --git a/ruoyi-system/src/main/java/com/ruoyi/warehouse/service/impl/PurchaseArrivalServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/warehouse/service/impl/PurchaseArrivalServiceImpl.java index 8b4bc33..b7adaa6 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/warehouse/service/impl/PurchaseArrivalServiceImpl.java +++ b/ruoyi-system/src/main/java/com/ruoyi/warehouse/service/impl/PurchaseArrivalServiceImpl.java @@ -1,16 +1,16 @@ package com.ruoyi.warehouse.service.impl; import java.math.BigDecimal; -import java.text.SimpleDateFormat; +import java.math.RoundingMode; import java.util.Date; import java.util.List; -import java.util.concurrent.atomic.AtomicInteger; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.ruoyi.common.exception.ServiceException; import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.project.service.IProjectBudgetService; import com.ruoyi.warehouse.domain.InRecord; import com.ruoyi.warehouse.domain.PurchaseArrival; import com.ruoyi.warehouse.domain.PurchaseArrivalAcceptanceRequest; @@ -20,11 +20,11 @@ import com.ruoyi.warehouse.mapper.InRecordMapper; import com.ruoyi.warehouse.mapper.PurchaseArrivalMapper; import com.ruoyi.warehouse.mapper.PurchaseRequestMapper; import com.ruoyi.warehouse.service.IPurchaseArrivalService; +import com.ruoyi.warehouse.util.WarehouseNoUtils; @Service public class PurchaseArrivalServiceImpl implements IPurchaseArrivalService { - private static final AtomicInteger ARRIVAL_SEQUENCE = new AtomicInteger(); @Autowired private PurchaseArrivalMapper purchaseArrivalMapper; @@ -35,10 +35,14 @@ public class PurchaseArrivalServiceImpl implements IPurchaseArrivalService @Autowired private InRecordMapper inRecordMapper; + @Autowired + private IProjectBudgetService projectBudgetService; + @Override public List selectPurchaseArrivalList(Long purchaseRequestId) { - if (purchaseRequestMapper.selectPurchaseRequestById(purchaseRequestId, null) == null) + PurchaseRequest request = purchaseRequestMapper.selectPurchaseRequestById(purchaseRequestId, null); + if (request == null) { throw new ServiceException("采购申请不存在"); } @@ -67,6 +71,15 @@ public class PurchaseArrivalServiceImpl implements IPurchaseArrivalService { throw new ServiceException("到货日期不能为空"); } + BigDecimal unitPrice = arrival.getActualUnitPrice() == null + ? request.getEstimatedUnitPrice() : arrival.getActualUnitPrice(); + if (unitPrice == null || unitPrice.signum() <= 0) + { + throw new ServiceException("本批实际含税单价必须大于0"); + } + arrival.setActualUnitPrice(unitPrice.setScale(2, RoundingMode.HALF_UP)); + arrival.setActualAmount(arrival.getArrivalQuantity().multiply(arrival.getActualUnitPrice()) + .setScale(2, RoundingMode.HALF_UP)); BigDecimal activeQuantity = purchaseArrivalMapper.selectActiveQuantityByRequestId(request.getId()); if (activeQuantity.add(arrival.getArrivalQuantity()).compareTo(request.getQuantity()) > 0) { @@ -116,6 +129,13 @@ public class PurchaseArrivalServiceImpl implements IPurchaseArrivalService if (Integer.valueOf(1).equals(arrival.getAcceptanceStatus())) { createPendingInRecord(arrival, username); + BigDecimal committedRelease = request.getEstimatedUnitPrice() + .multiply(arrival.getArrivalQuantity()).setScale(2, RoundingMode.HALF_UP); + projectBudgetService.settleExpense(request.getProjectId(), request.getExpenseCategoryId(), + committedRelease, arrival.getActualAmount(), "PURCHASE_ARRIVAL_SETTLE", arrival.getId(), + arrival.getArrivalNo(), "ARRIVAL:" + arrival.getId() + ":ACCEPT:" + + arrival.getAcceptedTime().getTime(), + "采购到货验收转实际支出"); BigDecimal accepted = purchaseArrivalMapper.selectAcceptedQuantityByRequestId(arrival.getPurchaseRequestId()); int status = accepted.compareTo(request.getQuantity()) >= 0 ? 4 : 3; purchaseRequestMapper.updatePurchaseStatus(request.getId(), status, @@ -186,6 +206,12 @@ public class PurchaseArrivalServiceImpl implements IPurchaseArrivalService if (Integer.valueOf(1).equals(arrival.getAcceptanceStatus())) { + BigDecimal committedRestore = request.getEstimatedUnitPrice() + .multiply(arrival.getArrivalQuantity()).setScale(2, RoundingMode.HALF_UP); + projectBudgetService.reverseSettlement(request.getProjectId(), request.getExpenseCategoryId(), + committedRestore, arrival.getActualAmount(), "PURCHASE_ARRIVAL_REVERSE", arrival.getId(), + arrival.getArrivalNo(), "ARRIVAL:" + arrival.getId() + ":REVOKE:" + arrival.getRevokedTime().getTime(), + "撤销采购到货验收"); BigDecimal accepted = purchaseArrivalMapper.selectAcceptedQuantityByRequestId(request.getId()); int status; if (accepted.signum() == 0) @@ -228,6 +254,8 @@ public class PurchaseArrivalServiceImpl implements IPurchaseArrivalService inRecord.setProductId(arrival.getProductId()); inRecord.setPurchaseRequestId(arrival.getPurchaseRequestId()); inRecord.setArrivalId(arrival.getId()); + inRecord.setSourceProjectId(arrival.getProjectId()); + inRecord.setExpenseCategoryId(arrival.getExpenseCategoryId()); inRecord.setSourceNo(arrival.getArrivalNo()); inRecord.setInType(2); inRecord.setInNum(arrival.getArrivalQuantity()); @@ -267,8 +295,6 @@ public class PurchaseArrivalServiceImpl implements IPurchaseArrivalService private String generateArrivalNo() { - int sequence = ARRIVAL_SEQUENCE.updateAndGet(value -> value >= 999 ? 0 : value + 1); - return "PA" + new SimpleDateFormat("yyyyMMddHHmmssSSS").format(new Date()) - + String.format("%03d", sequence); + return WarehouseNoUtils.next("PA"); } } diff --git a/ruoyi-system/src/main/java/com/ruoyi/warehouse/service/impl/PurchaseRequestServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/warehouse/service/impl/PurchaseRequestServiceImpl.java index 06660b7..ca126bb 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/warehouse/service/impl/PurchaseRequestServiceImpl.java +++ b/ruoyi-system/src/main/java/com/ruoyi/warehouse/service/impl/PurchaseRequestServiceImpl.java @@ -1,16 +1,21 @@ package com.ruoyi.warehouse.service.impl; import java.math.BigDecimal; -import java.text.SimpleDateFormat; -import java.util.Date; +import java.math.RoundingMode; import java.util.List; -import java.util.concurrent.atomic.AtomicInteger; +import java.util.Objects; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.ruoyi.common.exception.ServiceException; import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.project.domain.ExpenseCategory; +import com.ruoyi.project.domain.ProjectInfo; +import com.ruoyi.project.mapper.ProjectBudgetMapper; +import com.ruoyi.project.mapper.ProjectInfoMapper; +import com.ruoyi.project.service.IProjectBudgetService; +import com.ruoyi.project.service.IProjectInfoService; import com.ruoyi.warehouse.domain.ProductInfo; import com.ruoyi.warehouse.domain.PurchaseRequest; import com.ruoyi.warehouse.domain.PurchaseStatusUpdateRequest; @@ -19,11 +24,11 @@ import com.ruoyi.warehouse.mapper.PurchaseArrivalMapper; import com.ruoyi.warehouse.mapper.PurchaseRequestMapper; import com.ruoyi.warehouse.service.IProductInfoService; import com.ruoyi.warehouse.service.IPurchaseRequestService; +import com.ruoyi.warehouse.util.WarehouseNoUtils; @Service public class PurchaseRequestServiceImpl implements IPurchaseRequestService { - private static final AtomicInteger REQUEST_SEQUENCE = new AtomicInteger(); @Autowired private PurchaseRequestMapper purchaseRequestMapper; @@ -36,6 +41,18 @@ public class PurchaseRequestServiceImpl implements IPurchaseRequestService @Autowired private PurchaseArrivalMapper purchaseArrivalMapper; + @Autowired + private ProjectInfoMapper projectInfoMapper; + + @Autowired + private ProjectBudgetMapper projectBudgetMapper; + + @Autowired + private IProjectInfoService projectInfoService; + + @Autowired + private IProjectBudgetService projectBudgetService; + @Override public List selectPurchaseRequestList(PurchaseRequest purchaseRequest) { @@ -53,6 +70,7 @@ public class PurchaseRequestServiceImpl implements IPurchaseRequestService public int insertPurchaseRequest(PurchaseRequest purchaseRequest) { normalize(purchaseRequest); + resolveFunding(purchaseRequest); validateRequest(purchaseRequest); purchaseRequest.setApplicantId(SecurityUtils.getUserId()); purchaseRequest.setApplicantName(SecurityUtils.getUsername()); @@ -69,7 +87,12 @@ public class PurchaseRequestServiceImpl implements IPurchaseRequestService throw new ServiceException("申请单号已存在"); } resolveProduct(purchaseRequest); - return purchaseRequestMapper.insertPurchaseRequest(purchaseRequest); + int rows = purchaseRequestMapper.insertPurchaseRequest(purchaseRequest); + projectBudgetService.changeCommitment(purchaseRequest.getProjectId(), purchaseRequest.getExpenseCategoryId(), + purchaseRequest.getEstimatedAmount(), "PURCHASE_COMMIT", purchaseRequest.getId(), + purchaseRequest.getRequestNo(), "PURCHASE:" + purchaseRequest.getId() + ":CREATE", + "采购申请占用经费"); + return rows; } @Override @@ -83,6 +106,7 @@ public class PurchaseRequestServiceImpl implements IPurchaseRequestService PurchaseRequest existing = requireRequest(purchaseRequest.getId()); checkOwnerOrManager(existing); normalize(purchaseRequest); + resolveFunding(purchaseRequest); validateRequest(purchaseRequest); BigDecimal activeArrivalQuantity = purchaseArrivalMapper.selectActiveQuantityByRequestId(existing.getId()); @@ -94,6 +118,13 @@ public class PurchaseRequestServiceImpl implements IPurchaseRequestService { throw new ServiceException("已有到货记录,不能更换采购物料"); } + if (activeArrivalQuantity.signum() > 0 + && (!Objects.equals(existing.getProjectId(), purchaseRequest.getProjectId()) + || !Objects.equals(existing.getExpenseCategoryId(), purchaseRequest.getExpenseCategoryId()) + || money(existing.getEstimatedUnitPrice()).compareTo(money(purchaseRequest.getEstimatedUnitPrice())) != 0)) + { + throw new ServiceException("已有到货记录,不能更换项目、经费科目或预计单价"); + } if (StringUtils.isBlank(purchaseRequest.getRequestNo())) { @@ -119,6 +150,7 @@ public class PurchaseRequestServiceImpl implements IPurchaseRequestService { throw new ServiceException("无权修改其他员工的采购申请"); } + adjustPurchaseCommitment(existing, purchaseRequest); return rows; } @@ -138,6 +170,12 @@ public class PurchaseRequestServiceImpl implements IPurchaseRequestService { throw new ServiceException("申请 " + request.getRequestNo() + " 已有到货记录,不能删除"); } + if (!Integer.valueOf(5).equals(request.getPurchaseStatus())) + { + projectBudgetService.changeCommitment(request.getProjectId(), request.getExpenseCategoryId(), + money(request.getEstimatedAmount()).negate(), "PURCHASE_DELETE_RELEASE", request.getId(), + request.getRequestNo(), "PURCHASE:" + request.getId() + ":DELETE", "删除采购申请释放经费"); + } } int rows = purchaseRequestMapper.deletePurchaseRequestByIds(ids, currentApplicantScope()); if (rows != ids.length) @@ -166,6 +204,18 @@ public class PurchaseRequestServiceImpl implements IPurchaseRequestService { throw new ServiceException("已有待验收或已验收到货,不能取消采购申请"); } + if (!Integer.valueOf(5).equals(existing.getPurchaseStatus()) && Integer.valueOf(5).equals(status)) + { + projectBudgetService.changeCommitment(existing.getProjectId(), existing.getExpenseCategoryId(), + money(existing.getEstimatedAmount()).negate(), "PURCHASE_CANCEL_RELEASE", existing.getId(), + existing.getRequestNo(), "PURCHASE:" + existing.getId() + ":CANCEL", "取消采购释放经费"); + } + else if (Integer.valueOf(5).equals(existing.getPurchaseStatus()) && !Integer.valueOf(5).equals(status)) + { + projectBudgetService.changeCommitment(existing.getProjectId(), existing.getExpenseCategoryId(), + money(existing.getEstimatedAmount()), "PURCHASE_REOPEN_COMMIT", existing.getId(), + existing.getRequestNo(), null, "恢复采购重新占用经费"); + } return purchaseRequestMapper.updatePurchaseStatus(id, status, trimToNull(request.getProgressRemark()), SecurityUtils.getUsername()); } @@ -258,6 +308,8 @@ public class PurchaseRequestServiceImpl implements IPurchaseRequestService private void normalize(PurchaseRequest request) { request.setRequestNo(StringUtils.trim(request.getRequestNo())); + request.setProjectCode(trimToNull(request.getProjectCode())); + request.setExpenseCategoryCode(trimToNull(request.getExpenseCategoryCode())); request.setProductCode(trimToNull(request.getProductCode())); request.setMaterialName(StringUtils.trim(request.getMaterialName())); request.setMaterialBrand(trimToNull(request.getMaterialBrand())); @@ -282,6 +334,14 @@ public class PurchaseRequestServiceImpl implements IPurchaseRequestService { throw new ServiceException("需求数量必须大于0"); } + if (request.getProjectId() == null || request.getExpenseCategoryId() == null) + { + throw new ServiceException("采购项目和经费科目不能为空"); + } + if (request.getEstimatedUnitPrice() == null || request.getEstimatedUnitPrice().signum() <= 0) + { + throw new ServiceException("预计含税单价必须大于0"); + } if (StringUtils.isNotBlank(request.getPurchaseLink()) && !request.getPurchaseLink().matches("(?i)^https?://.+")) { @@ -289,6 +349,82 @@ public class PurchaseRequestServiceImpl implements IPurchaseRequestService } } + private void resolveFunding(PurchaseRequest request) + { + ProjectInfo project = null; + if (request.getProjectId() != null) + { + project = projectInfoMapper.selectProjectInfoById(request.getProjectId(), null); + } + else if (StringUtils.isNotBlank(request.getProjectCode())) + { + project = projectInfoMapper.selectProjectInfoByCode(request.getProjectCode()); + } + if (project == null) + { + throw new ServiceException("采购项目不存在"); + } + projectInfoService.requireActiveProject(project.getId()); + + ExpenseCategory category = null; + if (request.getExpenseCategoryId() != null) + { + category = projectBudgetMapper.selectExpenseCategoryById(request.getExpenseCategoryId()); + } + else if (StringUtils.isNotBlank(request.getExpenseCategoryCode())) + { + category = projectBudgetMapper.selectExpenseCategoryByCode(request.getExpenseCategoryCode()); + } + if (category == null || Integer.valueOf(1).equals(category.getStatus())) + { + throw new ServiceException("经费科目不存在或已停用"); + } + request.setProjectId(project.getId()); + request.setProjectCode(project.getProjectCode()); + request.setProjectName(project.getProjectName()); + request.setExpenseCategoryId(category.getId()); + request.setExpenseCategoryCode(category.getCategoryCode()); + request.setExpenseCategoryName(category.getCategoryName()); + request.setEstimatedUnitPrice(money(request.getEstimatedUnitPrice())); + request.setEstimatedAmount(request.getQuantity().multiply(request.getEstimatedUnitPrice()) + .setScale(2, RoundingMode.HALF_UP)); + } + + private void adjustPurchaseCommitment(PurchaseRequest existing, PurchaseRequest updated) + { + if (Integer.valueOf(5).equals(existing.getPurchaseStatus())) + { + return; + } + BigDecimal oldAmount = money(existing.getEstimatedAmount()); + BigDecimal newAmount = money(updated.getEstimatedAmount()); + if (!Objects.equals(existing.getProjectId(), updated.getProjectId()) + || !Objects.equals(existing.getExpenseCategoryId(), updated.getExpenseCategoryId())) + { + if (oldAmount.signum() != 0) + { + projectBudgetService.changeCommitment(existing.getProjectId(), existing.getExpenseCategoryId(), + oldAmount.negate(), "PURCHASE_MOVE_RELEASE", existing.getId(), existing.getRequestNo(), + null, "采购申请更换项目或科目,释放原占用"); + } + projectBudgetService.changeCommitment(updated.getProjectId(), updated.getExpenseCategoryId(), + newAmount, "PURCHASE_MOVE_COMMIT", updated.getId(), updated.getRequestNo(), + null, "采购申请更换项目或科目,重新占用"); + return; + } + BigDecimal delta = newAmount.subtract(oldAmount); + if (delta.signum() != 0) + { + projectBudgetService.changeCommitment(updated.getProjectId(), updated.getExpenseCategoryId(), delta, + "PURCHASE_COMMIT_ADJUST", updated.getId(), updated.getRequestNo(), null, "调整采购预计金额"); + } + } + + private BigDecimal money(BigDecimal value) + { + return value == null ? BigDecimal.ZERO.setScale(2) : value.setScale(2, RoundingMode.HALF_UP); + } + private void resolveProduct(PurchaseRequest request) { ProductInfo draft = new ProductInfo(); @@ -404,8 +540,6 @@ public class PurchaseRequestServiceImpl implements IPurchaseRequestService private String generateRequestNo() { - int sequence = REQUEST_SEQUENCE.updateAndGet(value -> value >= 999 ? 0 : value + 1); - return "PR" + new SimpleDateFormat("yyyyMMddHHmmssSSS").format(new Date()) - + String.format("%03d", sequence); + return WarehouseNoUtils.next("PR"); } } diff --git a/ruoyi-system/src/main/java/com/ruoyi/warehouse/service/impl/PurchaseSmartImportServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/warehouse/service/impl/PurchaseSmartImportServiceImpl.java index 1a91920..f4f7431 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/warehouse/service/impl/PurchaseSmartImportServiceImpl.java +++ b/ruoyi-system/src/main/java/com/ruoyi/warehouse/service/impl/PurchaseSmartImportServiceImpl.java @@ -13,8 +13,10 @@ import java.util.Base64; import java.util.Comparator; import java.util.List; import java.util.Locale; +import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; +import java.util.concurrent.Executor; import org.apache.commons.io.FilenameUtils; import org.apache.poi.ss.usermodel.DataFormatter; import org.apache.poi.ss.usermodel.FormulaEvaluator; @@ -24,6 +26,7 @@ import org.apache.poi.ss.usermodel.Workbook; import org.apache.poi.ss.usermodel.WorkbookFactory; import org.apache.poi.ss.util.CellRangeAddress; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; @@ -35,6 +38,7 @@ import com.ruoyi.common.utils.StringUtils; import com.ruoyi.warehouse.domain.ProductInfo; import com.ruoyi.warehouse.service.IProductInfoService; import com.ruoyi.warehouse.service.IPurchaseSmartImportService; +import com.ruoyi.warehouse.util.WarehouseFileUtils; @Service public class PurchaseSmartImportServiceImpl implements IPurchaseSmartImportService @@ -59,6 +63,10 @@ public class PurchaseSmartImportServiceImpl implements IPurchaseSmartImportServi @Autowired private IProductInfoService productService; + @Autowired + @Qualifier("warehouseTaskExecutor") + private Executor warehouseTaskExecutor; + @Override public JSONObject analyze(MultipartFile file) throws Exception { @@ -176,7 +184,7 @@ public class PurchaseSmartImportServiceImpl implements IPurchaseSmartImportServi { throw new CompletionException(e); } - })); + }, warehouseTaskExecutor)); } try @@ -745,12 +753,6 @@ public class PurchaseSmartImportServiceImpl implements IPurchaseSmartImportServi private void validateFile(MultipartFile file) { - if (file == null || file.isEmpty()) throw new ServiceException("请选择图片或Excel文件"); - if (file.getSize() > MAX_FILE_SIZE) throw new ServiceException("文件不能超过10MB"); - String extension = FilenameUtils.getExtension(file.getOriginalFilename()).toLowerCase(Locale.ROOT); - if (!List.of("xls", "xlsx", "png", "jpg", "jpeg", "webp").contains(extension)) - { - throw new ServiceException("仅支持 xls、xlsx、png、jpg、jpeg、webp 文件"); - } + WarehouseFileUtils.validateUpload(file, MAX_FILE_SIZE, Set.of("xls", "xlsx", "png", "jpg", "jpeg", "webp")); } } diff --git a/ruoyi-system/src/main/java/com/ruoyi/warehouse/service/impl/StockCheckRecordServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/warehouse/service/impl/StockCheckRecordServiceImpl.java index 5d00b7a..320ad7c 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/warehouse/service/impl/StockCheckRecordServiceImpl.java +++ b/ruoyi-system/src/main/java/com/ruoyi/warehouse/service/impl/StockCheckRecordServiceImpl.java @@ -14,6 +14,8 @@ import com.ruoyi.warehouse.domain.StockInfo; import com.ruoyi.warehouse.mapper.StockCheckRecordMapper; import com.ruoyi.warehouse.mapper.StockInfoMapper; import com.ruoyi.warehouse.service.IStockCheckRecordService; +import com.ruoyi.warehouse.service.IStockLotService; +import com.ruoyi.warehouse.util.WarehouseNoUtils; @Service public class StockCheckRecordServiceImpl implements IStockCheckRecordService @@ -24,6 +26,9 @@ public class StockCheckRecordServiceImpl implements IStockCheckRecordService @Autowired private StockInfoMapper stockInfoMapper; + @Autowired + private IStockLotService stockLotService; + @Override public List selectStockCheckRecordList(StockCheckRecord stockCheckRecord) { @@ -53,7 +58,7 @@ public class StockCheckRecordServiceImpl implements IStockCheckRecordService } if (StringUtils.isBlank(stockCheckRecord.getCheckNo())) { - stockCheckRecord.setCheckNo("CHK" + System.currentTimeMillis()); + stockCheckRecord.setCheckNo(WarehouseNoUtils.next("CHK")); } else if (!checkCheckNoUnique(stockCheckRecord)) { @@ -110,6 +115,11 @@ public class StockCheckRecordServiceImpl implements IStockCheckRecordService { throw new ServiceException("该盘点记录已生效"); } + StockInfo currentStock = stockInfoMapper.selectStockInfoByAreaProductForUpdate( + checkRecord.getAreaId(), checkRecord.getProductId()); + BigDecimal currentQuantity = currentStock == null ? BigDecimal.ZERO : currentStock.getStockNum(); + stockLotService.adjustLots(checkRecord.getAreaId(), checkRecord.getProductId(), + checkRecord.getRealNum().subtract(currentQuantity), checkRecord.getId(), username); stockInfoMapper.upsertSetStock(checkRecord.getAreaId(), checkRecord.getProductId(), checkRecord.getRealNum()); StockCheckRecord confirmRecord = new StockCheckRecord(); confirmRecord.setId(id); diff --git a/ruoyi-system/src/main/java/com/ruoyi/warehouse/service/impl/StockLotServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/warehouse/service/impl/StockLotServiceImpl.java new file mode 100644 index 0000000..407b815 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/warehouse/service/impl/StockLotServiceImpl.java @@ -0,0 +1,189 @@ +package com.ruoyi.warehouse.service.impl; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.List; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.SecurityUtils; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.project.domain.ExpenseCategory; +import com.ruoyi.project.mapper.ProjectBudgetMapper; +import com.ruoyi.project.service.IProjectInfoService; +import com.ruoyi.warehouse.domain.InRecord; +import com.ruoyi.warehouse.domain.OutRecord; +import com.ruoyi.warehouse.domain.PurchaseArrival; +import com.ruoyi.warehouse.domain.StockLot; +import com.ruoyi.warehouse.mapper.OutRecordMapper; +import com.ruoyi.warehouse.mapper.PurchaseArrivalMapper; +import com.ruoyi.warehouse.mapper.StockLotMapper; +import com.ruoyi.warehouse.service.IStockLotService; +import com.ruoyi.warehouse.util.WarehouseNoUtils; + +@Service +public class StockLotServiceImpl implements IStockLotService +{ + @Autowired + private StockLotMapper stockLotMapper; + + @Autowired + private OutRecordMapper outRecordMapper; + + @Autowired + private PurchaseArrivalMapper purchaseArrivalMapper; + + @Autowired + private ProjectBudgetMapper projectBudgetMapper; + + @Autowired + private IProjectInfoService projectInfoService; + + @Override + public List selectStockLotList(StockLot lot) + { + return stockLotMapper.selectStockLotList(lot); + } + + @Override + public void createInboundLot(InRecord inRecord, String username) + { + if (stockLotMapper.selectStockLotByInRecordId(inRecord.getId()) != null) + { + return; + } + Long sourceProjectId = inRecord.getSourceProjectId(); + Long categoryId = inRecord.getExpenseCategoryId(); + if (sourceProjectId == null) + { + sourceProjectId = projectInfoService.selectLegacyProjectId(); + } + if (categoryId == null) + { + ExpenseCategory legacy = projectBudgetMapper.selectExpenseCategoryByCode("LEGACY"); + categoryId = legacy == null ? null : legacy.getId(); + } + BigDecimal unitCost = BigDecimal.ZERO.setScale(2); + if (inRecord.getArrivalId() != null) + { + PurchaseArrival arrival = purchaseArrivalMapper.selectPurchaseArrivalById(inRecord.getArrivalId()); + if (arrival != null && arrival.getActualUnitPrice() != null) + { + unitCost = arrival.getActualUnitPrice().setScale(2, RoundingMode.HALF_UP); + } + } + StockLot lot = new StockLot(); + lot.setLotNo(WarehouseNoUtils.next("LOT")); + lot.setAreaId(inRecord.getAreaId()); + lot.setProductId(inRecord.getProductId()); + lot.setSourceProjectId(sourceProjectId); + lot.setExpenseCategoryId(categoryId); + lot.setPurchaseRequestId(inRecord.getPurchaseRequestId()); + lot.setArrivalId(inRecord.getArrivalId()); + lot.setInRecordId(inRecord.getId()); + lot.setReceivedQuantity(inRecord.getInNum()); + lot.setAvailableQuantity(inRecord.getInNum()); + lot.setUnitCost(unitCost); + lot.setLotStatus(0); + lot.setRemark("入库单:" + inRecord.getInNo()); + lot.setCreateBy(username); + stockLotMapper.insertStockLot(lot); + } + + @Override + public void allocateOutboundLots(OutRecord outRecord) + { + if (outRecord.getUsageProjectId() == null) + { + throw new ServiceException("使用项目不能为空"); + } + projectInfoService.requireActiveProject(outRecord.getUsageProjectId()); + List lots = stockLotMapper.selectAvailableLotsForUpdate( + outRecord.getAreaId(), outRecord.getProductId()); + BigDecimal remaining = outRecord.getOutNum(); + Long legacyProjectId = projectInfoService.selectLegacyProjectId(); + boolean crossProject = false; + for (StockLot lot : lots) + { + if (remaining.signum() <= 0) + { + break; + } + BigDecimal allocated = remaining.min(lot.getAvailableQuantity()); + boolean currentCross = !lot.getSourceProjectId().equals(outRecord.getUsageProjectId()) + && !lot.getSourceProjectId().equals(legacyProjectId); + crossProject = crossProject || currentCross; + if (stockLotMapper.deductStockLot(lot.getId(), allocated) != 1) + { + throw new ServiceException("库存批次已变化,请刷新后重试"); + } + BigDecimal unitCost = lot.getUnitCost() == null ? BigDecimal.ZERO : lot.getUnitCost(); + stockLotMapper.insertOutRecordLot(outRecord.getId(), lot.getId(), lot.getSourceProjectId(), + outRecord.getUsageProjectId(), allocated, unitCost, + allocated.multiply(unitCost).setScale(2, RoundingMode.HALF_UP)); + remaining = remaining.subtract(allocated); + } + if (remaining.signum() > 0) + { + throw new ServiceException("库存批次数量不足,缺少:" + remaining); + } + if (crossProject) + { + if (!SecurityUtils.isAdmin() && !SecurityUtils.hasPermi("project:inventory:cross-use")) + { + throw new ServiceException("库存来源项目与使用项目不同,无跨项目使用权限"); + } + if (StringUtils.isBlank(outRecord.getCrossProjectReason())) + { + throw new ServiceException("跨项目使用必须填写原因"); + } + } + outRecord.setCrossProjectFlag(crossProject ? 1 : 0); + outRecordMapper.updateOutRecordProjectFlags(outRecord); + } + + @Override + public void adjustLots(Long areaId, Long productId, BigDecimal difference, Long checkId, String username) + { + if (difference == null || difference.signum() == 0) + { + return; + } + if (difference.signum() > 0) + { + ExpenseCategory legacy = projectBudgetMapper.selectExpenseCategoryByCode("LEGACY"); + StockLot lot = new StockLot(); + lot.setLotNo(WarehouseNoUtils.next("ADJ")); + lot.setAreaId(areaId); + lot.setProductId(productId); + lot.setSourceProjectId(projectInfoService.selectLegacyProjectId()); + lot.setExpenseCategoryId(legacy == null ? null : legacy.getId()); + lot.setReceivedQuantity(difference); + lot.setAvailableQuantity(difference); + lot.setUnitCost(BigDecimal.ZERO.setScale(2)); + lot.setLotStatus(0); + lot.setRemark("盘库盘盈,盘库记录ID:" + checkId); + lot.setCreateBy(username); + stockLotMapper.insertStockLot(lot); + return; + } + BigDecimal remaining = difference.abs(); + for (StockLot lot : stockLotMapper.selectAvailableLotsForUpdate(areaId, productId)) + { + if (remaining.signum() <= 0) + { + break; + } + BigDecimal allocated = remaining.min(lot.getAvailableQuantity()); + if (stockLotMapper.deductStockLot(lot.getId(), allocated) != 1) + { + throw new ServiceException("盘库调整时库存批次已变化,请刷新后重试"); + } + remaining = remaining.subtract(allocated); + } + if (remaining.signum() > 0) + { + throw new ServiceException("库存批次与汇总库存不一致,无法完成盘亏调整"); + } + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/warehouse/service/impl/WmsBatchImportServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/warehouse/service/impl/WmsBatchImportServiceImpl.java index e992938..60309d5 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/warehouse/service/impl/WmsBatchImportServiceImpl.java +++ b/ruoyi-system/src/main/java/com/ruoyi/warehouse/service/impl/WmsBatchImportServiceImpl.java @@ -16,10 +16,12 @@ import java.time.Duration; import java.util.ArrayList; import java.util.Base64; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Map; -import java.util.concurrent.CompletableFuture; +import java.util.Set; +import java.util.concurrent.Executor; import org.apache.commons.io.FilenameUtils; import org.apache.poi.ss.usermodel.Cell; import org.apache.poi.ss.usermodel.DataFormatter; @@ -28,6 +30,7 @@ import org.apache.poi.ss.usermodel.Sheet; import org.apache.poi.ss.usermodel.Workbook; import org.apache.poi.ss.usermodel.WorkbookFactory; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; @@ -37,6 +40,7 @@ import com.alibaba.fastjson2.JSONObject; import com.ruoyi.common.config.RuoYiConfig; import com.ruoyi.common.core.domain.entity.SysUser; import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.common.utils.StringUtils; import com.ruoyi.system.service.ISysUserService; import com.ruoyi.warehouse.domain.InRecord; @@ -49,6 +53,8 @@ import com.ruoyi.warehouse.domain.WmsBatchItem; import com.ruoyi.warehouse.domain.WmsBatchJob; import com.ruoyi.warehouse.mapper.WmsBatchItemMapper; import com.ruoyi.warehouse.mapper.WmsBatchJobMapper; +import com.ruoyi.warehouse.mapper.ProductInfoMapper; +import com.ruoyi.warehouse.mapper.WarehouseAreaMapper; import com.ruoyi.warehouse.service.IInRecordService; import com.ruoyi.warehouse.service.IOutRecordService; import com.ruoyi.warehouse.service.IProductInfoService; @@ -56,10 +62,18 @@ import com.ruoyi.warehouse.service.IStockCheckRecordService; import com.ruoyi.warehouse.service.IStockInfoService; import com.ruoyi.warehouse.service.IWarehouseAreaService; import com.ruoyi.warehouse.service.IWmsBatchImportService; +import com.ruoyi.warehouse.util.WarehouseFileUtils; +import com.ruoyi.warehouse.util.WarehouseNoUtils; @Service public class WmsBatchImportServiceImpl implements IWmsBatchImportService { + private static final long MAX_UPLOAD_SIZE = 10L * 1024 * 1024; + private static final int MAX_BATCH_ITEMS = 2000; + private static final int DB_BATCH_SIZE = 100; + private static final Set ALLOWED_EXTENSIONS = Set.of("xls", "xlsx", "jpg", "jpeg", "png", "bmp"); + private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)) + .version(HttpClient.Version.HTTP_1_1).build(); private static final String ACTION_AREA = "create_area"; private static final String ACTION_PRODUCT = "create_product"; private static final String ACTION_IN = "create_in_record"; @@ -79,6 +93,9 @@ public class WmsBatchImportServiceImpl implements IWmsBatchImportService private static final int ITEM_DONE = 3; private static final int ITEM_FAILED = 4; + private volatile Map userAccountCache = Map.of(); + private volatile long userAccountCacheExpiresAt; + @Value("${agent.model.base-url}") private String baseUrl; @@ -94,6 +111,12 @@ public class WmsBatchImportServiceImpl implements IWmsBatchImportService @Autowired private WmsBatchItemMapper itemMapper; + @Autowired + private WarehouseAreaMapper areaMapper; + + @Autowired + private ProductInfoMapper productMapper; + @Autowired private IWarehouseAreaService areaService; @@ -115,20 +138,16 @@ public class WmsBatchImportServiceImpl implements IWmsBatchImportService @Autowired private ISysUserService userService; + @Autowired + @Qualifier("warehouseTaskExecutor") + private Executor warehouseTaskExecutor; + @Override public WmsBatchJob uploadExcel(MultipartFile file, String username) throws Exception { - if (file == null || file.isEmpty()) - { - throw new ServiceException("请选择要上传的文件"); - } - String extension = StringUtils.defaultString(FilenameUtils.getExtension(file.getOriginalFilename())).toLowerCase(Locale.ROOT); - if (!isExcel(extension) && !isImage(extension)) - { - throw new ServiceException("当前支持 xls/xlsx/jpg/jpeg/png/bmp 文件"); - } + String extension = WarehouseFileUtils.validateUpload(file, MAX_UPLOAD_SIZE, ALLOWED_EXTENSIONS); - String jobNo = "BATCH" + System.currentTimeMillis(); + String jobNo = WarehouseNoUtils.next("BATCH"); String safeName = jobNo + "." + extension; Path dir = Paths.get(RuoYiConfig.getUploadPath(), "wms-batch").toAbsolutePath().normalize(); Files.createDirectories(dir); @@ -154,11 +173,11 @@ public class WmsBatchImportServiceImpl implements IWmsBatchImportService if (isExcel(extension)) { - CompletableFuture.runAsync(() -> parseExcelJob(job.getId(), username)); + warehouseTaskExecutor.execute(() -> parseExcelJob(job.getId(), username)); } else { - CompletableFuture.runAsync(() -> parseImageJob(job.getId(), username)); + warehouseTaskExecutor.execute(() -> parseImageJob(job.getId(), username)); } return job; } @@ -166,18 +185,23 @@ public class WmsBatchImportServiceImpl implements IWmsBatchImportService @Override public List selectJobList(WmsBatchJob job) { + if (!SecurityUtils.isAdmin()) + { + job.setCreateBy(SecurityUtils.getUsername()); + } return jobMapper.selectWmsBatchJobList(job); } @Override public WmsBatchJob selectJobById(Long id) { - return jobMapper.selectWmsBatchJobById(id); + return requireAccessibleJob(id); } @Override public List selectItemList(Long jobId) { + requireAccessibleJob(jobId); WmsBatchItem query = new WmsBatchItem(); query.setJobId(jobId); return itemMapper.selectWmsBatchItemList(query); @@ -215,10 +239,11 @@ public class WmsBatchImportServiceImpl implements IWmsBatchImportService } WmsBatchJob job = jobMapper.selectWmsBatchJobById(jobId); List items = selectItemList(jobId); + Set selectedIds = itemIds == null || itemIds.isEmpty() ? null : new HashSet<>(itemIds); int rows = 0; for (WmsBatchItem item : items) { - if (itemIds != null && !itemIds.isEmpty() && !itemIds.contains(item.getId())) + if (selectedIds != null && !selectedIds.contains(item.getId())) { continue; } @@ -257,11 +282,7 @@ public class WmsBatchImportServiceImpl implements IWmsBatchImportService @Override public WmsBatchJob confirmJob(Long jobId, String username) { - WmsBatchJob job = jobMapper.selectWmsBatchJobById(jobId); - if (job == null) - { - throw new ServiceException("批量任务不存在"); - } + WmsBatchJob job = requireAccessibleJob(jobId); if (!Integer.valueOf(JOB_WAIT_CONFIRM).equals(job.getStatus())) { throw new ServiceException("当前任务还不能确认执行"); @@ -274,8 +295,15 @@ public class WmsBatchImportServiceImpl implements IWmsBatchImportService throw new ServiceException("存在校验失败的明细,请先修改或删除后再确认"); } - updateJob(jobId, JOB_EXECUTING, 0, null); List items = selectItemList(jobId); + for (WmsBatchItem item : items) + { + requireActionPermission(item.getActionType()); + } + if (jobMapper.claimWmsBatchJob(jobId) != 1) + { + throw new ServiceException("任务状态已变化,请刷新后重试"); + } int success = 0; int fail = 0; int total = items.size(); @@ -296,7 +324,10 @@ public class WmsBatchImportServiceImpl implements IWmsBatchImportService fail++; } itemMapper.updateWmsBatchItem(item); - updateJobProgress(jobId, JOB_EXECUTING, total, i + 1, success, fail); + if ((i + 1) % 10 == 0 || i + 1 == total) + { + updateJobProgress(jobId, JOB_EXECUTING, total, i + 1, success, fail); + } } WmsBatchJob finish = new WmsBatchJob(); finish.setId(jobId); @@ -331,19 +362,18 @@ public class WmsBatchImportServiceImpl implements IWmsBatchImportService normalizeItem(item); validateItem(item, username); fillParsedJson(item); - itemMapper.insertWmsBatchItem(item); - if ((i + 1) % 10 == 0 || i + 1 == total) - { - int progress = 10 + (int) Math.floor((i + 1) * 80.0 / total); - refreshJobCount(jobId, JOB_PARSING, progress, null); - } } + insertItemsInBatches(items); refreshJobCount(jobId, JOB_WAIT_CONFIRM, 100, null); } catch (Exception e) { updateJob(jobId, JOB_FAILED, 100, StringUtils.defaultIfBlank(e.getMessage(), "解析失败")); } + finally + { + deleteJobSource(jobId); + } } private void parseImageJob(Long jobId, String username) @@ -367,16 +397,18 @@ public class WmsBatchImportServiceImpl implements IWmsBatchImportService normalizeItem(item); validateItem(item, username); fillParsedJson(item); - itemMapper.insertWmsBatchItem(item); - int progress = 20 + (int) Math.floor((i + 1) * 70.0 / total); - refreshJobCount(jobId, JOB_PARSING, progress, null); } + insertItemsInBatches(items); refreshJobCount(jobId, JOB_WAIT_CONFIRM, 100, null); } catch (Exception e) { updateJob(jobId, JOB_FAILED, 100, StringUtils.defaultIfBlank(e.getMessage(), "图片解析失败")); } + finally + { + deleteJobSource(jobId); + } } private List parseImage(WmsBatchJob job, String username) throws Exception @@ -416,6 +448,10 @@ public class WmsBatchImportServiceImpl implements IWmsBatchImportService WmsBatchItem item = rowToItem(row, headerMap, formatter, sheetAction, username); item.setRowNo(rowIndex + 1); items.add(item); + if (items.size() > MAX_BATCH_ITEMS) + { + throw new ServiceException("单次最多导入" + MAX_BATCH_ITEMS + "条数据"); + } } } return items; @@ -425,7 +461,7 @@ public class WmsBatchImportServiceImpl implements IWmsBatchImportService { WmsBatchItem item = new WmsBatchItem(); item.setActionType(sheetAction); - item.setOperatorName(resolveNickName(username)); + item.setOperatorName(username); StringBuilder raw = new StringBuilder(); for (Map.Entry entry : headerMap.entrySet()) { @@ -464,6 +500,10 @@ public class WmsBatchImportServiceImpl implements IWmsBatchImportService { return items; } + if (rows.size() > MAX_BATCH_ITEMS) + { + throw new ServiceException("单次最多导入" + MAX_BATCH_ITEMS + "条数据"); + } for (int i = 0; i < rows.size(); i++) { JSONObject row = rows.getJSONObject(i); @@ -484,7 +524,7 @@ public class WmsBatchImportServiceImpl implements IWmsBatchImportService item.setOperatorName(trimToNull(firstText(row, "operatorName", "operator", "入库人", "出库人", "盘点人", "操作人"))); if (StringUtils.isBlank(item.getOperatorName())) { - item.setOperatorName(resolveNickName(username)); + item.setOperatorName(username); } item.setTypeCode(parseTypeCode(firstText(row, "typeCode", "inType", "outType", "类型", "入库类型", "出库类型"), item.getActionType())); item.setSourceNo(trimToNull(firstText(row, "sourceNo", "来源单据号", "关联单据号"))); @@ -575,15 +615,13 @@ public class WmsBatchImportServiceImpl implements IWmsBatchImportService body.put("max_tokens", 8192); body.put("stream", false); - HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)) - .version(HttpClient.Version.HTTP_1_1).build(); HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() .uri(URI.create(normalizeEndpoint(baseUrl))) .timeout(Duration.ofSeconds(120)) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(body.toJSONString(), StandardCharsets.UTF_8)); if (StringUtils.isNotBlank(apiKey)) requestBuilder.header("Authorization", "Bearer " + apiKey); - HttpResponse response = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + HttpResponse response = HTTP_CLIENT.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); if (response.statusCode() < 200 || response.statusCode() >= 300) { throw new ServiceException("图片模型解析失败,HTTP " + response.statusCode()); @@ -738,7 +776,11 @@ public class WmsBatchImportServiceImpl implements IWmsBatchImportService } if (StringUtils.isBlank(item.getOperatorName())) { - item.setOperatorName(resolveNickName(username)); + item.setOperatorName(username); + } + else + { + item.setOperatorName(resolveUserName(item.getOperatorName())); } if (findArea(item.getAreaName()) == null) { @@ -759,7 +801,11 @@ public class WmsBatchImportServiceImpl implements IWmsBatchImportService } if (StringUtils.isBlank(item.getOperatorName())) { - item.setOperatorName(resolveNickName(username)); + item.setOperatorName(username); + } + else + { + item.setOperatorName(resolveUserName(item.getOperatorName())); } WarehouseArea area = findArea(item.getAreaName()); ProductInfo product = findProduct(item); @@ -793,7 +839,11 @@ public class WmsBatchImportServiceImpl implements IWmsBatchImportService } if (StringUtils.isBlank(item.getOperatorName())) { - item.setOperatorName(resolveNickName(username)); + item.setOperatorName(username); + } + else + { + item.setOperatorName(resolveUserName(item.getOperatorName())); } if (findArea(item.getAreaName()) == null) { @@ -899,15 +949,58 @@ public class WmsBatchImportServiceImpl implements IWmsBatchImportService } private void ensureJobEditable(Long jobId) + { + WmsBatchJob job = requireAccessibleJob(jobId); + if (Integer.valueOf(JOB_EXECUTING).equals(job.getStatus()) || Integer.valueOf(JOB_DONE).equals(job.getStatus())) + { + throw new ServiceException("任务已执行,不能再修改明细"); + } + } + + private WmsBatchJob requireAccessibleJob(Long jobId) { WmsBatchJob job = jobMapper.selectWmsBatchJobById(jobId); if (job == null) { throw new ServiceException("批量任务不存在"); } - if (Integer.valueOf(JOB_EXECUTING).equals(job.getStatus()) || Integer.valueOf(JOB_DONE).equals(job.getStatus())) + if (!SecurityUtils.isAdmin() && !SecurityUtils.getUsername().equals(job.getCreateBy())) { - throw new ServiceException("任务已执行,不能再修改明细"); + throw new ServiceException("无权访问其他用户的批量任务"); + } + return job; + } + + private void requireActionPermission(String action) + { + boolean allowed; + if (ACTION_AREA.equals(action)) + { + allowed = SecurityUtils.hasPermi("warehouse:area:add"); + } + else if (ACTION_PRODUCT.equals(action)) + { + allowed = SecurityUtils.hasPermi("warehouse:product:add"); + } + else if (ACTION_IN.equals(action)) + { + allowed = SecurityUtils.hasPermi("warehouse:in:add"); + } + else if (ACTION_OUT.equals(action)) + { + allowed = SecurityUtils.hasPermi("warehouse:stock:out") || SecurityUtils.hasPermi("warehouse:out:add"); + } + else if (ACTION_CHECK.equals(action)) + { + allowed = SecurityUtils.hasPermi("warehouse:check:add"); + } + else + { + allowed = false; + } + if (!allowed) + { + throw new ServiceException("批量任务中包含无权执行的操作:" + action); } } @@ -948,14 +1041,7 @@ public class WmsBatchImportServiceImpl implements IWmsBatchImportService { return null; } - for (WarehouseArea area : areaService.selectWarehouseAreaAll()) - { - if (sameText(area.getAreaName(), areaName) || sameText(area.getAreaCode(), areaName)) - { - return area; - } - } - return null; + return areaMapper.selectWarehouseAreaByNameOrCode(areaName.trim()); } private ProductInfo findProduct(WmsBatchItem item) @@ -964,41 +1050,41 @@ public class WmsBatchImportServiceImpl implements IWmsBatchImportService { return null; } - for (ProductInfo product : productService.selectProductInfoAll()) - { - if (sameText(product.getProductName(), item.getProductName()) - && sameText(product.getBrand(), item.getBrand()) - && sameText(product.getCategory(), item.getCategory()) - && sameText(product.getSpec(), item.getSpec())) - { - return product; - } - } - return null; + ProductInfo query = new ProductInfo(); + query.setProductName(item.getProductName()); + query.setBrand(item.getBrand()); + query.setCategory(item.getCategory()); + query.setSpec(item.getSpec()); + return productMapper.checkProductInfoUnique(query); } private void refreshJobCount(Long jobId, Integer status, Integer progress, String errorMsg) { - WmsBatchItem validQuery = new WmsBatchItem(); - validQuery.setJobId(jobId); - validQuery.setStatus(ITEM_VALID); - WmsBatchItem invalidQuery = new WmsBatchItem(); - invalidQuery.setJobId(jobId); - invalidQuery.setStatus(ITEM_INVALID); - int valid = itemMapper.countByJobIdAndStatus(validQuery); - int invalid = itemMapper.countByJobIdAndStatus(invalidQuery); + WmsBatchJob stats = itemMapper.selectWmsBatchItemStats(jobId); + int total = stats == null || stats.getTotalCount() == null ? 0 : stats.getTotalCount(); + int valid = stats == null || stats.getSuccessCount() == null ? 0 : stats.getSuccessCount(); + int invalid = stats == null || stats.getFailCount() == null ? 0 : stats.getFailCount(); WmsBatchJob job = new WmsBatchJob(); job.setId(jobId); job.setStatus(status); job.setProgress(progress); - job.setTotalCount(valid + invalid); + job.setTotalCount(total); job.setSuccessCount(valid); job.setFailCount(invalid); job.setErrorMsg(errorMsg); jobMapper.updateWmsBatchJob(job); } + private void insertItemsInBatches(List items) + { + for (int start = 0; start < items.size(); start += DB_BATCH_SIZE) + { + int end = Math.min(start + DB_BATCH_SIZE, items.size()); + itemMapper.insertWmsBatchItemBatch(items.subList(start, end)); + } + } + private void updateJob(Long jobId, Integer status, Integer progress, String errorMsg) { WmsBatchJob job = new WmsBatchJob(); @@ -1021,6 +1107,28 @@ public class WmsBatchImportServiceImpl implements IWmsBatchImportService jobMapper.updateWmsBatchJob(job); } + private void deleteJobSource(Long jobId) + { + WmsBatchJob job = jobMapper.selectWmsBatchJobById(jobId); + if (job == null || StringUtils.isBlank(job.getFilePath())) + { + return; + } + try + { + Path uploadDir = Paths.get(RuoYiConfig.getUploadPath(), "wms-batch").toAbsolutePath().normalize(); + Path source = Paths.get(job.getFilePath()).toAbsolutePath().normalize(); + if (source.startsWith(uploadDir)) + { + Files.deleteIfExists(source); + } + } + catch (Exception ignored) + { + // 文件清理由后续运维任务兜底,不影响任务结果。 + } + } + private int findHeaderRow(Sheet sheet, DataFormatter formatter) { int max = Math.min(sheet.getLastRowNum(), 10); @@ -1386,18 +1494,47 @@ public class WmsBatchImportServiceImpl implements IWmsBatchImportService } } - private String resolveNickName(String value) + private String resolveUserName(String value) { if (StringUtils.isBlank(value)) { return value; } - SysUser user = userService.selectUserByUserName(value); - if (user != null && StringUtils.isNotBlank(user.getNickName())) + String trimmed = value.trim(); + return userAccountMap().getOrDefault(trimmed.toLowerCase(Locale.ROOT), trimmed); + } + + private Map userAccountMap() + { + long now = System.currentTimeMillis(); + if (now < userAccountCacheExpiresAt) { - return user.getNickName(); + return userAccountCache; + } + synchronized (this) + { + if (now < userAccountCacheExpiresAt) + { + return userAccountCache; + } + Map accounts = new HashMap<>(); + SysUser query = new SysUser(); + query.setStatus("0"); + for (SysUser user : userService.selectUserList(query)) + { + if (StringUtils.isNotBlank(user.getUserName())) + { + accounts.put(user.getUserName().trim().toLowerCase(Locale.ROOT), user.getUserName()); + } + if (StringUtils.isNotBlank(user.getNickName())) + { + accounts.putIfAbsent(user.getNickName().trim().toLowerCase(Locale.ROOT), user.getUserName()); + } + } + userAccountCache = Map.copyOf(accounts); + userAccountCacheExpiresAt = now + 60_000L; + return userAccountCache; } - return value.trim(); } private boolean sameText(String left, String right) diff --git a/ruoyi-system/src/main/java/com/ruoyi/warehouse/util/WarehouseFileUtils.java b/ruoyi-system/src/main/java/com/ruoyi/warehouse/util/WarehouseFileUtils.java new file mode 100644 index 0000000..457a3ef --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/warehouse/util/WarehouseFileUtils.java @@ -0,0 +1,144 @@ +package com.ruoyi.warehouse.util; + +import java.io.InputStream; +import java.util.Iterator; +import java.util.Locale; +import java.util.Set; +import javax.imageio.ImageIO; +import javax.imageio.ImageReader; +import javax.imageio.stream.ImageInputStream; +import org.apache.commons.io.FilenameUtils; +import org.springframework.web.multipart.MultipartFile; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.StringUtils; + +public final class WarehouseFileUtils +{ + private static final long MAX_IMAGE_PIXELS = 40_000_000L; + + private WarehouseFileUtils() + { + } + + public static String validateUpload(MultipartFile file, long maxFileSize, Set allowedExtensions) + { + if (file == null || file.isEmpty()) + { + throw new ServiceException("请选择要上传的文件"); + } + if (file.getSize() > maxFileSize) + { + throw new ServiceException("文件不能超过" + (maxFileSize / 1024 / 1024) + "MB"); + } + String extension = StringUtils.defaultString(FilenameUtils.getExtension(file.getOriginalFilename())) + .toLowerCase(Locale.ROOT); + if (!allowedExtensions.contains(extension)) + { + throw new ServiceException("不支持的文件类型"); + } + try + { + byte[] header; + try (InputStream input = file.getInputStream()) + { + header = input.readNBytes(12); + } + if ("xls".equals(extension) && !startsWith(header, 0xD0, 0xCF, 0x11, 0xE0)) + { + throw new ServiceException("文件内容不是有效的Excel文件"); + } + if ("xlsx".equals(extension) && !startsWith(header, 0x50, 0x4B)) + { + throw new ServiceException("文件内容不是有效的Excel文件"); + } + if (isImage(extension)) + { + validateImage(file, extension, header); + } + return extension; + } + catch (ServiceException e) + { + throw e; + } + catch (Exception e) + { + throw new ServiceException("无法读取上传文件,请确认文件未损坏"); + } + } + + private static void validateImage(MultipartFile file, String extension, byte[] header) throws Exception + { + if ("webp".equals(extension)) + { + if (!matchesAscii(header, 0, "RIFF") || !matchesAscii(header, 8, "WEBP")) + { + throw new ServiceException("文件内容不是有效的WebP图片"); + } + return; + } + try (ImageInputStream input = ImageIO.createImageInputStream(file.getInputStream())) + { + if (input == null) + { + throw new ServiceException("文件内容不是有效图片"); + } + Iterator readers = ImageIO.getImageReaders(input); + if (!readers.hasNext()) + { + throw new ServiceException("文件内容不是有效图片"); + } + ImageReader reader = readers.next(); + try + { + reader.setInput(input, true, true); + long pixels = (long) reader.getWidth(0) * reader.getHeight(0); + if (pixels <= 0 || pixels > MAX_IMAGE_PIXELS) + { + throw new ServiceException("图片尺寸过大,最大支持4000万像素"); + } + } + finally + { + reader.dispose(); + } + } + } + + private static boolean isImage(String extension) + { + return Set.of("jpg", "jpeg", "png", "bmp", "webp").contains(extension); + } + + private static boolean startsWith(byte[] source, int... prefix) + { + if (source.length < prefix.length) + { + return false; + } + for (int i = 0; i < prefix.length; i++) + { + if ((source[i] & 0xFF) != prefix[i]) + { + return false; + } + } + return true; + } + + private static boolean matchesAscii(byte[] source, int offset, String expected) + { + if (source.length < offset + expected.length()) + { + return false; + } + for (int i = 0; i < expected.length(); i++) + { + if (source[offset + i] != (byte) expected.charAt(i)) + { + return false; + } + } + return true; + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/warehouse/util/WarehouseNoUtils.java b/ruoyi-system/src/main/java/com/ruoyi/warehouse/util/WarehouseNoUtils.java new file mode 100644 index 0000000..9a2fcec --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/warehouse/util/WarehouseNoUtils.java @@ -0,0 +1,20 @@ +package com.ruoyi.warehouse.util; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.UUID; + +public final class WarehouseNoUtils +{ + private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS"); + + private WarehouseNoUtils() + { + } + + public static String next(String prefix) + { + String random = UUID.randomUUID().toString().replace("-", "").substring(0, 8).toUpperCase(); + return prefix + TIME_FORMATTER.format(LocalDateTime.now()) + random; + } +} diff --git a/ruoyi-system/src/main/resources/mapper/project/ProjectBudgetMapper.xml b/ruoyi-system/src/main/resources/mapper/project/ProjectBudgetMapper.xml new file mode 100644 index 0000000..4d092b0 --- /dev/null +++ b/ruoyi-system/src/main/resources/mapper/project/ProjectBudgetMapper.xml @@ -0,0 +1,176 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + select * from expense_category + + and category_code like concat('%', #{categoryCode}, '%') + and category_name like concat('%', #{categoryName}, '%') + and status = #{status} + + order by sort_order, id + + + + select * from expense_category where id = #{id} + + + + select * from expense_category where category_code = #{categoryCode} limit 1 + + + + insert into expense_category(category_code, category_name, sort_order, status, remark, create_by, create_time) + values(#{categoryCode}, #{categoryName}, #{sortOrder}, #{status}, #{remark}, #{createBy}, sysdate()) + + + + update expense_category set category_name = #{categoryName}, sort_order = #{sortOrder}, status = #{status}, + remark = #{remark}, update_by = #{updateBy}, update_time = sysdate() + where id = #{id} and category_code != 'LEGACY' + + + + delete from expense_category where id = #{id} and category_code != 'LEGACY' + + + + select (select count(*) from project_budget where expense_category_id = #{id}) + + (select count(*) from purchase_request where expense_category_id = #{id}) + + + + select b.id, b.project_id, p.project_code, p.project_name, b.expense_category_id, + c.category_code, c.category_name, b.budget_amount, b.committed_amount, b.spent_amount, + b.budget_amount - b.committed_amount - b.spent_amount as available_amount, + b.alert_percent, b.version, b.remark + from project_budget b + join project_info p on p.id = b.project_id + join expense_category c on c.id = b.expense_category_id + where b.project_id = #{projectId} + + and (p.manager_id = #{userId} or exists ( + select 1 from project_member pm where pm.project_id = p.id and pm.user_id = #{userId} + )) + + order by c.sort_order, c.id + + + + select b.id, b.project_id, p.project_code, p.project_name, b.expense_category_id, + c.category_code, c.category_name, b.budget_amount, b.committed_amount, b.spent_amount, + b.budget_amount - b.committed_amount - b.spent_amount as available_amount, + b.alert_percent, b.version, b.remark + from project_budget b + join project_info p on p.id = b.project_id + join expense_category c on c.id = b.expense_category_id + where b.id = #{id} + + + + select b.*, b.budget_amount - b.committed_amount - b.spent_amount as available_amount + from project_budget b + where b.project_id = #{projectId} and b.expense_category_id = #{expenseCategoryId} + for update + + + + insert into project_budget(project_id, expense_category_id, budget_amount, committed_amount, spent_amount, + alert_percent, version, remark, create_by, create_time) + values(#{projectId}, #{expenseCategoryId}, #{budgetAmount}, #{committedAmount}, #{spentAmount}, + #{alertPercent}, 0, #{remark}, #{createBy}, sysdate()) + + + + update project_budget + set budget_amount = #{budgetAmount}, committed_amount = #{committedAmount}, spent_amount = #{spentAmount}, + alert_percent = #{alertPercent}, version = version + 1, remark = #{remark}, + update_by = #{updateBy}, update_time = sysdate() + where id = #{id} + + + + select l.*, p.project_name, c.category_name + from project_fund_ledger l + join project_info p on p.id = l.project_id + join expense_category c on c.id = l.expense_category_id + + and l.project_id = #{ledger.projectId} + and l.expense_category_id = #{ledger.expenseCategoryId} + and l.biz_type = #{ledger.bizType} + and l.biz_no like concat('%', #{ledger.bizNo}, '%') + + and (p.manager_id = #{userId} or exists ( + select 1 from project_member pm where pm.project_id = p.id and pm.user_id = #{userId} + )) + + + order by l.id desc + + + + select * from project_fund_ledger where idempotency_key = #{idempotencyKey} limit 1 + + + + insert into project_fund_ledger(ledger_no, project_id, expense_category_id, biz_type, biz_id, biz_no, + budget_delta, committed_delta, spent_delta, balance_budget, + balance_committed, balance_spent, idempotency_key, operator, remark, create_time) + values(#{ledgerNo}, #{projectId}, #{expenseCategoryId}, #{bizType}, #{bizId}, #{bizNo}, + #{budgetDelta}, #{committedDelta}, #{spentDelta}, #{balanceBudget}, + #{balanceCommitted}, #{balanceSpent}, #{idempotencyKey}, #{operator}, #{remark}, sysdate()) + + diff --git a/ruoyi-system/src/main/resources/mapper/project/ProjectInfoMapper.xml b/ruoyi-system/src/main/resources/mapper/project/ProjectInfoMapper.xml new file mode 100644 index 0000000..d1e3899 --- /dev/null +++ b/ruoyi-system/src/main/resources/mapper/project/ProjectInfoMapper.xml @@ -0,0 +1,149 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + select p.id, p.project_code, p.project_name, p.manager_id, + coalesce(u.nick_name, u.user_name) as manager_name, + p.dept_id, d.dept_name, p.start_date, p.end_date, p.project_status, + p.currency, p.remark, p.create_by, p.create_time, p.update_by, p.update_time, + coalesce(b.budget_amount, 0) as budget_amount, + coalesce(b.committed_amount, 0) as committed_amount, + coalesce(b.spent_amount, 0) as spent_amount, + coalesce(b.budget_amount, 0) - coalesce(b.committed_amount, 0) - coalesce(b.spent_amount, 0) as available_amount + from project_info p + left join sys_user u on u.user_id = p.manager_id + left join sys_dept d on d.dept_id = p.dept_id + left join ( + select project_id, sum(budget_amount) budget_amount, + sum(committed_amount) committed_amount, sum(spent_amount) spent_amount + from project_budget group by project_id + ) b on b.project_id = p.id + + + + + + + and p.project_code like concat('%', #{project.projectCode}, '%') + + + and p.project_name like concat('%', #{project.projectName}, '%') + + and p.manager_id = #{project.managerId} + and p.project_status = #{project.projectStatus} + + and (p.manager_id = #{userId} or exists ( + select 1 from project_member pm where pm.project_id = p.id and pm.user_id = #{userId} + )) + + + order by p.id desc + + + + + where p.id = #{id} + + and (p.manager_id = #{userId} or exists ( + select 1 from project_member pm where pm.project_id = p.id and pm.user_id = #{userId} + )) + + + + + where p.project_code = #{projectCode} limit 1 + + + + + + + and (p.project_code like concat('%', #{keyword}, '%') or p.project_name like concat('%', #{keyword}, '%')) + + and p.project_status in (0, 1) + + and (p.manager_id = #{userId} or exists ( + select 1 from project_member pm where pm.project_id = p.id and pm.user_id = #{userId} + )) + + + order by p.project_status, p.project_name limit 100 + + + + insert into project_info(project_code, project_name, manager_id, dept_id, start_date, end_date, + project_status, currency, remark, create_by, create_time) + values(#{projectCode}, #{projectName}, #{managerId}, #{deptId}, #{startDate}, #{endDate}, + #{projectStatus}, #{currency}, #{remark}, #{createBy}, sysdate()) + + + + update project_info + set project_code = #{projectCode}, project_name = #{projectName}, manager_id = #{managerId}, + dept_id = #{deptId}, start_date = #{startDate}, end_date = #{endDate}, + project_status = #{projectStatus}, currency = #{currency}, remark = #{remark}, + update_by = #{updateBy}, update_time = sysdate() + where id = #{id} and project_code != 'LEGACY' + + + + delete from project_info where id = #{id} and project_code != 'LEGACY' + + + + select (select count(*) from purchase_request where project_id = #{projectId}) + + (select count(*) from stock_lot where source_project_id = #{projectId}) + + (select count(*) from out_record where usage_project_id = #{projectId}) + + (select count(*) from project_budget where project_id = #{projectId}) + + + + delete from project_member where project_id = #{projectId} + + + + insert into project_member(project_id, user_id, member_role, create_by, create_time) + values(#{projectId}, #{userId}, #{memberRole}, #{createBy}, sysdate()) + on duplicate key update member_role = values(member_role) + + + + select user_id from project_member where project_id = #{projectId} order by id + + + + select count(*) from project_info p + where p.id = #{projectId} + and (p.manager_id = #{userId} or exists ( + select 1 from project_member pm where pm.project_id = p.id and pm.user_id = #{userId} + )) + + + + select id from project_info where project_code = 'LEGACY' limit 1 + + diff --git a/ruoyi-system/src/main/resources/mapper/warehouse/AiCommandMapper.xml b/ruoyi-system/src/main/resources/mapper/warehouse/AiCommandMapper.xml new file mode 100644 index 0000000..d352665 --- /dev/null +++ b/ruoyi-system/src/main/resources/mapper/warehouse/AiCommandMapper.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + insert into ai_command(user_id, username, action, required_permission, request_text, plan_json, + command_status, expires_at, create_time) + values(#{userId}, #{username}, #{action}, #{requiredPermission}, #{requestText}, cast(#{planJson} as json), + #{commandStatus}, #{expiresAt}, sysdate()) + + + + select * from ai_command where id = #{id} and user_id = #{userId} for update + + + + update ai_command set command_status = 'EXECUTING', confirmed_at = sysdate(), update_time = sysdate() + where id = #{id} and user_id = #{userId} and command_status = 'WAIT_CONFIRM' and expires_at > sysdate() + + + + update ai_command set command_status = 'SUCCEEDED', result_json = cast(#{resultJson} as json), + executed_at = sysdate(), update_time = sysdate() + where id = #{id} and command_status = 'EXECUTING' + + diff --git a/ruoyi-system/src/main/resources/mapper/warehouse/InRecordMapper.xml b/ruoyi-system/src/main/resources/mapper/warehouse/InRecordMapper.xml index d6a27bc..04908fe 100644 --- a/ruoyi-system/src/main/resources/mapper/warehouse/InRecordMapper.xml +++ b/ruoyi-system/src/main/resources/mapper/warehouse/InRecordMapper.xml @@ -8,12 +8,17 @@ + + + + + @@ -29,15 +34,20 @@ - select ir.id, ir.in_no, ir.area_id, ir.product_id, ir.purchase_request_id, ir.arrival_id, ir.source_no, + select ir.id, ir.in_no, ir.area_id, ir.product_id, ir.purchase_request_id, ir.arrival_id, + ir.source_project_id, project.project_name as source_project_name, + ir.expense_category_id, ec.category_name as expense_category_name, ir.source_no, ir.in_type, ir.in_num, ir.in_status, coalesce(operator_user.nick_name, ir.operator) as operator, - coalesce(in_user.nick_name, ir.in_operator) as in_operator, + ir.in_operator, + coalesce(in_user.nick_name, ir.in_operator) as in_operator_name, ir.confirm_time, ir.remark, ir.create_time, ir.update_time, wa.area_code, wa.area_name, pi.product_code, pi.product_name, pi.brand, pi.category, pi.spec, pi.unit from in_record ir left join warehouse_area wa on wa.id = ir.area_id left join product_info pi on pi.id = ir.product_id + left join project_info project on project.id = ir.source_project_id + left join expense_category ec on ec.id = ir.expense_category_id left join sys_user operator_user on operator_user.user_name = ir.operator left join sys_user in_user on in_user.user_name = ir.in_operator @@ -48,6 +58,7 @@ and ir.in_no like concat('%', #{inNo}, '%') and ir.area_id = #{areaId} and ir.product_id = #{productId} + and ir.source_project_id = #{sourceProjectId} and wa.area_name like concat('%', #{areaName}, '%') and pi.product_name like concat('%', #{productName}, '%') and pi.brand like concat('%', #{brand}, '%') @@ -56,7 +67,8 @@ and ir.in_type = #{inType} and ir.in_status = #{inStatus} - and coalesce(in_user.nick_name, ir.in_operator) like concat('%', #{inOperator}, '%') + and (ir.in_operator like concat('%', #{inOperator}, '%') + or in_user.nick_name like concat('%', #{inOperator}, '%')) order by ir.id desc @@ -67,13 +79,15 @@ - select id, in_no, area_id, product_id, purchase_request_id, arrival_id, source_no, in_type, in_num, + select id, in_no, area_id, product_id, purchase_request_id, arrival_id, + source_project_id, expense_category_id, source_no, in_type, in_num, in_status, operator, in_operator, confirm_time, remark, create_time, update_time from in_record where id = #{id} for update - select id, in_no, area_id, product_id, purchase_request_id, arrival_id, source_no, in_type, in_num, + select id, in_no, area_id, product_id, purchase_request_id, arrival_id, + source_project_id, expense_category_id, source_no, in_type, in_num, in_status, operator, in_operator, confirm_time, remark, create_time, update_time from in_record where arrival_id = #{arrivalId} for update @@ -83,9 +97,11 @@ - insert into in_record(in_no, area_id, product_id, purchase_request_id, arrival_id, source_no, in_type, in_num, + insert into in_record(in_no, area_id, product_id, purchase_request_id, arrival_id, + source_project_id, expense_category_id, source_no, in_type, in_num, in_status, operator, in_operator, remark, create_time) - values(#{inNo}, #{areaId}, #{productId}, #{purchaseRequestId}, #{arrivalId}, #{sourceNo}, #{inType}, #{inNum}, + values(#{inNo}, #{areaId}, #{productId}, #{purchaseRequestId}, #{arrivalId}, + #{sourceProjectId}, #{expenseCategoryId}, #{sourceNo}, #{inType}, #{inNum}, #{inStatus}, #{operator}, #{inOperator}, #{remark}, sysdate()) @@ -95,6 +111,8 @@ in_no = #{inNo}, area_id = #{areaId}, product_id = #{productId}, + source_project_id = #{sourceProjectId}, + expense_category_id = #{expenseCategoryId}, in_type = #{inType}, in_num = #{inNum}, operator = #{operator}, diff --git a/ruoyi-system/src/main/resources/mapper/warehouse/OutRecordMapper.xml b/ruoyi-system/src/main/resources/mapper/warehouse/OutRecordMapper.xml index 879427b..5ca239b 100644 --- a/ruoyi-system/src/main/resources/mapper/warehouse/OutRecordMapper.xml +++ b/ruoyi-system/src/main/resources/mapper/warehouse/OutRecordMapper.xml @@ -6,11 +6,17 @@ + + + + + + @@ -26,15 +32,24 @@ - select orr.id, orr.out_no, orr.area_id, orr.product_id, orr.out_type, orr.out_num, - orr.source_no, + select orr.id, orr.out_no, orr.area_id, orr.product_id, orr.usage_project_id, + usage_project.project_name as usage_project_name, + orr.out_type, orr.out_num, orr.source_no, orr.cross_project_flag, orr.cross_project_reason, + lot_project.source_project_names, coalesce(operator_user.nick_name, orr.operator) as operator, - coalesce(out_user.nick_name, orr.out_operator) as out_operator, + orr.out_operator, + coalesce(out_user.nick_name, orr.out_operator) as out_operator_name, orr.out_time, orr.remark, orr.create_time, orr.update_time, wa.area_code, wa.area_name, pi.product_code, pi.product_name, pi.brand, pi.category, pi.spec, pi.unit from out_record orr left join warehouse_area wa on wa.id = orr.area_id left join product_info pi on pi.id = orr.product_id + left join project_info usage_project on usage_project.id = orr.usage_project_id + left join ( + select orl.out_record_id, group_concat(distinct p.project_name order by p.project_name separator '、') as source_project_names + from out_record_lot orl join project_info p on p.id = orl.source_project_id + group by orl.out_record_id + ) lot_project on lot_project.out_record_id = orr.id left join sys_user operator_user on operator_user.user_name = orr.operator left join sys_user out_user on out_user.user_name = orr.out_operator @@ -45,6 +60,8 @@ and orr.out_no like concat('%', #{outNo}, '%') and orr.area_id = #{areaId} and orr.product_id = #{productId} + and orr.usage_project_id = #{usageProjectId} + and orr.cross_project_flag = #{crossProjectFlag} and wa.area_name like concat('%', #{areaName}, '%') and pi.product_name like concat('%', #{productName}, '%') and pi.brand like concat('%', #{brand}, '%') @@ -52,7 +69,8 @@ and pi.spec like concat('%', #{spec}, '%') and orr.out_type = #{outType} - and coalesce(out_user.nick_name, orr.out_operator) like concat('%', #{outOperator}, '%') + and (orr.out_operator like concat('%', #{outOperator}, '%') + or out_user.nick_name like concat('%', #{outOperator}, '%')) and orr.source_no like concat('%', #{sourceNo}, '%') @@ -68,7 +86,15 @@ - insert into out_record(out_no, area_id, product_id, out_type, out_num, source_no, operator, out_operator, out_time, remark, create_time) - values(#{outNo}, #{areaId}, #{productId}, #{outType}, #{outNum}, #{sourceNo}, #{operator}, #{outOperator}, #{outTime}, #{remark}, sysdate()) + insert into out_record(out_no, area_id, product_id, usage_project_id, out_type, out_num, source_no, + cross_project_flag, cross_project_reason, operator, out_operator, out_time, remark, create_time) + values(#{outNo}, #{areaId}, #{productId}, #{usageProjectId}, #{outType}, #{outNum}, #{sourceNo}, + #{crossProjectFlag}, #{crossProjectReason}, #{operator}, #{outOperator}, #{outTime}, #{remark}, sysdate()) + + + update out_record set cross_project_flag = #{crossProjectFlag}, + cross_project_reason = #{crossProjectReason}, update_time = sysdate() + where id = #{id} + diff --git a/ruoyi-system/src/main/resources/mapper/warehouse/PurchaseArrivalMapper.xml b/ruoyi-system/src/main/resources/mapper/warehouse/PurchaseArrivalMapper.xml index 4ede05a..39bdd80 100644 --- a/ruoyi-system/src/main/resources/mapper/warehouse/PurchaseArrivalMapper.xml +++ b/ruoyi-system/src/main/resources/mapper/warehouse/PurchaseArrivalMapper.xml @@ -6,6 +6,8 @@ + + @@ -29,10 +31,16 @@ + + + + + - select pa.id, pa.arrival_no, pa.purchase_request_id, pa.arrival_quantity, pa.arrival_date, + select pa.id, pa.arrival_no, pa.purchase_request_id, pa.arrival_quantity, + pa.actual_unit_price, pa.actual_amount, pa.arrival_date, pa.acceptance_status, pa.acceptance_remark, coalesce(accepted_user.nick_name, pa.accepted_by) as accepted_by, pa.accepted_time, pa.pre_accept_purchase_status, @@ -42,10 +50,14 @@ coalesce(updated_user.nick_name, pa.update_by) as update_by, pa.update_time, pr.request_no, pr.applicant_id, coalesce(applicant_user.nick_name, pr.applicant_name) as applicant_name, pr.product_id, - pi.product_code, pr.material_name, pr.material_brand, pr.material_params, pr.unit + pi.product_code, pr.material_name, pr.material_brand, pr.material_params, pr.unit, + pr.project_id, project.project_name, pr.expense_category_id, + ec.category_name as expense_category_name, pr.estimated_unit_price from purchase_arrival pa join purchase_request pr on pr.id = pa.purchase_request_id left join product_info pi on pi.id = pr.product_id + left join project_info project on project.id = pr.project_id + left join expense_category ec on ec.id = pr.expense_category_id left join sys_user applicant_user on applicant_user.user_id = pr.applicant_id left join sys_user accepted_user on accepted_user.user_name = pa.accepted_by collate utf8mb4_unicode_ci left join sys_user revoked_user on revoked_user.user_name = pa.revoked_by collate utf8mb4_unicode_ci @@ -64,12 +76,14 @@ - select pa.id, pa.arrival_no, pa.purchase_request_id, pa.arrival_quantity, pa.arrival_date, + select pa.id, pa.arrival_no, pa.purchase_request_id, pa.arrival_quantity, + pa.actual_unit_price, pa.actual_amount, pa.arrival_date, pa.acceptance_status, pa.acceptance_remark, pa.accepted_by, pa.accepted_time, pa.pre_accept_purchase_status, pa.revoked_by, pa.revoked_time, pa.revoke_remark, pa.remark, pa.create_by, pa.create_time, pa.update_by, pa.update_time, pr.request_no, pr.applicant_id, pr.applicant_name, pr.product_id, - pi.product_code, pr.material_name, pr.material_brand, pr.material_params, pr.unit + pi.product_code, pr.material_name, pr.material_brand, pr.material_params, pr.unit, + pr.project_id, pr.expense_category_id, pr.estimated_unit_price from purchase_arrival pa join purchase_request pr on pr.id = pa.purchase_request_id left join product_info pi on pi.id = pr.product_id @@ -93,9 +107,11 @@ - insert into purchase_arrival(arrival_no, purchase_request_id, arrival_quantity, arrival_date, + insert into purchase_arrival(arrival_no, purchase_request_id, arrival_quantity, + actual_unit_price, actual_amount, arrival_date, acceptance_status, remark, create_by, create_time) - values(#{arrivalNo}, #{purchaseRequestId}, #{arrivalQuantity}, #{arrivalDate}, 0, + values(#{arrivalNo}, #{purchaseRequestId}, #{arrivalQuantity}, + #{actualUnitPrice}, #{actualAmount}, #{arrivalDate}, 0, #{remark}, #{createBy}, sysdate()) diff --git a/ruoyi-system/src/main/resources/mapper/warehouse/PurchaseRequestMapper.xml b/ruoyi-system/src/main/resources/mapper/warehouse/PurchaseRequestMapper.xml index 848b646..63695d2 100644 --- a/ruoyi-system/src/main/resources/mapper/warehouse/PurchaseRequestMapper.xml +++ b/ruoyi-system/src/main/resources/mapper/warehouse/PurchaseRequestMapper.xml @@ -6,12 +6,20 @@ + + + + + + + + @@ -37,8 +45,13 @@ - select pr.id, pr.request_no, pr.product_id, pr.purchase_batch_id, pr.material_name, pr.material_brand, pr.category, pr.material_params, - pr.purchase_link, pr.quantity, pr.unit, pr.expected_date, pr.applicant_id, + select pr.id, pr.request_no, pr.product_id, pr.purchase_batch_id, + pr.project_id, project.project_code, project.project_name, + pr.expense_category_id, ec.category_code as expense_category_code, + ec.category_name as expense_category_name, + pr.material_name, pr.material_brand, pr.category, pr.material_params, + pr.purchase_link, pr.quantity, pr.estimated_unit_price, pr.estimated_amount, + pr.unit, pr.expected_date, pr.applicant_id, coalesce(applicant_user.nick_name, pr.applicant_name) as applicant_name, pr.purchase_status, pr.purchase_batch_no, pr.progress_remark, pr.remark, pr.create_by, pr.create_time, pr.update_by, pr.update_time, @@ -49,12 +62,15 @@ greatest(pr.quantity - coalesce(pa.accepted_quantity, 0) - coalesce(pa.pending_quantity, 0), 0) as remaining_quantity from purchase_request pr left join product_info pi on pi.id = pr.product_id + left join project_info project on project.id = pr.project_id + left join expense_category ec on ec.id = pr.expense_category_id left join sys_user applicant_user on applicant_user.user_id = pr.applicant_id left join ( select purchase_request_id, sum(case when acceptance_status = 1 then arrival_quantity else 0 end) as accepted_quantity, sum(case when acceptance_status = 0 then arrival_quantity else 0 end) as pending_quantity from purchase_arrival + where acceptance_status in (0, 1) group by purchase_request_id ) pa on pa.purchase_request_id = pr.id @@ -64,9 +80,11 @@ and pr.request_no like concat('%', #{requestNo}, '%') and pr.product_id = #{productId} + and pr.project_id = #{projectId} + and pr.expense_category_id = #{expenseCategoryId} - and lower(concat_ws(' ', pr.material_name, pr.material_brand, pr.category, pr.material_params, pi.product_name, pi.product_code)) - like concat('%', lower(#{materialName}), '%') + and convert(lower(concat_ws(' ', pr.material_name, pr.material_brand, pr.category, pr.material_params, pi.product_name, pi.product_code)) using utf8mb4) collate utf8mb4_bin + like concat('%', convert(lower(#{materialName}) using utf8mb4) collate utf8mb4_bin, '%') and pr.category like concat('%', #{category}, '%') and pr.applicant_id = #{applicantId} @@ -87,9 +105,11 @@ - select pr.id, pr.request_no, pr.product_id, pr.purchase_batch_id, pr.material_name, + select pr.id, pr.request_no, pr.product_id, pr.purchase_batch_id, pr.project_id, + pr.expense_category_id, pr.material_name, pr.material_brand, pr.category, pr.material_params, pr.purchase_link, pr.quantity, - pr.unit, pr.expected_date, pr.applicant_id, pr.applicant_name, pr.purchase_status, + pr.estimated_unit_price, pr.estimated_amount, pr.unit, pr.expected_date, + pr.applicant_id, pr.applicant_name, pr.purchase_status, pr.purchase_batch_no, pr.progress_remark, pr.remark, pr.create_by, pr.create_time, pr.update_by, pr.update_time from purchase_request pr where pr.id = #{id} for update @@ -101,12 +121,16 @@ insert into purchase_request( - request_no, product_id, material_name, material_brand, category, material_params, purchase_link, - quantity, unit, expected_date, applicant_id, applicant_name, purchase_status, purchase_batch_no, + request_no, product_id, project_id, expense_category_id, + material_name, material_brand, category, material_params, purchase_link, + quantity, estimated_unit_price, estimated_amount, unit, expected_date, + applicant_id, applicant_name, purchase_status, purchase_batch_no, progress_remark, remark, create_by, create_time ) values ( - #{requestNo}, #{productId}, #{materialName}, #{materialBrand}, #{category}, #{materialParams}, #{purchaseLink}, - #{quantity}, #{unit}, #{expectedDate}, #{applicantId}, #{applicantName}, #{purchaseStatus}, #{purchaseBatchNo}, + #{requestNo}, #{productId}, #{projectId}, #{expenseCategoryId}, + #{materialName}, #{materialBrand}, #{category}, #{materialParams}, #{purchaseLink}, + #{quantity}, #{estimatedUnitPrice}, #{estimatedAmount}, #{unit}, #{expectedDate}, + #{applicantId}, #{applicantName}, #{purchaseStatus}, #{purchaseBatchNo}, #{progressRemark}, #{remark}, #{createBy}, sysdate() ) @@ -116,12 +140,16 @@ request_no = #{purchaseRequest.requestNo}, product_id = #{purchaseRequest.productId}, + project_id = #{purchaseRequest.projectId}, + expense_category_id = #{purchaseRequest.expenseCategoryId}, material_name = #{purchaseRequest.materialName}, material_brand = #{purchaseRequest.materialBrand}, category = #{purchaseRequest.category}, material_params = #{purchaseRequest.materialParams}, purchase_link = #{purchaseRequest.purchaseLink}, quantity = #{purchaseRequest.quantity}, + estimated_unit_price = #{purchaseRequest.estimatedUnitPrice}, + estimated_amount = #{purchaseRequest.estimatedAmount}, unit = #{purchaseRequest.unit}, expected_date = #{purchaseRequest.expectedDate}, applicant_id = #{purchaseRequest.applicantId}, diff --git a/ruoyi-system/src/main/resources/mapper/warehouse/StockInfoMapper.xml b/ruoyi-system/src/main/resources/mapper/warehouse/StockInfoMapper.xml index bb7843d..83fbf16 100644 --- a/ruoyi-system/src/main/resources/mapper/warehouse/StockInfoMapper.xml +++ b/ruoyi-system/src/main/resources/mapper/warehouse/StockInfoMapper.xml @@ -37,6 +37,13 @@ and lower(concat_ws(' ', pi.product_name, pi.brand, pi.category, pi.spec, pi.unit, wa.area_name)) like concat('%', lower(#{productName}), '%') + + + and lower(concat_ws(' ', pi.product_name, pi.brand, pi.category, pi.spec, pi.unit, + pi.product_code, wa.area_name, wa.area_code)) + like concat('%', #{token}, '%') + + order by si.id desc diff --git a/ruoyi-system/src/main/resources/mapper/warehouse/StockLotMapper.xml b/ruoyi-system/src/main/resources/mapper/warehouse/StockLotMapper.xml new file mode 100644 index 0000000..aa59f48 --- /dev/null +++ b/ruoyi-system/src/main/resources/mapper/warehouse/StockLotMapper.xml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + select sl.*, wa.area_name, pi.product_name, project.project_name as source_project_name, + ec.category_name as expense_category_name, pr.request_no as purchase_request_no + from stock_lot sl + left join warehouse_area wa on wa.id = sl.area_id + left join product_info pi on pi.id = sl.product_id + left join project_info project on project.id = sl.source_project_id + left join expense_category ec on ec.id = sl.expense_category_id + left join purchase_request pr on pr.id = sl.purchase_request_id + + + + + + and sl.area_id = #{areaId} + and sl.product_id = #{productId} + and sl.source_project_id = #{sourceProjectId} + and sl.lot_status = #{lotStatus} + + order by sl.create_time, sl.id + + + + select * from stock_lot + where area_id = #{areaId} and product_id = #{productId} + and lot_status = 0 and available_quantity > 0 + order by create_time, id for update + + + + where sl.in_record_id = #{inRecordId} limit 1 + + + + insert into stock_lot(lot_no, area_id, product_id, source_project_id, expense_category_id, + purchase_request_id, arrival_id, in_record_id, received_quantity, + available_quantity, unit_cost, lot_status, remark, create_by, create_time) + values(#{lotNo}, #{areaId}, #{productId}, #{sourceProjectId}, #{expenseCategoryId}, + #{purchaseRequestId}, #{arrivalId}, #{inRecordId}, #{receivedQuantity}, + #{availableQuantity}, #{unitCost}, #{lotStatus}, #{remark}, #{createBy}, sysdate()) + + + + update stock_lot + set available_quantity = available_quantity - #{quantity}, + lot_status = case when available_quantity - #{quantity} = 0 then 1 else lot_status end, + update_time = sysdate() + where id = #{id} and lot_status = 0 and available_quantity >= #{quantity} + + + + insert into out_record_lot(out_record_id, stock_lot_id, source_project_id, usage_project_id, + quantity, unit_cost, amount, create_time) + values(#{outRecordId}, #{stockLotId}, #{sourceProjectId}, #{usageProjectId}, + #{quantity}, #{unitCost}, #{amount}, sysdate()) + + diff --git a/ruoyi-system/src/main/resources/mapper/warehouse/WarehouseAreaMapper.xml b/ruoyi-system/src/main/resources/mapper/warehouse/WarehouseAreaMapper.xml index 83f499a..af552b0 100644 --- a/ruoyi-system/src/main/resources/mapper/warehouse/WarehouseAreaMapper.xml +++ b/ruoyi-system/src/main/resources/mapper/warehouse/WarehouseAreaMapper.xml @@ -31,6 +31,12 @@ where id = #{id} + + + where area_name = #{value} or area_code = #{value} + order by id limit 1 + + where area_code = #{areaCode} limit 1 diff --git a/ruoyi-system/src/main/resources/mapper/warehouse/WmsBatchItemMapper.xml b/ruoyi-system/src/main/resources/mapper/warehouse/WmsBatchItemMapper.xml index 55f6395..101529b 100644 --- a/ruoyi-system/src/main/resources/mapper/warehouse/WmsBatchItemMapper.xml +++ b/ruoyi-system/src/main/resources/mapper/warehouse/WmsBatchItemMapper.xml @@ -27,6 +27,12 @@ + + + + + + select id, job_id, row_no, action_type, raw_text, parsed_json, status, error_msg, warning_msg, ref_id, area_name, product_name, brand, category, spec, unit, quantity, operator_name, type_code, @@ -52,6 +58,13 @@ select count(1) from wms_batch_item where job_id = #{jobId} and status = #{status} + + select count(1) as total_count, + coalesce(sum(case when status = 1 then 1 else 0 end), 0) as success_count, + coalesce(sum(case when status = 2 then 1 else 0 end), 0) as fail_count + from wms_batch_item where job_id = #{jobId} + + insert into wms_batch_item(job_id, row_no, action_type, raw_text, parsed_json, status, error_msg, warning_msg, ref_id, area_name, product_name, brand, category, spec, unit, quantity, @@ -61,6 +74,19 @@ #{operatorName}, #{typeCode}, #{sourceNo}, #{remark}, sysdate()) + + insert into wms_batch_item(job_id, row_no, action_type, raw_text, parsed_json, status, error_msg, warning_msg, + ref_id, area_name, product_name, brand, category, spec, unit, quantity, + operator_name, type_code, source_no, remark, create_time) + values + + (#{item.jobId}, #{item.rowNo}, #{item.actionType}, #{item.rawText}, #{item.parsedJson}, #{item.status}, + #{item.errorMsg}, #{item.warningMsg}, #{item.refId}, #{item.areaName}, #{item.productName}, #{item.brand}, + #{item.category}, #{item.spec}, #{item.unit}, #{item.quantity}, #{item.operatorName}, #{item.typeCode}, + #{item.sourceNo}, #{item.remark}, sysdate()) + + + update wms_batch_item diff --git a/ruoyi-system/src/main/resources/mapper/warehouse/WmsBatchJobMapper.xml b/ruoyi-system/src/main/resources/mapper/warehouse/WmsBatchJobMapper.xml index 798a29a..c2b7bfd 100644 --- a/ruoyi-system/src/main/resources/mapper/warehouse/WmsBatchJobMapper.xml +++ b/ruoyi-system/src/main/resources/mapper/warehouse/WmsBatchJobMapper.xml @@ -32,6 +32,7 @@ and source_type = #{sourceType} and biz_type = #{bizType} and status = #{status} + and create_by = #{createBy} order by id desc @@ -64,4 +65,10 @@ where id = #{id} + + + update wms_batch_job + set status = 3, progress = 0, error_msg = null, update_time = sysdate() + where id = #{id} and status = 2 + diff --git a/ruoyi-ui/src/api/project/project.js b/ruoyi-ui/src/api/project/project.js new file mode 100644 index 0000000..0e78452 --- /dev/null +++ b/ruoyi-ui/src/api/project/project.js @@ -0,0 +1,57 @@ +import request from '@/utils/request' + +export function listProject(query) { + return request({ url: '/project/info/list', method: 'get', params: query }) +} + +export function getProject(id) { + return request({ url: '/project/info/' + id, method: 'get' }) +} + +export function projectOptions(keyword, includeArchived = false) { + return request({ url: '/project/info/options', method: 'get', params: { keyword, includeArchived } }) +} + +export function addProject(data) { + return request({ url: '/project/info', method: 'post', data }) +} + +export function updateProject(data) { + return request({ url: '/project/info', method: 'put', data }) +} + +export function delProject(id) { + return request({ url: '/project/info/' + id, method: 'delete' }) +} + +export function listProjectBudget(projectId) { + return request({ url: `/project/info/${projectId}/budgets`, method: 'get' }) +} + +export function saveProjectBudget(data) { + return request({ url: '/project/info/budget', method: 'post', data }) +} + +export function listFundLedger(query) { + return request({ url: '/project/info/ledger/list', method: 'get', params: query }) +} + +export function categoryOptions() { + return request({ url: '/project/info/category/options', method: 'get' }) +} + +export function listCategory(query) { + return request({ url: '/project/info/category/list', method: 'get', params: query }) +} + +export function addCategory(data) { + return request({ url: '/project/info/category', method: 'post', data }) +} + +export function updateCategory(data) { + return request({ url: '/project/info/category', method: 'put', data }) +} + +export function delCategory(id) { + return request({ url: '/project/info/category/' + id, method: 'delete' }) +} diff --git a/ruoyi-ui/src/api/warehouse/stock.js b/ruoyi-ui/src/api/warehouse/stock.js index c0f37c2..2905d2a 100644 --- a/ruoyi-ui/src/api/warehouse/stock.js +++ b/ruoyi-ui/src/api/warehouse/stock.js @@ -11,3 +11,7 @@ export function getStock(id) { export function outStock(data) { return request({ url: '/warehouse/stock/out', method: 'post', data }) } + +export function listStockLots(query) { + return request({ url: '/warehouse/stock/lots', method: 'get', params: query }) +} diff --git a/ruoyi-ui/src/utils/echarts.js b/ruoyi-ui/src/utils/echarts.js new file mode 100644 index 0000000..069aacf --- /dev/null +++ b/ruoyi-ui/src/utils/echarts.js @@ -0,0 +1,19 @@ +import * as echarts from 'echarts/core' +import { BarChart, GaugeChart, LineChart, PieChart, RadarChart } from 'echarts/charts' +import { GridComponent, LegendComponent, RadarComponent, TooltipComponent } from 'echarts/components' +import { CanvasRenderer } from 'echarts/renderers' + +echarts.use([ + BarChart, + GaugeChart, + LineChart, + PieChart, + RadarChart, + GridComponent, + LegendComponent, + RadarComponent, + TooltipComponent, + CanvasRenderer +]) + +export default echarts diff --git a/ruoyi-ui/src/views/dashboard/BarChart.vue b/ruoyi-ui/src/views/dashboard/BarChart.vue index 88e7ef6..49950d9 100644 --- a/ruoyi-ui/src/views/dashboard/BarChart.vue +++ b/ruoyi-ui/src/views/dashboard/BarChart.vue @@ -3,8 +3,7 @@ + + diff --git a/ruoyi-ui/src/views/warehouse/inRecord/index.vue b/ruoyi-ui/src/views/warehouse/inRecord/index.vue index 4d16067..845efe4 100644 --- a/ruoyi-ui/src/views/warehouse/inRecord/index.vue +++ b/ruoyi-ui/src/views/warehouse/inRecord/index.vue @@ -32,6 +32,11 @@ + + + + + 搜索 重置 @@ -56,6 +61,9 @@ + + {{ scope.row.sourceProjectName || '-' }} / {{ scope.row.expenseCategoryName }} + {{ scope.row.productName || '-' }} @@ -75,7 +83,7 @@ - + {{ parseTime(scope.row.confirmTime) }} @@ -103,6 +111,16 @@ + + + + + + + + + + @@ -129,6 +147,7 @@ import { listInRecord, getInRecord, delInRecord, addInRecord, updateInRecord, co import { optionselectArea } from '@/api/warehouse/area' import { optionselectProduct } from '@/api/warehouse/product' import { optionselectUser } from '@/api/warehouse/user' +import { projectOptions, categoryOptions } from '@/api/project/project' export default { name: 'InRecord', @@ -145,13 +164,17 @@ export default { areaOptions: [], productOptions: [], userOptions: [], + projectOptionList: [], + expenseCategoryOptions: [], title: '', open: false, - queryParams: { pageNum: 1, pageSize: 10, areaId: undefined, productName: undefined, brand: undefined, category: undefined, spec: undefined, inOperator: undefined, inType: undefined, inStatus: undefined }, + queryParams: { pageNum: 1, pageSize: 10, areaId: undefined, productName: undefined, brand: undefined, category: undefined, spec: undefined, inOperator: undefined, inType: undefined, inStatus: undefined, sourceProjectId: undefined }, form: {}, rules: { areaId: [{ required: true, message: '库区不能为空', trigger: 'change' }], productId: [{ required: true, message: '产品不能为空', trigger: 'change' }], + sourceProjectId: [{ required: true, message: '来源项目不能为空', trigger: 'change' }], + expenseCategoryId: [{ required: true, message: '经费科目不能为空', trigger: 'change' }], inOperator: [{ required: true, message: '入库人不能为空', trigger: 'change' }], inType: [{ required: true, message: '入库类型不能为空', trigger: 'change' }], inNum: [{ required: true, message: '入库数量不能为空', trigger: 'blur' }] @@ -175,7 +198,7 @@ export default { return user.nickName || user.userName }, userValue(user) { - return user.nickName || user.userName + return user.userName }, areaLabel(area) { return area.areaName || area.areaCode @@ -185,6 +208,7 @@ export default { const details = [product.brand, product.category, product.spec, product.unit].filter(item => item) return details.length ? name + '(' + details.join(' / ') + ')' : name }, + projectLabel(project) { return project.projectName + '(' + project.projectCode + ')' }, getDistinctOptions(field) { const values = this.productOptions.map(item => item[field]).filter(item => item !== undefined && item !== null && item !== '') return Array.from(new Set(values)) @@ -193,6 +217,8 @@ export default { optionselectArea().then(res => { this.areaOptions = res.data }) optionselectProduct().then(res => { this.productOptions = res.data }) optionselectUser().then(res => { this.userOptions = res.data }) + projectOptions('').then(res => { this.projectOptionList = res.data || [] }) + categoryOptions().then(res => { this.expenseCategoryOptions = res.data || [] }) }, getList() { this.loading = true @@ -204,7 +230,7 @@ export default { }, cancel() { this.open = false; this.reset() }, reset() { - this.form = { id: undefined, inNo: undefined, areaId: undefined, productId: undefined, inOperator: undefined, inType: 2, inNum: undefined, remark: undefined } + this.form = { id: undefined, inNo: undefined, areaId: undefined, productId: undefined, sourceProjectId: undefined, expenseCategoryId: undefined, inOperator: undefined, inType: 2, inNum: undefined, remark: undefined } this.resetForm('form') }, handleQuery() { this.queryParams.pageNum = 1; this.getList() }, diff --git a/ruoyi-ui/src/views/warehouse/outRecord/index.vue b/ruoyi-ui/src/views/warehouse/outRecord/index.vue index f2a8581..976d72d 100644 --- a/ruoyi-ui/src/views/warehouse/outRecord/index.vue +++ b/ruoyi-ui/src/views/warehouse/outRecord/index.vue @@ -32,6 +32,11 @@ + + + + + 搜索 重置 @@ -62,9 +67,12 @@ + + + 是否 - + {{ parseTime(scope.row.outTime) }} @@ -85,6 +93,11 @@ + + + + + @@ -97,6 +110,7 @@ +