feat(wms): add project-funded procurement traceability

This commit is contained in:
lixiaolong 2026-09-28 10:55:48 +08:00
parent 79773cc96e
commit 759e2ab545
82 changed files with 4909 additions and 310 deletions

3
.gitignore vendored
View File

@ -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

View File

@ -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) 完成业务回归,再发布应用。

View File

@ -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 服务事务回归通过并完成回滚校验,未保留测试项目、采购、到货、入库或库存批次数据。

View File

@ -191,8 +191,7 @@
<version>3.13.0</version>
<configuration>
<parameters>true</parameters>
<source>${java.version}</source>
<target>${java.version}</target>
<release>${java.version}</release>
<encoding>${project.build.sourceEncoding}</encoding>
</configuration>
</plugin>

View File

@ -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<ExpenseCategory> 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));
}
}

View File

@ -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()));

View File

@ -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<PurchaseRequest> util = new ExcelUtil<PurchaseRequest>(PurchaseRequest.class);
List<PurchaseRequest> 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)
{

View File

@ -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<ProductInfo> products) throws IOException
public static void write(HttpServletResponse response, List<ProductInfo> products,
List<ProjectInfo> projects, List<ExpenseCategory> 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<ProductInfo> products)
static XSSFWorkbook createTemplateWorkbook(List<ProductInfo> products,
List<ProjectInfo> projects, List<ExpenseCategory> 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<String> projectValues(List<ProjectInfo> projects)
{
List<String> values = new ArrayList<>();
for (ProjectInfo project : projects)
{
if (StringUtils.isNotBlank(project.getProjectCode())) values.add(project.getProjectCode());
}
return values;
}
private static List<String> categoryValues(List<ExpenseCategory> categories)
{
List<String> 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();

View File

@ -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)

View File

@ -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<SysUser> users = userService.selectUserList(user);
List<WarehouseUserOption> 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)
{
}
}

View File

@ -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)
{

View File

@ -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; }
}

View File

@ -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; }
}

View File

@ -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; }
}

View File

@ -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<Long> 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<Long> getMemberIds() { return memberIds; }
public void setMemberIds(List<Long> 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; }
}

View File

@ -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<ExpenseCategory> 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<ProjectBudget> 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<ProjectFundLedger> selectFundLedgerList(@Param("ledger") ProjectFundLedger ledger,
@Param("userId") Long userId);
ProjectFundLedger selectLedgerByIdempotencyKey(String idempotencyKey);
int insertFundLedger(ProjectFundLedger ledger);
}

View File

@ -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<ProjectInfo> selectProjectInfoList(@Param("project") ProjectInfo project, @Param("userId") Long userId);
ProjectInfo selectProjectInfoById(@Param("id") Long id, @Param("userId") Long userId);
ProjectInfo selectProjectInfoByCode(String projectCode);
List<ProjectInfo> 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<Long> selectProjectMemberIds(Long projectId);
int countProjectAccess(@Param("projectId") Long projectId, @Param("userId") Long userId);
Long selectLegacyProjectId();
}

View File

@ -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<ExpenseCategory> selectExpenseCategoryList(ExpenseCategory category);
int insertExpenseCategory(ExpenseCategory category);
int updateExpenseCategory(ExpenseCategory category);
int deleteExpenseCategory(Long id);
List<ProjectBudget> selectProjectBudgetList(Long projectId);
int saveProjectBudget(ProjectBudget budget);
List<ProjectFundLedger> 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);
}

View File

@ -0,0 +1,25 @@
package com.ruoyi.project.service;
import java.util.List;
import com.ruoyi.project.domain.ProjectInfo;
public interface IProjectInfoService
{
List<ProjectInfo> selectProjectInfoList(ProjectInfo project);
ProjectInfo selectProjectInfoById(Long id);
List<ProjectInfo> 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();
}

View File

@ -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<ExpenseCategory> 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<ProjectBudget> 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<ProjectFundLedger> 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();
}
}

View File

@ -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<ProjectInfo> 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<ProjectInfo> 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<Long> 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");
}
}

View File

@ -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;
}
}

View File

@ -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; }
}

View File

@ -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; }

View File

@ -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; }

View File

@ -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; }
}

View File

@ -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; }

View File

@ -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; }
}

View File

@ -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;

View File

@ -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<AgentResultItem> 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;

View File

@ -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; }
}

View File

@ -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);
}

View File

@ -12,4 +12,6 @@ public interface OutRecordMapper
public OutRecord checkOutNoUnique(String outNo);
public int insertOutRecord(OutRecord outRecord);
public int updateOutRecordProjectFlags(OutRecord outRecord);
}

View File

@ -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<StockLot> selectStockLotList(StockLot lot);
List<StockLot> 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);
}

View File

@ -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);

View File

@ -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<WmsBatchItem> selectWmsBatchItemList(WmsBatchItem item);
public WmsBatchItem selectWmsBatchItemById(Long id);
public int insertWmsBatchItem(WmsBatchItem item);
public int insertWmsBatchItemBatch(@Param("items") List<WmsBatchItem> 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);
}

View File

@ -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);
}

View File

@ -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<StockLot> 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);
}

View File

@ -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<AgentResultItem> 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<AgentResultItem> 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<ProjectInfo> 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<ExpenseCategory> 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<ProjectInfo> 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<ProjectInfo> 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<ExpenseCategory> 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<ProjectInfo> 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<ExpenseCategory> 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<AgentResultItem> 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<PurchaseRequest> 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)

View File

@ -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<InRecord> 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();
}
}

View File

@ -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<OutRecord> 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();
}
}

View File

@ -16,6 +16,9 @@ import com.ruoyi.warehouse.service.IProductInfoService;
@Service
public class ProductInfoServiceImpl implements IProductInfoService
{
private volatile List<ProductInfo> 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<String, Integer> 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<ProductInfo> 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;

View File

@ -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<PurchaseArrival> 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");
}
}

View File

@ -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<PurchaseRequest> 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");
}
}

View File

@ -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"));
}
}

View File

@ -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<StockCheckRecord> 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);

View File

@ -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<StockLot> 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<StockLot> 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("库存批次与汇总库存不一致,无法完成盘亏调整");
}
}
}

View File

@ -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<String> 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<String, String> 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<WmsBatchJob> 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<WmsBatchItem> 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<WmsBatchItem> items = selectItemList(jobId);
Set<Long> 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<WmsBatchItem> 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<WmsBatchItem> 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<Integer, String> 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<String> response = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
HttpResponse<String> 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<WmsBatchItem> 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<String, String> userAccountMap()
{
long now = System.currentTimeMillis();
if (now < userAccountCacheExpiresAt)
{
return user.getNickName();
return userAccountCache;
}
synchronized (this)
{
if (now < userAccountCacheExpiresAt)
{
return userAccountCache;
}
Map<String, String> 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)

View File

@ -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<String> 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<ImageReader> 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;
}
}

View File

@ -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;
}
}

View File

@ -0,0 +1,176 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.project.mapper.ProjectBudgetMapper">
<resultMap id="ExpenseCategoryResult" type="com.ruoyi.project.domain.ExpenseCategory">
<id property="id" column="id"/>
<result property="categoryCode" column="category_code"/>
<result property="categoryName" column="category_name"/>
<result property="sortOrder" column="sort_order"/>
<result property="status" column="status"/>
<result property="remark" column="remark"/>
<result property="createBy" column="create_by"/>
<result property="createTime" column="create_time"/>
<result property="updateBy" column="update_by"/>
<result property="updateTime" column="update_time"/>
</resultMap>
<resultMap id="ProjectBudgetResult" type="com.ruoyi.project.domain.ProjectBudget">
<id property="id" column="id"/>
<result property="projectId" column="project_id"/>
<result property="projectCode" column="project_code"/>
<result property="projectName" column="project_name"/>
<result property="expenseCategoryId" column="expense_category_id"/>
<result property="categoryCode" column="category_code"/>
<result property="categoryName" column="category_name"/>
<result property="budgetAmount" column="budget_amount"/>
<result property="committedAmount" column="committed_amount"/>
<result property="spentAmount" column="spent_amount"/>
<result property="availableAmount" column="available_amount"/>
<result property="alertPercent" column="alert_percent"/>
<result property="version" column="version"/>
<result property="remark" column="remark"/>
</resultMap>
<resultMap id="ProjectFundLedgerResult" type="com.ruoyi.project.domain.ProjectFundLedger">
<id property="id" column="id"/>
<result property="ledgerNo" column="ledger_no"/>
<result property="projectId" column="project_id"/>
<result property="projectName" column="project_name"/>
<result property="expenseCategoryId" column="expense_category_id"/>
<result property="categoryName" column="category_name"/>
<result property="bizType" column="biz_type"/>
<result property="bizId" column="biz_id"/>
<result property="bizNo" column="biz_no"/>
<result property="budgetDelta" column="budget_delta"/>
<result property="committedDelta" column="committed_delta"/>
<result property="spentDelta" column="spent_delta"/>
<result property="balanceBudget" column="balance_budget"/>
<result property="balanceCommitted" column="balance_committed"/>
<result property="balanceSpent" column="balance_spent"/>
<result property="idempotencyKey" column="idempotency_key"/>
<result property="operator" column="operator"/>
<result property="remark" column="remark"/>
<result property="createTime" column="create_time"/>
</resultMap>
<select id="selectExpenseCategoryList" parameterType="com.ruoyi.project.domain.ExpenseCategory" resultMap="ExpenseCategoryResult">
select * from expense_category
<where>
<if test="categoryCode != null and categoryCode != ''">and category_code like concat('%', #{categoryCode}, '%')</if>
<if test="categoryName != null and categoryName != ''">and category_name like concat('%', #{categoryName}, '%')</if>
<if test="status != null">and status = #{status}</if>
</where>
order by sort_order, id
</select>
<select id="selectExpenseCategoryById" parameterType="Long" resultMap="ExpenseCategoryResult">
select * from expense_category where id = #{id}
</select>
<select id="selectExpenseCategoryByCode" parameterType="String" resultMap="ExpenseCategoryResult">
select * from expense_category where category_code = #{categoryCode} limit 1
</select>
<insert id="insertExpenseCategory" parameterType="com.ruoyi.project.domain.ExpenseCategory" useGeneratedKeys="true" keyProperty="id">
insert into expense_category(category_code, category_name, sort_order, status, remark, create_by, create_time)
values(#{categoryCode}, #{categoryName}, #{sortOrder}, #{status}, #{remark}, #{createBy}, sysdate())
</insert>
<update id="updateExpenseCategory" parameterType="com.ruoyi.project.domain.ExpenseCategory">
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'
</update>
<delete id="deleteExpenseCategory" parameterType="Long">
delete from expense_category where id = #{id} and category_code != 'LEGACY'
</delete>
<select id="countCategoryBusiness" parameterType="Long" resultType="int">
select (select count(*) from project_budget where expense_category_id = #{id})
+ (select count(*) from purchase_request where expense_category_id = #{id})
</select>
<select id="selectProjectBudgetList" resultMap="ProjectBudgetResult">
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}
<if test="userId != null">
and (p.manager_id = #{userId} or exists (
select 1 from project_member pm where pm.project_id = p.id and pm.user_id = #{userId}
))
</if>
order by c.sort_order, c.id
</select>
<select id="selectProjectBudgetById" parameterType="Long" resultMap="ProjectBudgetResult">
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>
<select id="selectProjectBudgetForUpdate" resultMap="ProjectBudgetResult">
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
</select>
<insert id="insertProjectBudget" parameterType="com.ruoyi.project.domain.ProjectBudget" useGeneratedKeys="true" keyProperty="id">
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())
</insert>
<update id="updateProjectBudgetBalance" parameterType="com.ruoyi.project.domain.ProjectBudget">
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}
</update>
<select id="selectFundLedgerList" resultMap="ProjectFundLedgerResult">
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
<where>
<if test="ledger.projectId != null">and l.project_id = #{ledger.projectId}</if>
<if test="ledger.expenseCategoryId != null">and l.expense_category_id = #{ledger.expenseCategoryId}</if>
<if test="ledger.bizType != null and ledger.bizType != ''">and l.biz_type = #{ledger.bizType}</if>
<if test="ledger.bizNo != null and ledger.bizNo != ''">and l.biz_no like concat('%', #{ledger.bizNo}, '%')</if>
<if test="userId != null">
and (p.manager_id = #{userId} or exists (
select 1 from project_member pm where pm.project_id = p.id and pm.user_id = #{userId}
))
</if>
</where>
order by l.id desc
</select>
<select id="selectLedgerByIdempotencyKey" parameterType="String" resultMap="ProjectFundLedgerResult">
select * from project_fund_ledger where idempotency_key = #{idempotencyKey} limit 1
</select>
<insert id="insertFundLedger" parameterType="com.ruoyi.project.domain.ProjectFundLedger" useGeneratedKeys="true" keyProperty="id">
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())
</insert>
</mapper>

View File

@ -0,0 +1,149 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.project.mapper.ProjectInfoMapper">
<resultMap id="ProjectInfoResult" type="com.ruoyi.project.domain.ProjectInfo">
<id property="id" column="id"/>
<result property="projectCode" column="project_code"/>
<result property="projectName" column="project_name"/>
<result property="managerId" column="manager_id"/>
<result property="managerName" column="manager_name"/>
<result property="deptId" column="dept_id"/>
<result property="deptName" column="dept_name"/>
<result property="startDate" column="start_date"/>
<result property="endDate" column="end_date"/>
<result property="projectStatus" column="project_status"/>
<result property="currency" column="currency"/>
<result property="remark" column="remark"/>
<result property="createBy" column="create_by"/>
<result property="createTime" column="create_time"/>
<result property="updateBy" column="update_by"/>
<result property="updateTime" column="update_time"/>
<result property="budgetAmount" column="budget_amount"/>
<result property="committedAmount" column="committed_amount"/>
<result property="spentAmount" column="spent_amount"/>
<result property="availableAmount" column="available_amount"/>
</resultMap>
<sql id="selectProjectInfoVo">
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
</sql>
<select id="selectProjectInfoList" resultMap="ProjectInfoResult">
<include refid="selectProjectInfoVo"/>
<where>
<if test="project.projectCode != null and project.projectCode != ''">
and p.project_code like concat('%', #{project.projectCode}, '%')
</if>
<if test="project.projectName != null and project.projectName != ''">
and p.project_name like concat('%', #{project.projectName}, '%')
</if>
<if test="project.managerId != null">and p.manager_id = #{project.managerId}</if>
<if test="project.projectStatus != null">and p.project_status = #{project.projectStatus}</if>
<if test="userId != null">
and (p.manager_id = #{userId} or exists (
select 1 from project_member pm where pm.project_id = p.id and pm.user_id = #{userId}
))
</if>
</where>
order by p.id desc
</select>
<select id="selectProjectInfoById" resultMap="ProjectInfoResult">
<include refid="selectProjectInfoVo"/>
where p.id = #{id}
<if test="userId != null">
and (p.manager_id = #{userId} or exists (
select 1 from project_member pm where pm.project_id = p.id and pm.user_id = #{userId}
))
</if>
</select>
<select id="selectProjectInfoByCode" parameterType="String" resultMap="ProjectInfoResult">
<include refid="selectProjectInfoVo"/> where p.project_code = #{projectCode} limit 1
</select>
<select id="selectProjectOptions" resultMap="ProjectInfoResult">
<include refid="selectProjectInfoVo"/>
<where>
<if test="keyword != null and keyword != ''">
and (p.project_code like concat('%', #{keyword}, '%') or p.project_name like concat('%', #{keyword}, '%'))
</if>
<if test="!includeArchived">and p.project_status in (0, 1)</if>
<if test="userId != null">
and (p.manager_id = #{userId} or exists (
select 1 from project_member pm where pm.project_id = p.id and pm.user_id = #{userId}
))
</if>
</where>
order by p.project_status, p.project_name limit 100
</select>
<insert id="insertProjectInfo" parameterType="com.ruoyi.project.domain.ProjectInfo" useGeneratedKeys="true" keyProperty="id">
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())
</insert>
<update id="updateProjectInfo" parameterType="com.ruoyi.project.domain.ProjectInfo">
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'
</update>
<delete id="deleteProjectInfo" parameterType="Long">
delete from project_info where id = #{id} and project_code != 'LEGACY'
</delete>
<select id="countProjectBusiness" parameterType="Long" resultType="int">
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})
</select>
<delete id="deleteProjectMembers" parameterType="Long">
delete from project_member where project_id = #{projectId}
</delete>
<insert id="insertProjectMember">
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)
</insert>
<select id="selectProjectMemberIds" parameterType="Long" resultType="long">
select user_id from project_member where project_id = #{projectId} order by id
</select>
<select id="countProjectAccess" resultType="int">
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>
<select id="selectLegacyProjectId" resultType="long">
select id from project_info where project_code = 'LEGACY' limit 1
</select>
</mapper>

View File

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.warehouse.mapper.AiCommandMapper">
<resultMap id="AiCommandResult" type="com.ruoyi.warehouse.domain.AiCommand">
<id property="id" column="id"/>
<result property="userId" column="user_id"/>
<result property="username" column="username"/>
<result property="action" column="action"/>
<result property="requiredPermission" column="required_permission"/>
<result property="requestText" column="request_text"/>
<result property="planJson" column="plan_json"/>
<result property="resultJson" column="result_json"/>
<result property="commandStatus" column="command_status"/>
<result property="expiresAt" column="expires_at"/>
<result property="confirmedAt" column="confirmed_at"/>
<result property="executedAt" column="executed_at"/>
<result property="errorMessage" column="error_message"/>
<result property="createTime" column="create_time"/>
<result property="updateTime" column="update_time"/>
</resultMap>
<insert id="insertAiCommand" parameterType="com.ruoyi.warehouse.domain.AiCommand" useGeneratedKeys="true" keyProperty="id">
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())
</insert>
<select id="selectAiCommandForUpdate" resultMap="AiCommandResult">
select * from ai_command where id = #{id} and user_id = #{userId} for update
</select>
<update id="markExecuting">
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>
<update id="markSucceeded">
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'
</update>
</mapper>

View File

@ -8,12 +8,17 @@
<result property="productId" column="product_id"/>
<result property="purchaseRequestId" column="purchase_request_id"/>
<result property="arrivalId" column="arrival_id"/>
<result property="sourceProjectId" column="source_project_id"/>
<result property="sourceProjectName" column="source_project_name"/>
<result property="expenseCategoryId" column="expense_category_id"/>
<result property="expenseCategoryName" column="expense_category_name"/>
<result property="sourceNo" column="source_no"/>
<result property="inType" column="in_type"/>
<result property="inNum" column="in_num"/>
<result property="inStatus" column="in_status"/>
<result property="operator" column="operator"/>
<result property="inOperator" column="in_operator"/>
<result property="inOperatorName" column="in_operator_name"/>
<result property="confirmTime" column="confirm_time"/>
<result property="remark" column="remark"/>
<result property="createTime" column="create_time"/>
@ -29,15 +34,20 @@
</resultMap>
<sql id="selectInRecordVo">
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
</sql>
@ -48,6 +58,7 @@
<if test="inNo != null and inNo != ''">and ir.in_no like concat('%', #{inNo}, '%')</if>
<if test="areaId != null">and ir.area_id = #{areaId}</if>
<if test="productId != null">and ir.product_id = #{productId}</if>
<if test="sourceProjectId != null">and ir.source_project_id = #{sourceProjectId}</if>
<if test="areaName != null and areaName != ''">and wa.area_name like concat('%', #{areaName}, '%')</if>
<if test="productName != null and productName != ''">and pi.product_name like concat('%', #{productName}, '%')</if>
<if test="brand != null and brand != ''">and pi.brand like concat('%', #{brand}, '%')</if>
@ -56,7 +67,8 @@
<if test="inType != null">and ir.in_type = #{inType}</if>
<if test="inStatus != null">and ir.in_status = #{inStatus}</if>
<if test="inOperator != null and inOperator != ''">
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}, '%'))
</if>
</where>
order by ir.id desc
@ -67,13 +79,15 @@
</select>
<select id="selectInRecordByIdForUpdate" parameterType="Long" resultMap="InRecordResult">
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>
<select id="selectInRecordByArrivalIdForUpdate" parameterType="Long" resultMap="InRecordResult">
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
</select>
@ -83,9 +97,11 @@
</select>
<insert id="insertInRecord" parameterType="InRecord" useGeneratedKeys="true" keyProperty="id">
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())
</insert>
@ -95,6 +111,8 @@
<if test="inNo != null and inNo != ''">in_no = #{inNo},</if>
<if test="areaId != null">area_id = #{areaId},</if>
<if test="productId != null">product_id = #{productId},</if>
<if test="sourceProjectId != null">source_project_id = #{sourceProjectId},</if>
<if test="expenseCategoryId != null">expense_category_id = #{expenseCategoryId},</if>
<if test="inType != null">in_type = #{inType},</if>
<if test="inNum != null">in_num = #{inNum},</if>
<if test="operator != null">operator = #{operator},</if>

View File

@ -6,11 +6,17 @@
<result property="outNo" column="out_no"/>
<result property="areaId" column="area_id"/>
<result property="productId" column="product_id"/>
<result property="usageProjectId" column="usage_project_id"/>
<result property="usageProjectName" column="usage_project_name"/>
<result property="outType" column="out_type"/>
<result property="outNum" column="out_num"/>
<result property="sourceNo" column="source_no"/>
<result property="crossProjectFlag" column="cross_project_flag"/>
<result property="crossProjectReason" column="cross_project_reason"/>
<result property="sourceProjectNames" column="source_project_names"/>
<result property="operator" column="operator"/>
<result property="outOperator" column="out_operator"/>
<result property="outOperatorName" column="out_operator_name"/>
<result property="outTime" column="out_time"/>
<result property="remark" column="remark"/>
<result property="createTime" column="create_time"/>
@ -26,15 +32,24 @@
</resultMap>
<sql id="selectOutRecordVo">
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
</sql>
@ -45,6 +60,8 @@
<if test="outNo != null and outNo != ''">and orr.out_no like concat('%', #{outNo}, '%')</if>
<if test="areaId != null">and orr.area_id = #{areaId}</if>
<if test="productId != null">and orr.product_id = #{productId}</if>
<if test="usageProjectId != null">and orr.usage_project_id = #{usageProjectId}</if>
<if test="crossProjectFlag != null">and orr.cross_project_flag = #{crossProjectFlag}</if>
<if test="areaName != null and areaName != ''">and wa.area_name like concat('%', #{areaName}, '%')</if>
<if test="productName != null and productName != ''">and pi.product_name like concat('%', #{productName}, '%')</if>
<if test="brand != null and brand != ''">and pi.brand like concat('%', #{brand}, '%')</if>
@ -52,7 +69,8 @@
<if test="spec != null and spec != ''">and pi.spec like concat('%', #{spec}, '%')</if>
<if test="outType != null">and orr.out_type = #{outType}</if>
<if test="outOperator != null and outOperator != ''">
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}, '%'))
</if>
<if test="sourceNo != null and sourceNo != ''">and orr.source_no like concat('%', #{sourceNo}, '%')</if>
</where>
@ -68,7 +86,15 @@
</select>
<insert id="insertOutRecord" parameterType="OutRecord" useGeneratedKeys="true" keyProperty="id">
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())
</insert>
<update id="updateOutRecordProjectFlags" parameterType="OutRecord">
update out_record set cross_project_flag = #{crossProjectFlag},
cross_project_reason = #{crossProjectReason}, update_time = sysdate()
where id = #{id}
</update>
</mapper>

View File

@ -6,6 +6,8 @@
<result property="arrivalNo" column="arrival_no"/>
<result property="purchaseRequestId" column="purchase_request_id"/>
<result property="arrivalQuantity" column="arrival_quantity"/>
<result property="actualUnitPrice" column="actual_unit_price"/>
<result property="actualAmount" column="actual_amount"/>
<result property="arrivalDate" column="arrival_date"/>
<result property="acceptanceStatus" column="acceptance_status"/>
<result property="acceptanceRemark" column="acceptance_remark"/>
@ -29,10 +31,16 @@
<result property="materialBrand" column="material_brand"/>
<result property="materialParams" column="material_params"/>
<result property="unit" column="unit"/>
<result property="projectId" column="project_id"/>
<result property="projectName" column="project_name"/>
<result property="expenseCategoryId" column="expense_category_id"/>
<result property="expenseCategoryName" column="expense_category_name"/>
<result property="estimatedUnitPrice" column="estimated_unit_price"/>
</resultMap>
<sql id="selectPurchaseArrivalVo">
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>
<select id="selectPurchaseArrivalByIdForUpdate" parameterType="Long" resultMap="PurchaseArrivalResult">
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 @@
</select>
<insert id="insertPurchaseArrival" parameterType="PurchaseArrival" useGeneratedKeys="true" keyProperty="id">
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())
</insert>

View File

@ -6,12 +6,20 @@
<result property="requestNo" column="request_no"/>
<result property="productId" column="product_id"/>
<result property="purchaseBatchId" column="purchase_batch_id"/>
<result property="projectId" column="project_id"/>
<result property="projectCode" column="project_code"/>
<result property="projectName" column="project_name"/>
<result property="expenseCategoryId" column="expense_category_id"/>
<result property="expenseCategoryCode" column="expense_category_code"/>
<result property="expenseCategoryName" column="expense_category_name"/>
<result property="materialName" column="material_name"/>
<result property="materialBrand" column="material_brand"/>
<result property="category" column="category"/>
<result property="materialParams" column="material_params"/>
<result property="purchaseLink" column="purchase_link"/>
<result property="quantity" column="quantity"/>
<result property="estimatedUnitPrice" column="estimated_unit_price"/>
<result property="estimatedAmount" column="estimated_amount"/>
<result property="unit" column="unit"/>
<result property="expectedDate" column="expected_date"/>
<result property="applicantId" column="applicant_id"/>
@ -37,8 +45,13 @@
</resultMap>
<sql id="selectPurchaseRequestVo">
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
</sql>
@ -64,9 +80,11 @@
<where>
<if test="requestNo != null and requestNo != ''">and pr.request_no like concat('%', #{requestNo}, '%')</if>
<if test="productId != null">and pr.product_id = #{productId}</if>
<if test="projectId != null">and pr.project_id = #{projectId}</if>
<if test="expenseCategoryId != null">and pr.expense_category_id = #{expenseCategoryId}</if>
<if test="materialName != null and materialName != ''">
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, '%')
</if>
<if test="category != null and category != ''">and pr.category like concat('%', #{category}, '%')</if>
<if test="applicantId != null">and pr.applicant_id = #{applicantId}</if>
@ -87,9 +105,11 @@
</select>
<select id="selectPurchaseRequestByIdForUpdate" parameterType="Long" resultMap="PurchaseRequestResult">
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 id="insertPurchaseRequest" parameterType="PurchaseRequest" useGeneratedKeys="true" keyProperty="id">
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()
)
</insert>
@ -116,12 +140,16 @@
<set>
<if test="purchaseRequest.requestNo != null and purchaseRequest.requestNo != ''">request_no = #{purchaseRequest.requestNo},</if>
product_id = #{purchaseRequest.productId},
project_id = #{purchaseRequest.projectId},
expense_category_id = #{purchaseRequest.expenseCategoryId},
<if test="purchaseRequest.materialName != null and purchaseRequest.materialName != ''">material_name = #{purchaseRequest.materialName},</if>
<if test="purchaseRequest.materialBrand != null">material_brand = #{purchaseRequest.materialBrand},</if>
<if test="purchaseRequest.category != null">category = #{purchaseRequest.category},</if>
<if test="purchaseRequest.materialParams != null">material_params = #{purchaseRequest.materialParams},</if>
<if test="purchaseRequest.purchaseLink != null">purchase_link = #{purchaseRequest.purchaseLink},</if>
<if test="purchaseRequest.quantity != null">quantity = #{purchaseRequest.quantity},</if>
estimated_unit_price = #{purchaseRequest.estimatedUnitPrice},
estimated_amount = #{purchaseRequest.estimatedAmount},
<if test="purchaseRequest.unit != null">unit = #{purchaseRequest.unit},</if>
<if test="purchaseRequest.expectedDate != null">expected_date = #{purchaseRequest.expectedDate},</if>
<if test="purchaseRequest.applicantId != null">applicant_id = #{purchaseRequest.applicantId},</if>

View File

@ -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}), '%')
</if>
<if test="params.keywordTokens != null and params.keywordTokens.size() > 0">
<foreach collection="params.keywordTokens" item="token">
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}, '%')
</foreach>
</if>
</where>
order by si.id desc
</select>

View File

@ -0,0 +1,86 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.warehouse.mapper.StockLotMapper">
<resultMap id="StockLotResult" type="StockLot">
<id property="id" column="id"/>
<result property="lotNo" column="lot_no"/>
<result property="areaId" column="area_id"/>
<result property="areaName" column="area_name"/>
<result property="productId" column="product_id"/>
<result property="productName" column="product_name"/>
<result property="sourceProjectId" column="source_project_id"/>
<result property="sourceProjectName" column="source_project_name"/>
<result property="expenseCategoryId" column="expense_category_id"/>
<result property="expenseCategoryName" column="expense_category_name"/>
<result property="purchaseRequestId" column="purchase_request_id"/>
<result property="purchaseRequestNo" column="purchase_request_no"/>
<result property="arrivalId" column="arrival_id"/>
<result property="inRecordId" column="in_record_id"/>
<result property="receivedQuantity" column="received_quantity"/>
<result property="availableQuantity" column="available_quantity"/>
<result property="unitCost" column="unit_cost"/>
<result property="lotStatus" column="lot_status"/>
<result property="remark" column="remark"/>
<result property="createBy" column="create_by"/>
<result property="createTime" column="create_time"/>
<result property="updateBy" column="update_by"/>
<result property="updateTime" column="update_time"/>
</resultMap>
<sql id="selectStockLotVo">
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
</sql>
<select id="selectStockLotList" parameterType="StockLot" resultMap="StockLotResult">
<include refid="selectStockLotVo"/>
<where>
<if test="areaId != null">and sl.area_id = #{areaId}</if>
<if test="productId != null">and sl.product_id = #{productId}</if>
<if test="sourceProjectId != null">and sl.source_project_id = #{sourceProjectId}</if>
<if test="lotStatus != null">and sl.lot_status = #{lotStatus}</if>
</where>
order by sl.create_time, sl.id
</select>
<select id="selectAvailableLotsForUpdate" resultMap="StockLotResult">
select * from stock_lot
where area_id = #{areaId} and product_id = #{productId}
and lot_status = 0 and available_quantity &gt; 0
order by create_time, id for update
</select>
<select id="selectStockLotByInRecordId" parameterType="Long" resultMap="StockLotResult">
<include refid="selectStockLotVo"/> where sl.in_record_id = #{inRecordId} limit 1
</select>
<insert id="insertStockLot" parameterType="StockLot" useGeneratedKeys="true" keyProperty="id">
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())
</insert>
<update id="deductStockLot">
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 &gt;= #{quantity}
</update>
<insert id="insertOutRecordLot">
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())
</insert>
</mapper>

View File

@ -31,6 +31,12 @@
<include refid="selectWarehouseAreaVo"/> where id = #{id}
</select>
<select id="selectWarehouseAreaByNameOrCode" resultMap="WarehouseAreaResult">
<include refid="selectWarehouseAreaVo"/>
where area_name = #{value} or area_code = #{value}
order by id limit 1
</select>
<select id="checkAreaCodeUnique" parameterType="String" resultMap="WarehouseAreaResult">
<include refid="selectWarehouseAreaVo"/> where area_code = #{areaCode} limit 1
</select>

View File

@ -27,6 +27,12 @@
<result property="updateTime" column="update_time"/>
</resultMap>
<resultMap type="WmsBatchJob" id="WmsBatchItemStatsResult">
<result property="totalCount" column="total_count"/>
<result property="successCount" column="success_count"/>
<result property="failCount" column="fail_count"/>
</resultMap>
<sql id="selectWmsBatchItemVo">
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>
<select id="selectWmsBatchItemStats" parameterType="Long" resultMap="WmsBatchItemStatsResult">
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}
</select>
<insert id="insertWmsBatchItem" parameterType="WmsBatchItem" useGeneratedKeys="true" keyProperty="id">
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>
<insert id="insertWmsBatchItemBatch">
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
<foreach collection="items" item="item" separator=",">
(#{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())
</foreach>
</insert>
<update id="updateWmsBatchItem" parameterType="WmsBatchItem">
update wms_batch_item
<set>

View File

@ -32,6 +32,7 @@
<if test="sourceType != null and sourceType != ''">and source_type = #{sourceType}</if>
<if test="bizType != null and bizType != ''">and biz_type = #{bizType}</if>
<if test="status != null">and status = #{status}</if>
<if test="createBy != null and createBy != ''">and create_by = #{createBy}</if>
</where>
order by id desc
</select>
@ -64,4 +65,10 @@
</set>
where id = #{id}
</update>
<update id="claimWmsBatchJob" parameterType="Long">
update wms_batch_job
set status = 3, progress = 0, error_msg = null, update_time = sysdate()
where id = #{id} and status = 2
</update>
</mapper>

View File

@ -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' })
}

View File

@ -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 })
}

View File

@ -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

View File

@ -3,8 +3,7 @@
</template>
<script>
import * as echarts from 'echarts'
require('echarts/theme/macarons') // echarts theme
import echarts from '@/utils/echarts'
import resize from './mixins/resize'
const animationDuration = 6000
@ -44,7 +43,7 @@ export default {
},
methods: {
initChart() {
this.chart = echarts.init(this.$el, 'macarons')
this.chart = echarts.init(this.$el)
this.chart.setOption({
tooltip: {

View File

@ -3,8 +3,7 @@
</template>
<script>
import * as echarts from 'echarts'
require('echarts/theme/macarons') // echarts theme
import echarts from '@/utils/echarts'
import resize from './mixins/resize'
export default {
@ -58,7 +57,7 @@ export default {
},
methods: {
initChart() {
this.chart = echarts.init(this.$el, 'macarons')
this.chart = echarts.init(this.$el)
this.setOptions(this.chartData)
},
setOptions({ expectedData, actualData } = {}) {

View File

@ -3,8 +3,7 @@
</template>
<script>
import * as echarts from 'echarts'
require('echarts/theme/macarons') // echarts theme
import echarts from '@/utils/echarts'
import resize from './mixins/resize'
export default {
@ -42,7 +41,7 @@ export default {
},
methods: {
initChart() {
this.chart = echarts.init(this.$el, 'macarons')
this.chart = echarts.init(this.$el)
this.chart.setOption({
tooltip: {

View File

@ -3,8 +3,7 @@
</template>
<script>
import * as echarts from 'echarts'
require('echarts/theme/macarons') // echarts theme
import echarts from '@/utils/echarts'
import resize from './mixins/resize'
const animationDuration = 3000
@ -44,7 +43,7 @@ export default {
},
methods: {
initChart() {
this.chart = echarts.init(this.$el, 'macarons')
this.chart = echarts.init(this.$el)
this.chart.setOption({
tooltip: {

View File

@ -8,12 +8,12 @@
<div class="hero-actions">
<el-button type="primary" icon="el-icon-top-right" @click="$router.push('/warehouse/outRecord')" v-hasPermi="['warehouse:out:list']">出库登记</el-button>
<el-button type="success" icon="el-icon-shopping-cart-2" @click="$router.push('/purchase')" v-hasPermi="['warehouse:purchase:list']">采购申请</el-button>
<el-button type="primary" icon="el-icon-upload2" @click="$router.push('/warehouse/batch-import')">批量导入</el-button>
<el-button type="primary" icon="el-icon-upload2" @click="$router.push('/warehouse/batch-import')" v-hasPermi="['warehouse:in:add', 'warehouse:stock:out', 'warehouse:out:add', 'warehouse:check:add', 'warehouse:product:add', 'warehouse:area:add']">批量导入</el-button>
<el-button type="primary" plain icon="el-icon-mobile-phone" @click="$router.push('/mobile')">移动端入口</el-button>
</div>
</section>
<section class="agent-panel">
<section class="agent-panel" v-hasPermi="['ai:assistant:use']">
<div class="agent-title">
<div>
<h2>智能库存助手</h2>
@ -68,14 +68,19 @@
<el-button type="primary" size="mini" plain @click="selectAgentResult(scope.row)">选择</el-button>
</template>
</el-table-column>
<el-table-column label="库区" prop="areaName" width="120" />
<el-table-column v-if="agent.plan.action !== 'query_purchase'" label="库区" prop="areaName" width="120" />
<el-table-column v-else label="申请单号" prop="recordNo" width="165" />
<el-table-column label="产品" min-width="220">
<template slot-scope="scope">
<div class="product-name">{{ scope.row.productName || '-' }}</div>
<div class="product-meta">{{ productMeta(scope.row) }}</div>
</template>
</el-table-column>
<el-table-column label="库存" prop="stockNum" align="right" width="110" />
<el-table-column v-if="agent.plan.action === 'query_purchase'" label="项目" prop="projectName" min-width="150" />
<el-table-column v-if="agent.plan.action === 'query_purchase'" label="经费科目" prop="expenseCategoryName" width="110" />
<el-table-column v-if="agent.plan.action === 'query_purchase'" label="采购状态" prop="purchaseStatusName" width="100" />
<el-table-column v-if="agent.plan.action === 'query_purchase'" label="到货" width="130"><template slot-scope="scope">{{ scope.row.acceptedQuantity || 0 }} / {{ scope.row.quantity || 0 }}</template></el-table-column>
<el-table-column v-else label="库存" prop="stockNum" align="right" width="110" />
</el-table>
</div>
</section>
@ -193,6 +198,9 @@ export default {
{ label: '规格', value: plan.spec },
{ label: '单位', value: plan.unit },
{ label: '数量', value: plan.quantity },
{ label: '来源项目', value: plan.sourceProjectName },
{ label: '经费科目', value: plan.expenseCategoryName },
{ label: '使用项目', value: plan.usageProjectName },
{ label: '操作人', value: plan.operatorName },
{ label: '当前库存', value: plan.currentStock },
{ label: '预计库存', value: plan.afterStock },
@ -254,10 +262,24 @@ export default {
if (!this.agent.plan || !item) {
return
}
const plan = this.completePlanWithResult(this.agent.plan, item)
this.agent.plan = plan
this.agent.contextResults = [item]
this.$modal.msgSuccess('已选择:' + (item.productName || '库存项'))
const text = this.selectionInstruction(this.agent.plan, item)
this.agent.loading = true
analyzeAgent({ text, contextResults: [item] }).then(res => {
this.agent.plan = res.data
this.agent.contextResults = [item]
this.$modal.msgSuccess('已选择:' + (item.productName || '库存项'))
}).finally(() => { this.agent.loading = false })
},
selectionInstruction(plan, item) {
const quantity = plan.quantity || 1
const unit = item.unit || plan.unit || '个'
const project = plan.action === 'create_in_record'
? (plan.sourceProjectName ? `,来源项目${plan.sourceProjectName}` : '') + (plan.expenseCategoryName ? `,经费科目${plan.expenseCategoryName}` : '')
: (plan.usageProjectName ? `,使用项目${plan.usageProjectName}` : '')
const operator = plan.operatorName ? `,操作人${plan.operatorName}` : ''
if (plan.action === 'create_in_record') return `第一个入库${quantity}${unit}${project}${operator}`
if (plan.action === 'create_stock_check') return `盘点第一个实际${quantity}${unit}${operator}`
return `第一个出库${quantity}${unit}${project}${operator}`
},
completePlanWithResult(source, item) {
const plan = Object.assign({}, source, {
@ -304,6 +326,7 @@ export default {
actionLabel(action) {
const map = {
query_stock: '查询库存',
query_purchase: '查询采购状态',
create_in_record: '入库登记',
confirm_in_record: '确认入库',
create_out_record: '出库',

View File

@ -2,7 +2,7 @@
<div class="mobile-app">
<header class="mobile-header">
<div>
<div class="mobile-title">库存管理</div>
<div class="mobile-title">仓储与采购</div>
<div class="mobile-subtitle">{{ nickName || name || '移动端' }}</div>
</div>
<el-button size="mini" plain icon="el-icon-switch-button" @click="handleLogout">退出</el-button>
@ -29,7 +29,7 @@
</div>
</div>
<div class="mobile-agent">
<div class="mobile-agent" v-hasPermi="['ai:assistant:use']">
<div class="agent-head">
<div>
<div class="agent-title">智能库存助手</div>
@ -79,10 +79,13 @@
<article v-for="(item, index) in (agent.plan.results || [])" :key="'agent-result-' + index + '-' + (item.id || item.productId || '')" class="agent-stock">
<div>
<div class="card-title">第 {{ index + 1 }} 项:{{ item.productName || '-' }}</div>
<div class="meta-line">{{ item.areaName || '-' }} | {{ productMeta(item) }}</div>
<div class="meta-line" v-if="agent.plan.action === 'query_purchase'">{{ item.projectName || '-' }} | {{ item.expenseCategoryName || '-' }} | {{ item.recordNo || '-' }}</div>
<div class="meta-line" v-else>{{ item.areaName || '-' }} | {{ productMeta(item) }}</div>
</div>
<div class="agent-stock-action">
<strong>{{ item.stockNum === undefined || item.stockNum === null ? '-' : item.stockNum }}</strong>
<strong v-if="agent.plan.action === 'query_purchase'">{{ item.purchaseStatusName }} {{ item.acceptedQuantity || 0 }}/{{ item.quantity || 0 }}</strong>
<strong v-else>{{ item.stockNum === undefined || item.stockNum === null ? '-' : item.stockNum }}</strong>
<el-button v-if="agent.plan.action === 'query_purchase' && item.id" type="success" size="mini" plain @click="openAgentPurchaseArrival(item)">到货明细</el-button>
<el-button v-if="canSelectAgentResult(agent.plan)" type="primary" size="mini" plain @click="selectAgentResult(item)">选择</el-button>
</div>
</article>
@ -90,13 +93,14 @@
</div>
<div class="quick-panel">
<button @click="openInForm">入库登记</button>
<button @click="openOutForm">出库登记</button>
<button @click="openCheckForm">盘库登记</button>
<button @click="switchTab('stock')">查库存</button>
<button @click="openInForm" v-hasPermi="['warehouse:in:add']">入库登记</button>
<button @click="openOutForm" v-hasPermi="['warehouse:stock:out', 'warehouse:out:add']">出库登记</button>
<button @click="openCheckForm" v-hasPermi="['warehouse:check:add']">盘库登记</button>
<button @click="switchTab('stock')" v-hasPermi="['warehouse:stock:list']">查库存</button>
<button @click="switchTab('purchase')" v-hasPermi="['warehouse:purchase:list', 'warehouse:purchase:manage']">采购到货</button>
<button @click="switchTab('product')" v-hasPermi="['warehouse:product:list']">产品维护</button>
<button @click="switchTab('area')" v-hasPermi="['warehouse:area:list']">库区维护</button>
<button @click="switchTab('batch')">批量导入</button>
<button @click="switchTab('batch')" v-hasPermi="['warehouse:in:add', 'warehouse:stock:out', 'warehouse:out:add', 'warehouse:check:add', 'warehouse:product:add', 'warehouse:area:add']">批量导入</button>
</div>
<div class="panel-title">库存概览</div>
@ -138,6 +142,58 @@
</article>
</section>
<section v-show="activeTab === 'purchase'" class="mobile-section">
<div class="section-head">
<div>
<div class="section-title">采购与到货</div>
<div class="section-subtitle">查看全部采购进度,本人或采购管理员可登记和验收到货</div>
</div>
</div>
<div class="filter-panel purchase-filter">
<el-input v-model="purchaseQuery.materialName" placeholder="物料、参数或编码" clearable />
<el-select v-model="purchaseQuery.purchaseStatus" placeholder="采购状态" clearable>
<el-option v-for="item in purchaseStatusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-select v-model="purchaseQuery.applicantName" placeholder="申请人" clearable filterable>
<el-option v-for="item in userOptions" :key="item.userId" :label="userLabel(item)" :value="item.userName" />
</el-select>
<el-button type="primary" icon="el-icon-search" @click="loadPurchases(true)">搜索</el-button>
</div>
<el-button class="full-button" type="primary" icon="el-icon-plus" @click="openPurchaseForm()" v-hasPermi="['warehouse:purchase:add', 'warehouse:purchase:manage']">新增采购申请</el-button>
<div v-loading="purchaseLoading">
<div v-if="!purchaseLoading && purchaseList.length === 0" class="empty-tip">暂无采购申请</div>
<article v-for="item in purchaseList" :key="'purchase-' + item.id" class="mobile-card purchase-card">
<div class="card-head">
<div>
<div class="card-title">{{ item.materialName || '-' }}</div>
<div class="card-subtitle">{{ item.requestNo }} · {{ item.applicantName || '-' }}</div>
</div>
<el-tag size="mini" :type="purchaseStatusType(item.purchaseStatus)">{{ purchaseStatusLabel(item.purchaseStatus) }}</el-tag>
</div>
<div class="purchase-project">{{ item.projectName || '-' }} / {{ item.expenseCategoryName || '-' }}</div>
<div class="meta-line">{{ [item.materialBrand, item.category, item.materialParams].filter(Boolean).join(' / ') || '未填写物料明细' }}</div>
<div class="purchase-progress">
<span>采购 {{ item.quantity }} {{ item.unit || '' }}</span>
<span>已验收 {{ item.acceptedQuantity || 0 }}</span>
<span v-if="Number(item.pendingArrivalQuantity || 0) > 0" class="pending">待验收 {{ item.pendingArrivalQuantity }}</span>
</div>
<div class="record-line">
<span>预计金额:¥{{ money(item.estimatedAmount) }}</span>
<span>期望:{{ item.expectedDate || '-' }}</span>
<span v-if="item.purchaseBatchNo">批次:{{ item.purchaseBatchNo }}</span>
</div>
<div class="card-actions purchase-actions">
<el-button type="success" size="mini" icon="el-icon-box" @click="openPurchaseArrival(item)" v-hasPermi="['warehouse:purchase:list', 'warehouse:purchase:manage']">{{ canOperatePurchase(item) ? '到货/验收' : '到货明细' }}</el-button>
<el-button v-if="canOperatePurchase(item)" type="primary" size="mini" icon="el-icon-edit" @click="openPurchaseForm(item)" v-hasPermi="['warehouse:purchase:edit', 'warehouse:purchase:manage']">修改</el-button>
<el-button v-if="canOperatePurchase(item) && Number(item.acceptedQuantity || 0) === 0" size="mini" icon="el-icon-refresh" @click="openPurchaseStatus(item)" v-hasPermi="['warehouse:purchase:edit', 'warehouse:purchase:manage']">状态</el-button>
<el-button v-if="canOperatePurchase(item)" type="danger" size="mini" icon="el-icon-delete" @click="deletePurchaseItem(item)" v-hasPermi="['warehouse:purchase:remove', 'warehouse:purchase:manage']">删除</el-button>
</div>
</article>
<el-button v-if="purchaseList.length < purchaseTotal" class="full-button" plain icon="el-icon-more" @click="loadMorePurchases">加载更多({{ purchaseList.length }}/{{ purchaseTotal }})</el-button>
</div>
</section>
<section v-show="activeTab === 'in'" class="mobile-section">
<div class="filter-panel">
<el-select v-model="inQuery.areaId" placeholder="库区" clearable filterable>
@ -166,7 +222,7 @@
<div class="meta-line">{{ productMeta(item) }}</div>
<div class="record-line">
<span>数量:{{ item.inNum }}</span>
<span>入库人:{{ item.inOperator || '-' }}</span>
<span>入库人:{{ item.inOperatorName || item.inOperator || '-' }}</span>
</div>
<div class="card-actions" v-if="item.inStatus === 0">
<el-button type="primary" size="mini" icon="el-icon-edit" @click="openInForm(item)" v-hasPermi="['warehouse:in:edit']">修改</el-button>
@ -201,7 +257,7 @@
</div>
<div class="meta-line">{{ productMeta(item) }}</div>
<div class="record-line">
<span>出库人:{{ item.outOperator || '-' }}</span>
<span>出库人:{{ item.outOperatorName || item.outOperator || '-' }}</span>
<span>{{ parseTime(item.outTime, '{m}-{d} {h}:{i}') }}</span>
</div>
</article>
@ -410,14 +466,43 @@
<nav class="bottom-nav">
<button :class="{ active: activeTab === 'home' }" @click="switchTab('home')"><i class="el-icon-s-home"></i><span>首页</span></button>
<button :class="{ active: activeTab === 'stock' }" @click="switchTab('stock')"><i class="el-icon-box"></i><span>库存</span></button>
<button :class="{ active: activeTab === 'in' }" @click="switchTab('in')"><i class="el-icon-bottom-left"></i><span>入库</span></button>
<button :class="{ active: activeTab === 'out' }" @click="switchTab('out')"><i class="el-icon-top-right"></i><span>出库</span></button>
<button :class="{ active: activeTab === 'check' }" @click="switchTab('check')"><i class="el-icon-document-checked"></i><span>盘库</span></button>
<button :class="{ active: activeTab === 'stock' }" @click="switchTab('stock')" v-hasPermi="['warehouse:stock:list']"><i class="el-icon-box"></i><span>库存</span></button>
<button :class="{ active: activeTab === 'purchase' }" @click="switchTab('purchase')" v-hasPermi="['warehouse:purchase:list', 'warehouse:purchase:manage']"><i class="el-icon-shopping-cart-2"></i><span>采购</span></button>
<button :class="{ active: activeTab === 'in' }" @click="switchTab('in')" v-hasPermi="['warehouse:in:list']"><i class="el-icon-bottom-left"></i><span>入库</span></button>
<button :class="{ active: activeTab === 'out' }" @click="switchTab('out')" v-hasPermi="['warehouse:out:list']"><i class="el-icon-top-right"></i><span>出库</span></button>
<button :class="{ active: activeTab === 'check' }" @click="switchTab('check')" v-hasPermi="['warehouse:check:list']"><i class="el-icon-document-checked"></i><span>盘库</span></button>
</nav>
<el-dialog :title="dialogTitle" :visible.sync="dialogOpen" custom-class="mobile-dialog" append-to-body>
<el-form ref="mobileForm" :model="form" :rules="formRules" label-width="82px">
<template v-if="dialogType === 'purchase'">
<el-form-item label="采购项目" prop="projectId">
<el-select v-model="form.projectId" placeholder="请选择项目" filterable>
<el-option v-for="item in projectOptionList" :key="item.id" :label="projectLabel(item)" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="经费科目" prop="expenseCategoryId">
<el-select v-model="form.expenseCategoryId" placeholder="请选择科目" filterable>
<el-option v-for="item in expenseCategoryOptions" :key="item.id" :label="item.categoryName" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="已有产品" prop="productId">
<el-select v-model="form.productId" placeholder="可选,选择后自动填充" clearable filterable @change="handlePurchaseProductChange">
<el-option v-for="item in productOptions" :key="item.id" :label="productLabel(item)" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="物料名称" prop="materialName"><el-input v-model="form.materialName" /></el-form-item>
<el-form-item label="品牌"><el-input v-model="form.materialBrand" /></el-form-item>
<el-form-item label="类别"><el-input v-model="form.category" /></el-form-item>
<el-form-item label="参数规格"><el-input v-model="form.materialParams" /></el-form-item>
<el-form-item label="数量" prop="quantity"><el-input-number v-model="form.quantity" :min="0.001" :precision="3" :controls="false" /></el-form-item>
<el-form-item label="预计单价" prop="estimatedUnitPrice"><el-input-number v-model="form.estimatedUnitPrice" :min="0.01" :precision="2" :controls="false" /></el-form-item>
<el-form-item label="单位"><el-input v-model="form.unit" /></el-form-item>
<el-form-item label="期望到货"><el-date-picker v-model="form.expectedDate" type="date" value-format="yyyy-MM-dd" placeholder="请选择日期" /></el-form-item>
<el-form-item label="采购链接" prop="purchaseLink"><el-input v-model="form.purchaseLink" /></el-form-item>
<el-form-item label="备注"><el-input v-model="form.remark" type="textarea" /></el-form-item>
</template>
<template v-if="dialogType === 'in'">
<el-form-item label="库区" prop="areaId">
<el-select v-model="form.areaId" placeholder="请选择库区" filterable>
@ -429,6 +514,16 @@
<el-option v-for="item in productOptions" :key="item.id" :label="productLabel(item)" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="来源项目" prop="sourceProjectId">
<el-select v-model="form.sourceProjectId" placeholder="请选择来源项目" filterable>
<el-option v-for="item in projectOptionList" :key="item.id" :label="projectLabel(item)" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="经费科目" prop="expenseCategoryId">
<el-select v-model="form.expenseCategoryId" placeholder="请选择经费科目" filterable>
<el-option v-for="item in expenseCategoryOptions" :key="item.id" :label="item.categoryName" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="入库人" prop="inOperator">
<el-select v-model="form.inOperator" placeholder="请选择入库人" filterable>
<el-option v-for="item in userOptions" :key="item.userId" :label="userLabel(item)" :value="userValue(item)" />
@ -457,6 +552,11 @@
<el-option v-for="item in productOptions" :key="item.id" :label="productLabel(item)" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="使用项目" prop="usageProjectId">
<el-select v-model="form.usageProjectId" placeholder="请选择使用项目" filterable>
<el-option v-for="item in projectOptionList" :key="item.id" :label="projectLabel(item)" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="出库人" prop="outOperator">
<el-select v-model="form.outOperator" placeholder="请选择出库人" filterable>
<el-option v-for="item in userOptions" :key="item.userId" :label="userLabel(item)" :value="userValue(item)" />
@ -469,6 +569,7 @@
</el-form-item>
<el-form-item label="数量" prop="outNum"><el-input-number v-model="form.outNum" :min="0.001" :precision="3" :step="1" /></el-form-item>
<el-form-item label="关联单号"><el-input v-model="form.sourceNo" /></el-form-item>
<el-form-item label="跨项目原因"><el-input v-model="form.crossProjectReason" type="textarea" placeholder="来源项目与使用项目不同时必填" /></el-form-item>
<el-form-item label="备注"><el-input v-model="form.remark" type="textarea" /></el-form-item>
</template>
@ -528,6 +629,64 @@
<el-button type="primary" @click="submitDialog">确定</el-button>
</div>
</el-dialog>
<el-dialog title="采购状态" :visible.sync="purchaseStatusOpen" custom-class="mobile-dialog" append-to-body>
<el-form ref="mobilePurchaseStatusForm" :model="purchaseStatusForm" :rules="purchaseStatusRules" label-width="82px">
<el-form-item label="采购状态" prop="purchaseStatus">
<el-select v-model="purchaseStatusForm.purchaseStatus">
<el-option v-for="item in manualPurchaseStatusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="进度说明"><el-input v-model="purchaseStatusForm.progressRemark" type="textarea" :rows="3" /></el-form-item>
</el-form>
<div slot="footer" class="dialog-footer"><el-button @click="purchaseStatusOpen = false">取消</el-button><el-button type="primary" @click="submitPurchaseStatus">确定</el-button></div>
</el-dialog>
<el-dialog title="到货登记与验收" :visible.sync="purchaseArrivalOpen" custom-class="mobile-dialog mobile-arrival-dialog" append-to-body>
<div v-if="purchaseArrivalRequest" class="mobile-arrival-head">
<strong>{{ purchaseArrivalRequest.materialName }}</strong>
<span>{{ purchaseArrivalRequest.projectName }} / {{ purchaseArrivalRequest.expenseCategoryName }}</span>
<div class="purchase-progress">
<span>采购 {{ purchaseArrivalRequest.quantity }} {{ purchaseArrivalRequest.unit || '' }}</span>
<span>已验收 {{ purchaseArrivalRequest.acceptedQuantity || 0 }}</span>
<span class="pending">可登记 {{ purchaseArrivalRequest.remainingQuantity || 0 }}</span>
</div>
</div>
<el-alert v-if="purchaseArrivalRequest && !canOperatePurchase(purchaseArrivalRequest)" title="当前为只读模式,仅申请人和采购管理员可以登记或验收到货" type="info" :closable="false" show-icon />
<el-form v-if="purchaseArrivalRequest && canOperatePurchase(purchaseArrivalRequest) && Number(purchaseArrivalRequest.remainingQuantity || 0) > 0" ref="mobileArrivalForm" :model="purchaseArrivalForm" :rules="purchaseArrivalRules" label-width="88px" class="mobile-arrival-form">
<el-form-item label="本次到货" prop="arrivalQuantity"><el-input-number v-model="purchaseArrivalForm.arrivalQuantity" :min="0.001" :max="Number(purchaseArrivalRequest.remainingQuantity || 0)" :precision="3" :controls="false" /></el-form-item>
<el-form-item label="实际单价" prop="actualUnitPrice"><el-input-number v-model="purchaseArrivalForm.actualUnitPrice" :min="0.01" :precision="2" :controls="false" /></el-form-item>
<el-form-item label="到货日期" prop="arrivalDate"><el-date-picker v-model="purchaseArrivalForm.arrivalDate" type="date" value-format="yyyy-MM-dd" /></el-form-item>
<el-form-item label="备注"><el-input v-model="purchaseArrivalForm.remark" /></el-form-item>
<el-button class="full-button" type="primary" icon="el-icon-plus" @click="submitPurchaseArrival">登记到货</el-button>
</el-form>
<div v-loading="purchaseArrivalLoading" class="mobile-arrival-list">
<div v-if="!purchaseArrivalLoading && purchaseArrivalList.length === 0" class="empty-tip">暂无到货记录</div>
<article v-for="item in purchaseArrivalList" :key="'arrival-' + item.id" class="arrival-card">
<div class="card-head">
<div>
<div class="card-title">{{ item.arrivalNo }}</div>
<div class="card-subtitle">{{ item.arrivalDate }} · {{ item.arrivalQuantity }} {{ purchaseArrivalRequest && purchaseArrivalRequest.unit || '' }}</div>
</div>
<el-tag size="mini" :type="acceptanceType(item.acceptanceStatus)">{{ acceptanceLabel(item.acceptanceStatus) }}</el-tag>
</div>
<div class="record-line"><span>单价:¥{{ money(item.actualUnitPrice) }}</span><span>金额:¥{{ money(item.actualAmount) }}</span></div>
<div v-if="item.remark || item.acceptanceRemark || item.revokeRemark" class="arrival-notes">
<div v-if="item.remark">到货:{{ item.remark }}</div>
<div v-if="item.acceptanceRemark">验收:{{ item.acceptanceRemark }}</div>
<div v-if="item.revokeRemark">撤销:{{ item.revokeRemark }}</div>
</div>
<div v-if="purchaseArrivalRequest && canOperatePurchase(purchaseArrivalRequest)" class="card-actions purchase-actions">
<template v-if="Number(item.acceptanceStatus) === 0">
<el-button type="success" size="mini" icon="el-icon-check" @click="acceptMobileArrival(item, 1)" v-hasPermi="['warehouse:purchase:edit', 'warehouse:purchase:manage']">验收通过</el-button>
<el-button type="warning" size="mini" icon="el-icon-close" @click="acceptMobileArrival(item, 2)" v-hasPermi="['warehouse:purchase:edit', 'warehouse:purchase:manage']">拒收</el-button>
<el-button type="danger" size="mini" icon="el-icon-delete" @click="deleteMobileArrival(item)" v-hasPermi="['warehouse:purchase:remove', 'warehouse:purchase:manage']">删除</el-button>
</template>
<el-button v-else size="mini" icon="el-icon-refresh-left" @click="revokeMobileArrival(item)" v-hasPermi="['warehouse:purchase:edit', 'warehouse:purchase:manage']">撤销验收</el-button>
</div>
</article>
</div>
</el-dialog>
</div>
</template>
@ -540,9 +699,11 @@ import { listStock, outStock } from '@/api/warehouse/stock'
import { listInRecord, getInRecord, addInRecord, updateInRecord, delInRecord, confirmInRecord } from '@/api/warehouse/inRecord'
import { listOutRecord, addOutRecord } from '@/api/warehouse/outRecord'
import { listStockCheck, addStockCheck, confirmStockCheck } from '@/api/warehouse/stockCheck'
import { listPurchase, getPurchase, addPurchase, updatePurchase, updatePurchaseStatus, delPurchase, listPurchaseArrival, addPurchaseArrival, acceptPurchaseArrival, revokePurchaseArrival, delPurchaseArrival } from '@/api/warehouse/purchase'
import { analyzeAgent, executeAgent } from '@/api/warehouse/agent'
import { listBatchJob, uploadBatchFile, getBatchJob, listBatchItems, updateBatchItemAction, confirmBatchJob } from '@/api/warehouse/batchImport'
import { compressImageForUpload } from '@/utils/imageCompress'
import { projectOptions, categoryOptions as expenseCategoryOptionApi } from '@/api/project/project'
export default {
name: 'MobileWarehouse',
@ -553,10 +714,15 @@ export default {
areaOptions: [],
productOptions: [],
userOptions: [],
projectOptionList: [],
expenseCategoryOptions: [],
stockList: [],
inList: [],
outList: [],
checkList: [],
purchaseList: [],
purchaseTotal: 0,
purchaseLoading: false,
productList: [],
areaList: [],
summary: { stockTotal: 0, pendingIn: 0, pendingCheck: 0, recentOut: 0 },
@ -564,6 +730,7 @@ export default {
inQuery: { pageNum: 1, pageSize: 20, areaId: undefined, productName: undefined, brand: undefined, inStatus: undefined },
outQuery: { pageNum: 1, pageSize: 20, areaId: undefined, productName: undefined, brand: undefined, spec: undefined },
checkQuery: { pageNum: 1, pageSize: 20, areaId: undefined, productName: undefined, brand: undefined, checkStatus: undefined },
purchaseQuery: { pageNum: 1, pageSize: 20, materialName: undefined, purchaseStatus: undefined, applicantName: undefined },
productQuery: { pageNum: 1, pageSize: 50, productName: undefined },
areaQuery: { pageNum: 1, pageSize: 50, areaName: undefined },
dialogOpen: false,
@ -571,6 +738,27 @@ export default {
dialogTitle: '',
form: {},
formRules: {},
purchaseStatusOpen: false,
purchaseStatusForm: {},
purchaseStatusRules: { purchaseStatus: [{ required: true, message: '请选择采购状态', trigger: 'change' }] },
purchaseArrivalOpen: false,
purchaseArrivalLoading: false,
purchaseArrivalRequest: null,
purchaseArrivalList: [],
purchaseArrivalForm: {},
purchaseArrivalRules: {
arrivalQuantity: [{ required: true, message: '请输入到货数量', trigger: 'change' }],
actualUnitPrice: [{ required: true, message: '请输入实际单价', trigger: 'change' }],
arrivalDate: [{ required: true, message: '请选择到货日期', trigger: 'change' }]
},
purchaseStatusOptions: [
{ value: 0, label: '待采购', type: 'info' },
{ value: 1, label: '已下单', type: '' },
{ value: 2, label: '采购中', type: 'warning' },
{ value: 3, label: '部分到货', type: 'warning' },
{ value: 4, label: '已到齐', type: 'success' },
{ value: 5, label: '已取消', type: 'danger' }
],
agent: {
text: '',
loading: false,
@ -595,7 +783,16 @@ export default {
}
},
computed: {
...mapGetters(['name', 'nickName']),
...mapGetters(['name', 'nickName', 'id', 'permissions']),
canManagePurchase() {
return (this.permissions || []).includes('*:*:*') || (this.permissions || []).includes('warehouse:purchase:manage')
},
canViewPurchase() {
return this.canManagePurchase || (this.permissions || []).includes('warehouse:purchase:list')
},
manualPurchaseStatusOptions() {
return this.purchaseStatusOptions.filter(item => [0, 1, 2, 5].includes(item.value))
},
brandOptions() {
return this.distinctProductOptions('brand')
},
@ -621,6 +818,9 @@ export default {
{ label: '规格', value: plan.spec },
{ label: '单位', value: plan.unit },
{ label: '数量', value: plan.quantity },
{ label: '来源项目', value: plan.sourceProjectName },
{ label: '经费科目', value: plan.expenseCategoryName },
{ label: '使用项目', value: plan.usageProjectName },
{ label: '人员', value: plan.operatorName },
{ label: '当前', value: plan.currentStock },
{ label: '预计', value: plan.afterStock },
@ -636,6 +836,186 @@ export default {
this.stopBatchPolling()
},
methods: {
money(value) {
return Number(value || 0).toFixed(2)
},
purchaseStatusLabel(status) {
const item = this.purchaseStatusOptions.find(option => option.value === Number(status))
return item ? item.label : '未知'
},
purchaseStatusType(status) {
const item = this.purchaseStatusOptions.find(option => option.value === Number(status))
return item ? item.type : 'info'
},
acceptanceLabel(status) {
return { 0: '待验收', 1: '已通过', 2: '已拒收' }[Number(status)] || '未知'
},
acceptanceType(status) {
return { 0: 'warning', 1: 'success', 2: 'danger' }[Number(status)] || 'info'
},
canOperatePurchase(row) {
return this.canManagePurchase || Number(row && row.applicantId) === Number(this.id)
},
hasAnyPermission(required) {
const current = this.permissions || []
return current.includes('*:*:*') || required.some(permission => current.includes(permission))
},
openAgentPurchaseArrival(item) {
this.switchTab('purchase')
getPurchase(item.id).then(res => this.openPurchaseArrival(res.data))
},
loadPurchases(reset = false) {
if (!this.canViewPurchase) return
if (reset) this.purchaseQuery.pageNum = 1
this.purchaseLoading = true
listPurchase(this.purchaseQuery).then(res => {
const rows = res.rows || []
this.purchaseList = reset || this.purchaseQuery.pageNum === 1 ? rows : this.purchaseList.concat(rows)
this.purchaseTotal = res.total || 0
}).finally(() => { this.purchaseLoading = false })
},
loadMorePurchases() {
if (this.purchaseLoading || this.purchaseList.length >= this.purchaseTotal) return
this.purchaseQuery.pageNum += 1
this.loadPurchases(false)
},
openPurchaseForm(row) {
this.dialogType = 'purchase'
this.dialogTitle = row && row.id ? '修改采购申请' : '新增采购申请'
this.formRules = {
projectId: [{ required: true, message: '请选择采购项目', trigger: 'change' }],
expenseCategoryId: [{ required: true, message: '请选择经费科目', trigger: 'change' }],
materialName: [{ required: true, message: '请输入物料名称', trigger: 'blur' }],
quantity: [{ required: true, message: '请输入需求数量', trigger: 'change' }],
estimatedUnitPrice: [{ required: true, message: '请输入预计单价', trigger: 'change' }],
purchaseLink: [{ pattern: /^https?:\/\//i, message: '链接必须以 http:// 或 https:// 开头', trigger: 'blur' }]
}
const setForm = data => {
this.form = Object.assign({
id: undefined,
projectId: undefined,
expenseCategoryId: undefined,
productId: undefined,
materialName: undefined,
materialBrand: undefined,
category: undefined,
materialParams: undefined,
quantity: 1,
estimatedUnitPrice: undefined,
unit: undefined,
expectedDate: undefined,
purchaseLink: undefined,
remark: undefined
}, data || {})
this.dialogOpen = true
this.$nextTick(() => this.$refs.mobileForm && this.$refs.mobileForm.clearValidate())
}
if (row && row.id) getPurchase(row.id).then(res => setForm(res.data))
else setForm()
},
handlePurchaseProductChange(productId) {
if (!productId) return
const product = this.productOptions.find(item => Number(item.id) === Number(productId))
if (!product) return
this.form.materialName = product.productName
this.form.materialBrand = product.brand
this.form.category = product.category
this.form.materialParams = product.spec
this.form.unit = product.unit
},
deletePurchaseItem(row) {
this.$modal.confirm('确认删除采购申请“' + (row.requestNo || '-') + '”?').then(() => delPurchase(row.id)).then(() => {
this.$modal.msgSuccess('采购申请已删除')
this.loadPurchases(true)
}).catch(() => {})
},
openPurchaseStatus(row) {
this.purchaseStatusForm = { id: row.id, purchaseStatus: row.purchaseStatus, progressRemark: row.progressRemark }
this.purchaseStatusOpen = true
this.$nextTick(() => this.$refs.mobilePurchaseStatusForm && this.$refs.mobilePurchaseStatusForm.clearValidate())
},
submitPurchaseStatus() {
this.$refs.mobilePurchaseStatusForm.validate(valid => {
if (!valid) return
updatePurchaseStatus(this.purchaseStatusForm.id, this.purchaseStatusForm).then(() => {
this.$modal.msgSuccess('采购状态已更新')
this.purchaseStatusOpen = false
this.loadPurchases(true)
})
})
},
openPurchaseArrival(row) {
this.purchaseArrivalOpen = true
this.purchaseArrivalRequest = Object.assign({}, row)
this.resetPurchaseArrivalForm()
this.refreshPurchaseArrival(row.id)
},
refreshPurchaseArrival(requestId) {
this.purchaseArrivalLoading = true
Promise.all([getPurchase(requestId), listPurchaseArrival(requestId)]).then(([requestRes, arrivalRes]) => {
this.purchaseArrivalRequest = requestRes.data
this.purchaseArrivalList = arrivalRes.data || []
this.resetPurchaseArrivalForm()
}).finally(() => { this.purchaseArrivalLoading = false })
},
resetPurchaseArrivalForm() {
this.purchaseArrivalForm = {
purchaseRequestId: this.purchaseArrivalRequest && this.purchaseArrivalRequest.id,
arrivalQuantity: undefined,
actualUnitPrice: this.purchaseArrivalRequest && this.purchaseArrivalRequest.estimatedUnitPrice,
arrivalDate: this.parseTime(new Date(), '{y}-{m}-{d}'),
remark: undefined
}
this.$nextTick(() => this.$refs.mobileArrivalForm && this.$refs.mobileArrivalForm.clearValidate())
},
submitPurchaseArrival() {
if (!this.purchaseArrivalRequest || !this.$refs.mobileArrivalForm) return
this.purchaseArrivalForm.purchaseRequestId = this.purchaseArrivalRequest.id
this.$refs.mobileArrivalForm.validate(valid => {
if (!valid) return
addPurchaseArrival(this.purchaseArrivalForm).then(() => {
this.$modal.msgSuccess('到货已登记,等待验收')
this.refreshPurchaseArrival(this.purchaseArrivalRequest.id)
this.loadPurchases(true)
})
})
},
acceptMobileArrival(row, status) {
if (status === 1) {
this.$modal.confirm('确认数量、单价和物料无误并通过验收?').then(() => acceptPurchaseArrival(row.id, { acceptanceStatus: 1 })).then(() => {
this.$modal.msgSuccess('验收通过,已生成待入库记录')
this.refreshPurchaseArrival(this.purchaseArrivalRequest.id)
this.loadPurchases(true)
this.loadSummary()
}).catch(() => {})
return
}
this.$prompt('请输入拒收原因', '拒收到货', { inputValidator: value => !!(value && value.trim()) || '拒收原因不能为空' }).then(({ value }) => {
return acceptPurchaseArrival(row.id, { acceptanceStatus: 2, acceptanceRemark: value })
}).then(() => {
this.$modal.msgSuccess('已拒收,本次数量可以重新登记')
this.refreshPurchaseArrival(this.purchaseArrivalRequest.id)
this.loadPurchases(true)
}).catch(() => {})
},
deleteMobileArrival(row) {
this.$modal.confirm('确认删除待验收到货记录“' + row.arrivalNo + '”?').then(() => delPurchaseArrival(row.id)).then(() => {
this.$modal.msgSuccess('到货记录已删除')
this.refreshPurchaseArrival(this.purchaseArrivalRequest.id)
this.loadPurchases(true)
}).catch(() => {})
},
revokeMobileArrival(row) {
const action = Number(row.acceptanceStatus) === 1 ? '验收通过' : '拒收'
this.$prompt('请输入撤销原因', '撤销' + action, { inputValidator: value => !!(value && value.trim()) || '撤销原因不能为空' }).then(({ value }) => {
return revokePurchaseArrival(row.id, { revokeRemark: value })
}).then(() => {
this.$modal.msgSuccess('验收已撤销')
this.refreshPurchaseArrival(this.purchaseArrivalRequest.id)
this.loadPurchases(true)
this.loadSummary()
}).catch(() => {})
},
analyzeAgentText() {
const text = (this.agent.text || '').trim()
if (!text) {
@ -688,14 +1068,25 @@ export default {
if (!this.agent.plan || !item) {
return
}
const plan = this.completePlanWithResult(this.agent.plan, item)
this.agent.plan = plan
this.agent.contextResults = [item]
if (plan.executable) {
this.autoFillAgentForm(plan)
} else {
this.$modal.msgWarning(plan.message || '已选择产品,请补充缺少的信息')
}
const text = this.selectionInstruction(this.agent.plan, item)
this.agent.loading = true
analyzeAgent({ text, contextResults: [item] }).then(res => {
this.agent.plan = res.data
this.agent.contextResults = [item]
if (res.data.executable) this.autoFillAgentForm(res.data)
else this.$modal.msgWarning(res.data.message || '已选择产品,请补充缺少的信息')
}).finally(() => { this.agent.loading = false })
},
selectionInstruction(plan, item) {
const quantity = plan.quantity || 1
const unit = item.unit || plan.unit || '个'
const project = plan.action === 'create_in_record'
? (plan.sourceProjectName ? `,来源项目${plan.sourceProjectName}` : '') + (plan.expenseCategoryName ? `,经费科目${plan.expenseCategoryName}` : '')
: (plan.usageProjectName ? `,使用项目${plan.usageProjectName}` : '')
const operator = plan.operatorName ? `,操作人${plan.operatorName}` : ''
if (plan.action === 'create_in_record') return `第一个入库${quantity}${unit}${project}${operator}`
if (plan.action === 'create_stock_check') return `盘点第一个实际${quantity}${unit}${operator}`
return `第一个出库${quantity}${unit}${project}${operator}`
},
completePlanWithResult(source, item) {
const plan = Object.assign({}, source, {
@ -753,10 +1144,12 @@ export default {
category: plan.category,
spec: plan.spec,
unit: plan.unit,
usageProjectId: plan.usageProjectId,
outOperator: plan.operatorName,
outType: plan.outType || 1,
outNum: plan.quantity,
sourceNo: undefined,
crossProjectReason: plan.crossProjectReason,
remark: plan.remark
}
this.setOutRules()
@ -766,10 +1159,12 @@ export default {
this.form = {
areaId: plan.areaId,
productId: plan.productId,
usageProjectId: plan.usageProjectId,
outOperator: plan.operatorName,
outType: plan.outType || 1,
outNum: plan.quantity,
sourceNo: undefined,
crossProjectReason: plan.crossProjectReason,
remark: plan.remark
}
this.setOutRules()
@ -779,6 +1174,8 @@ export default {
this.form = {
areaId: plan.areaId,
productId: plan.productId,
sourceProjectId: plan.sourceProjectId,
expenseCategoryId: plan.expenseCategoryId,
inOperator: plan.operatorName,
inType: plan.inType || 2,
inNum: plan.quantity,
@ -845,6 +1242,7 @@ export default {
actionLabel(action) {
const map = {
query_stock: '查库存',
query_purchase: '查采购状态',
create_in_record: '入库',
confirm_in_record: '确认入库',
create_out_record: '出库',
@ -858,19 +1256,22 @@ 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 || [] })
expenseCategoryOptionApi().then(res => { this.expenseCategoryOptions = res.data || [] })
},
refreshAll() {
this.loadStock()
this.loadInRecords()
this.loadOutRecords()
this.loadCheckRecords()
if (this.hasAnyPermission(['warehouse:stock:list'])) this.loadStock()
if (this.hasAnyPermission(['warehouse:in:list'])) this.loadInRecords()
if (this.hasAnyPermission(['warehouse:out:list'])) this.loadOutRecords()
if (this.hasAnyPermission(['warehouse:check:list'])) this.loadCheckRecords()
if (this.canViewPurchase) this.loadPurchases(true)
this.loadSummary()
},
loadSummary() {
listStock({ pageNum: 1, pageSize: 1 }).then(res => { this.summary.stockTotal = res.total || 0 })
listInRecord({ pageNum: 1, pageSize: 1, inStatus: 0 }).then(res => { this.summary.pendingIn = res.total || 0 })
listStockCheck({ pageNum: 1, pageSize: 1, checkStatus: 0 }).then(res => { this.summary.pendingCheck = res.total || 0 })
listOutRecord({ pageNum: 1, pageSize: 1 }).then(res => { this.summary.recentOut = res.total || 0 })
if (this.hasAnyPermission(['warehouse:stock:list'])) listStock({ pageNum: 1, pageSize: 1 }).then(res => { this.summary.stockTotal = res.total || 0 })
if (this.hasAnyPermission(['warehouse:in:list'])) listInRecord({ pageNum: 1, pageSize: 1, inStatus: 0 }).then(res => { this.summary.pendingIn = res.total || 0 })
if (this.hasAnyPermission(['warehouse:check:list'])) listStockCheck({ pageNum: 1, pageSize: 1, checkStatus: 0 }).then(res => { this.summary.pendingCheck = res.total || 0 })
if (this.hasAnyPermission(['warehouse:out:list'])) listOutRecord({ pageNum: 1, pageSize: 1 }).then(res => { this.summary.recentOut = res.total || 0 })
},
loadStock() {
listStock(this.stockQuery).then(res => { this.stockList = res.rows || [] })
@ -1043,6 +1444,7 @@ export default {
switchTab(tab) {
this.activeTab = tab
if (tab === 'stock') this.loadStock()
if (tab === 'purchase') this.loadPurchases(true)
if (tab === 'in') this.loadInRecords()
if (tab === 'out') this.loadOutRecords()
if (tab === 'check') this.loadCheckRecords()
@ -1059,11 +1461,12 @@ 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 + ')' },
userLabel(user) {
return user.nickName || user.userName
},
userValue(user) {
return user.nickName || user.userName
return user.userName
},
productMeta(row) {
return [row.brand && '品牌:' + row.brand, row.category && '类别:' + row.category, row.spec && '规格:' + row.spec, row.unit && '单位:' + row.unit].filter(Boolean).join(' ')
@ -1082,6 +1485,8 @@ export default {
inNo: undefined,
areaId: undefined,
productId: undefined,
sourceProjectId: undefined,
expenseCategoryId: undefined,
inOperator: undefined,
inType: 2,
inNum: undefined,
@ -1100,6 +1505,8 @@ export default {
this.formRules = {
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' }]
@ -1108,7 +1515,7 @@ export default {
openOutForm() {
this.dialogType = 'out'
this.dialogTitle = '出库登记'
this.form = { areaId: undefined, productId: undefined, outOperator: undefined, outType: 1, outNum: undefined, sourceNo: undefined, remark: undefined }
this.form = { areaId: undefined, productId: undefined, usageProjectId: undefined, outOperator: undefined, outType: 1, outNum: undefined, sourceNo: undefined, crossProjectReason: undefined, remark: undefined }
this.setOutRules()
this.dialogOpen = true
this.$nextTick(() => this.resetForm('mobileForm'))
@ -1116,7 +1523,7 @@ export default {
openStockOutForm(row) {
this.dialogType = 'stockOut'
this.dialogTitle = '库存出库'
this.form = { id: row.id, productName: row.productName, brand: row.brand, category: row.category, spec: row.spec, unit: row.unit, outOperator: undefined, outType: 1, outNum: undefined, sourceNo: undefined, remark: undefined }
this.form = { id: row.id, productName: row.productName, brand: row.brand, category: row.category, spec: row.spec, unit: row.unit, usageProjectId: undefined, outOperator: undefined, outType: 1, outNum: undefined, sourceNo: undefined, crossProjectReason: undefined, remark: undefined }
this.setOutRules()
this.dialogOpen = true
this.$nextTick(() => this.resetForm('mobileForm'))
@ -1125,6 +1532,7 @@ export default {
this.formRules = {
areaId: [{ required: this.dialogType === 'out', message: '请选择库区', trigger: 'change' }],
productId: [{ required: this.dialogType === 'out', message: '请选择产品', trigger: 'change' }],
usageProjectId: [{ required: true, message: '请选择使用项目', trigger: 'change' }],
outOperator: [{ required: true, message: '请选择出库人', trigger: 'change' }],
outType: [{ required: true, message: '请选择类型', trigger: 'change' }],
outNum: [{ required: true, message: '请输入数量', trigger: 'blur' }]
@ -1247,6 +1655,7 @@ export default {
if (this.dialogType === 'out') request = addOutRecord(this.form)
if (this.dialogType === 'stockOut') request = outStock(this.form)
if (this.dialogType === 'check') request = addStockCheck(this.form)
if (this.dialogType === 'purchase') request = this.form.id ? updatePurchase(this.form) : addPurchase(this.form)
if (this.dialogType === 'product') request = this.form.id ? updateProduct(this.form) : addProduct(this.form)
if (this.dialogType === 'area') request = this.form.id ? updateArea(this.form) : addArea(this.form)
request.then(() => {
@ -1254,6 +1663,11 @@ export default {
const hasPendingNextPlan = !!this.agent.pendingNextPlan
this.$modal.msgSuccess(editMaintainData ? '修改成功' : '保存成功')
this.dialogOpen = false
if (this.dialogType === 'purchase') {
this.loadPurchases(true)
this.loadOptions()
return
}
if (this.dialogType === 'product') {
this.loadProducts()
this.loadOptions()
@ -1626,6 +2040,79 @@ export default {
font-weight: 700;
white-space: nowrap;
}
.purchase-project {
margin-top: 8px;
color: #303133;
font-size: 13px;
font-weight: 600;
line-height: 20px;
}
.purchase-progress {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.purchase-progress span {
padding: 4px 7px;
color: #606266;
background: #f2f5f8;
border-radius: 4px;
font-size: 12px;
}
.purchase-progress .pending {
color: #b26a00;
background: #fdf6ec;
}
.purchase-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 7px;
}
.purchase-actions .el-button {
margin-left: 0;
}
.mobile-arrival-head {
margin-bottom: 12px;
padding: 10px;
background: #f7f9fc;
border-radius: 7px;
}
.mobile-arrival-head strong,
.mobile-arrival-head > span {
display: block;
line-height: 22px;
}
.mobile-arrival-head > span {
color: #7a8794;
font-size: 12px;
}
.mobile-arrival-form {
margin-top: 12px;
padding: 12px 10px 2px;
border: 1px solid #dfe7f1;
border-radius: 7px;
}
.mobile-arrival-list {
min-height: 80px;
margin-top: 12px;
}
.arrival-card {
margin-bottom: 10px;
padding: 10px;
border: 1px solid #e4e9f0;
border-radius: 7px;
}
.arrival-notes {
margin-top: 7px;
padding: 7px;
color: #606266;
background: #f7f9fc;
border-radius: 4px;
font-size: 12px;
line-height: 19px;
}
.record-line,
.check-grid {
display: flex;
@ -1655,17 +2142,17 @@ export default {
bottom: 0;
left: 0;
z-index: 20;
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
display: flex;
height: 62px;
background: #ffffff;
border-top: 1px solid #e6ebf2;
}
.bottom-nav button {
flex: 1;
border: 0;
background: transparent;
color: #7a8794;
font-size: 12px;
font-size: 11px;
}
.bottom-nav i {
display: block;
@ -1705,7 +2192,12 @@ export default {
margin-top: 6vh !important;
}
.mobile-dialog .el-dialog__body {
max-height: 72vh;
padding: 12px 16px 0;
overflow-y: auto;
}
.mobile-arrival-dialog .el-dialog__body {
padding-bottom: 12px;
}
@media (max-width: 768px) {
.el-message-box__wrapper {

View File

@ -66,7 +66,7 @@
<script>
import { getCache } from "@/api/monitor/cache"
import * as echarts from "echarts"
import echarts from "@/utils/echarts"
export default {
name: "Cache",
@ -76,6 +76,7 @@ export default {
commandstats: null,
// 使用内存
usedmemory: null,
resizeHandler: null,
// cache信息
cache: []
}
@ -84,6 +85,11 @@ export default {
this.getList()
this.openLoading()
},
beforeDestroy() {
if (this.resizeHandler) window.removeEventListener("resize", this.resizeHandler)
if (this.commandstats) this.commandstats.dispose()
if (this.usedmemory) this.usedmemory.dispose()
},
methods: {
/** 查缓存询信息 */
getList() {
@ -91,7 +97,7 @@ export default {
this.cache = response.data
this.$modal.closeLoading()
this.commandstats = echarts.init(this.$refs.commandstats, "macarons")
this.commandstats = echarts.init(this.$refs.commandstats)
this.commandstats.setOption({
tooltip: {
trigger: "item",
@ -110,7 +116,7 @@ export default {
}
]
})
this.usedmemory = echarts.init(this.$refs.usedmemory, "macarons")
this.usedmemory = echarts.init(this.$refs.usedmemory)
this.usedmemory.setOption({
tooltip: {
formatter: "{b} <br/>{a} : " + this.cache.info.used_memory_human,
@ -133,10 +139,11 @@ export default {
}
]
})
window.addEventListener("resize", () => {
this.resizeHandler = () => {
this.commandstats.resize()
this.usedmemory.resize()
})
}
window.addEventListener("resize", this.resizeHandler)
})
},
// 打开加载层

View File

@ -0,0 +1,185 @@
<template>
<div class="app-container">
<el-form ref="queryForm" :model="queryParams" size="small" :inline="true" v-show="showSearch">
<el-form-item label="项目编码" prop="projectCode">
<el-input v-model="queryParams.projectCode" clearable placeholder="请输入项目编码" @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="项目名称" prop="projectName">
<el-input v-model="queryParams.projectName" clearable placeholder="请输入项目名称" @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="状态" prop="projectStatus">
<el-select v-model="queryParams.projectStatus" clearable placeholder="全部状态">
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-row :gutter="10" class="mb8">
<el-col :span="1.5"><el-button type="primary" plain icon="el-icon-plus" size="mini" @click="handleAdd" v-hasPermi="['project:project:add']">新增项目</el-button></el-col>
<el-col :span="1.5"><el-button type="success" plain icon="el-icon-edit" size="mini" :disabled="single" @click="handleUpdate" v-hasPermi="['project:project:edit']">修改</el-button></el-col>
<el-col :span="1.5"><el-button type="danger" plain icon="el-icon-delete" size="mini" :disabled="single" @click="handleDelete" v-hasPermi="['project:project:remove']">删除</el-button></el-col>
<el-col :span="1.5"><el-button type="warning" plain icon="el-icon-coin" size="mini" @click="openCategories" v-hasPermi="['project:category:edit']">经费科目</el-button></el-col>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList" />
</el-row>
<el-table v-loading="loading" :data="projectList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="项目编码" prop="projectCode" min-width="120" />
<el-table-column label="项目名称" prop="projectName" min-width="180" show-overflow-tooltip />
<el-table-column label="负责人" prop="managerName" min-width="100" />
<el-table-column label="归属部门" prop="deptName" min-width="120" />
<el-table-column label="状态" align="center" width="90">
<template slot-scope="scope"><el-tag :type="statusType(scope.row.projectStatus)" size="mini">{{ statusLabel(scope.row.projectStatus) }}</el-tag></template>
</el-table-column>
<el-table-column label="预算" prop="budgetAmount" align="right" min-width="110"><template slot-scope="scope">{{ money(scope.row.budgetAmount) }}</template></el-table-column>
<el-table-column label="已占用" prop="committedAmount" align="right" min-width="110"><template slot-scope="scope">{{ money(scope.row.committedAmount) }}</template></el-table-column>
<el-table-column label="已支出" prop="spentAmount" align="right" min-width="110"><template slot-scope="scope">{{ money(scope.row.spentAmount) }}</template></el-table-column>
<el-table-column label="可用" prop="availableAmount" align="right" min-width="110"><template slot-scope="scope"><span :class="{ danger: Number(scope.row.availableAmount) < 0 }">{{ money(scope.row.availableAmount) }}</span></template></el-table-column>
<el-table-column label="操作" align="center" width="230" fixed="right">
<template slot-scope="scope">
<el-button size="mini" type="text" icon="el-icon-coin" @click="openBudget(scope.row)" v-hasPermi="['project:budget:list', 'project:budget:edit', 'project:project:manage']">经费</el-button>
<el-button v-if="scope.row.projectCode !== 'LEGACY'" size="mini" type="text" icon="el-icon-edit" @click="handleUpdate(scope.row)" v-hasPermi="['project:project:edit']">修改</el-button>
<el-button v-if="scope.row.projectCode !== 'LEGACY'" size="mini" type="text" icon="el-icon-delete" @click="handleDelete(scope.row)" v-hasPermi="['project:project:remove']">删除</el-button>
</template>
</el-table-column>
</el-table>
<pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" />
<el-dialog :title="title" :visible.sync="open" width="720px" append-to-body>
<el-form ref="form" :model="form" :rules="rules" label-width="100px">
<el-row :gutter="20">
<el-col :span="12"><el-form-item label="项目编码" prop="projectCode"><el-input v-model="form.projectCode" placeholder="字母、数字、下划线或中划线" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="项目名称" prop="projectName"><el-input v-model="form.projectName" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="负责人" prop="managerId"><el-select v-model="form.managerId" filterable clearable style="width:100%"><el-option v-for="item in userOptions" :key="item.userId" :label="userLabel(item)" :value="item.userId" /></el-select></el-form-item></el-col>
<el-col :span="12"><el-form-item label="项目状态" prop="projectStatus"><el-select v-model="form.projectStatus" style="width:100%"><el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item></el-col>
<el-col :span="12"><el-form-item label="开始日期" prop="startDate"><el-date-picker v-model="form.startDate" type="date" value-format="yyyy-MM-dd" style="width:100%" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="结束日期" prop="endDate"><el-date-picker v-model="form.endDate" type="date" value-format="yyyy-MM-dd" style="width:100%" /></el-form-item></el-col>
<el-col :span="24"><el-form-item label="项目成员" prop="memberIds"><el-select v-model="form.memberIds" multiple filterable collapse-tags style="width:100%"><el-option v-for="item in userOptions" :key="item.userId" :label="userLabel(item)" :value="item.userId" /></el-select></el-form-item></el-col>
<el-col :span="24"><el-form-item label="备注" prop="remark"><el-input v-model="form.remark" type="textarea" /></el-form-item></el-col>
</el-row>
</el-form>
<div slot="footer"><el-button type="primary" @click="submitForm">确定</el-button><el-button @click="open=false">取消</el-button></div>
</el-dialog>
<el-dialog :title="budgetTitle" :visible.sync="budgetOpen" width="980px" append-to-body>
<el-row :gutter="10" class="mb8">
<el-col :span="1.5"><el-button type="primary" plain size="mini" icon="el-icon-plus" @click="openBudgetEdit()" v-hasPermi="['project:budget:edit', 'project:project:manage']">配置科目预算</el-button></el-col>
<el-col :span="1.5"><el-button type="info" plain size="mini" icon="el-icon-notebook-2" @click="openLedger">查看流水</el-button></el-col>
</el-row>
<el-table :data="budgetList" border>
<el-table-column label="经费科目" prop="categoryName" min-width="130"><template slot-scope="scope">{{ scope.row.categoryName }}({{ scope.row.categoryCode }})</template></el-table-column>
<el-table-column label="预算" align="right" prop="budgetAmount"><template slot-scope="scope">{{ money(scope.row.budgetAmount) }}</template></el-table-column>
<el-table-column label="已占用" align="right" prop="committedAmount"><template slot-scope="scope">{{ money(scope.row.committedAmount) }}</template></el-table-column>
<el-table-column label="已支出" align="right" prop="spentAmount"><template slot-scope="scope">{{ money(scope.row.spentAmount) }}</template></el-table-column>
<el-table-column label="可用" align="right" prop="availableAmount"><template slot-scope="scope">{{ money(scope.row.availableAmount) }}</template></el-table-column>
<el-table-column label="预警线" align="center" width="90"><template slot-scope="scope">{{ scope.row.alertPercent }}%</template></el-table-column>
<el-table-column label="操作" align="center" width="80"><template slot-scope="scope"><el-button type="text" @click="openBudgetEdit(scope.row)" v-hasPermi="['project:budget:edit', 'project:project:manage']">调整</el-button></template></el-table-column>
</el-table>
</el-dialog>
<el-dialog title="配置项目经费" :visible.sync="budgetEditOpen" width="480px" append-to-body>
<el-form ref="budgetForm" :model="budgetForm" :rules="budgetRules" label-width="100px">
<el-form-item label="经费科目" prop="expenseCategoryId"><el-select v-model="budgetForm.expenseCategoryId" :disabled="!!budgetForm.id" style="width:100%"><el-option v-for="item in categoryOptionList" :key="item.id" :label="item.categoryName + '(' + item.categoryCode + ')'" :value="item.id" /></el-select></el-form-item>
<el-form-item label="预算金额" prop="budgetAmount"><el-input-number v-model="budgetForm.budgetAmount" :min="0" :precision="2" :controls="false" style="width:100%" /></el-form-item>
<el-form-item label="预警比例" prop="alertPercent"><el-input-number v-model="budgetForm.alertPercent" :min="0" :max="100" :precision="2" style="width:100%" /></el-form-item>
<el-form-item label="调整说明" prop="remark"><el-input v-model="budgetForm.remark" type="textarea" /></el-form-item>
</el-form>
<div slot="footer"><el-button type="primary" @click="submitBudget">保存</el-button><el-button @click="budgetEditOpen=false">取消</el-button></div>
</el-dialog>
<el-dialog title="经费流水" :visible.sync="ledgerOpen" width="1100px" append-to-body>
<el-table :data="ledgerList" border max-height="500">
<el-table-column label="时间" prop="createTime" width="165" />
<el-table-column label="科目" prop="categoryName" min-width="100" />
<el-table-column label="业务类型" prop="bizType" min-width="120" />
<el-table-column label="业务单号" prop="bizNo" min-width="150" />
<el-table-column label="预算变化" prop="budgetDelta" align="right" />
<el-table-column label="占用变化" prop="committedDelta" align="right" />
<el-table-column label="支出变化" prop="spentDelta" align="right" />
<el-table-column label="操作人" prop="operator" width="100" />
<el-table-column label="说明" prop="remark" min-width="160" show-overflow-tooltip />
</el-table>
<pagination v-show="ledgerTotal > 0" :total="ledgerTotal" :page.sync="ledgerQuery.pageNum" :limit.sync="ledgerQuery.pageSize" @pagination="getLedger" />
</el-dialog>
<el-dialog title="经费科目维护" :visible.sync="categoryOpen" width="760px" append-to-body>
<el-button type="primary" plain size="mini" icon="el-icon-plus" class="mb8" @click="editCategory()">新增科目</el-button>
<el-table :data="categoryList" border>
<el-table-column label="编码" prop="categoryCode" />
<el-table-column label="名称" prop="categoryName" />
<el-table-column label="排序" prop="sortOrder" width="80" />
<el-table-column label="状态" width="80"><template slot-scope="scope"><el-tag :type="scope.row.status === 0 ? 'success' : 'info'">{{ scope.row.status === 0 ? '正常' : '停用' }}</el-tag></template></el-table-column>
<el-table-column label="操作" width="130"><template slot-scope="scope"><el-button v-if="scope.row.categoryCode !== 'LEGACY'" type="text" @click="editCategory(scope.row)">修改</el-button><el-button v-if="scope.row.categoryCode !== 'LEGACY'" type="text" @click="removeCategory(scope.row)">删除</el-button></template></el-table-column>
</el-table>
</el-dialog>
<el-dialog :title="categoryForm.id ? '修改经费科目' : '新增经费科目'" :visible.sync="categoryEditOpen" width="460px" append-to-body>
<el-form ref="categoryForm" :model="categoryForm" :rules="categoryRules" label-width="90px">
<el-form-item label="科目编码" prop="categoryCode"><el-input v-model="categoryForm.categoryCode" :disabled="!!categoryForm.id" /></el-form-item>
<el-form-item label="科目名称" prop="categoryName"><el-input v-model="categoryForm.categoryName" /></el-form-item>
<el-form-item label="排序" prop="sortOrder"><el-input-number v-model="categoryForm.sortOrder" :min="0" /></el-form-item>
<el-form-item label="状态" prop="status"><el-radio-group v-model="categoryForm.status"><el-radio :label="0">正常</el-radio><el-radio :label="1">停用</el-radio></el-radio-group></el-form-item>
<el-form-item label="备注" prop="remark"><el-input v-model="categoryForm.remark" type="textarea" /></el-form-item>
</el-form>
<div slot="footer"><el-button type="primary" @click="submitCategory">保存</el-button><el-button @click="categoryEditOpen=false">取消</el-button></div>
</el-dialog>
</div>
</template>
<script>
import { listProject, getProject, addProject, updateProject, delProject, listProjectBudget, saveProjectBudget, listFundLedger, listCategory, categoryOptions, addCategory, updateCategory, delCategory } from '@/api/project/project'
import { optionselectUser } from '@/api/warehouse/user'
export default {
name: 'ProjectInfo',
data() {
return {
loading: false, showSearch: true, total: 0, projectList: [], ids: [], single: true,
queryParams: { pageNum: 1, pageSize: 10, projectCode: undefined, projectName: undefined, projectStatus: undefined },
statusOptions: [{ value: 0, label: '筹备' }, { value: 1, label: '进行中' }, { value: 2, label: '已完成' }, { value: 3, label: '已归档' }],
userOptions: [], open: false, title: '', form: {},
rules: { projectCode: [{ required: true, message: '项目编码不能为空', trigger: 'blur' }], projectName: [{ required: true, message: '项目名称不能为空', trigger: 'blur' }] },
budgetOpen: false, budgetTitle: '', currentProject: null, budgetList: [], budgetEditOpen: false, budgetForm: {}, categoryOptionList: [],
budgetRules: { expenseCategoryId: [{ required: true, message: '请选择经费科目', trigger: 'change' }], budgetAmount: [{ required: true, message: '预算金额不能为空', trigger: 'blur' }] },
ledgerOpen: false, ledgerList: [], ledgerTotal: 0, ledgerQuery: { pageNum: 1, pageSize: 10, projectId: undefined },
categoryOpen: false, categoryEditOpen: false, categoryList: [], categoryForm: {},
categoryRules: { categoryCode: [{ required: true, message: '编码不能为空', trigger: 'blur' }], categoryName: [{ required: true, message: '名称不能为空', trigger: 'blur' }] }
}
},
created() { this.getList(); optionselectUser().then(res => { this.userOptions = res.data || [] }) },
methods: {
getList() { this.loading = true; listProject(this.queryParams).then(res => { this.projectList = res.rows; this.total = res.total }).finally(() => { this.loading = false }) },
handleQuery() { this.queryParams.pageNum = 1; this.getList() },
resetQuery() { this.resetForm('queryForm'); this.handleQuery() },
handleSelectionChange(rows) { this.ids = rows.map(item => item.id); this.single = rows.length !== 1 },
reset() { this.form = { id: undefined, projectCode: undefined, projectName: undefined, managerId: undefined, startDate: undefined, endDate: undefined, projectStatus: 1, currency: 'CNY', memberIds: [], remark: undefined }; this.resetForm('form') },
handleAdd() { this.reset(); this.title = '新增项目'; this.open = true },
handleUpdate(row) { const id = row.id || this.ids[0]; getProject(id).then(res => { this.form = res.data; this.form.memberIds = this.form.memberIds || []; this.title = '修改项目'; this.open = true }) },
submitForm() { this.$refs.form.validate(valid => { if (!valid) return; (this.form.id ? updateProject(this.form) : addProject(this.form)).then(() => { this.$modal.msgSuccess(this.form.id ? '修改成功' : '新增成功'); this.open = false; this.getList() }) }) },
handleDelete(row) { const id = row.id || this.ids[0]; const item = row.id ? row : this.projectList.find(p => p.id === id); this.$modal.confirm('确认删除项目“' + (item ? item.projectName : id) + '”?').then(() => delProject(id)).then(() => { this.$modal.msgSuccess('删除成功'); this.getList() }).catch(() => {}) },
openBudget(row) { this.currentProject = row; this.budgetTitle = `项目经费:${row.projectName}`; this.budgetOpen = true; this.loadBudget(); categoryOptions().then(res => { this.categoryOptionList = res.data || [] }) },
loadBudget() { listProjectBudget(this.currentProject.id).then(res => { this.budgetList = res.data || [] }) },
openBudgetEdit(row) { this.budgetForm = row ? { ...row } : { projectId: this.currentProject.id, expenseCategoryId: undefined, budgetAmount: 0, alertPercent: 80, remark: undefined }; this.budgetEditOpen = true; this.$nextTick(() => this.$refs.budgetForm && this.$refs.budgetForm.clearValidate()) },
submitBudget() { this.budgetForm.projectId = this.currentProject.id; this.$refs.budgetForm.validate(valid => { if (!valid) return; saveProjectBudget(this.budgetForm).then(() => { this.$modal.msgSuccess('经费预算已保存'); this.budgetEditOpen = false; this.loadBudget(); this.getList() }) }) },
openLedger() { this.ledgerQuery = { pageNum: 1, pageSize: 10, projectId: this.currentProject.id }; this.ledgerOpen = true; this.getLedger() },
getLedger() { listFundLedger(this.ledgerQuery).then(res => { this.ledgerList = res.rows || []; this.ledgerTotal = res.total || 0 }) },
openCategories() { this.categoryOpen = true; this.loadCategories() },
loadCategories() { listCategory({}).then(res => { this.categoryList = res.data || [] }) },
editCategory(row) { this.categoryForm = row ? { ...row } : { id: undefined, categoryCode: undefined, categoryName: undefined, sortOrder: 0, status: 0, remark: undefined }; this.categoryEditOpen = true; this.$nextTick(() => this.$refs.categoryForm && this.$refs.categoryForm.clearValidate()) },
submitCategory() { this.$refs.categoryForm.validate(valid => { if (!valid) return; (this.categoryForm.id ? updateCategory(this.categoryForm) : addCategory(this.categoryForm)).then(() => { this.$modal.msgSuccess('经费科目已保存'); this.categoryEditOpen = false; this.loadCategories() }) }) },
removeCategory(row) { this.$modal.confirm('确认删除经费科目“' + row.categoryName + '”?').then(() => delCategory(row.id)).then(() => { this.$modal.msgSuccess('删除成功'); this.loadCategories() }).catch(() => {}) },
userLabel(user) { return (user.nickName || user.userName) + (user.userName ? `(${user.userName})` : '') },
statusLabel(value) { const item = this.statusOptions.find(i => i.value === value); return item ? item.label : '-' },
statusType(value) { return ({ 0: 'warning', 1: 'success', 2: 'info', 3: '' })[value] || '' },
money(value) { return Number(value || 0).toFixed(2) }
}
}
</script>
<style scoped>
.danger { color: #f56c6c; font-weight: 600; }
</style>

View File

@ -32,6 +32,11 @@
<el-option v-for="dict in dict.type.wms_in_status" :key="dict.value" :label="dict.label" :value="dict.value" />
</el-select>
</el-form-item>
<el-form-item label="来源项目" prop="sourceProjectId">
<el-select v-model="queryParams.sourceProjectId" placeholder="请选择来源项目" clearable filterable>
<el-option v-for="item in projectOptionList" :key="item.id" :label="projectLabel(item)" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
@ -56,6 +61,9 @@
</template>
</el-table-column>
<el-table-column label="来源单号" align="center" prop="sourceNo" min-width="170" show-overflow-tooltip />
<el-table-column label="来源项目" min-width="180" show-overflow-tooltip>
<template slot-scope="scope">{{ scope.row.sourceProjectName || '-' }}<span v-if="scope.row.expenseCategoryName"> / {{ scope.row.expenseCategoryName }}</span></template>
</el-table-column>
<el-table-column label="产品信息" min-width="280">
<template slot-scope="scope">
<div class="product-name">{{ scope.row.productName || '-' }}</div>
@ -75,7 +83,7 @@
<template slot-scope="scope"><dict-tag :options="dict.type.wms_in_status" :value="scope.row.inStatus" /></template>
</el-table-column>
<el-table-column label="系统操作人" align="center" prop="operator" />
<el-table-column label="入库人" align="center" prop="inOperator" />
<el-table-column label="入库人" align="center" prop="inOperatorName" />
<el-table-column label="确认时间" align="center" prop="confirmTime" width="180">
<template slot-scope="scope">{{ parseTime(scope.row.confirmTime) }}</template>
</el-table-column>
@ -103,6 +111,16 @@
<el-option v-for="item in productOptions" :key="item.id" :label="productLabel(item)" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="来源项目" prop="sourceProjectId">
<el-select v-model="form.sourceProjectId" :disabled="!!form.arrivalId" placeholder="请选择物资来源项目" filterable style="width:100%">
<el-option v-for="item in projectOptionList" :key="item.id" :label="projectLabel(item)" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="经费科目" prop="expenseCategoryId">
<el-select v-model="form.expenseCategoryId" :disabled="!!form.arrivalId" placeholder="请选择经费科目" filterable style="width:100%">
<el-option v-for="item in expenseCategoryOptions" :key="item.id" :label="item.categoryName" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="入库人" prop="inOperator">
<el-select v-model="form.inOperator" placeholder="请选择入库人" filterable>
<el-option v-for="item in userOptions" :key="item.userId" :label="userLabel(item)" :value="userValue(item)" />
@ -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() },

View File

@ -32,6 +32,11 @@
<el-option v-for="dict in dict.type.wms_out_type" :key="dict.value" :label="dict.label" :value="dict.value" />
</el-select>
</el-form-item>
<el-form-item label="使用项目" prop="usageProjectId">
<el-select v-model="queryParams.usageProjectId" placeholder="请选择使用项目" clearable filterable>
<el-option v-for="item in projectOptionList" :key="item.id" :label="projectLabel(item)" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
@ -62,9 +67,12 @@
<template slot-scope="scope"><dict-tag :options="dict.type.wms_out_type" :value="scope.row.outType" /></template>
</el-table-column>
<el-table-column label="数量" align="right" prop="outNum" />
<el-table-column label="使用项目" prop="usageProjectName" min-width="150" show-overflow-tooltip />
<el-table-column label="来源项目" prop="sourceProjectNames" min-width="160" show-overflow-tooltip />
<el-table-column label="跨项目" width="90" align="center"><template slot-scope="scope"><el-tag v-if="scope.row.crossProjectFlag === 1" type="warning" size="mini">是</el-tag><span v-else>否</span></template></el-table-column>
<el-table-column label="关联单据号" align="center" prop="sourceNo" />
<el-table-column label="系统操作人" align="center" prop="operator" />
<el-table-column label="出库人" align="center" prop="outOperator" />
<el-table-column label="出库人" align="center" prop="outOperatorName" />
<el-table-column label="出库时间" align="center" prop="outTime" width="180">
<template slot-scope="scope">{{ parseTime(scope.row.outTime) }}</template>
</el-table-column>
@ -85,6 +93,11 @@
<el-option v-for="item in productOptions" :key="item.id" :label="productLabel(item)" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="使用项目" prop="usageProjectId">
<el-select v-model="form.usageProjectId" placeholder="请选择物资使用项目" filterable style="width:100%">
<el-option v-for="item in projectOptionList" :key="item.id" :label="projectLabel(item)" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="出库人" prop="outOperator">
<el-select v-model="form.outOperator" placeholder="请选择出库人" filterable>
<el-option v-for="item in userOptions" :key="item.userId" :label="userLabel(item)" :value="userValue(item)" />
@ -97,6 +110,7 @@
</el-form-item>
<el-form-item label="出库数量" prop="outNum"><el-input-number v-model="form.outNum" :min="0.001" :precision="3" :step="1" controls-position="right" /></el-form-item>
<el-form-item label="关联单据号" prop="sourceNo"><el-input v-model="form.sourceNo" placeholder="请输入关联单据号" /></el-form-item>
<el-form-item label="跨项目原因" prop="crossProjectReason"><el-input v-model="form.crossProjectReason" type="textarea" placeholder="若消耗的库存来源项目与使用项目不同,此项必填" /></el-form-item>
<el-form-item label="备注" prop="remark"><el-input v-model="form.remark" type="textarea" placeholder="请输入备注" /></el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
@ -112,6 +126,7 @@ import { listOutRecord, addOutRecord } from '@/api/warehouse/outRecord'
import { optionselectArea } from '@/api/warehouse/area'
import { optionselectProduct } from '@/api/warehouse/product'
import { optionselectUser } from '@/api/warehouse/user'
import { projectOptions } from '@/api/project/project'
export default {
name: 'OutRecord',
@ -125,12 +140,14 @@ export default {
areaOptions: [],
productOptions: [],
userOptions: [],
projectOptionList: [],
open: false,
queryParams: { pageNum: 1, pageSize: 10, areaId: undefined, productName: undefined, brand: undefined, category: undefined, spec: undefined, outOperator: undefined, outType: undefined },
queryParams: { pageNum: 1, pageSize: 10, areaId: undefined, productName: undefined, brand: undefined, category: undefined, spec: undefined, outOperator: undefined, outType: undefined, usageProjectId: undefined },
form: {},
rules: {
areaId: [{ required: true, message: '库区不能为空', trigger: 'change' }],
productId: [{ required: true, message: '产品不能为空', trigger: 'change' }],
usageProjectId: [{ required: true, message: '使用项目不能为空', trigger: 'change' }],
outOperator: [{ required: true, message: '出库人不能为空', trigger: 'change' }],
outType: [{ required: true, message: '出库类型不能为空', trigger: 'change' }],
outNum: [{ required: true, message: '出库数量不能为空', trigger: 'blur' }]
@ -154,7 +171,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
@ -164,6 +181,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))
@ -172,6 +190,7 @@ 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 || [] })
},
getList() {
this.loading = true
@ -183,7 +202,7 @@ export default {
},
cancel() { this.open = false; this.reset() },
reset() {
this.form = { outNo: undefined, areaId: undefined, productId: undefined, outOperator: undefined, outType: 1, outNum: undefined, sourceNo: undefined, remark: undefined }
this.form = { outNo: undefined, areaId: undefined, productId: undefined, usageProjectId: undefined, outOperator: undefined, outType: 1, outNum: undefined, sourceNo: undefined, crossProjectReason: undefined, remark: undefined }
this.resetForm('form')
},
handleQuery() { this.queryParams.pageNum = 1; this.getList() },

View File

@ -14,6 +14,16 @@
<el-option v-for="item in batchFilterOptions" :key="item.id" :label="batchLabel(item)" :value="item.batchNo" />
</el-select>
</el-form-item>
<el-form-item label="采购项目" prop="projectId">
<el-select v-model="queryParams.projectId" filterable clearable placeholder="请选择项目">
<el-option v-for="item in projectOptionList" :key="item.id" :label="projectLabel(item)" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="经费科目" prop="expenseCategoryId">
<el-select v-model="queryParams.expenseCategoryId" filterable clearable placeholder="请选择科目">
<el-option v-for="item in expenseCategoryOptions" :key="item.id" :label="item.categoryName" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="申请人" prop="applicantName">
<el-select v-model="queryParams.applicantName" filterable clearable placeholder="请选择申请人">
<el-option v-for="item in userOptions" :key="item.userId" :label="userLabel(item)" :value="item.userName" />
@ -59,6 +69,10 @@
<el-table v-loading="loading" :data="purchaseList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" :selectable="canOperate" />
<el-table-column label="申请人" prop="applicantName" width="110" />
<el-table-column label="采购项目" min-width="170" show-overflow-tooltip>
<template slot-scope="scope">{{ scope.row.projectName }}({{ scope.row.projectCode }})</template>
</el-table-column>
<el-table-column label="经费科目" prop="expenseCategoryName" width="110" />
<el-table-column label="物料名称" prop="materialName" min-width="150" show-overflow-tooltip />
<el-table-column label="品牌" prop="materialBrand" width="110" show-overflow-tooltip />
<el-table-column label="关联库房物料" min-width="170" show-overflow-tooltip>
@ -78,6 +92,9 @@
<el-table-column label="数量" width="110" align="right">
<template slot-scope="scope">{{ scope.row.quantity }} {{ scope.row.unit || '' }}</template>
</el-table-column>
<el-table-column label="预计金额" width="120" align="right">
<template slot-scope="scope">{{ money(scope.row.estimatedAmount) }}</template>
</el-table-column>
<el-table-column label="到货验收" min-width="190">
<template slot-scope="scope">
<div>已验收 {{ scope.row.acceptedQuantity || 0 }} / {{ scope.row.quantity }}</div>
@ -95,7 +112,7 @@
<el-table-column label="操作" align="center" width="285" fixed="right" class-name="small-padding fixed-width">
<template slot-scope="scope">
<el-button v-if="canOperate(scope.row)" size="mini" type="text" icon="el-icon-edit" @click="handleUpdate(scope.row)" v-hasPermi="['warehouse:purchase:edit', 'warehouse:purchase:manage']">修改</el-button>
<el-button size="mini" type="text" icon="el-icon-box" @click="handleArrival(scope.row)" v-hasPermi="['warehouse:purchase:list', 'warehouse:purchase:manage']">到货</el-button>
<el-button size="mini" type="text" icon="el-icon-box" @click="handleArrival(scope.row)" v-hasPermi="['warehouse:purchase:list', 'warehouse:purchase:manage']">{{ canOperate(scope.row) ? '到货' : '到货明细' }}</el-button>
<el-button v-if="canOperate(scope.row) && Number(scope.row.acceptedQuantity || 0) === 0" size="mini" type="text" icon="el-icon-refresh" @click="handleStatus(scope.row)" v-hasPermi="['warehouse:purchase:edit', 'warehouse:purchase:manage']">状态</el-button>
<el-button v-if="canManageAll" size="mini" type="text" icon="el-icon-sort" @click="handleAssignBatch(scope.row)" v-hasPermi="['warehouse:purchase:manage']">转批次</el-button>
<el-button v-if="canOperate(scope.row)" size="mini" type="text" icon="el-icon-delete" @click="handleDelete(scope.row)" v-hasPermi="['warehouse:purchase:remove', 'warehouse:purchase:manage']">删除</el-button>
@ -108,6 +125,20 @@
<el-dialog :title="title" :visible.sync="open" width="720px" custom-class="purchase-dialog" append-to-body>
<el-form ref="form" :model="form" :rules="rules" label-width="105px">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="采购项目" prop="projectId">
<el-select v-model="form.projectId" filterable placeholder="请选择采购项目" style="width:100%">
<el-option v-for="item in projectOptionList" :key="item.id" :label="projectLabel(item)" :value="item.id" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="经费科目" prop="expenseCategoryId">
<el-select v-model="form.expenseCategoryId" filterable placeholder="请选择经费科目" style="width:100%">
<el-option v-for="item in expenseCategoryOptions" :key="item.id" :label="item.categoryName + '(' + item.categoryCode + ')'" :value="item.id" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="已有产品" prop="productId">
<el-select v-model="form.productId" filterable clearable placeholder="选择后自动填充;不选择则按下方信息复用或新增" @change="handleExistingProductChange">
@ -151,6 +182,8 @@
</el-form-item>
</el-col>
<el-col :span="12"><el-form-item label="需求数量" prop="quantity"><el-input-number v-model="form.quantity" :min="0.001" :precision="3" :controls="false" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="预计含税单价" prop="estimatedUnitPrice"><el-input-number v-model="form.estimatedUnitPrice" :min="0.01" :precision="2" :controls="false" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="预计总金额"><el-input :value="money(Number(form.quantity || 0) * Number(form.estimatedUnitPrice || 0))" disabled /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="期望到货日期" prop="expectedDate"><el-date-picker v-model="form.expectedDate" type="date" value-format="yyyy-MM-dd" placeholder="请选择日期" /></el-form-item></el-col>
<el-col :span="24"><el-form-item label="产品备注" prop="productRemark"><el-input v-model="form.productRemark" placeholder="新建产品时写入产品信息,复用已有产品时不覆盖" /></el-form-item></el-col>
<el-col :span="24"><el-form-item label="采购链接" prop="purchaseLink"><el-input v-model="form.purchaseLink" placeholder="请输入商品或供应商链接" /></el-form-item></el-col>
@ -249,42 +282,51 @@
<el-table-column v-if="gridRows.some(row => row.smartTip)" label="识别提示" min-width="210">
<template slot-scope="scope"><span class="smart-tip">{{ scope.row.smartTip || '-' }}</span></template>
</el-table-column>
<el-table-column label="采购项目" min-width="190">
<template slot-scope="scope"><el-select v-model="scope.row.projectId" filterable @paste.native="handleGridPaste($event, scope.$index, 0)"><el-option v-for="item in projectOptionList" :key="item.id" :label="projectLabel(item)" :value="item.id" /></el-select></template>
</el-table-column>
<el-table-column label="经费科目" min-width="150">
<template slot-scope="scope"><el-select v-model="scope.row.expenseCategoryId" filterable @paste.native="handleGridPaste($event, scope.$index, 1)"><el-option v-for="item in expenseCategoryOptions" :key="item.id" :label="item.categoryName" :value="item.id" /></el-select></template>
</el-table-column>
<el-table-column label="关联物料编码" min-width="150">
<template slot-scope="scope">
<el-select v-model="scope.row.productCode" filterable clearable allow-create default-first-option @change="handleGridCodeChange(scope.row)" @paste.native="handleGridPaste($event, scope.$index, 0)">
<el-select v-model="scope.row.productCode" filterable clearable allow-create default-first-option @change="handleGridCodeChange(scope.row)" @paste.native="handleGridPaste($event, scope.$index, 2)">
<el-option v-for="item in productCodeOptions" :key="item.id" :label="item.productCode + ' / ' + item.productName" :value="item.productCode" />
</el-select>
</template>
</el-table-column>
<el-table-column label="物料名称" min-width="170">
<template slot-scope="scope"><el-select v-model="scope.row.materialName" filterable allow-create default-first-option @change="handleGridNameChange(scope.row)" @paste.native="handleGridPaste($event, scope.$index, 1)"><el-option v-for="item in productNameOptions" :key="item" :label="item" :value="item" /></el-select></template>
<template slot-scope="scope"><el-select v-model="scope.row.materialName" filterable allow-create default-first-option @change="handleGridNameChange(scope.row)" @paste.native="handleGridPaste($event, scope.$index, 3)"><el-option v-for="item in productNameOptions" :key="item" :label="item" :value="item" /></el-select></template>
</el-table-column>
<el-table-column label="品牌" min-width="130">
<template slot-scope="scope"><el-select v-model="scope.row.materialBrand" filterable clearable allow-create default-first-option @paste.native="handleGridPaste($event, scope.$index, 2)"><el-option v-for="item in brandOptions" :key="item" :label="item" :value="item" /></el-select></template>
<template slot-scope="scope"><el-select v-model="scope.row.materialBrand" filterable clearable allow-create default-first-option @paste.native="handleGridPaste($event, scope.$index, 4)"><el-option v-for="item in brandOptions" :key="item" :label="item" :value="item" /></el-select></template>
</el-table-column>
<el-table-column label="分类" min-width="130">
<template slot-scope="scope"><el-select v-model="scope.row.category" filterable clearable allow-create default-first-option @paste.native="handleGridPaste($event, scope.$index, 3)"><el-option v-for="item in categoryOptions" :key="item" :label="item" :value="item" /></el-select></template>
<template slot-scope="scope"><el-select v-model="scope.row.category" filterable clearable allow-create default-first-option @paste.native="handleGridPaste($event, scope.$index, 5)"><el-option v-for="item in categoryOptions" :key="item" :label="item" :value="item" /></el-select></template>
</el-table-column>
<el-table-column label="参数规格" min-width="160">
<template slot-scope="scope"><el-select v-model="scope.row.materialParams" filterable clearable allow-create default-first-option @paste.native="handleGridPaste($event, scope.$index, 4)"><el-option v-for="item in specOptions" :key="item" :label="item" :value="item" /></el-select></template>
<template slot-scope="scope"><el-select v-model="scope.row.materialParams" filterable clearable allow-create default-first-option @paste.native="handleGridPaste($event, scope.$index, 6)"><el-option v-for="item in specOptions" :key="item" :label="item" :value="item" /></el-select></template>
</el-table-column>
<el-table-column label="单位" min-width="100">
<template slot-scope="scope"><el-select v-model="scope.row.unit" filterable clearable allow-create default-first-option @paste.native="handleGridPaste($event, scope.$index, 5)"><el-option v-for="item in unitOptions" :key="item" :label="item" :value="item" /></el-select></template>
<template slot-scope="scope"><el-select v-model="scope.row.unit" filterable clearable allow-create default-first-option @paste.native="handleGridPaste($event, scope.$index, 7)"><el-option v-for="item in unitOptions" :key="item" :label="item" :value="item" /></el-select></template>
</el-table-column>
<el-table-column label="需求数量" min-width="110">
<template slot-scope="scope"><el-input v-model="scope.row.quantity" @paste.native="handleGridPaste($event, scope.$index, 6)" /></template>
<template slot-scope="scope"><el-input v-model="scope.row.quantity" @paste.native="handleGridPaste($event, scope.$index, 8)" /></template>
</el-table-column>
<el-table-column label="预计含税单价" min-width="130">
<template slot-scope="scope"><el-input v-model="scope.row.estimatedUnitPrice" @paste.native="handleGridPaste($event, scope.$index, 9)" /></template>
</el-table-column>
<el-table-column label="期望到货日期" min-width="140">
<template slot-scope="scope"><el-date-picker v-model="scope.row.expectedDate" type="date" value-format="yyyy-MM-dd" @paste.native="handleGridPaste($event, scope.$index, 7)" /></template>
<template slot-scope="scope"><el-date-picker v-model="scope.row.expectedDate" type="date" value-format="yyyy-MM-dd" @paste.native="handleGridPaste($event, scope.$index, 10)" /></template>
</el-table-column>
<el-table-column label="采购链接" min-width="210">
<template slot-scope="scope"><el-input v-model="scope.row.purchaseLink" @paste.native="handleGridPaste($event, scope.$index, 8)" /></template>
<template slot-scope="scope"><el-input v-model="scope.row.purchaseLink" @paste.native="handleGridPaste($event, scope.$index, 11)" /></template>
</el-table-column>
<el-table-column label="产品备注" min-width="180">
<template slot-scope="scope"><el-input v-model="scope.row.productRemark" @paste.native="handleGridPaste($event, scope.$index, 9)" /></template>
<template slot-scope="scope"><el-input v-model="scope.row.productRemark" @paste.native="handleGridPaste($event, scope.$index, 12)" /></template>
</el-table-column>
<el-table-column label="申请备注" min-width="180">
<template slot-scope="scope"><el-input v-model="scope.row.remark" @paste.native="handleGridPaste($event, scope.$index, 10)" /></template>
<template slot-scope="scope"><el-input v-model="scope.row.remark" @paste.native="handleGridPaste($event, scope.$index, 13)" /></template>
</el-table-column>
<el-table-column label="操作" width="55" fixed="right" align="center">
<template slot-scope="scope"><el-button type="text" icon="el-icon-delete" title="删除行" @click="removeGridRow(scope.$index)" /></template>
@ -311,6 +353,7 @@
<el-dialog title="分批到货与验收" :visible.sync="arrivalOpen" width="1100px" custom-class="arrival-dialog" append-to-body>
<div v-if="arrivalRequest" class="arrival-header">
<strong>{{ arrivalRequest.materialName }}</strong>
<span>{{ arrivalRequest.projectName }} / {{ arrivalRequest.expenseCategoryName }}</span>
<span>采购 {{ arrivalRequest.quantity }} {{ arrivalRequest.unit || '' }}</span>
<span>已验收 {{ arrivalRequest.acceptedQuantity || 0 }}</span>
<span>待验收 {{ arrivalRequest.pendingArrivalQuantity || 0 }}</span>
@ -318,6 +361,7 @@
</div>
<el-form v-if="arrivalRequest && canOperate(arrivalRequest)" ref="arrivalForm" :model="arrivalForm" :rules="arrivalRules" :inline="true" size="small" class="arrival-form">
<el-form-item label="本次到货" prop="arrivalQuantity"><el-input-number v-model="arrivalForm.arrivalQuantity" :min="0.001" :max="Number(arrivalRequest.remainingQuantity || 0)" :precision="3" :controls="false" /></el-form-item>
<el-form-item label="实际含税单价" prop="actualUnitPrice"><el-input-number v-model="arrivalForm.actualUnitPrice" :min="0.01" :precision="2" :controls="false" /></el-form-item>
<el-form-item label="到货日期" prop="arrivalDate"><el-date-picker v-model="arrivalForm.arrivalDate" type="date" value-format="yyyy-MM-dd" /></el-form-item>
<el-form-item label="备注" prop="remark"><el-input v-model="arrivalForm.remark" /></el-form-item>
<el-form-item><el-button type="primary" :disabled="Number(arrivalRequest.remainingQuantity) <= 0" @click="submitArrival">登记到货</el-button></el-form-item>
@ -326,6 +370,8 @@
<el-table-column label="到货单号" prop="arrivalNo" min-width="175" />
<el-table-column label="到货日期" prop="arrivalDate" width="110" />
<el-table-column label="数量" prop="arrivalQuantity" width="100" align="right" />
<el-table-column label="实际单价" prop="actualUnitPrice" width="110" align="right" />
<el-table-column label="实际金额" prop="actualAmount" width="120" align="right" />
<el-table-column label="验收状态" width="100" align="center"><template slot-scope="scope"><el-tag :type="acceptanceType(scope.row.acceptanceStatus)" size="mini">{{ acceptanceLabel(scope.row.acceptanceStatus) }}</el-tag></template></el-table-column>
<el-table-column label="到货备注" prop="remark" min-width="130" show-overflow-tooltip />
<el-table-column label="验收说明" prop="acceptanceRemark" min-width="130" show-overflow-tooltip />
@ -352,6 +398,7 @@
import { listPurchase, getPurchase, addPurchase, tableAddPurchase, smartImportPurchase, updatePurchase, updatePurchaseStatus, delPurchase, listPurchaseArrival, addPurchaseArrival, acceptPurchaseArrival, revokePurchaseArrival, delPurchaseArrival, listPurchaseBatch, optionPurchaseBatch, filterOptionPurchaseBatch, addPurchaseBatch, updatePurchaseBatch, assignPurchaseBatch, delPurchaseBatch } from '@/api/warehouse/purchase'
import { optionselectProduct } from '@/api/warehouse/product'
import { optionselectUser } from '@/api/warehouse/user'
import { projectOptions, categoryOptions } from '@/api/project/project'
import ExcelImportDialog from '@/components/ExcelImportDialog'
export default {
@ -367,6 +414,8 @@ export default {
total: 0,
purchaseList: [],
productOptions: [],
projectOptionList: [],
expenseCategoryOptions: [],
open: false,
assignOpen: false,
batchOpen: false,
@ -379,13 +428,14 @@ export default {
smartFile: null,
smartFileList: [],
smartImportResultRows: null,
gridFields: ['productCode', 'materialName', 'materialBrand', 'category', 'materialParams', 'unit', 'quantity', 'expectedDate', 'purchaseLink', 'productRemark', 'remark'],
gridFields: ['projectCode', 'expenseCategoryCode', 'productCode', 'materialName', 'materialBrand', 'category', 'materialParams', 'unit', 'quantity', 'estimatedUnitPrice', 'expectedDate', 'purchaseLink', 'productRemark', 'remark'],
gridHeaderAliases: {
'项目编码': 'projectCode', '经费科目编码': 'expenseCategoryCode',
'关联物料编码': 'productCode', '物料编码': 'productCode',
'物料名称': 'materialName', '产品名称': 'materialName',
'品牌': 'materialBrand', '类别': 'category', '分类': 'category',
'参数规格': 'materialParams', '规格型号': 'materialParams',
'单位': 'unit', '需求数量': 'quantity', '数量': 'quantity',
'单位': 'unit', '需求数量': 'quantity', '数量': 'quantity', '预计含税单价': 'estimatedUnitPrice', '预计单价': 'estimatedUnitPrice',
'期望到货日期': 'expectedDate', '期望到货': 'expectedDate',
'采购链接': 'purchaseLink', '产品备注': 'productRemark', '申请备注': 'remark'
},
@ -422,13 +472,16 @@ export default {
{ value: 4, label: '已归档', type: '' },
{ value: 5, label: '已取消', type: 'danger' }
],
queryParams: { pageNum: 1, pageSize: 10, materialName: undefined, purchaseStatus: undefined, purchaseBatchNo: undefined, applicantName: undefined },
queryParams: { pageNum: 1, pageSize: 10, materialName: undefined, purchaseStatus: undefined, purchaseBatchNo: undefined, applicantName: undefined, projectId: undefined, expenseCategoryId: undefined },
form: {},
assignForm: {},
batchForm: {},
rules: {
projectId: [{ required: true, message: '采购项目不能为空', trigger: 'change' }],
expenseCategoryId: [{ required: true, message: '经费科目不能为空', trigger: 'change' }],
materialName: [{ required: true, message: '物料名称不能为空', trigger: 'blur' }],
quantity: [{ required: true, message: '需求数量不能为空', trigger: 'change' }],
estimatedUnitPrice: [{ required: true, message: '预计含税单价不能为空', trigger: 'change' }],
purchaseLink: [{ pattern: /^https?:\/\//i, message: '链接必须以 http:// 或 https:// 开头', trigger: 'blur' }]
},
assignRules: {
@ -441,6 +494,7 @@ export default {
statusRules: { purchaseStatus: [{ required: true, message: '请选择采购状态', trigger: 'change' }] },
arrivalRules: {
arrivalQuantity: [{ required: true, message: '到货数量不能为空', trigger: 'change' }],
actualUnitPrice: [{ required: true, message: '实际含税单价不能为空', trigger: 'change' }],
arrivalDate: [{ required: true, message: '到货日期不能为空', trigger: 'change' }]
}
}
@ -462,10 +516,17 @@ export default {
this.getList()
this.getProductOptions()
this.getUserOptions()
this.getFundingOptions()
this.searchBatchFilterOptions('')
if (this.canManageAll) this.getBatchOptions()
},
methods: {
money(value) { return Number(value || 0).toFixed(2) },
projectLabel(project) { return project.projectName + '(' + project.projectCode + ')' },
getFundingOptions() {
projectOptions('').then(response => { this.projectOptionList = response.data || [] })
categoryOptions().then(response => { this.expenseCategoryOptions = response.data || [] })
},
userLabel(user) {
const account = user.userName || ''
return user.nickName && user.nickName !== account ? `${user.nickName}(${account})` : account
@ -487,7 +548,7 @@ export default {
}).finally(() => { this.loading = false })
},
reset() {
this.form = { id: undefined, productId: undefined, materialName: undefined, materialBrand: undefined, category: undefined, materialParams: undefined, productRemark: undefined, purchaseLink: undefined, quantity: 1, unit: undefined, expectedDate: undefined, remark: undefined }
this.form = { id: undefined, projectId: undefined, expenseCategoryId: undefined, productId: undefined, materialName: undefined, materialBrand: undefined, category: undefined, materialParams: undefined, productRemark: undefined, purchaseLink: undefined, quantity: 1, estimatedUnitPrice: undefined, unit: undefined, expectedDate: undefined, remark: undefined }
this.resetForm('form')
},
cancel() { this.open = false; this.reset() },
@ -557,7 +618,7 @@ export default {
}).finally(() => { this.arrivalLoading = false })
},
resetArrivalForm() {
this.arrivalForm = { purchaseRequestId: this.arrivalRequest && this.arrivalRequest.id, arrivalQuantity: undefined, arrivalDate: this.parseTime(new Date(), '{y}-{m}-{d}'), remark: undefined }
this.arrivalForm = { purchaseRequestId: this.arrivalRequest && this.arrivalRequest.id, arrivalQuantity: undefined, actualUnitPrice: this.arrivalRequest && this.arrivalRequest.estimatedUnitPrice, arrivalDate: this.parseTime(new Date(), '{y}-{m}-{d}'), remark: undefined }
this.$nextTick(() => this.$refs.arrivalForm && this.$refs.arrivalForm.clearValidate())
},
submitArrival() {
@ -665,7 +726,7 @@ export default {
}).finally(() => { this.smartImportLoading = false })
},
emptyGridRow() {
return { productId: undefined, productCode: undefined, materialName: undefined, materialBrand: undefined, category: undefined, materialParams: undefined, unit: undefined, quantity: 1, expectedDate: undefined, purchaseLink: undefined, productRemark: undefined, remark: undefined }
return { projectId: undefined, projectCode: undefined, expenseCategoryId: undefined, expenseCategoryCode: undefined, productId: undefined, productCode: undefined, materialName: undefined, materialBrand: undefined, category: undefined, materialParams: undefined, unit: undefined, quantity: 1, estimatedUnitPrice: undefined, expectedDate: undefined, purchaseLink: undefined, productRemark: undefined, remark: undefined }
},
addGridRow() { this.gridRows.push(this.emptyGridRow()) },
removeGridRow(index) {
@ -698,7 +759,7 @@ export default {
cells.forEach((value, columnOffset) => {
const field = hasHeader ? headerFields[columnOffset] : this.gridFields[startColumn + columnOffset]
if (!field) return
this.$set(target, field, field === 'quantity' ? this.parseGridNumber(value) : (field === 'expectedDate' ? this.normalizeGridDate(value) : value.trim()))
this.$set(target, field, ['quantity', 'estimatedUnitPrice'].includes(field) ? this.parseGridNumber(value) : (field === 'expectedDate' ? this.normalizeGridDate(value) : value.trim()))
})
if (target.productCode) this.handleGridCodeChange(target)
else if (target.materialName) this.handleGridNameChange(target)
@ -743,14 +804,26 @@ export default {
if (!rows.length) return this.$modal.msgError('请至少录入一行采购数据')
for (let index = 0; index < rows.length; index++) {
const row = rows[index]
if (!row.projectId && row.projectCode) {
const project = this.projectOptionList.find(item => this.sameText(item.projectCode, row.projectCode))
if (project) row.projectId = project.id
}
if (!row.expenseCategoryId && row.expenseCategoryCode) {
const category = this.expenseCategoryOptions.find(item => this.sameText(item.categoryCode, row.expenseCategoryCode))
if (category) row.expenseCategoryId = category.id
}
if (!row.projectId) return this.$modal.msgError('第' + (index + 1) + '行采购项目不能为空')
if (!row.expenseCategoryId) return this.$modal.msgError('第' + (index + 1) + '行经费科目不能为空')
if (!row.materialName) return this.$modal.msgError('第' + (index + 1) + '行物料名称不能为空')
if (!Number.isFinite(Number(row.quantity)) || Number(row.quantity) <= 0) return this.$modal.msgError('第' + (index + 1) + '行数量必须大于0')
if (!Number.isFinite(Number(row.estimatedUnitPrice)) || Number(row.estimatedUnitPrice) <= 0) return this.$modal.msgError('第' + (index + 1) + '行预计单价必须大于0')
if (row.purchaseLink && !/^https?:\/\//i.test(row.purchaseLink)) return this.$modal.msgError('第' + (index + 1) + '行采购链接格式不正确')
row.quantity = Number(row.quantity)
row.estimatedUnitPrice = Number(row.estimatedUnitPrice)
}
this.gridSaving = true
const payload = rows.map(row => {
const item = { productId: row.productId }
const item = { productId: row.productId, projectId: row.projectId, expenseCategoryId: row.expenseCategoryId }
this.gridFields.forEach(field => { item[field] = row[field] })
return item
})

View File

@ -37,8 +37,9 @@
<el-table-column label="最近盘库时间" align="center" prop="lastCheckTime" width="180">
<template slot-scope="scope">{{ parseTime(scope.row.lastCheckTime) }}</template>
</el-table-column>
<el-table-column label="操作" align="center" width="120" fixed="right">
<el-table-column label="操作" align="center" width="170" fixed="right">
<template slot-scope="scope">
<el-button size="mini" type="text" icon="el-icon-collection" @click="handleLots(scope.row)" v-hasPermi="['warehouse:stock:list']">批次</el-button>
<el-button size="mini" type="text" icon="el-icon-sold-out" @click="handleOut(scope.row)" v-hasPermi="['warehouse:stock:out']">出库</el-button>
</template>
</el-table-column>
@ -51,6 +52,11 @@
<el-form-item label="库区"><el-input :value="form.areaName" disabled /></el-form-item>
<el-form-item label="产品"><el-input :value="productLabel(form)" disabled /></el-form-item>
<el-form-item label="当前库存"><el-input :value="form.stockNum" disabled /></el-form-item>
<el-form-item label="使用项目" prop="usageProjectId">
<el-select v-model="form.usageProjectId" placeholder="请选择物资使用项目" filterable style="width:100%">
<el-option v-for="item in projectOptionList" :key="item.id" :label="projectLabel(item)" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="出库单号" prop="outNo"><el-input v-model="form.outNo" placeholder="不填则自动生成" /></el-form-item>
<el-form-item label="出库人" prop="outOperator">
<el-select v-model="form.outOperator" placeholder="请选择出库人" filterable>
@ -64,6 +70,7 @@
</el-form-item>
<el-form-item label="出库数量" prop="outNum"><el-input-number v-model="form.outNum" :min="0.001" :precision="3" :step="1" controls-position="right" /></el-form-item>
<el-form-item label="关联单据号" prop="sourceNo"><el-input v-model="form.sourceNo" placeholder="请输入关联单据号" /></el-form-item>
<el-form-item label="跨项目原因" prop="crossProjectReason"><el-input v-model="form.crossProjectReason" type="textarea" placeholder="若库存来源项目与使用项目不同,此项必填" /></el-form-item>
<el-form-item label="备注" prop="remark"><el-input v-model="form.remark" type="textarea" placeholder="请输入备注" /></el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
@ -71,13 +78,27 @@
<el-button @click="cancel">取 消</el-button>
</div>
</el-dialog>
<el-dialog title="库存来源批次" :visible.sync="lotOpen" width="980px" append-to-body>
<el-table v-loading="lotLoading" :data="lotList" border>
<el-table-column label="批次号" prop="lotNo" min-width="175" />
<el-table-column label="来源项目" prop="sourceProjectName" min-width="160" />
<el-table-column label="经费科目" prop="expenseCategoryName" min-width="110" />
<el-table-column label="采购单号" prop="purchaseRequestNo" min-width="160" />
<el-table-column label="入库数量" prop="receivedQuantity" align="right" />
<el-table-column label="可用数量" prop="availableQuantity" align="right" />
<el-table-column label="单位成本" prop="unitCost" align="right" />
<el-table-column label="入库时间" prop="createTime" width="165" />
</el-table>
</el-dialog>
</div>
</template>
<script>
import { listStock, outStock } from '@/api/warehouse/stock'
import { listStock, outStock, listStockLots } from '@/api/warehouse/stock'
import { optionselectArea } from '@/api/warehouse/area'
import { optionselectUser } from '@/api/warehouse/user'
import { projectOptions } from '@/api/project/project'
export default {
name: 'StockInfo',
@ -90,10 +111,15 @@ export default {
stockList: [],
areaOptions: [],
userOptions: [],
projectOptionList: [],
lotOpen: false,
lotLoading: false,
lotList: [],
open: false,
queryParams: { pageNum: 1, pageSize: 10, areaId: undefined, productName: undefined },
form: {},
rules: {
usageProjectId: [{ required: true, message: '使用项目不能为空', trigger: 'change' }],
outOperator: [{ required: true, message: '出库人不能为空', trigger: 'change' }],
outType: [{ required: true, message: '出库类型不能为空', trigger: 'change' }],
outNum: [{ required: true, message: '出库数量不能为空', trigger: 'blur' }]
@ -109,7 +135,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
@ -119,9 +145,11 @@ 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 + ')' },
getOptions() {
optionselectArea().then(res => { this.areaOptions = res.data })
optionselectUser().then(res => { this.userOptions = res.data })
projectOptions('').then(res => { this.projectOptionList = res.data || [] })
},
getList() {
this.loading = true
@ -141,11 +169,13 @@ export default {
spec: row.spec,
unit: row.unit,
stockNum: row.stockNum,
usageProjectId: undefined,
outNo: undefined,
outOperator: undefined,
outType: 1,
outNum: undefined,
sourceNo: undefined,
crossProjectReason: undefined,
remark: undefined
}
this.resetForm('form')
@ -154,6 +184,11 @@ export default {
handleQuery() { this.queryParams.pageNum = 1; this.getList() },
resetQuery() { this.resetForm('queryForm'); this.handleQuery() },
handleOut(row) { this.resetFormData(row); this.open = true },
handleLots(row) {
this.lotOpen = true
this.lotLoading = true
listStockLots({ areaId: row.areaId, productId: row.productId }).then(res => { this.lotList = res.data || [] }).finally(() => { this.lotLoading = false })
},
submitOut() {
this.$refs.form.validate(valid => {
if (!valid) return

View File

@ -16,6 +16,7 @@ create table if not exists wms_batch_job (
create_time datetime default null comment '创建时间',
update_time datetime default null comment '更新时间',
primary key (id),
key idx_wms_batch_job_creator (create_by, id),
unique key uk_wms_batch_job_no (job_no)
) engine=innodb default charset=utf8mb4 comment='WMS批量导入任务';
@ -45,5 +46,34 @@ create table if not exists wms_batch_item (
update_time datetime default null comment '更新时间',
primary key (id),
key idx_wms_batch_item_job (job_id),
key idx_wms_batch_item_job_status (job_id, status),
key idx_wms_batch_item_status (status)
) engine=innodb default charset=utf8mb4 comment='WMS批量导入明细';
set @wms_batch_item_job_status_index = (
select count(*) from information_schema.statistics
where table_schema = database() and table_name = 'wms_batch_item'
and index_name = 'idx_wms_batch_item_job_status'
);
set @wms_batch_item_job_status_sql = if(
@wms_batch_item_job_status_index = 0,
'alter table wms_batch_item add key idx_wms_batch_item_job_status (job_id, status)',
'select 1'
);
prepare wms_batch_item_job_status_stmt from @wms_batch_item_job_status_sql;
execute wms_batch_item_job_status_stmt;
deallocate prepare wms_batch_item_job_status_stmt;
set @wms_batch_job_creator_index = (
select count(*) from information_schema.statistics
where table_schema = database() and table_name = 'wms_batch_job'
and index_name = 'idx_wms_batch_job_creator'
);
set @wms_batch_job_creator_sql = if(
@wms_batch_job_creator_index = 0,
'alter table wms_batch_job add key idx_wms_batch_job_creator (create_by, id)',
'select 1'
);
prepare wms_batch_job_creator_stmt from @wms_batch_job_creator_sql;
execute wms_batch_job_creator_stmt;
deallocate prepare wms_batch_job_creator_stmt;

View File

@ -9,6 +9,7 @@ create table warehouse_area (
create_time datetime default null comment '创建时间',
update_time datetime default null comment '更新时间',
primary key (id),
key idx_warehouse_area_name (area_name),
unique key uk_warehouse_area_code (area_code)
) engine=innodb auto_increment=1 comment='库区定义表';
@ -25,6 +26,7 @@ create table product_info (
create_time datetime default null comment '创建时间',
update_time datetime default null comment '更新时间',
primary key (id),
key idx_product_info_identity (product_name, brand, category, spec),
unique key uk_product_info_code (product_code)
) engine=innodb auto_increment=1 comment='产品信息表';
@ -124,6 +126,20 @@ insert into sys_dict_data values(108, 3, '销售出库', '3', 'wms_out_type', ''
insert into sys_dict_data values(109, 1, '待确认', '0', 'wms_check_status', '', 'warning', 'Y', '0', 'admin', sysdate(), '', null, '');
insert into sys_dict_data values(110, 2, '盘点生效', '1', 'wms_check_status', '', 'success', 'N', '0', 'admin', sysdate(), '', null, '');
-- 仓库记录按账号关联用户昵称,给频繁关联字段补充索引。
set @sys_user_name_index = (
select count(*) from information_schema.statistics
where table_schema = database() and table_name = 'sys_user' and index_name = 'idx_sys_user_user_name'
);
set @sys_user_name_index_sql = if(
@sys_user_name_index = 0,
'alter table sys_user add key idx_sys_user_user_name (user_name)',
'select 1'
);
prepare sys_user_name_index_stmt from @sys_user_name_index_sql;
execute sys_user_name_index_stmt;
deallocate prepare sys_user_name_index_stmt;
insert into sys_menu values('2000', '库存管理', '0', '5', 'warehouse', null, '', '', 1, 0, 'M', '0', '0', '', 'dict', 'admin', sysdate(), '', null, '库存管理目录');
insert into sys_menu values('2001', '库区定义', '2000', '1', 'area', 'warehouse/area/index', '', '', 1, 0, 'C', '0', '0', 'warehouse:area:list', 'tree', 'admin', sysdate(), '', null, '库区定义菜单');
insert into sys_menu values('2002', '产品信息', '2000', '2', 'product', 'warehouse/product/index', '', '', 1, 0, 'C', '0', '0', 'warehouse:product:list', 'dict', 'admin', sysdate(), '', null, '产品信息菜单');

View File

@ -0,0 +1,64 @@
-- WMS性能与人员账号规范化升级脚本,可在现有数据库上重复执行。
set @idx_exists = (select count(*) from information_schema.statistics
where table_schema = database() and table_name = 'sys_user' and index_name = 'idx_sys_user_user_name');
set @ddl = if(@idx_exists = 0, 'alter table sys_user add key idx_sys_user_user_name (user_name)', 'select 1');
prepare stmt from @ddl; execute stmt; deallocate prepare stmt;
set @idx_exists = (select count(*) from information_schema.statistics
where table_schema = database() and table_name = 'warehouse_area' and index_name = 'idx_warehouse_area_name');
set @ddl = if(@idx_exists = 0, 'alter table warehouse_area add key idx_warehouse_area_name (area_name)', 'select 1');
prepare stmt from @ddl; execute stmt; deallocate prepare stmt;
set @idx_exists = (select count(*) from information_schema.statistics
where table_schema = database() and table_name = 'product_info' and index_name = 'idx_product_info_identity');
set @ddl = if(@idx_exists = 0,
'alter table product_info add key idx_product_info_identity (product_name, brand, category, spec)', 'select 1');
prepare stmt from @ddl; execute stmt; deallocate prepare stmt;
set @idx_exists = (select count(*) from information_schema.statistics
where table_schema = database() and table_name = 'purchase_arrival'
and index_name = 'idx_purchase_arrival_request_status');
set @ddl = if(@idx_exists = 0,
'alter table purchase_arrival add key idx_purchase_arrival_request_status (purchase_request_id, acceptance_status)',
'select 1');
prepare stmt from @ddl; execute stmt; deallocate prepare stmt;
set @idx_exists = (select count(*) from information_schema.statistics
where table_schema = database() and table_name = 'wms_batch_item'
and index_name = 'idx_wms_batch_item_job_status');
set @ddl = if(@idx_exists = 0,
'alter table wms_batch_item add key idx_wms_batch_item_job_status (job_id, status)', 'select 1');
prepare stmt from @ddl; execute stmt; deallocate prepare stmt;
set @idx_exists = (select count(*) from information_schema.statistics
where table_schema = database() and table_name = 'wms_batch_job'
and index_name = 'idx_wms_batch_job_creator');
set @ddl = if(@idx_exists = 0,
'alter table wms_batch_job add key idx_wms_batch_job_creator (create_by, id)', 'select 1');
prepare stmt from @ddl; execute stmt; deallocate prepare stmt;
-- 仅在昵称唯一对应一个有效账号时迁移历史数据,避免同名用户被错误关联。
update in_record ir
join (
select nick_name, min(user_name) as user_name
from sys_user where del_flag = '0' and nick_name is not null and nick_name != ''
group by nick_name having count(*) = 1
) u on u.nick_name = ir.in_operator
set ir.in_operator = u.user_name;
update out_record orr
join (
select nick_name, min(user_name) as user_name
from sys_user where del_flag = '0' and nick_name is not null and nick_name != ''
group by nick_name having count(*) = 1
) u on u.nick_name = orr.out_operator
set orr.out_operator = u.user_name;
update stock_check_record scr
join (
select nick_name, min(user_name) as user_name
from sys_user where del_flag = '0' and nick_name is not null and nick_name != ''
group by nick_name having count(*) = 1
) u on u.nick_name = scr.check_user
set scr.check_user = u.user_name;

View File

@ -0,0 +1,362 @@
-- 项目经费、库存来源批次、跨项目使用及 AI 指令审计。
-- 用于从当前 WMS 结构升级。所有增量字段和索引均先检查再创建,允许在中断后重新执行。
-- 执行前必须显式确认 database() 是目标库,并完成数据库备份。
create table if not exists project_info (
id bigint(20) not null auto_increment comment '主键',
project_code varchar(64) not null comment '项目编码',
project_name varchar(200) not null comment '项目名称',
manager_id bigint(20) default null comment '项目负责人用户ID',
dept_id bigint(20) default null comment '归属部门ID',
start_date date default null comment '开始日期',
end_date date default null comment '结束日期',
project_status tinyint(1) not null default 1 comment '状态:0筹备 1进行中 2已完成 3已归档',
currency varchar(8) not null default 'CNY' comment '币种',
remark varchar(500) default null comment '备注',
create_by varchar(64) default null comment '创建人',
create_time datetime default null comment '创建时间',
update_by varchar(64) default null comment '更新人',
update_time datetime default null comment '更新时间',
primary key (id),
unique key uk_project_info_code (project_code),
key idx_project_info_name (project_name),
key idx_project_info_manager (manager_id),
key idx_project_info_status (project_status)
) engine=innodb default charset=utf8mb4 comment='项目主表';
create table if not exists project_member (
id bigint(20) not null auto_increment comment '主键',
project_id bigint(20) not null comment '项目ID',
user_id bigint(20) not null comment '用户ID',
member_role varchar(32) not null default 'MEMBER' comment '成员角色',
create_by varchar(64) default null comment '创建人',
create_time datetime default null comment '创建时间',
primary key (id),
unique key uk_project_member (project_id, user_id),
key idx_project_member_user (user_id)
) engine=innodb default charset=utf8mb4 comment='项目成员表';
create table if not exists expense_category (
id bigint(20) not null auto_increment comment '主键',
category_code varchar(64) not null comment '经费科目编码',
category_name varchar(100) not null comment '经费科目名称',
sort_order int not null default 0 comment '显示顺序',
status tinyint(1) not null default 0 comment '状态:0正常 1停用',
remark varchar(500) default null comment '备注',
create_by varchar(64) default null comment '创建人',
create_time datetime default null comment '创建时间',
update_by varchar(64) default null comment '更新人',
update_time datetime default null comment '更新时间',
primary key (id),
unique key uk_expense_category_code (category_code),
unique key uk_expense_category_name (category_name)
) engine=innodb default charset=utf8mb4 comment='项目经费科目';
create table if not exists project_budget (
id bigint(20) not null auto_increment comment '主键',
project_id bigint(20) not null comment '项目ID',
expense_category_id bigint(20) not null comment '经费科目ID',
budget_amount decimal(18,2) not null default 0.00 comment '预算金额',
committed_amount decimal(18,2) not null default 0.00 comment '已占用金额',
spent_amount decimal(18,2) not null default 0.00 comment '已支出金额',
alert_percent decimal(5,2) not null default 80.00 comment '预警百分比',
version int not null default 0 comment '乐观锁版本',
remark varchar(500) default null comment '备注',
create_by varchar(64) default null comment '创建人',
create_time datetime default null comment '创建时间',
update_by varchar(64) default null comment '更新人',
update_time datetime default null comment '更新时间',
primary key (id),
unique key uk_project_budget (project_id, expense_category_id),
key idx_project_budget_category (expense_category_id)
) engine=innodb default charset=utf8mb4 comment='项目分科目预算';
create table if not exists project_fund_ledger (
id bigint(20) not null auto_increment comment '主键',
ledger_no varchar(64) not null comment '流水号',
project_id bigint(20) not null comment '项目ID',
expense_category_id bigint(20) not null comment '经费科目ID',
biz_type varchar(32) not null comment '业务类型',
biz_id bigint(20) default null comment '业务主键',
biz_no varchar(64) default null comment '业务单号',
budget_delta decimal(18,2) not null default 0.00 comment '预算变化额',
committed_delta decimal(18,2) not null default 0.00 comment '占用变化额',
spent_delta decimal(18,2) not null default 0.00 comment '支出变化额',
balance_budget decimal(18,2) not null default 0.00 comment '变更后预算额',
balance_committed decimal(18,2) not null default 0.00 comment '变更后占用额',
balance_spent decimal(18,2) not null default 0.00 comment '变更后支出额',
idempotency_key varchar(128) default null comment '幂等键',
operator varchar(64) default null comment '操作人',
remark varchar(500) default null comment '说明',
create_time datetime not null default current_timestamp comment '创建时间',
primary key (id),
unique key uk_project_fund_ledger_no (ledger_no),
unique key uk_project_fund_idempotency (idempotency_key),
key idx_project_fund_project_category (project_id, expense_category_id, create_time),
key idx_project_fund_biz (biz_type, biz_id)
) engine=innodb default charset=utf8mb4 comment='项目经费不可变流水';
create table if not exists stock_lot (
id bigint(20) not null auto_increment comment '主键',
lot_no varchar(64) not null comment '库存批次号',
area_id bigint(20) not null comment '库区ID',
product_id bigint(20) not null comment '产品ID',
source_project_id bigint(20) not null comment '采购或来源项目ID',
expense_category_id bigint(20) default null comment '来源经费科目ID',
purchase_request_id bigint(20) default null comment '采购申请ID',
arrival_id bigint(20) default null comment '到货ID',
in_record_id bigint(20) default null comment '入库记录ID',
received_quantity decimal(16,3) not null comment '入库数量',
available_quantity decimal(16,3) not null comment '可用数量',
unit_cost decimal(18,2) not null default 0.00 comment '单位成本',
lot_status tinyint(1) not null default 0 comment '状态:0可用 1用尽 2冻结',
remark varchar(500) default null comment '备注',
create_by varchar(64) default null comment '创建人',
create_time datetime default null comment '创建时间',
update_by varchar(64) default null comment '更新人',
update_time datetime default null comment '更新时间',
primary key (id),
unique key uk_stock_lot_no (lot_no),
unique key uk_stock_lot_in_record (in_record_id),
key idx_stock_lot_available (area_id, product_id, lot_status, create_time),
key idx_stock_lot_source_project (source_project_id),
key idx_stock_lot_purchase (purchase_request_id)
) engine=innodb default charset=utf8mb4 comment='库存来源批次';
create table if not exists out_record_lot (
id bigint(20) not null auto_increment comment '主键',
out_record_id bigint(20) not null comment '出库记录ID',
stock_lot_id bigint(20) not null comment '库存批次ID',
source_project_id bigint(20) not null comment '来源项目ID',
usage_project_id bigint(20) not null comment '使用项目ID',
quantity decimal(16,3) not null comment '出库数量',
unit_cost decimal(18,2) not null default 0.00 comment '单位成本',
amount decimal(18,2) not null default 0.00 comment '使用成本',
create_time datetime not null default current_timestamp comment '创建时间',
primary key (id),
key idx_out_record_lot_out (out_record_id),
key idx_out_record_lot_stock (stock_lot_id),
key idx_out_record_lot_project_flow (source_project_id, usage_project_id)
) engine=innodb default charset=utf8mb4 comment='出库库存批次分摊';
create table if not exists ai_command (
id bigint(20) not null auto_increment comment '主键',
user_id bigint(20) not null comment '发起用户ID',
username varchar(64) not null comment '发起账号',
action varchar(64) not null comment '动作',
required_permission varchar(128) default null comment '所需权限',
request_text varchar(1000) default null comment '原始文本',
plan_json json not null comment '服务器确认的执行计划',
result_json json default null comment '执行结果',
command_status varchar(20) not null comment 'WAIT_CONFIRM/EXECUTING/SUCCEEDED/REJECTED/EXPIRED/FAILED',
expires_at datetime not null comment '过期时间',
confirmed_at datetime default null comment '人工确认时间',
executed_at datetime default null comment '执行完成时间',
error_message varchar(1000) default null comment '失败原因',
create_time datetime not null default current_timestamp comment '创建时间',
update_time datetime default null comment '更新时间',
primary key (id),
key idx_ai_command_user_status (user_id, command_status, create_time),
key idx_ai_command_expire (expires_at)
) engine=innodb default charset=utf8mb4 comment='AI 待确认业务指令';
set @column_exists = (select count(*) from information_schema.columns
where table_schema = database() and table_name = 'purchase_request' and column_name = 'project_id');
set @ddl = if(@column_exists = 0,
'alter table purchase_request add column project_id bigint(20) default null comment ''采购项目ID'' after purchase_batch_id',
'select 1');
prepare stmt from @ddl; execute stmt; deallocate prepare stmt;
set @column_exists = (select count(*) from information_schema.columns
where table_schema = database() and table_name = 'purchase_request' and column_name = 'expense_category_id');
set @ddl = if(@column_exists = 0,
'alter table purchase_request add column expense_category_id bigint(20) default null comment ''经费科目ID'' after project_id',
'select 1');
prepare stmt from @ddl; execute stmt; deallocate prepare stmt;
set @column_exists = (select count(*) from information_schema.columns
where table_schema = database() and table_name = 'purchase_request' and column_name = 'estimated_unit_price');
set @ddl = if(@column_exists = 0,
'alter table purchase_request add column estimated_unit_price decimal(18,2) default null comment ''预计含税单价'' after quantity',
'select 1');
prepare stmt from @ddl; execute stmt; deallocate prepare stmt;
set @column_exists = (select count(*) from information_schema.columns
where table_schema = database() and table_name = 'purchase_request' and column_name = 'estimated_amount');
set @ddl = if(@column_exists = 0,
'alter table purchase_request add column estimated_amount decimal(18,2) default null comment ''预计总金额'' after estimated_unit_price',
'select 1');
prepare stmt from @ddl; execute stmt; deallocate prepare stmt;
set @idx_exists = (select count(*) from information_schema.statistics
where table_schema = database() and table_name = 'purchase_request'
and index_name = 'idx_purchase_request_project');
set @ddl = if(@idx_exists = 0,
'alter table purchase_request add key idx_purchase_request_project (project_id, expense_category_id)',
'select 1');
prepare stmt from @ddl; execute stmt; deallocate prepare stmt;
set @column_exists = (select count(*) from information_schema.columns
where table_schema = database() and table_name = 'purchase_arrival' and column_name = 'actual_unit_price');
set @ddl = if(@column_exists = 0,
'alter table purchase_arrival add column actual_unit_price decimal(18,2) default null comment ''本批实际含税单价'' after arrival_quantity',
'select 1');
prepare stmt from @ddl; execute stmt; deallocate prepare stmt;
set @column_exists = (select count(*) from information_schema.columns
where table_schema = database() and table_name = 'purchase_arrival' and column_name = 'actual_amount');
set @ddl = if(@column_exists = 0,
'alter table purchase_arrival add column actual_amount decimal(18,2) default null comment ''本批实际金额'' after actual_unit_price',
'select 1');
prepare stmt from @ddl; execute stmt; deallocate prepare stmt;
set @column_exists = (select count(*) from information_schema.columns
where table_schema = database() and table_name = 'in_record' and column_name = 'source_project_id');
set @ddl = if(@column_exists = 0,
'alter table in_record add column source_project_id bigint(20) default null comment ''来源项目ID'' after arrival_id',
'select 1');
prepare stmt from @ddl; execute stmt; deallocate prepare stmt;
set @column_exists = (select count(*) from information_schema.columns
where table_schema = database() and table_name = 'in_record' and column_name = 'expense_category_id');
set @ddl = if(@column_exists = 0,
'alter table in_record add column expense_category_id bigint(20) default null comment ''来源经费科目ID'' after source_project_id',
'select 1');
prepare stmt from @ddl; execute stmt; deallocate prepare stmt;
set @idx_exists = (select count(*) from information_schema.statistics
where table_schema = database() and table_name = 'in_record'
and index_name = 'idx_in_record_source_project');
set @ddl = if(@idx_exists = 0,
'alter table in_record add key idx_in_record_source_project (source_project_id)',
'select 1');
prepare stmt from @ddl; execute stmt; deallocate prepare stmt;
set @column_exists = (select count(*) from information_schema.columns
where table_schema = database() and table_name = 'out_record' and column_name = 'usage_project_id');
set @ddl = if(@column_exists = 0,
'alter table out_record add column usage_project_id bigint(20) default null comment ''使用项目ID'' after product_id',
'select 1');
prepare stmt from @ddl; execute stmt; deallocate prepare stmt;
set @column_exists = (select count(*) from information_schema.columns
where table_schema = database() and table_name = 'out_record' and column_name = 'cross_project_flag');
set @ddl = if(@column_exists = 0,
'alter table out_record add column cross_project_flag tinyint(1) not null default 0 comment ''是否跨项目使用'' after source_no',
'select 1');
prepare stmt from @ddl; execute stmt; deallocate prepare stmt;
set @column_exists = (select count(*) from information_schema.columns
where table_schema = database() and table_name = 'out_record' and column_name = 'cross_project_reason');
set @ddl = if(@column_exists = 0,
'alter table out_record add column cross_project_reason varchar(500) default null comment ''跨项目使用原因'' after cross_project_flag',
'select 1');
prepare stmt from @ddl; execute stmt; deallocate prepare stmt;
set @idx_exists = (select count(*) from information_schema.statistics
where table_schema = database() and table_name = 'out_record'
and index_name = 'idx_out_record_usage_project');
set @ddl = if(@idx_exists = 0,
'alter table out_record add key idx_out_record_usage_project (usage_project_id)',
'select 1');
prepare stmt from @ddl; execute stmt; deallocate prepare stmt;
insert into project_info(project_code, project_name, manager_id, project_status, currency, remark, create_by, create_time)
select 'LEGACY', '历史待归属项目', 1, 3, 'CNY', '升级前数据统一归入该项目,后续不得虚构原始归属', 'migration', sysdate()
where not exists (select 1 from project_info where project_code = 'LEGACY');
insert into expense_category(category_code, category_name, sort_order, status, remark, create_by, create_time)
select 'MATERIAL', '材料费', 10, 0, '材料及耗材', 'migration', sysdate()
where not exists (select 1 from expense_category where category_code = 'MATERIAL');
insert into expense_category(category_code, category_name, sort_order, status, remark, create_by, create_time)
select 'EQUIPMENT', '设备费', 20, 0, '仪器设备购置', 'migration', sysdate()
where not exists (select 1 from expense_category where category_code = 'EQUIPMENT');
insert into expense_category(category_code, category_name, sort_order, status, remark, create_by, create_time)
select 'TRIAL', '试制费', 30, 0, '试制及加工费用', 'migration', sysdate()
where not exists (select 1 from expense_category where category_code = 'TRIAL');
insert into expense_category(category_code, category_name, sort_order, status, remark, create_by, create_time)
select 'OTHER', '其他费用', 90, 0, '其他项目费用', 'migration', sysdate()
where not exists (select 1 from expense_category where category_code = 'OTHER');
insert into expense_category(category_code, category_name, sort_order, status, remark, create_by, create_time)
select 'LEGACY', '历史待归属', 99, 0, '升级前无法确认的经费科目', 'migration', sysdate()
where not exists (select 1 from expense_category where category_code = 'LEGACY');
set @legacy_project_id = (select id from project_info where project_code = 'LEGACY' limit 1);
set @legacy_category_id = (select id from expense_category where category_code = 'LEGACY' limit 1);
insert ignore into project_member(project_id, user_id, member_role, create_by, create_time)
values(@legacy_project_id, 1, 'MANAGER', 'migration', sysdate());
update purchase_request
set project_id = @legacy_project_id,
expense_category_id = @legacy_category_id,
estimated_unit_price = coalesce(estimated_unit_price, 0),
estimated_amount = coalesce(estimated_amount, 0)
where project_id is null;
update in_record
set source_project_id = @legacy_project_id,
expense_category_id = @legacy_category_id
where source_project_id is null;
update out_record
set usage_project_id = @legacy_project_id
where usage_project_id is null;
insert ignore into stock_lot(
lot_no, area_id, product_id, source_project_id, expense_category_id,
received_quantity, available_quantity, unit_cost, lot_status, remark, create_by, create_time
)
select concat('LEGACY-', si.id), si.area_id, si.product_id, @legacy_project_id, @legacy_category_id,
si.stock_num, si.stock_num, 0,
case when si.stock_num > 0 then 0 else 1 end,
'由升级时库存汇总生成,原采购来源未知', 'migration', coalesce(si.create_time, sysdate())
from stock_info si
where not exists (select 1 from stock_lot sl where sl.lot_no = concat('LEGACY-', si.id));
-- 项目管理菜单及权限。固定使用 2100-2113,当前项目菜单最大编号为 2042。
insert into sys_menu(menu_id, menu_name, parent_id, order_num, path, component, query, route_name,
is_frame, is_cache, menu_type, visible, status, perms, icon,
create_by, create_time, update_by, update_time, remark)
select 2100, '项目管理', 0, 7, 'project', null, '', '', 1, 0, 'M', '0', '0', '', 'money',
'admin', sysdate(), '', null, '项目、经费和跨项目使用管理'
where not exists (select 1 from sys_menu where menu_id = 2100);
insert into sys_menu values
(2101, '项目与经费', 2100, 1, 'info', 'project/info/index', '', '', 1, 0, 'C', '0', '0', 'project:project:list', 'tree', 'admin', sysdate(), '', null, '项目及经费预算管理'),
(2102, '项目查询', 2101, 1, '#', '', '', '', 1, 0, 'F', '0', '0', 'project:project:query', '#', 'admin', sysdate(), '', null, ''),
(2103, '项目新增', 2101, 2, '#', '', '', '', 1, 0, 'F', '0', '0', 'project:project:add', '#', 'admin', sysdate(), '', null, ''),
(2104, '项目修改', 2101, 3, '#', '', '', '', 1, 0, 'F', '0', '0', 'project:project:edit', '#', 'admin', sysdate(), '', null, ''),
(2105, '项目删除', 2101, 4, '#', '', '', '', 1, 0, 'F', '0', '0', 'project:project:remove', '#', 'admin', sysdate(), '', null, ''),
(2106, '项目成员维护', 2101, 5, '#', '', '', '', 1, 0, 'F', '0', '0', 'project:project:member', '#', 'admin', sysdate(), '', null, ''),
(2107, '项目经费查询', 2101, 6, '#', '', '', '', 1, 0, 'F', '0', '0', 'project:budget:list', '#', 'admin', sysdate(), '', null, ''),
(2108, '项目经费维护', 2101, 7, '#', '', '', '', 1, 0, 'F', '0', '0', 'project:budget:edit', '#', 'admin', sysdate(), '', null, ''),
(2109, '经费科目维护', 2101, 8, '#', '', '', '', 1, 0, 'F', '0', '0', 'project:category:edit', '#', 'admin', sysdate(), '', null, ''),
(2110, '项目总览权限', 2101, 9, '#', '', '', '', 1, 0, 'F', '0', '0', 'project:project:manage', '#', 'admin', sysdate(), '', null, ''),
(2111, '项目物资使用', 2101, 10, '#', '', '', '', 1, 0, 'F', '0', '0', 'project:inventory:use', '#', 'admin', sysdate(), '', null, ''),
(2112, '跨项目使用', 2101, 11, '#', '', '', '', 1, 0, 'F', '0', '0', 'project:inventory:cross-use', '#', 'admin', sysdate(), '', null, ''),
(2113, 'AI助手使用', 2101, 12, '#', '', '', '', 1, 0, 'F', '1', '0', 'ai:assistant:use', '#', 'admin', sysdate(), '', null, '所有正常角色默认拥有,仅控制入口,具体动作仍校验业务权限')
on duplicate key update menu_id = values(menu_id);
-- 所有现有正常角色均可进入 AI 助手;业务查询和写入仍由原仓库权限控制。
insert ignore into sys_role_menu(role_id, menu_id)
select role_id, 2113 from sys_role where status = '0';
-- 延续现有业务角色能力:采购和库存角色可查看自己参与的项目及经费;原有出库角色获得项目物资使用权限。
insert ignore into sys_role_menu(role_id, menu_id)
select distinct role_id, target_menu_id
from (
select rm.role_id, targets.target_menu_id
from sys_role_menu rm
join sys_menu source_menu on source_menu.menu_id = rm.menu_id
join (
select 2100 target_menu_id union all select 2101 union all select 2102 union all select 2107
) targets
where source_menu.perms in ('warehouse:purchase:add', 'warehouse:purchase:manage', 'warehouse:stock:list')
) role_project_menu;
insert ignore into sys_role_menu(role_id, menu_id)
select distinct rm.role_id, 2111
from sys_role_menu rm
join sys_menu m on m.menu_id = rm.menu_id
where m.perms in ('warehouse:stock:out', 'warehouse:out:add');

View File

@ -74,6 +74,7 @@ create table if not exists purchase_arrival (
primary key (id),
unique key uk_purchase_arrival_no (arrival_no),
key idx_purchase_arrival_request (purchase_request_id),
key idx_purchase_arrival_request_status (purchase_request_id, acceptance_status),
key idx_purchase_arrival_status (acceptance_status)
) engine=innodb default charset=utf8mb4 comment='采购分批到货及验收记录';
@ -117,6 +118,20 @@ prepare arrival_revoke_stmt from @arrival_revoke_sql;
execute arrival_revoke_stmt;
deallocate prepare arrival_revoke_stmt;
set @purchase_arrival_request_status_index = (
select count(*) from information_schema.statistics
where table_schema = database() and table_name = 'purchase_arrival'
and index_name = 'idx_purchase_arrival_request_status'
);
set @purchase_arrival_request_status_sql = if(
@purchase_arrival_request_status_index = 0,
'alter table purchase_arrival add key idx_purchase_arrival_request_status (purchase_request_id, acceptance_status)',
'select 1'
);
prepare purchase_arrival_request_status_stmt from @purchase_arrival_request_status_sql;
execute purchase_arrival_request_status_stmt;
deallocate prepare purchase_arrival_request_status_stmt;
alter table purchase_request modify column purchase_status tinyint(1) not null default 0
comment '状态:0待采购 1已下单 2采购中 3部分到货 4已到齐 5已取消';