From 16bb22a113b28b8a6ddc43ec1c008b08a7779f09 Mon Sep 17 00:00:00 2001 From: geht <2947093423@qq.com> Date: Sat, 9 May 2026 15:55:11 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=8E=9F=E6=96=99=E5=85=A5?= =?UTF-8?q?=E5=9C=BA=E8=AE=B0=E5=BD=95=E5=8A=9F=E8=83=BD=EF=BC=8C=E5=8C=85?= =?UTF-8?q?=E5=90=AB=E5=85=8D=E5=AF=86=E6=8E=A5=E5=8F=A3=E5=92=8C=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=90=8C=E6=AD=A5=EF=BC=8C=E6=9B=B4=E6=96=B0=E7=9B=B8?= =?UTF-8?q?=E5=85=B3=E6=8E=A7=E5=88=B6=E5=99=A8=E3=80=81=E5=AE=9E=E4=BD=93?= =?UTF-8?q?=E5=92=8C=E6=9C=8D=E5=8A=A1=EF=BC=8C=E6=94=AF=E6=8C=81=E6=9D=A1?= =?UTF-8?q?=E7=A0=81/=E6=89=B9=E6=AC=A1=E5=8F=B7=E7=94=9F=E6=88=90?= =?UTF-8?q?=E5=8F=8A=E7=AE=A1=E7=90=86=EF=BC=8C=E4=BC=98=E5=8C=96=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E4=BD=93=E9=AA=8C=E5=92=8C=E7=B3=BB=E7=BB=9F=E5=AE=9E?= =?UTF-8?q?=E6=97=B6=E6=95=B0=E6=8D=AE=E5=A4=84=E7=90=86=E8=83=BD=E5=8A=9B?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mes-mixer-material-import-from-image.sql | 132 ++++ .../common/constant/FillRuleConstant.java | 5 + .../org/jeecg/config/shiro/ShiroConfig.java | 2 + .../MesXslDesktopAnonController.java | 80 +++ .../MesXslRawMaterialEntryController.java | 7 + .../xslmes/entity/MesXslRawMaterialEntry.java | 4 + .../xslmes/rule/RawMaterialBarcodeRule.java | 31 + .../IMesXslRawMaterialEntryService.java | 9 + .../service/MesXslStompNotifyService.java | 5 + .../MesXslRawMaterialEntryServiceImpl.java | 23 +- ....9.2_42__mes_raw_material_barcode_rule.sql | 30 + .../MesXslRawMaterialEntryChangedEvent.cs | 13 + .../Core/Services/IRawMaterialEntryService.cs | 26 + .../Entity/MesXslRawMaterialEntry.cs | 89 +++ .../YY.Admin.Core/SeedData/SysMenuSeedData.cs | 2 + .../SeedData/SysTenantMenuSeedData.cs | 1 + .../Category/CategorySyncCoordinator.cs | 3 + .../Customer/CustomerSyncCoordinator.cs | 13 +- .../Service/Dict/DictSyncCoordinator.cs | 3 + .../Service/Dict/JeecgDictSyncService.cs | 12 + .../MixerMaterial/MixerMaterialService.cs | 15 +- .../MixerMaterialSyncCoordinator.cs | 9 + .../RawMaterialEntryService.cs | 626 ++++++++++++++++++ .../RawMaterialEntrySyncCoordinator.cs | 83 +++ .../Supplier/SupplierSyncCoordinator.cs | 8 + .../Service/SyncPollManager.cs | 58 ++ .../Service/Vehicle/VehicleSyncCoordinator.cs | 16 +- .../WeightRecordSyncCoordinator.cs | 13 +- .../Hubs/StompWebSocketService.cs | 4 + .../YY.Admin/Module/NavigationExtensions.cs | 3 + yy-admin-master/YY.Admin/Module/SyncModule.cs | 11 + .../ViewModels/Control/MenuTreeViewModel.cs | 7 +- .../RawMaterialEntryEditDialogViewModel.cs | 278 ++++++++ .../RawMaterialEntryListViewModel.cs | 204 ++++++ .../RawMaterialEntryEditDialogView.xaml | 400 +++++++++++ .../RawMaterialEntryEditDialogView.xaml.cs | 11 + .../RawMaterialEntryListView.xaml | 161 +++++ .../RawMaterialEntryListView.xaml.cs | 11 + 38 files changed, 2398 insertions(+), 10 deletions(-) create mode 100644 jeecg-boot/db/mes-mixer-material-import-from-image.sql create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/rule/RawMaterialBarcodeRule.java create mode 100644 jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_42__mes_raw_material_barcode_rule.sql create mode 100644 yy-admin-master/YY.Admin.Core/Core/Events/MesXslRawMaterialEntryChangedEvent.cs create mode 100644 yy-admin-master/YY.Admin.Core/Core/Services/IRawMaterialEntryService.cs create mode 100644 yy-admin-master/YY.Admin.Core/Entity/MesXslRawMaterialEntry.cs create mode 100644 yy-admin-master/YY.Admin.Services/Service/RawMaterialEntry/RawMaterialEntryService.cs create mode 100644 yy-admin-master/YY.Admin.Services/Service/RawMaterialEntry/RawMaterialEntrySyncCoordinator.cs create mode 100644 yy-admin-master/YY.Admin.Services/Service/SyncPollManager.cs create mode 100644 yy-admin-master/YY.Admin/ViewModels/RawMaterialEntry/RawMaterialEntryEditDialogViewModel.cs create mode 100644 yy-admin-master/YY.Admin/ViewModels/RawMaterialEntry/RawMaterialEntryListViewModel.cs create mode 100644 yy-admin-master/YY.Admin/Views/RawMaterialEntry/RawMaterialEntryEditDialogView.xaml create mode 100644 yy-admin-master/YY.Admin/Views/RawMaterialEntry/RawMaterialEntryEditDialogView.xaml.cs create mode 100644 yy-admin-master/YY.Admin/Views/RawMaterialEntry/RawMaterialEntryListView.xaml create mode 100644 yy-admin-master/YY.Admin/Views/RawMaterialEntry/RawMaterialEntryListView.xaml.cs diff --git a/jeecg-boot/db/mes-mixer-material-import-from-image.sql b/jeecg-boot/db/mes-mixer-material-import-from-image.sql new file mode 100644 index 0000000..13b3529 --- /dev/null +++ b/jeecg-boot/db/mes-mixer-material-import-from-image.sql @@ -0,0 +1,132 @@ +-- 从截图识别生成:密炼物料导入 SQL(目标表:mes_mixer_material) +-- 说明: +-- 1) feed_manage_status=1(投管), use_status=1(使用), shelf_life_days=365 +-- 2) 物料大类/小类按分类字典映射到 sys_category.id +-- 物料大类:名称「原辅材料」,类型编码 code = XSLMES_MATERIAL_RAW_AUX(sys_category.code) +-- 物料小类:名称「炭黑」,且 pid 指向大类(若已维护父子关系) +-- 3) tenant_id / sys_org_code 未知,先置空 +-- 4) material_name / material_desc / alias_name 中如包含 TODO_*,请在执行前按业务名称修正 +-- 5) 为避免重复,先按 material_code 删除后再插入 + +SET NAMES utf8mb4; +SET FOREIGN_KEY_CHECKS = 0; + +-- 大类:优先按类型编码匹配,否则按名称「原辅材料」 +SET @major_category_id := ( + SELECT sc.id + FROM sys_category sc + WHERE sc.code = 'XSLMES_MATERIAL_RAW_AUX' OR sc.name = '原辅材料' + ORDER BY + (CASE WHEN sc.code = 'XSLMES_MATERIAL_RAW_AUX' THEN 0 WHEN sc.name = '原辅材料' THEN 1 ELSE 2 END), + sc.create_time DESC + LIMIT 1 +); + +-- 小类:名称「炭黑」,优先挂在大类下 +SET @minor_category_id := ( + SELECT sc.id + FROM sys_category sc + WHERE sc.name = '炭黑' + AND ( + @major_category_id IS NULL + OR sc.pid = @major_category_id + OR sc.pid IS NULL + OR sc.pid = '0' + ) + ORDER BY (CASE WHEN sc.pid = @major_category_id THEN 0 ELSE 1 END), sc.create_time DESC + LIMIT 1 +); + +DELETE FROM mes_mixer_material +WHERE material_code IN ( + '110110001','110110002','110110003','110110004','110110005','110110006','110110007','110110008', + '110110009','110110010','110110011','110110012','110110013','110110014','110110015','110110016', + '110110017','110110018','110110019','110110020','110110021','110110022','110110023','110110024', + '110110025','110110026','110110027','110110028' +); + +INSERT INTO mes_mixer_material ( + id, + material_code, + material_name, + erp_code, + major_category_id, + minor_category_id, + material_desc, + alias_name, + feed_manage_status, + use_status, + specific_gravity, + shelf_life_days, + min_bake_minutes, + total_safety_stock_kg, + qualified_safety_stock_kg, + remark, + tenant_id, + sys_org_code, + create_by, + create_time, + del_flag +) +SELECT + REPLACE(UUID(), '-', '') AS id, + t.material_code, + t.material_name, + t.erp_code, + @major_category_id AS major_category_id, + @minor_category_id AS minor_category_id, + t.material_desc, + t.alias_name, + 1 AS feed_manage_status, + 1 AS use_status, + t.specific_gravity, + 365 AS shelf_life_days, + t.min_bake_minutes, + NULL AS total_safety_stock_kg, + NULL AS qualified_safety_stock_kg, + '图片识别导入' AS remark, + NULL AS tenant_id, + NULL AS sys_org_code, + 'image_import' AS create_by, + NOW() AS create_time, + 0 AS del_flag +FROM ( + -- material_code(9位), material_name, erp_code, material_desc, alias_name, specific_gravity, min_bake_minutes + SELECT '110110001' AS material_code, '炭黑-N220' AS material_name, '10201' AS erp_code, '炭黑-N220' AS material_desc, '炭黑-N220' AS alias_name, 1.82 AS specific_gravity, NULL AS min_bake_minutes + UNION ALL SELECT '110110002', '炭黑-N330', '10203', '炭黑-N330', '炭黑-N330', 1.82, NULL + UNION ALL SELECT '110110003', '炭黑-N660', '10205', '炭黑-N660', '炭黑-N660', 1.82, NULL + UNION ALL SELECT '110110004', '裂解炭黑', '10206', '裂解炭黑', '裂解炭黑', 1.95, NULL + UNION ALL SELECT '110110005', '陶土1', '10254', '填料-陶土1', '填料-陶土1', 2.60, NULL + UNION ALL SELECT '110110006', '碳酸钙', '10253', '填料-碳酸钙', '填料-碳酸钙', 2.70, NULL + UNION ALL SELECT '110110007', '硫酸钡', '10252', '填料-硫酸钡', '填料-硫酸钡', 4.20, NULL + UNION ALL SELECT '110110008', '二氧化钛中阳', '10251', '二氧化钛中阳', '二氧化钛中阳', 2.20, NULL + UNION ALL SELECT '110110009', '炭黑-N234', '10202', '炭黑-N234', '炭黑-N234', 1.85, NULL + UNION ALL SELECT '110110010', '炭黑 N339', '10204', '炭黑 N339', '炭黑 N339', 1.85, NULL + UNION ALL SELECT '110110011', '云母粉', '10255', '云母粉', '云母粉', 2.00, NULL + UNION ALL SELECT '110110012', '硬脂酸2', '10543', '硬脂酸2', '硬脂酸2', 0.85, 0 + UNION ALL SELECT '110110013', 'FI-N339', '10204', 'FI-N339', 'FI-N339', 1.85, 4 + UNION ALL SELECT '110110014', 'IL N330', '10203', 'IL N330', 'IL N330', 1.85, 4 + UNION ALL SELECT '110110015', '卡博特N234', '11006', '卡博特N234', '卡博特N234', 1.10, 0 + UNION ALL SELECT '110110016', '卡博特234', '11006', '卡博特234', '卡博特234', 1.80, 8 + UNION ALL SELECT '110110017', '头条B-N220', '10201', '头条B-N220', '头条B-N220', 1.82, 0 + UNION ALL SELECT '110110018', '集采B-N330', '10203', '集采B-N330', '集采B-N330', 1.82, 24 + UNION ALL SELECT '110110019', '集采B-N660', '10205', '集采B-N660', '集采B-N660', 1.82, 24 + UNION ALL SELECT '110110020', '集采B-裂解炭黑', '10206', '集采B-裂解炭黑', '集采B-裂解炭黑', 1.82, 24 + UNION ALL SELECT '110110021', '集采B-碳酸钙', '10253', '集采B-碳酸钙', '集采B-碳酸钙', 1.82, 24 + UNION ALL SELECT '110110022', '集采B-陶土', '10254', '集采B-陶土', '集采B-陶土', 1.82, 24 + UNION ALL SELECT '110110023', '集采B-N234', '10204', '集采B-N234', '集采B-N234', 1.82, 24 + UNION ALL SELECT '110110024', '集采B-N339', '10204', '集采B-N339', '集采B-N339', 1.82, 24 + UNION ALL SELECT '110110025', '氧化锌2', '10256', '氧化锌2', '氧化锌2', 5.55, 0 + UNION ALL SELECT '110110026', '炭黑-N550', '10209', '炭黑-N550', '炭黑-N550', 1.82, 0 + UNION ALL SELECT '110110027', 'AH', '10606', 'AH', 'AH', 1.18, 0 + UNION ALL SELECT '110110028', '旺格-N220', '10201', '旺格-N220', '旺格-N220', 1.82, 0 +) t; + +SET FOREIGN_KEY_CHECKS = 1; + +-- 执行后可核对: +-- SELECT @major_category_id AS major_category_id, @minor_category_id AS minor_category_id; +-- SELECT material_code, material_name, erp_code, major_category_id, minor_category_id, specific_gravity, min_bake_minutes +-- FROM mes_mixer_material +-- WHERE material_code BETWEEN '110110001' AND '110110028' +-- ORDER BY material_code; diff --git a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/constant/FillRuleConstant.java b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/constant/FillRuleConstant.java index d2b07d7..bc05dd9 100644 --- a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/constant/FillRuleConstant.java +++ b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/constant/FillRuleConstant.java @@ -22,4 +22,9 @@ public class FillRuleConstant { */ public static final String CATEGORY = "category_code_rule"; + /** + * MES 原料入场记录 条码/批次号 + */ + public static final String MES_RAW_MATERIAL_BARCODE = "mes_raw_material_barcode_rule"; + } diff --git a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/config/shiro/ShiroConfig.java b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/config/shiro/ShiroConfig.java index 7ee6d49..5b7c808 100644 --- a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/config/shiro/ShiroConfig.java +++ b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/config/shiro/ShiroConfig.java @@ -203,6 +203,8 @@ public class ShiroConfig { filterChainDefinitionMap.put("/xslmes/mesXslSupplier/anon/**", "anon"); // MES磅单管理免密接口(供桌面端调用) filterChainDefinitionMap.put("/xslmes/mesXslWeightRecord/anon/**", "anon"); + // MES原料入场记录免密接口(供桌面端调用) + filterChainDefinitionMap.put("/xslmes/mesXslRawMaterialEntry/anon/**", "anon"); // MES密炼物料管理免密接口(供桌面端调用) filterChainDefinitionMap.put("/mes/material/mixerMaterial/anon/**", "anon"); // 系统分类字典免密接口(供桌面端调用) diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/controller/MesXslDesktopAnonController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/controller/MesXslDesktopAnonController.java index 18ab6b2..f8f31fc 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/controller/MesXslDesktopAnonController.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/controller/MesXslDesktopAnonController.java @@ -14,10 +14,12 @@ import org.jeecg.common.system.query.QueryGenerator; import org.jeecg.common.util.oConvertUtils; import org.jeecg.modules.xslmes.constant.MesXslCustomerBizStatus; import org.jeecg.modules.xslmes.entity.MesXslCustomer; +import org.jeecg.modules.xslmes.entity.MesXslRawMaterialEntry; import org.jeecg.modules.xslmes.entity.MesXslSupplier; import org.jeecg.modules.xslmes.entity.MesXslVehicle; import org.jeecg.modules.xslmes.entity.MesXslWeightRecord; import org.jeecg.modules.xslmes.service.IMesXslCustomerService; +import org.jeecg.modules.xslmes.service.IMesXslRawMaterialEntryService; import org.jeecg.modules.xslmes.service.IMesXslSupplierService; import org.jeecg.modules.xslmes.service.IMesXslVehicleService; import org.jeecg.modules.xslmes.service.IMesXslWeightRecordService; @@ -26,6 +28,7 @@ import org.springframework.web.bind.annotation.*; import java.util.Arrays; import java.util.Objects; +import org.apache.commons.lang3.StringUtils; /** * 桌面端免密接口 — 统一入口 @@ -45,6 +48,7 @@ public class MesXslDesktopAnonController { private final IMesXslCustomerService customerService; private final IMesXslSupplierService supplierService; private final IMesXslWeightRecordService weightRecordService; + private final IMesXslRawMaterialEntryService rawMaterialEntryService; private final MesXslStompNotifyService stompNotify; // ═══════════════════════════ 车辆管理 ═══════════════════════════ @@ -422,6 +426,82 @@ public class MesXslDesktopAnonController { return Result.OK("批量删除成功!"); } + // ═══════════════════════════ 原料入场记录 ═══════════════════════════ + + @Operation(summary = "原料入场记录-免密分页列表查询") + @GetMapping("/xslmes/mesXslRawMaterialEntry/anon/list") + public Result> rawMaterialEntryAnonList( + MesXslRawMaterialEntry entity, + @RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo, + @RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize, + HttpServletRequest req) { + QueryWrapper qw = QueryGenerator.initQueryWrapper(entity, req.getParameterMap()); + qw.orderByDesc("create_time"); + IPage page = rawMaterialEntryService.page(new Page<>(pageNo, pageSize), qw); + return Result.OK(page); + } + + @Operation(summary = "原料入场记录-免密通过id查询") + @GetMapping("/xslmes/mesXslRawMaterialEntry/anon/queryById") + public Result rawMaterialEntryAnonQueryById(@RequestParam(name = "id") String id) { + MesXslRawMaterialEntry entity = rawMaterialEntryService.getById(id); + return entity != null ? Result.OK(entity) : Result.error("未找到对应数据"); + } + + @Operation(summary = "原料入场记录-免密生成条码/批次号") + @GetMapping("/xslmes/mesXslRawMaterialEntry/anon/generateBarcode") + public Result rawMaterialEntryAnonGenerateBarcode( + @RequestParam(name = "materialCode", defaultValue = "") String materialCode) { + return Result.OK(rawMaterialEntryService.generateBarcode(materialCode)); + } + + @Operation(summary = "原料入场记录-免密添加") + @PostMapping("/xslmes/mesXslRawMaterialEntry/anon/add") + public Result rawMaterialEntryAnonAdd(@RequestBody MesXslRawMaterialEntry entity) { + // 条码/批次号为空时服务端自动生成 + if (StringUtils.isBlank(entity.getBarcode())) { + String code = rawMaterialEntryService.generateBarcode( + StringUtils.defaultString(entity.getMaterialCode(), "")); + entity.setBarcode(code); + } + if (StringUtils.isBlank(entity.getBatchNo())) { + entity.setBatchNo(entity.getBarcode()); + } + rawMaterialEntryService.save(entity); + stompNotify.publishRawMaterialEntryChanged("add", entity.getId()); + return Result.OK("添加成功!"); + } + + @Operation(summary = "原料入场记录-免密编辑") + @RequestMapping(value = "/xslmes/mesXslRawMaterialEntry/anon/edit", method = {RequestMethod.PUT, RequestMethod.POST}) + public Result rawMaterialEntryAnonEdit(@RequestBody MesXslRawMaterialEntry entity) { + if (oConvertUtils.isEmpty(entity.getId())) { + return Result.error("主键不能为空"); + } + boolean ok = rawMaterialEntryService.updateById(entity); + if (!ok) { + return Result.error("数据已被他人修改,请刷新后重试"); + } + stompNotify.publishRawMaterialEntryChanged("edit", entity.getId()); + return Result.OK("编辑成功!"); + } + + @Operation(summary = "原料入场记录-免密删除") + @DeleteMapping("/xslmes/mesXslRawMaterialEntry/anon/delete") + public Result rawMaterialEntryAnonDelete(@RequestParam(name = "id") String id) { + rawMaterialEntryService.removeById(id); + stompNotify.publishRawMaterialEntryChanged("delete", id); + return Result.OK("删除成功!"); + } + + @Operation(summary = "原料入场记录-免密批量删除") + @DeleteMapping("/xslmes/mesXslRawMaterialEntry/anon/deleteBatch") + public Result rawMaterialEntryAnonDeleteBatch(@RequestParam(name = "ids") String ids) { + rawMaterialEntryService.removeByIds(Arrays.asList(ids.split(","))); + stompNotify.publishRawMaterialEntryChanged("batchDelete", ids); + return Result.OK("批量删除成功!"); + } + // ─────────────────────────── 车辆私有辅助 ──────────────────────────── private void applyWeightBillType(MesXslWeightRecord record) { diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/controller/MesXslRawMaterialEntryController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/controller/MesXslRawMaterialEntryController.java index 6d878e4..219af62 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/controller/MesXslRawMaterialEntryController.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/controller/MesXslRawMaterialEntryController.java @@ -7,6 +7,7 @@ import org.jeecg.common.api.vo.Result; import org.jeecg.common.system.query.QueryGenerator; import org.jeecg.modules.xslmes.entity.MesXslRawMaterialEntry; import org.jeecg.modules.xslmes.service.IMesXslRawMaterialEntryService; +import org.jeecg.modules.xslmes.service.MesXslStompNotifyService; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; @@ -36,6 +37,8 @@ public class MesXslRawMaterialEntryController extends JeecgController add(@RequestBody MesXslRawMaterialEntry mesXslRawMaterialEntry) { mesXslRawMaterialEntryService.save(mesXslRawMaterialEntry); + stompNotify.publishRawMaterialEntryChanged("add", mesXslRawMaterialEntry.getId()); return Result.OK("添加成功!"); } @@ -64,6 +68,7 @@ public class MesXslRawMaterialEntryController extends JeecgController edit(@RequestBody MesXslRawMaterialEntry mesXslRawMaterialEntry) { mesXslRawMaterialEntryService.updateById(mesXslRawMaterialEntry); + stompNotify.publishRawMaterialEntryChanged("edit", mesXslRawMaterialEntry.getId()); return Result.OK("编辑成功!"); } @@ -73,6 +78,7 @@ public class MesXslRawMaterialEntryController extends JeecgController delete(@RequestParam(name = "id", required = true) String id) { mesXslRawMaterialEntryService.removeById(id); + stompNotify.publishRawMaterialEntryChanged("delete", id); return Result.OK("删除成功!"); } @@ -82,6 +88,7 @@ public class MesXslRawMaterialEntryController extends JeecgController deleteBatch(@RequestParam(name = "ids", required = true) String ids) { this.mesXslRawMaterialEntryService.removeByIds(Arrays.asList(ids.split(","))); + stompNotify.publishRawMaterialEntryChanged("batchDelete", ids); return Result.OK("批量删除成功!"); } diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/entity/MesXslRawMaterialEntry.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/entity/MesXslRawMaterialEntry.java index 22247ca..44412ba 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/entity/MesXslRawMaterialEntry.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/entity/MesXslRawMaterialEntry.java @@ -57,6 +57,10 @@ public class MesXslRawMaterialEntry implements Serializable { @Schema(description = "物料ID") private String materialId; + @Excel(name = "物料编码", width = 20) + @Schema(description = "物料编码(用于生成条码/批次号)") + private String materialCode; + @Excel(name = "物料名称", width = 20) @Schema(description = "物料名称") private String materialName; diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/rule/RawMaterialBarcodeRule.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/rule/RawMaterialBarcodeRule.java new file mode 100644 index 0000000..310d04c --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/rule/RawMaterialBarcodeRule.java @@ -0,0 +1,31 @@ +package org.jeecg.modules.xslmes.rule; + +import com.alibaba.fastjson.JSONObject; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.jeecg.common.handler.IFillRuleHandler; +import org.jeecg.common.util.SpringContextUtils; +import org.jeecg.modules.xslmes.service.IMesXslRawMaterialEntryService; + +/** + * 填值规则:原料入场记录 条码/批次号生成 + * 格式:QH + 物料编码 + 日期(yyMMdd) + 当日序号(001起) + * rule_code: mes_raw_material_barcode_rule + */ +@Slf4j +public class RawMaterialBarcodeRule implements IFillRuleHandler { + + @Override + public Object execute(JSONObject params, JSONObject formData) { + String materialCode = ""; + if (formData != null && StringUtils.isNotBlank(formData.getString("materialCode"))) { + materialCode = formData.getString("materialCode").trim(); + } else if (params != null && StringUtils.isNotBlank(params.getString("materialCode"))) { + materialCode = params.getString("materialCode").trim(); + } + log.info("原料入场条码生成规则 materialCode={}", materialCode); + IMesXslRawMaterialEntryService service = + SpringContextUtils.getBean(IMesXslRawMaterialEntryService.class); + return service.generateBarcode(materialCode); + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/service/IMesXslRawMaterialEntryService.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/service/IMesXslRawMaterialEntryService.java index e0d95e7..df6290c 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/service/IMesXslRawMaterialEntryService.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/service/IMesXslRawMaterialEntryService.java @@ -10,4 +10,13 @@ import com.baomidou.mybatisplus.extension.service.IService; * @Version: V1.0 */ public interface IMesXslRawMaterialEntryService extends IService { + + /** + * 生成条码/批次号 + * 格式:QH + 物料编码 + 日期(yyMMdd) + 当日序号(001起) + * + * @param materialCode 物料编码 + * @return 生成的条码字符串 + */ + String generateBarcode(String materialCode); } diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/service/MesXslStompNotifyService.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/service/MesXslStompNotifyService.java index 4f3301d..aa8320e 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/service/MesXslStompNotifyService.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/service/MesXslStompNotifyService.java @@ -40,6 +40,11 @@ public class MesXslStompNotifyService { publish("/topic/sync/mes-weight-records", "MES_WEIGHT_RECORD_CHANGED", "weightRecordId", weightRecordId, action); } + /** 广播原料入场记录变更事件到 /topic/sync/mes-raw-material-entries */ + public void publishRawMaterialEntryChanged(String action, String entryId) { + publish("/topic/sync/mes-raw-material-entries", "MES_RAW_MATERIAL_ENTRY_CHANGED", "entryId", entryId, action); + } + /** 广播密炼物料数据变更事件到 /topic/sync/mes-mixer-materials */ public void publishMixerMaterialChanged(String action, String mixerMaterialId) { publish("/topic/sync/mes-mixer-materials", "MIXER_MATERIAL_CHANGED", "mixerMaterialId", mixerMaterialId, action); diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/service/impl/MesXslRawMaterialEntryServiceImpl.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/service/impl/MesXslRawMaterialEntryServiceImpl.java index e266e34..8e150db 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/service/impl/MesXslRawMaterialEntryServiceImpl.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/service/impl/MesXslRawMaterialEntryServiceImpl.java @@ -1,10 +1,14 @@ package org.jeecg.modules.xslmes.service.impl; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import org.jeecg.modules.xslmes.entity.MesXslRawMaterialEntry; import org.jeecg.modules.xslmes.mapper.MesXslRawMaterialEntryMapper; import org.jeecg.modules.xslmes.service.IMesXslRawMaterialEntryService; import org.springframework.stereotype.Service; -import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; + +import java.text.SimpleDateFormat; +import java.util.Date; /** * @Description: 原料入场记录 @@ -13,5 +17,20 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; * @Version: V1.0 */ @Service -public class MesXslRawMaterialEntryServiceImpl extends ServiceImpl implements IMesXslRawMaterialEntryService { +public class MesXslRawMaterialEntryServiceImpl + extends ServiceImpl + implements IMesXslRawMaterialEntryService { + + @Override + public String generateBarcode(String materialCode) { + if (materialCode == null) materialCode = ""; + String dateStr = new SimpleDateFormat("yyMMdd").format(new Date()); + String prefix = "QH" + materialCode.trim() + dateStr; + + LambdaQueryWrapper qw = new LambdaQueryWrapper<>(); + qw.likeRight(MesXslRawMaterialEntry::getBarcode, prefix); + long count = this.count(qw); + + return prefix + String.format("%03d", count + 1); + } } diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_42__mes_raw_material_barcode_rule.sql b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_42__mes_raw_material_barcode_rule.sql new file mode 100644 index 0000000..46ba98b --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_42__mes_raw_material_barcode_rule.sql @@ -0,0 +1,30 @@ +-- ============================================================ +-- 1. 原料入场记录表:补充 material_code 列 +-- ============================================================ +SET @col_exists := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mes_xsl_raw_material_entry' + AND COLUMN_NAME = 'material_code' +); +SET @ddl := IF(@col_exists = 0, + 'ALTER TABLE `mes_xsl_raw_material_entry` ADD COLUMN `material_code` varchar(100) DEFAULT NULL COMMENT ''物料编码'' AFTER `material_id`', + 'SELECT 1' +); +PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; + +-- ============================================================ +-- 2. 注册填值规则 mes_raw_material_barcode_rule +-- ============================================================ +INSERT INTO `sys_fill_rule` (`id`, `rule_name`, `rule_code`, `rule_class`, `rule_params`, `create_by`, `create_time`) +SELECT '1920000000000001001', + 'MES原料入场条码/批次号规则', + 'mes_raw_material_barcode_rule', + 'org.jeecg.modules.xslmes.rule.RawMaterialBarcodeRule', + NULL, + 'admin', + NOW() +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM `sys_fill_rule` WHERE `rule_code` = 'mes_raw_material_barcode_rule' +); diff --git a/yy-admin-master/YY.Admin.Core/Core/Events/MesXslRawMaterialEntryChangedEvent.cs b/yy-admin-master/YY.Admin.Core/Core/Events/MesXslRawMaterialEntryChangedEvent.cs new file mode 100644 index 0000000..d3f0d37 --- /dev/null +++ b/yy-admin-master/YY.Admin.Core/Core/Events/MesXslRawMaterialEntryChangedEvent.cs @@ -0,0 +1,13 @@ +using Prism.Events; + +namespace YY.Admin.Core.Events; + +public class RawMaterialEntryChangedPayload +{ + public string? Action { get; set; } + public string? EntryId { get; set; } +} + +public class RawMaterialEntryChangedEvent : PubSubEvent +{ +} diff --git a/yy-admin-master/YY.Admin.Core/Core/Services/IRawMaterialEntryService.cs b/yy-admin-master/YY.Admin.Core/Core/Services/IRawMaterialEntryService.cs new file mode 100644 index 0000000..f708b76 --- /dev/null +++ b/yy-admin-master/YY.Admin.Core/Core/Services/IRawMaterialEntryService.cs @@ -0,0 +1,26 @@ +using YY.Admin.Core.Entity; + +namespace YY.Admin.Core.Services; + +public record RawMaterialEntryPageResult(List Records, long Total, int PageNo, int PageSize); + +public interface IRawMaterialEntryService +{ + Task PageAsync( + int pageNo, int pageSize, + string? barcode = null, + string? batchNo = null, + string? billNo = null, + string? materialName = null, + string? supplierName = null, + CancellationToken ct = default); + + Task GetByIdAsync(string id, CancellationToken ct = default); + Task AddAsync(MesXslRawMaterialEntry entry, CancellationToken ct = default); + Task EditAsync(MesXslRawMaterialEntry entry, CancellationToken ct = default); + Task DeleteAsync(string id, CancellationToken ct = default); + Task DeleteBatchAsync(string ids, CancellationToken ct = default); + + /// 调用后端接口生成条码/批次号(格式:QH+物料编码+yyMMdd+序号) + Task GenerateBarcodeAsync(string materialCode, CancellationToken ct = default); +} diff --git a/yy-admin-master/YY.Admin.Core/Entity/MesXslRawMaterialEntry.cs b/yy-admin-master/YY.Admin.Core/Entity/MesXslRawMaterialEntry.cs new file mode 100644 index 0000000..506d21c --- /dev/null +++ b/yy-admin-master/YY.Admin.Core/Entity/MesXslRawMaterialEntry.cs @@ -0,0 +1,89 @@ +namespace YY.Admin.Core.Entity; + +public class MesXslRawMaterialEntry +{ + public string? Id { get; set; } + public string? Barcode { get; set; } + public string? BatchNo { get; set; } + public DateTime? EntryTime { get; set; } + public string? WeightRecordId { get; set; } + public string? BillNo { get; set; } + public string? MaterialId { get; set; } + public string? MaterialCode { get; set; } + public string? MaterialName { get; set; } + public string? SupplyCustomer { get; set; } + public string? SupplierId { get; set; } + public string? SupplierName { get; set; } + public string? ManufacturerMaterialName { get; set; } + public string? ShelfLife { get; set; } + public double? TotalWeight { get; set; } + public int? TotalPortions { get; set; } + public double? PortionWeight { get; set; } + public int? PortionPackages { get; set; } + + /// 检测结果:0未检 1合格 2不合格 + public string? TestResult { get; set; } + + /// 检测状态:0送样 1已批准 + public string? TestStatus { get; set; } + + /// 打印标记:1已打印 0未打印 + public string? PrintFlag { get; set; } + + /// 入库结存:1是 0否 + public string? StockBalance { get; set; } + + public string? WarehouseLocation { get; set; } + public string? UnloadOperator { get; set; } + + /// 是否特采:1是 0否 + public string? IsSpecialAdoption { get; set; } + + public string? SpecialAdoptionOperator { get; set; } + public DateTime? SpecialAdoptionTime { get; set; } + public string? SpecialAdoptionReason { get; set; } + public string? Status { get; set; } + public string? Remark { get; set; } + + public string? CreateBy { get; set; } + public DateTime? CreateTime { get; set; } + public string? UpdateBy { get; set; } + public DateTime? UpdateTime { get; set; } + public int? TenantId { get; set; } + + public string TestResultText => TestResult switch + { + "0" => "未检", + "1" => "合格", + "2" => "不合格", + _ => TestResult ?? "" + }; + + public string TestStatusText => TestStatus switch + { + "0" => "送样", + "1" => "已批准", + _ => TestStatus ?? "" + }; + + public string PrintFlagText => PrintFlag switch + { + "1" => "已打印", + "0" => "未打印", + _ => PrintFlag ?? "" + }; + + public string StockBalanceText => StockBalance switch + { + "1" => "是", + "0" => "否", + _ => StockBalance ?? "" + }; + + public string IsSpecialAdoptionText => IsSpecialAdoption switch + { + "1" => "是", + "0" => "否", + _ => IsSpecialAdoption ?? "" + }; +} diff --git a/yy-admin-master/YY.Admin.Core/SeedData/SysMenuSeedData.cs b/yy-admin-master/YY.Admin.Core/SeedData/SysMenuSeedData.cs index 4d2a209..e770fd9 100644 --- a/yy-admin-master/YY.Admin.Core/SeedData/SysMenuSeedData.cs +++ b/yy-admin-master/YY.Admin.Core/SeedData/SysMenuSeedData.cs @@ -38,6 +38,8 @@ public class SysMenuSeedData : ISqlSugarEntitySeedData new SysMenu{ Id=1300150010501, Pid=1300150000101, Title="地磅称重操作", Path="/xslmes/weightRecordOperation", Name="weightRecordOperation", Component="WeightRecordOperationView", Icon="", Type=MenuTypeEnum.Menu, CreateTime=DateTime.Parse("2022-02-10 00:00:00"), OrderNo=104 }, // 密炼物料信息 new SysMenu{ Id=1300150010601, Pid=1300150000101, Title="密炼物料信息", Path="/xslmes/mesMixerMaterial", Name="mesMixerMaterial", Component="MixerMaterialListView", Icon="", Type=MenuTypeEnum.Menu, CreateTime=DateTime.Parse("2022-02-10 00:00:00"), OrderNo=105 }, + // 原料入场记录 + new SysMenu{ Id=1300150010701, Pid=1300150000101, Title="原料入场记录", Path="/xslmes/mesXslRawMaterialEntry", Name="mesXslRawMaterialEntry", Component="RawMaterialEntryListView", Icon="", Type=MenuTypeEnum.Menu, CreateTime=DateTime.Parse("2022-02-10 00:00:00"), OrderNo=106 }, #endregion diff --git a/yy-admin-master/YY.Admin.Core/SeedData/SysTenantMenuSeedData.cs b/yy-admin-master/YY.Admin.Core/SeedData/SysTenantMenuSeedData.cs index c512df7..ae242c3 100644 --- a/yy-admin-master/YY.Admin.Core/SeedData/SysTenantMenuSeedData.cs +++ b/yy-admin-master/YY.Admin.Core/SeedData/SysTenantMenuSeedData.cs @@ -27,6 +27,7 @@ public class SysTenantMenuSeedData : ISqlSugarEntitySeedData new SysTenantMenu(){ TenantId=1300000000001,MenuId=1300150010401}, new SysTenantMenu(){ TenantId=1300000000001,MenuId=1300150010501}, new SysTenantMenu(){ TenantId=1300000000001,MenuId=1300150010601}, + new SysTenantMenu(){ TenantId=1300000000001,MenuId=1300150010701}, new SysTenantMenu(){ TenantId=1300000000001,MenuId=1300200012101}, new SysTenantMenu(){ TenantId=1300000000001,MenuId=1300200012111}, new SysTenantMenu(){ TenantId=1300000000001,MenuId=1300200012121}, diff --git a/yy-admin-master/YY.Admin.Services/Service/Category/CategorySyncCoordinator.cs b/yy-admin-master/YY.Admin.Services/Service/Category/CategorySyncCoordinator.cs index fbcd25c..f6b219e 100644 --- a/yy-admin-master/YY.Admin.Services/Service/Category/CategorySyncCoordinator.cs +++ b/yy-admin-master/YY.Admin.Services/Service/Category/CategorySyncCoordinator.cs @@ -15,6 +15,7 @@ public class CategorySyncCoordinator : ISingletonDependency public CategorySyncCoordinator( IEventAggregator eventAggregator, IJeecgCategorySyncService categorySyncService, + SyncPollManager pollManager, ILoggerService logger) { _eventAggregator = eventAggregator; @@ -26,6 +27,8 @@ public class CategorySyncCoordinator : ISingletonDependency _eventAggregator.GetEvent() .Subscribe(OnNetworkStatusChanged, ThreadOption.BackgroundThread); + pollManager.Register("分类字典", () => SyncAndPublishAsync("poll", null)); + _logger.Information("[分类字典] CategorySyncCoordinator 已启动"); _ = Task.Run(() => SyncAndPublishAsync("startup", null)); } diff --git a/yy-admin-master/YY.Admin.Services/Service/Customer/CustomerSyncCoordinator.cs b/yy-admin-master/YY.Admin.Services/Service/Customer/CustomerSyncCoordinator.cs index a46a4e7..0011355 100644 --- a/yy-admin-master/YY.Admin.Services/Service/Customer/CustomerSyncCoordinator.cs +++ b/yy-admin-master/YY.Admin.Services/Service/Customer/CustomerSyncCoordinator.cs @@ -11,7 +11,10 @@ public class CustomerSyncCoordinator : ISingletonDependency private readonly IEventAggregator _eventAggregator; private readonly ILoggerService _logger; - public CustomerSyncCoordinator(IEventAggregator eventAggregator, ILoggerService logger) + public CustomerSyncCoordinator( + IEventAggregator eventAggregator, + SyncPollManager pollManager, + ILoggerService logger) { _eventAggregator = eventAggregator; _logger = logger; @@ -19,6 +22,14 @@ public class CustomerSyncCoordinator : ISingletonDependency .Subscribe(OnRemoteCommand, ThreadOption.BackgroundThread); _eventAggregator.GetEvent() .Subscribe(OnNetworkStatusChanged, ThreadOption.BackgroundThread); + + pollManager.Register("客户", () => + { + _eventAggregator.GetEvent() + .Publish(new CustomerChangedPayload { Action = "poll" }); + return Task.CompletedTask; + }); + _logger.Information("[客户推送] CustomerSyncCoordinator 已启动"); } diff --git a/yy-admin-master/YY.Admin.Services/Service/Dict/DictSyncCoordinator.cs b/yy-admin-master/YY.Admin.Services/Service/Dict/DictSyncCoordinator.cs index 396aee6..3ba10a1 100644 --- a/yy-admin-master/YY.Admin.Services/Service/Dict/DictSyncCoordinator.cs +++ b/yy-admin-master/YY.Admin.Services/Service/Dict/DictSyncCoordinator.cs @@ -15,6 +15,7 @@ public class DictSyncCoordinator : ISingletonDependency public DictSyncCoordinator( IEventAggregator eventAggregator, IJeecgDictSyncService dictSyncService, + SyncPollManager pollManager, ILoggerService logger) { _eventAggregator = eventAggregator; @@ -26,6 +27,8 @@ public class DictSyncCoordinator : ISingletonDependency _eventAggregator.GetEvent() .Subscribe(OnNetworkStatusChanged, ThreadOption.BackgroundThread); + pollManager.Register("数据字典", () => SyncAndPublishAsync("poll", null)); + _logger.Information("[数据字典] DictSyncCoordinator 已启动"); _ = Task.Run(() => SyncAndPublishAsync("startup", null)); } diff --git a/yy-admin-master/YY.Admin.Services/Service/Dict/JeecgDictSyncService.cs b/yy-admin-master/YY.Admin.Services/Service/Dict/JeecgDictSyncService.cs index 51ecfb4..e936e29 100644 --- a/yy-admin-master/YY.Admin.Services/Service/Dict/JeecgDictSyncService.cs +++ b/yy-admin-master/YY.Admin.Services/Service/Dict/JeecgDictSyncService.cs @@ -82,6 +82,7 @@ public class JeecgDictSyncService : IJeecgDictSyncService, ISingletonDependency const int pageSize = 500; var pageNo = 1; var synced = 0; + var seenIds = new HashSet(StringComparer.OrdinalIgnoreCase); while (true) { @@ -111,6 +112,8 @@ public class JeecgDictSyncService : IJeecgDictSyncService, ISingletonDependency continue; } + seenIds.Add(id); + var existing = await _dbContext.Queryable() .ClearFilter() .Where(x => x.Id == id) @@ -161,6 +164,15 @@ public class JeecgDictSyncService : IJeecgDictSyncService, ISingletonDependency pageNo++; } + // 删除本地存在但后端已移除的字典项(如后端删除重建导致 ID 变化) + if (seenIds.Count > 0) + { + var seenList = seenIds.ToList(); + await _dbContext.Deleteable() + .Where(x => !seenList.Contains(x.Id)) + .ExecuteCommandAsync(); + } + return synced; } diff --git a/yy-admin-master/YY.Admin.Services/Service/MixerMaterial/MixerMaterialService.cs b/yy-admin-master/YY.Admin.Services/Service/MixerMaterial/MixerMaterialService.cs index c05fb60..d6791b1 100644 --- a/yy-admin-master/YY.Admin.Services/Service/MixerMaterial/MixerMaterialService.cs +++ b/yy-admin-master/YY.Admin.Services/Service/MixerMaterial/MixerMaterialService.cs @@ -51,6 +51,7 @@ public class MixerMaterialService : IMixerMaterialService, ISingletonDependency } private string BaseUrl => (_configuration.GetValue("JeecgIntegration:BaseUrl") ?? "http://localhost:8080/jeecg-boot").TrimEnd('/'); + private int DefaultTenantId => (int?)_configuration.GetValue("JeecgIntegration:DefaultTenantId") ?? 1002; private HttpClient CreateClient() => _httpClientFactory.CreateClient("JeecgApi"); private async Task LoadCacheAsync() @@ -84,7 +85,8 @@ public class MixerMaterialService : IMixerMaterialService, ISingletonDependency public async Task SyncFromRemoteAsync(CancellationToken ct = default) { - if (!await _syncLock.WaitAsync(0, ct).ConfigureAwait(false)) return; + // 等待当前正在进行的同步结束,再执行本次同步(WaitAsync(0) 会静默跳过,导致 STOMP 通知丢失) + await _syncLock.WaitAsync(ct).ConfigureAwait(false); try { var all = new List(); @@ -96,10 +98,15 @@ public class MixerMaterialService : IMixerMaterialService, ISingletonDependency var qs = HttpUtility.ParseQueryString(string.Empty); qs["pageNo"] = pageNo.ToString(); qs["pageSize"] = pageSize.ToString(); + // mes_mixer_material 不在多租户隔离表中,不传 tenantId 可拉取全部记录 var url = $"{BaseUrl}/mes/material/mixerMaterial/anon/list?{qs}"; using var client = CreateClient(); var resp = await client.GetAsync(url, ct).ConfigureAwait(false); - if (!resp.IsSuccessStatusCode) break; + if (!resp.IsSuccessStatusCode) + { + _logger.Warning($"[密炼物料] 同步请求失败,状态码:{(int)resp.StatusCode}"); + break; + } var json = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); using var doc = JsonDocument.Parse(json); @@ -119,6 +126,10 @@ public class MixerMaterialService : IMixerMaterialService, ISingletonDependency await SaveCacheAsync(all).ConfigureAwait(false); _logger.Information($"[密炼物料] 同步完成,共 {all.Count} 条"); } + else + { + _logger.Warning("[密炼物料] 同步返回 0 条,可能是 tenantId 配置有误或网络异常,保留原缓存"); + } } catch (Exception ex) { diff --git a/yy-admin-master/YY.Admin.Services/Service/MixerMaterial/MixerMaterialSyncCoordinator.cs b/yy-admin-master/YY.Admin.Services/Service/MixerMaterial/MixerMaterialSyncCoordinator.cs index 52b3d84..30cd1a7 100644 --- a/yy-admin-master/YY.Admin.Services/Service/MixerMaterial/MixerMaterialSyncCoordinator.cs +++ b/yy-admin-master/YY.Admin.Services/Service/MixerMaterial/MixerMaterialSyncCoordinator.cs @@ -3,6 +3,7 @@ using System.Text.Json; using YY.Admin.Core; using YY.Admin.Core.Events; using YY.Admin.Core.Services; +using YY.Admin.Services.Service; namespace YY.Admin.Services.Service.MixerMaterial; @@ -15,6 +16,7 @@ public class MixerMaterialSyncCoordinator : ISingletonDependency public MixerMaterialSyncCoordinator( IEventAggregator eventAggregator, IMixerMaterialService mixerMaterialService, + SyncPollManager pollManager, ILoggerService logger) { _eventAggregator = eventAggregator; @@ -26,6 +28,13 @@ public class MixerMaterialSyncCoordinator : ISingletonDependency _eventAggregator.GetEvent() .Subscribe(OnNetworkStatusChanged, ThreadOption.BackgroundThread); + pollManager.Register("密炼物料", async () => + { + await _mixerMaterialService.SyncFromRemoteAsync().ConfigureAwait(false); + _eventAggregator.GetEvent() + .Publish(new MixerMaterialChangedPayload { Action = "poll" }); + }); + _logger.Information("[密炼物料推送] MixerMaterialSyncCoordinator 已启动"); _ = _mixerMaterialService.SyncFromRemoteAsync(); } diff --git a/yy-admin-master/YY.Admin.Services/Service/RawMaterialEntry/RawMaterialEntryService.cs b/yy-admin-master/YY.Admin.Services/Service/RawMaterialEntry/RawMaterialEntryService.cs new file mode 100644 index 0000000..36feb44 --- /dev/null +++ b/yy-admin-master/YY.Admin.Services/Service/RawMaterialEntry/RawMaterialEntryService.cs @@ -0,0 +1,626 @@ +using Microsoft.Extensions.Configuration; +using System.Net.Http; +using System.IO; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Web; +using Prism.Events; +using YY.Admin.Core; +using YY.Admin.Core.Entity; +using YY.Admin.Core.Events; +using YY.Admin.Core.Services; + +namespace YY.Admin.Services.Service.RawMaterialEntry; + +public class RawMaterialEntryService : IRawMaterialEntryService, ISingletonDependency +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly IConfiguration _configuration; + private readonly INetworkMonitor _networkMonitor; + private readonly IEventAggregator _eventAggregator; + private readonly ILoggerService _logger; + private readonly SemaphoreSlim _syncLock = new(1, 1); + private readonly object _cacheLock = new(); + private readonly string _pendingOpsFilePath; + private readonly string _cacheFilePath; + private List _pendingOps = new(); + private List _localCache = new(); + + private static readonly JsonSerializerOptions _jsonOpts = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new NullableDateTimeJsonConverter() } + }; + + public RawMaterialEntryService( + IHttpClientFactory httpClientFactory, + IConfiguration configuration, + INetworkMonitor networkMonitor, + IEventAggregator eventAggregator, + ILoggerService logger) + { + _httpClientFactory = httpClientFactory; + _configuration = configuration; + _networkMonitor = networkMonitor; + _eventAggregator = eventAggregator; + _logger = logger; + + var appDataDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "YY.Admin", "sync-cache"); + Directory.CreateDirectory(appDataDir); + _pendingOpsFilePath = Path.Combine(appDataDir, "mes-xsl-raw-material-entry-pending-ops.json"); + _cacheFilePath = Path.Combine(appDataDir, "mes-xsl-raw-material-entry-cache.json"); + + LoadPendingOpsFromDisk(); + LoadCacheFromDisk(); + _logger.Information($"[原料入场] 服务初始化完成,缓存={_localCache.Count},待上传={_pendingOps.Count},在线={_networkMonitor.IsOnline}"); + + _networkMonitor.StatusChanged += OnNetworkStatusChanged; + if (_networkMonitor.IsOnline) + { + _ = Task.Run(() => SyncAfterReconnectAsync(CancellationToken.None)); + } + } + + private const int MaxPendingRetries = 5; + private string BaseUrl => (_configuration.GetValue("JeecgIntegration:BaseUrl") ?? "http://localhost:8080/jeecg-boot").TrimEnd('/'); + private int DefaultTenantId => (int?)_configuration.GetValue("JeecgIntegration:DefaultTenantId") ?? 1002; + + private HttpClient CreateClient() => _httpClientFactory.CreateClient("JeecgApi"); + + public async Task PageAsync( + int pageNo, int pageSize, + string? barcode = null, string? batchNo = null, string? billNo = null, + string? materialName = null, string? supplierName = null, + CancellationToken ct = default) + { + List? source = null; + if (_networkMonitor.IsOnline) + { + try + { + source = await FetchRemoteListAsync(ct).ConfigureAwait(false); + lock (_cacheLock) + { + _localCache = source.Select(Clone).ToList(); + SaveCacheToDiskUnsafe(); + } + _logger.Information($"[原料入场列表] 远端拉取成功 count={source.Count}"); + } + catch (Exception ex) + { + source = null; + _logger.Warning($"[原料入场列表] 远端拉取失败,回退缓存:{ex.Message}"); + } + } + + lock (_cacheLock) + { + source ??= _localCache.Select(Clone).ToList(); + source = ApplyPendingOpsSnapshotUnsafe(source); + } + + var filtered = ApplyFilters(source, barcode, batchNo, billNo, materialName, supplierName); + var total = filtered.Count; + var pageRecords = filtered.Skip(Math.Max(0, (pageNo - 1) * pageSize)).Take(pageSize).ToList(); + return new RawMaterialEntryPageResult(pageRecords, total, pageNo, pageSize); + } + + public async Task GetByIdAsync(string id, CancellationToken ct = default) + { + if (_networkMonitor.IsOnline) + { + try + { + var url = $"{BaseUrl}/xslmes/mesXslRawMaterialEntry/anon/queryById?id={Uri.EscapeDataString(id)}&tenantId={DefaultTenantId}"; + using var client = CreateClient(); + var resp = await client.GetAsync(url, ct).ConfigureAwait(false); + if (!resp.IsSuccessStatusCode) return null; + var json = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + using var doc = JsonDocument.Parse(json); + if (!doc.RootElement.TryGetProperty("result", out var resultEl)) return null; + return resultEl.Deserialize(_jsonOpts); + } + catch (Exception ex) + { + _logger.Warning($"[原料入场详情] 远端查询失败,回退缓存 id={id}:{ex.Message}"); + } + } + + lock (_cacheLock) + { + return _localCache.FirstOrDefault(e => string.Equals(e.Id, id, StringComparison.OrdinalIgnoreCase)) is { } found + ? Clone(found) : null; + } + } + + public async Task AddAsync(MesXslRawMaterialEntry entry, CancellationToken ct = default) + { + if (!entry.TenantId.HasValue || entry.TenantId.Value <= 0) entry.TenantId = DefaultTenantId; + var local = Clone(entry); + if (string.IsNullOrWhiteSpace(local.Id)) local.Id = $"local-{Guid.NewGuid():N}"; + + if (_networkMonitor.IsOnline) + { + try + { + var ok = await RemoteAddAsync(local, ct).ConfigureAwait(false); + if (ok) { UpsertLocalCache(local); return true; } + return false; + } + catch (Exception ex) + { + _logger.Warning($"[原料入场新增] 远端失败,转离线 id={local.Id}:{ex.Message}"); + } + } + + EnqueuePendingOperation(new EntryPendingOperation { OpType = EntryOpType.Add, EntryId = local.Id, Entry = local, CreatedAt = DateTime.UtcNow }); + UpsertLocalCache(local); + return true; + } + + public async Task EditAsync(MesXslRawMaterialEntry entry, CancellationToken ct = default) + { + if (!entry.TenantId.HasValue || entry.TenantId.Value <= 0) entry.TenantId = DefaultTenantId; + var local = Clone(entry); + + if (_networkMonitor.IsOnline) + { + try + { + var (ok, _) = await RemoteEditAsync(local, ct).ConfigureAwait(false); + if (ok) { UpsertLocalCache(local); return true; } + return false; + } + catch (Exception ex) + { + _logger.Warning($"[原料入场修改] 远端失败,转离线 id={local.Id}:{ex.Message}"); + } + } + + EnqueuePendingOperation(new EntryPendingOperation { OpType = EntryOpType.Edit, EntryId = local.Id, Entry = local, AnchorUpdateTime = local.UpdateTime, CreatedAt = DateTime.UtcNow }); + UpsertLocalCache(local); + return true; + } + + public async Task DeleteAsync(string id, CancellationToken ct = default) + { + if (_networkMonitor.IsOnline) + { + try + { + var ok = await RemoteDeleteAsync(id, ct).ConfigureAwait(false); + if (ok) { RemoveFromLocalCache(id); return true; } + return false; + } + catch (Exception ex) + { + _logger.Warning($"[原料入场删除] 远端失败,转离线 id={id}:{ex.Message}"); + } + } + + DateTime? anchor; + lock (_cacheLock) { anchor = _localCache.FirstOrDefault(e => string.Equals(e.Id, id, StringComparison.OrdinalIgnoreCase))?.UpdateTime; } + EnqueuePendingOperation(new EntryPendingOperation { OpType = EntryOpType.Delete, EntryId = id, AnchorUpdateTime = anchor, CreatedAt = DateTime.UtcNow }); + RemoveFromLocalCache(id); + return true; + } + + public async Task DeleteBatchAsync(string ids, CancellationToken ct = default) + { + var idList = ids.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var allSuccess = true; + foreach (var id in idList) allSuccess &= await DeleteAsync(id, ct).ConfigureAwait(false); + return allSuccess; + } + + public async Task GenerateBarcodeAsync(string materialCode, CancellationToken ct = default) + { + var url = $"{BaseUrl}/xslmes/mesXslRawMaterialEntry/anon/generateBarcode?materialCode={Uri.EscapeDataString(materialCode ?? "")}"; + try + { + using var client = CreateClient(); + var resp = await client.GetAsync(url, ct).ConfigureAwait(false); + resp.EnsureSuccessStatusCode(); + var json = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.TryGetProperty("result", out var el)) + return el.GetString(); + } + catch (Exception ex) + { + _logger.Warning($"[原料入场] 生成条码失败: {ex.Message}"); + } + return null; + } + + // ─── Remote ──────────────────────────────────────────────────────────────── + + private async Task> FetchRemoteListAsync(CancellationToken ct) + { + var query = HttpUtility.ParseQueryString(string.Empty); + query["pageNo"] = "1"; + query["pageSize"] = "10000"; + query["tenantId"] = DefaultTenantId.ToString(); + var url = $"{BaseUrl}/xslmes/mesXslRawMaterialEntry/anon/list?{query}"; + using var client = CreateClient(); + var resp = await client.GetAsync(url, ct).ConfigureAwait(false); + resp.EnsureSuccessStatusCode(); + var json = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + using var doc = JsonDocument.Parse(json); + var result = doc.RootElement.GetProperty("result"); + return result.GetProperty("records").Deserialize>(_jsonOpts) ?? new(); + } + + private async Task RemoteAddAsync(MesXslRawMaterialEntry entry, CancellationToken ct) + { + var url = $"{BaseUrl}/xslmes/mesXslRawMaterialEntry/anon/add?tenantId={DefaultTenantId}"; + var payload = Clone(entry); + if (IsLocalTempId(payload.Id)) payload.Id = null; + return await PostJsonAsync(url, payload, ct).ConfigureAwait(false); + } + + private async Task<(bool Ok, bool IsVersionConflict)> RemoteEditAsync(MesXslRawMaterialEntry entry, CancellationToken ct) + { + var url = $"{BaseUrl}/xslmes/mesXslRawMaterialEntry/anon/edit?tenantId={DefaultTenantId}"; + return await PostJsonCheckVersionAsync(url, entry, ct).ConfigureAwait(false); + } + + private async Task RemoteDeleteAsync(string id, CancellationToken ct) + { + var url = $"{BaseUrl}/xslmes/mesXslRawMaterialEntry/anon/delete?id={Uri.EscapeDataString(id)}&tenantId={DefaultTenantId}"; + using var client = CreateClient(); + var resp = await client.DeleteAsync(url, ct).ConfigureAwait(false); + return resp.IsSuccessStatusCode && await IsSuccessResultAsync(resp, ct).ConfigureAwait(false); + } + + private async Task PostJsonAsync(string url, object body, CancellationToken ct) + { + var content = new StringContent(JsonSerializer.Serialize(body, _jsonOpts), Encoding.UTF8, "application/json"); + using var client = CreateClient(); + var resp = await client.PostAsync(url, content, ct).ConfigureAwait(false); + return resp.IsSuccessStatusCode && await IsSuccessResultAsync(resp, ct).ConfigureAwait(false); + } + + private async Task<(bool Ok, bool IsVersionConflict)> PostJsonCheckVersionAsync(string url, object body, CancellationToken ct) + { + var content = new StringContent(JsonSerializer.Serialize(body, _jsonOpts), Encoding.UTF8, "application/json"); + using var client = CreateClient(); + var resp = await client.PostAsync(url, content, ct).ConfigureAwait(false); + if (!resp.IsSuccessStatusCode) return (false, false); + try + { + var json = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + using var doc = JsonDocument.Parse(json); + int code = 200; + if (doc.RootElement.TryGetProperty("code", out var codeEl)) code = codeEl.GetInt32(); + if (code == 200) return (true, false); + if (doc.RootElement.TryGetProperty("message", out var msgEl)) + if ((msgEl.GetString() ?? "").Contains("已被他人修改")) return (false, true); + return (false, false); + } + catch { return (true, false); } + } + + private static async Task IsSuccessResultAsync(HttpResponseMessage resp, CancellationToken ct) + { + try + { + var json = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.TryGetProperty("code", out var code)) return code.GetInt32() == 200; + if (doc.RootElement.TryGetProperty("success", out var success)) return success.GetBoolean(); + return true; + } + catch { return true; } + } + + // ─── Network / Reconnect ─────────────────────────────────────────────────── + + private void OnNetworkStatusChanged(bool isOnline) + { + if (!isOnline) return; + _ = Task.Run(() => SyncAfterReconnectAsync(CancellationToken.None)); + } + + private async Task SyncAfterReconnectAsync(CancellationToken cancellationToken) + { + var pushResult = await PushPendingOnReconnectAsync(cancellationToken).ConfigureAwait(false); + if (!_networkMonitor.IsOnline) return; + try + { + var remote = await FetchRemoteListAsync(cancellationToken).ConfigureAwait(false); + lock (_cacheLock) { _localCache = remote.Select(Clone).ToList(); SaveCacheToDiskUnsafe(); } + _eventAggregator.GetEvent().Publish(new RawMaterialEntryChangedPayload { Action = "pull" }); + _logger.Information($"[原料入场重连] 全量回拉成功 count={remote.Count}"); + } + catch (Exception ex) { _logger.Warning($"[原料入场重连] 全量回拉失败:{ex.Message}"); } + + if (pushResult.PushedCount > 0 || pushResult.ConflictCount > 0 || pushResult.NewRecordsPushed > 0) + { + _eventAggregator.GetEvent().Publish(new SyncConflictPayload + { + EntityName = "原料入场", + PushedCount = pushResult.PushedCount, + ConflictCount = pushResult.ConflictCount, + NewRecordsPushed = pushResult.NewRecordsPushed + }); + } + } + + private sealed record PendingReplayResult(bool Ok, bool IsConflict, string? EntityId); + + private async Task PushPendingOnReconnectAsync(CancellationToken cancellationToken) + { + if (!await _syncLock.WaitAsync(0, cancellationToken).ConfigureAwait(false)) + return new PushPendingResult(0, 0, 0); + try + { + List snapshot; + lock (_cacheLock) { snapshot = _pendingOps.OrderBy(x => x.CreatedAt).ToList(); } + + int pushed = 0, conflicts = 0, newPushed = 0; + foreach (var op in snapshot) + { + if (!_networkMonitor.IsOnline) break; + lock (_cacheLock) { if (!_pendingOps.Any(x => x.Id == op.Id)) continue; } + + var result = await ExecutePendingOperationAsync(op, cancellationToken).ConfigureAwait(false); + if (!result.Ok) + { + lock (_cacheLock) + { + op.RetryCount++; + if (op.RetryCount >= MaxPendingRetries) _pendingOps.RemoveAll(x => x.Id == op.Id); + SavePendingOpsToDiskUnsafe(); + } + break; + } + if (result.IsConflict) + { + conflicts++; + if (!string.IsNullOrWhiteSpace(result.EntityId)) RemovePendingOpsByEntryId(result.EntityId!); + continue; + } + lock (_cacheLock) + { + if (op.OpType == EntryOpType.Add) newPushed++; else pushed++; + _pendingOps.RemoveAll(x => x.Id == op.Id); + SavePendingOpsToDiskUnsafe(); + } + } + return new PushPendingResult(pushed, conflicts, newPushed); + } + finally { _syncLock.Release(); } + } + + private async Task ExecutePendingOperationAsync(EntryPendingOperation op, CancellationToken ct) + { + try + { + switch (op.OpType) + { + case EntryOpType.Add: + var ok = op.Entry != null && await RemoteAddAsync(op.Entry, ct).ConfigureAwait(false); + return ok ? new PendingReplayResult(true, false, op.EntryId) : new PendingReplayResult(false, false, null); + + case EntryOpType.Edit: + if (op.Entry == null || string.IsNullOrWhiteSpace(op.Entry.Id)) + return new PendingReplayResult(false, false, null); + var id = op.Entry.Id; + var remote = await FetchRemoteSingleAsync(id, ct).ConfigureAwait(false); + if (remote != null && op.AnchorUpdateTime != null && remote.UpdateTime != op.AnchorUpdateTime) + { + UpsertLocalCache(remote); + return new PendingReplayResult(true, true, id); + } + var (editOk, isConflict) = await RemoteEditAsync(op.Entry, ct).ConfigureAwait(false); + if (isConflict) { var fresh = await FetchRemoteSingleAsync(id, ct).ConfigureAwait(false); if (fresh != null) UpsertLocalCache(fresh); return new PendingReplayResult(true, true, id); } + return editOk ? new PendingReplayResult(true, false, id) : new PendingReplayResult(false, false, null); + + case EntryOpType.Delete: + if (string.IsNullOrWhiteSpace(op.EntryId)) return new PendingReplayResult(false, false, null); + var delId = op.EntryId!; + var delRemote = await FetchRemoteSingleAsync(delId, ct).ConfigureAwait(false); + if (delRemote == null) return new PendingReplayResult(true, false, delId); + if (op.AnchorUpdateTime != null && delRemote.UpdateTime != op.AnchorUpdateTime) { UpsertLocalCache(delRemote); return new PendingReplayResult(true, true, delId); } + var delOk = await RemoteDeleteAsync(delId, ct).ConfigureAwait(false); + return delOk ? new PendingReplayResult(true, false, delId) : new PendingReplayResult(false, false, null); + + default: + return new PendingReplayResult(true, false, null); + } + } + catch (Exception ex) + { + _logger.Warning($"[原料入场推送] 执行异常 op={op.OpType},entryId={op.EntryId}:{ex.Message}"); + return new PendingReplayResult(false, false, null); + } + } + + private async Task FetchRemoteSingleAsync(string id, CancellationToken ct) + { + try + { + var url = $"{BaseUrl}/xslmes/mesXslRawMaterialEntry/anon/queryById?id={Uri.EscapeDataString(id)}&tenantId={DefaultTenantId}"; + using var client = CreateClient(); + var resp = await client.GetAsync(url, ct).ConfigureAwait(false); + if (!resp.IsSuccessStatusCode) return null; + var json = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.TryGetProperty("result", out var resultEl)) + return resultEl.Deserialize(_jsonOpts); + return null; + } + catch { return null; } + } + + // ─── Local cache helpers ─────────────────────────────────────────────────── + + private static List ApplyFilters( + List source, + string? barcode, string? batchNo, string? billNo, + string? materialName, string? supplierName) + { + IEnumerable q = source; + if (!string.IsNullOrWhiteSpace(barcode)) + q = q.Where(e => (e.Barcode ?? "").Contains(barcode, StringComparison.OrdinalIgnoreCase)); + if (!string.IsNullOrWhiteSpace(batchNo)) + q = q.Where(e => (e.BatchNo ?? "").Contains(batchNo, StringComparison.OrdinalIgnoreCase)); + if (!string.IsNullOrWhiteSpace(billNo)) + q = q.Where(e => (e.BillNo ?? "").Contains(billNo, StringComparison.OrdinalIgnoreCase)); + if (!string.IsNullOrWhiteSpace(materialName)) + q = q.Where(e => (e.MaterialName ?? "").Contains(materialName, StringComparison.OrdinalIgnoreCase)); + if (!string.IsNullOrWhiteSpace(supplierName)) + q = q.Where(e => (e.SupplierName ?? "").Contains(supplierName, StringComparison.OrdinalIgnoreCase)); + return q.OrderByDescending(e => e.EntryTime ?? e.CreateTime ?? DateTime.MinValue).ToList(); + } + + private List ApplyPendingOpsSnapshotUnsafe(List source) + { + var map = source.Where(e => !string.IsNullOrWhiteSpace(e.Id)) + .ToDictionary(e => e.Id!, Clone, StringComparer.OrdinalIgnoreCase); + + foreach (var op in _pendingOps.OrderBy(x => x.CreatedAt)) + { + switch (op.OpType) + { + case EntryOpType.Add: + case EntryOpType.Edit: + if (op.Entry != null && !string.IsNullOrWhiteSpace(op.Entry.Id)) + map[op.Entry.Id] = Clone(op.Entry); + break; + case EntryOpType.Delete: + if (!string.IsNullOrWhiteSpace(op.EntryId)) + map.Remove(op.EntryId); + break; + } + } + return map.Values.ToList(); + } + + private void EnqueuePendingOperation(EntryPendingOperation op) + { + lock (_cacheLock) { _pendingOps.Add(op); SavePendingOpsToDiskUnsafe(); } + } + + private void UpsertLocalCache(MesXslRawMaterialEntry entry) + { + lock (_cacheLock) + { + var idx = _localCache.FindIndex(e => string.Equals(e.Id, entry.Id, StringComparison.OrdinalIgnoreCase)); + if (idx >= 0) _localCache[idx] = Clone(entry); + else _localCache.Insert(0, Clone(entry)); + SaveCacheToDiskUnsafe(); + } + } + + private void RemoveFromLocalCache(string id) + { + lock (_cacheLock) { _localCache.RemoveAll(e => string.Equals(e.Id, id, StringComparison.OrdinalIgnoreCase)); SaveCacheToDiskUnsafe(); } + } + + private void RemovePendingOpsByEntryId(string entryId) + { + lock (_cacheLock) + { + _pendingOps.RemoveAll(x => + (!string.IsNullOrWhiteSpace(x.EntryId) && string.Equals(x.EntryId, entryId, StringComparison.OrdinalIgnoreCase)) || + (x.Entry?.Id != null && string.Equals(x.Entry.Id, entryId, StringComparison.OrdinalIgnoreCase))); + SavePendingOpsToDiskUnsafe(); + } + } + + private void LoadPendingOpsFromDisk() + { + try + { + if (!File.Exists(_pendingOpsFilePath)) return; + var data = JsonSerializer.Deserialize>(File.ReadAllText(_pendingOpsFilePath), _jsonOpts); + _pendingOps = data ?? new(); + } + catch { _pendingOps = new(); } + } + + private void LoadCacheFromDisk() + { + try + { + if (!File.Exists(_cacheFilePath)) return; + var data = JsonSerializer.Deserialize>(File.ReadAllText(_cacheFilePath), _jsonOpts); + _localCache = data ?? new(); + } + catch { _localCache = new(); } + } + + private void SavePendingOpsToDiskUnsafe() => + File.WriteAllText(_pendingOpsFilePath, JsonSerializer.Serialize(_pendingOps, _jsonOpts)); + + private void SaveCacheToDiskUnsafe() => + File.WriteAllText(_cacheFilePath, JsonSerializer.Serialize(_localCache, _jsonOpts)); + + private static MesXslRawMaterialEntry Clone(MesXslRawMaterialEntry e) => new() + { + Id = e.Id, Barcode = e.Barcode, BatchNo = e.BatchNo, EntryTime = e.EntryTime, + WeightRecordId = e.WeightRecordId, BillNo = e.BillNo, MaterialId = e.MaterialId, + MaterialName = e.MaterialName, SupplyCustomer = e.SupplyCustomer, SupplierId = e.SupplierId, + SupplierName = e.SupplierName, ManufacturerMaterialName = e.ManufacturerMaterialName, + ShelfLife = e.ShelfLife, TotalWeight = e.TotalWeight, TotalPortions = e.TotalPortions, + PortionWeight = e.PortionWeight, PortionPackages = e.PortionPackages, + TestResult = e.TestResult, TestStatus = e.TestStatus, PrintFlag = e.PrintFlag, + StockBalance = e.StockBalance, WarehouseLocation = e.WarehouseLocation, + UnloadOperator = e.UnloadOperator, IsSpecialAdoption = e.IsSpecialAdoption, + SpecialAdoptionOperator = e.SpecialAdoptionOperator, SpecialAdoptionTime = e.SpecialAdoptionTime, + SpecialAdoptionReason = e.SpecialAdoptionReason, Status = e.Status, Remark = e.Remark, + CreateBy = e.CreateBy, CreateTime = e.CreateTime, UpdateBy = e.UpdateBy, + UpdateTime = e.UpdateTime, TenantId = e.TenantId + }; + + private static bool IsLocalTempId(string? id) => + !string.IsNullOrWhiteSpace(id) && id.StartsWith("local-", StringComparison.OrdinalIgnoreCase); + + private sealed class EntryPendingOperation + { + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + public EntryOpType OpType { get; set; } + public string? EntryId { get; set; } + public MesXslRawMaterialEntry? Entry { get; set; } + public DateTime? AnchorUpdateTime { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public int RetryCount { get; set; } = 0; + } + + private enum EntryOpType { Add = 1, Edit = 2, Delete = 3 } + + private sealed class NullableDateTimeJsonConverter : JsonConverter + { + private static readonly string[] SupportedFormats = + [ + "yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm:ss.fff", + "yyyy-MM-ddTHH:mm:ss", "yyyy-MM-ddTHH:mm:ss.fff", + "yyyy-MM-ddTHH:mm:ssZ", "yyyy-MM-ddTHH:mm:ss.fffZ" + ]; + + public override DateTime? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) return null; + if (reader.TokenType == JsonTokenType.String) + { + var raw = reader.GetString(); + if (string.IsNullOrWhiteSpace(raw)) return null; + if (DateTime.TryParseExact(raw, SupportedFormats, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AssumeLocal, out var exact)) return exact; + if (DateTime.TryParse(raw, out var fallback)) return fallback; + } + throw new JsonException($"无法将 JSON 值转换为 DateTime?,token={reader.TokenType}"); + } + + public override void Write(Utf8JsonWriter writer, DateTime? value, JsonSerializerOptions options) + { + if (value.HasValue) writer.WriteStringValue(value.Value.ToString("yyyy-MM-dd HH:mm:ss")); + else writer.WriteNullValue(); + } + } +} diff --git a/yy-admin-master/YY.Admin.Services/Service/RawMaterialEntry/RawMaterialEntrySyncCoordinator.cs b/yy-admin-master/YY.Admin.Services/Service/RawMaterialEntry/RawMaterialEntrySyncCoordinator.cs new file mode 100644 index 0000000..1810151 --- /dev/null +++ b/yy-admin-master/YY.Admin.Services/Service/RawMaterialEntry/RawMaterialEntrySyncCoordinator.cs @@ -0,0 +1,83 @@ +using Prism.Events; +using System.Text.Json; +using YY.Admin.Core; +using YY.Admin.Core.Events; +using YY.Admin.Core.Services; + +namespace YY.Admin.Services.Service.RawMaterialEntry; + +/// +/// 监听 STOMP 收到的原料入场记录变更信号,转发为桌面端 Prism 事件,触发列表刷新。 +/// +public class RawMaterialEntrySyncCoordinator : ISingletonDependency +{ + private readonly IEventAggregator _eventAggregator; + private readonly ILoggerService _logger; + private SubscriptionToken? _remoteCommandToken; + private SubscriptionToken? _networkStatusToken; + + public RawMaterialEntrySyncCoordinator( + IEventAggregator eventAggregator, + SyncPollManager pollManager, + ILoggerService logger) + { + _eventAggregator = eventAggregator; + _logger = logger; + _remoteCommandToken = _eventAggregator + .GetEvent() + .Subscribe(OnRemoteCommand, ThreadOption.BackgroundThread); + _networkStatusToken = _eventAggregator + .GetEvent() + .Subscribe(OnNetworkStatusChanged, ThreadOption.BackgroundThread); + + pollManager.Register("原料入场", () => + { + _eventAggregator.GetEvent() + .Publish(new RawMaterialEntryChangedPayload { Action = "poll" }); + return Task.CompletedTask; + }); + + _logger.Information("[原料入场] RawMaterialEntrySyncCoordinator 已启动"); + } + + private void OnNetworkStatusChanged(NetworkStatusChangedPayload payload) + { + if (!payload.IsOnline) return; + _logger.Information("[原料入场] 网络恢复,触发补偿刷新"); + _eventAggregator.GetEvent().Publish(new RawMaterialEntryChangedPayload { Action = "reconnect" }); + } + + private void OnRemoteCommand(RemoteCommandPayload payload) + { + try + { + var json = payload.CommandJson ?? string.Empty; + if (string.IsNullOrWhiteSpace(json)) return; + + using var doc = JsonDocument.Parse(json); + if (!doc.RootElement.TryGetProperty("cmd", out var cmdEl)) return; + + var cmd = cmdEl.GetString() ?? string.Empty; + if (!cmd.Equals("MES_RAW_MATERIAL_ENTRY_CHANGED", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + doc.RootElement.TryGetProperty("action", out var actionEl); + doc.RootElement.TryGetProperty("entryId", out var idEl); + + var changedPayload = new RawMaterialEntryChangedPayload + { + Action = actionEl.GetString() ?? string.Empty, + EntryId = idEl.ValueKind == JsonValueKind.String ? idEl.GetString() : null, + }; + + _logger.Information($"收到原料入场变更信号: action={changedPayload.Action}, entryId={changedPayload.EntryId}"); + _eventAggregator.GetEvent().Publish(changedPayload); + } + catch (Exception ex) + { + _logger.Warning($"处理 STOMP 原料入场变更信号失败: {ex.Message}"); + } + } +} diff --git a/yy-admin-master/YY.Admin.Services/Service/Supplier/SupplierSyncCoordinator.cs b/yy-admin-master/YY.Admin.Services/Service/Supplier/SupplierSyncCoordinator.cs index 2baf190..f43095e 100644 --- a/yy-admin-master/YY.Admin.Services/Service/Supplier/SupplierSyncCoordinator.cs +++ b/yy-admin-master/YY.Admin.Services/Service/Supplier/SupplierSyncCoordinator.cs @@ -15,6 +15,7 @@ public class SupplierSyncCoordinator : ISingletonDependency public SupplierSyncCoordinator( IEventAggregator eventAggregator, ISupplierService supplierService, + SyncPollManager pollManager, ILoggerService logger) { _eventAggregator = eventAggregator; @@ -24,6 +25,13 @@ public class SupplierSyncCoordinator : ISingletonDependency .Subscribe(OnRemoteCommand, ThreadOption.BackgroundThread); _eventAggregator.GetEvent() .Subscribe(OnNetworkStatusChanged, ThreadOption.BackgroundThread); + + pollManager.Register("供应商", () => + { + _eventAggregator.GetEvent() + .Publish(new SupplierChangedPayload { Action = "poll" }); + return Task.CompletedTask; + }); } private async void OnNetworkStatusChanged(NetworkStatusChangedPayload payload) diff --git a/yy-admin-master/YY.Admin.Services/Service/SyncPollManager.cs b/yy-admin-master/YY.Admin.Services/Service/SyncPollManager.cs new file mode 100644 index 0000000..4c70167 --- /dev/null +++ b/yy-admin-master/YY.Admin.Services/Service/SyncPollManager.cs @@ -0,0 +1,58 @@ +using YY.Admin.Core; + +namespace YY.Admin.Services.Service; + +/// +/// 统一后台轮询管理器。 +/// 各模块通过 注册轮询任务,定时器统一触发。 +/// ★ 只需修改 即可调整所有模块的轮询间隔。 +/// +public class SyncPollManager : ISingletonDependency +{ + // ★ 唯一轮询间隔配置 — 修改这里即可调整所有模块的轮询频率 + public static readonly TimeSpan PollInterval = TimeSpan.FromMinutes(5); + + private readonly List<(string Name, Func Task)> _tasks = []; + private readonly Timer _timer; + private readonly ILoggerService _logger; + + public SyncPollManager(ILoggerService logger) + { + _logger = logger; + // 首次延迟一个完整周期,确保所有协调器启动后都已完成注册 + _timer = new Timer(OnTick, null, PollInterval, PollInterval); + _logger.Information($"[轮询管理器] 已启动,间隔 {PollInterval.TotalMinutes} 分钟"); + } + + /// 注册一个需要定时执行的轮询任务 + /// 任务名称,用于日志 + /// 异步轮询委托 + public void Register(string name, Func pollTask) + { + lock (_tasks) + _tasks.Add((name, pollTask)); + _logger.Information($"[轮询管理器] 注册任务: {name}"); + } + + private void OnTick(object? _) + { + _ = Task.Run(async () => + { + List<(string Name, Func Task)> snapshot; + lock (_tasks) snapshot = [.. _tasks]; + + _logger.Debug($"[轮询管理器] 触发,执行 {snapshot.Count} 个任务"); + foreach (var (name, task) in snapshot) + { + try + { + await task().ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.Warning($"[轮询管理器] 任务 [{name}] 异常: {ex.Message}"); + } + } + }); + } +} diff --git a/yy-admin-master/YY.Admin.Services/Service/Vehicle/VehicleSyncCoordinator.cs b/yy-admin-master/YY.Admin.Services/Service/Vehicle/VehicleSyncCoordinator.cs index 57e3e33..46d554b 100644 --- a/yy-admin-master/YY.Admin.Services/Service/Vehicle/VehicleSyncCoordinator.cs +++ b/yy-admin-master/YY.Admin.Services/Service/Vehicle/VehicleSyncCoordinator.cs @@ -16,18 +16,28 @@ public class VehicleSyncCoordinator : ISingletonDependency private SubscriptionToken? _remoteCommandToken; private SubscriptionToken? _networkStatusToken; - public VehicleSyncCoordinator(IEventAggregator eventAggregator, ILoggerService logger) + public VehicleSyncCoordinator( + IEventAggregator eventAggregator, + SyncPollManager pollManager, + ILoggerService logger) { _eventAggregator = eventAggregator; _logger = logger; _remoteCommandToken = _eventAggregator .GetEvent() .Subscribe(OnRemoteCommand, ThreadOption.BackgroundThread); - // 断线重连后补拉一次,覆盖离线期间漏掉的 STOMP 事件 _networkStatusToken = _eventAggregator .GetEvent() .Subscribe(OnNetworkStatusChanged, ThreadOption.BackgroundThread); - _logger.Information("[车辆推送] VehicleSyncCoordinator 已启动,开始监听 RemoteCommandReceivedEvent"); + + pollManager.Register("车辆", () => + { + _eventAggregator.GetEvent() + .Publish(new VehicleChangedPayload { Action = "poll" }); + return Task.CompletedTask; + }); + + _logger.Information("[车辆推送] VehicleSyncCoordinator 已启动"); } private void OnNetworkStatusChanged(NetworkStatusChangedPayload payload) diff --git a/yy-admin-master/YY.Admin.Services/Service/WeightRecord/WeightRecordSyncCoordinator.cs b/yy-admin-master/YY.Admin.Services/Service/WeightRecord/WeightRecordSyncCoordinator.cs index f9de1a7..0cded0c 100644 --- a/yy-admin-master/YY.Admin.Services/Service/WeightRecord/WeightRecordSyncCoordinator.cs +++ b/yy-admin-master/YY.Admin.Services/Service/WeightRecord/WeightRecordSyncCoordinator.cs @@ -16,7 +16,10 @@ public class WeightRecordSyncCoordinator : ISingletonDependency private SubscriptionToken? _remoteCommandToken; private SubscriptionToken? _networkStatusToken; - public WeightRecordSyncCoordinator(IEventAggregator eventAggregator, ILoggerService logger) + public WeightRecordSyncCoordinator( + IEventAggregator eventAggregator, + SyncPollManager pollManager, + ILoggerService logger) { _eventAggregator = eventAggregator; _logger = logger; @@ -26,6 +29,14 @@ public class WeightRecordSyncCoordinator : ISingletonDependency _networkStatusToken = _eventAggregator .GetEvent() .Subscribe(OnNetworkStatusChanged, ThreadOption.BackgroundThread); + + pollManager.Register("磅单", () => + { + _eventAggregator.GetEvent() + .Publish(new MesXslWeightRecordChangedPayload { Action = "poll" }); + return Task.CompletedTask; + }); + _logger.Information("[磅单推送] WeightRecordSyncCoordinator 已启动"); } diff --git a/yy-admin-master/YY.Admin/Infrastructure/Hubs/StompWebSocketService.cs b/yy-admin-master/YY.Admin/Infrastructure/Hubs/StompWebSocketService.cs index d6a191a..adde5e8 100644 --- a/yy-admin-master/YY.Admin/Infrastructure/Hubs/StompWebSocketService.cs +++ b/yy-admin-master/YY.Admin/Infrastructure/Hubs/StompWebSocketService.cs @@ -150,6 +150,10 @@ public class StompWebSocketService : ISignalRService await SendFrameAsync( BuildSubscribeFrame("sub-sys-dicts", "/topic/sync/sys-dicts"), cancellationToken).ConfigureAwait(false); + // 原料入场记录变更:订阅 /topic/sync/mes-raw-material-entries + await SendFrameAsync( + BuildSubscribeFrame("sub-mes-raw-material-entries", "/topic/sync/mes-raw-material-entries"), + cancellationToken).ConfigureAwait(false); // 订阅服务端 PONG 回复(应用层假在线检测) await SendFrameAsync( diff --git a/yy-admin-master/YY.Admin/Module/NavigationExtensions.cs b/yy-admin-master/YY.Admin/Module/NavigationExtensions.cs index cd3b20f..77e4c6a 100644 --- a/yy-admin-master/YY.Admin/Module/NavigationExtensions.cs +++ b/yy-admin-master/YY.Admin/Module/NavigationExtensions.cs @@ -13,6 +13,7 @@ using YY.Admin.Views.MixerMaterial; using YY.Admin.ViewModels.Vehicle; using YY.Admin.Views.Vehicle; using YY.Admin.Views.WeightRecord; +using YY.Admin.Views.RawMaterialEntry; namespace YY.Admin { @@ -70,6 +71,8 @@ namespace YY.Admin containerRegistry.RegisterForNavigation(); // 密炼物料信息 containerRegistry.RegisterForNavigation(); + // 原料入场记录 + containerRegistry.RegisterForNavigation(); } } public class DialogWindow : Window, IDialogWindow diff --git a/yy-admin-master/YY.Admin/Module/SyncModule.cs b/yy-admin-master/YY.Admin/Module/SyncModule.cs index d313637..3ee60b3 100644 --- a/yy-admin-master/YY.Admin/Module/SyncModule.cs +++ b/yy-admin-master/YY.Admin/Module/SyncModule.cs @@ -13,11 +13,13 @@ using YY.Admin.Infrastructure.Hubs; using YY.Admin.Infrastructure.Network; using YY.Admin.Infrastructure.Storage; using YY.Admin.Infrastructure.Sync; +using YY.Admin.Services.Service; using YY.Admin.Services.Service.Category; using YY.Admin.Services.Service.Customer; using YY.Admin.Services.Service.Dict; using YY.Admin.Services.Service.MixerMaterial; using YY.Admin.Services.Service.Supplier; +using YY.Admin.Services.Service.RawMaterialEntry; using YY.Admin.Services.Service.Vehicle; using YY.Admin.Services.Service.WeightRecord; @@ -52,10 +54,15 @@ public class SyncModule : IModule // 密炼物料信息:API直连 + STOMP实时通知 containerRegistry.RegisterSingleton(); containerRegistry.RegisterSingleton(); + // 原料入场记录:免密 API 直连 + STOMP 实时通知 + containerRegistry.RegisterSingleton(); + containerRegistry.RegisterSingleton(); // 分类字典:启动同步 + 断线重连补刷 containerRegistry.RegisterSingleton(); // 数据字典:启动同步 + 断线重连补刷 containerRegistry.RegisterSingleton(); + // 统一轮询管理器(修改 SyncPollManager.PollInterval 即可调整所有模块的轮询间隔) + containerRegistry.RegisterSingleton(); var serviceCollection = new ServiceCollection(); serviceCollection.AddTransient(); @@ -100,6 +107,8 @@ public class SyncModule : IModule _ = Task.Run(() => signalService.ConnectUnifiedDeviceChannelAsync(CancellationToken.None)); // 强制实例化事件订阅器(单例,构造函数内完成订阅注册) _ = containerProvider.Resolve(); + // 统一轮询管理器必须在各协调器之前实例化,确保 Register() 能正常接收注册 + _ = containerProvider.Resolve(); // 强制实例化车辆同步协调器(构造函数内订阅 STOMP 车辆变更事件) _ = containerProvider.Resolve(); // 强制实例化客户同步协调器 @@ -110,6 +119,8 @@ public class SyncModule : IModule _ = containerProvider.Resolve(); // 强制实例化密炼物料同步协调器 _ = containerProvider.Resolve(); + // 强制实例化原料入场记录同步协调器 + _ = containerProvider.Resolve(); // 强制实例化分类字典同步协调器 _ = containerProvider.Resolve(); // 强制实例化数据字典同步协调器 diff --git a/yy-admin-master/YY.Admin/ViewModels/Control/MenuTreeViewModel.cs b/yy-admin-master/YY.Admin/ViewModels/Control/MenuTreeViewModel.cs index ce8666b..899b378 100644 --- a/yy-admin-master/YY.Admin/ViewModels/Control/MenuTreeViewModel.cs +++ b/yy-admin-master/YY.Admin/ViewModels/Control/MenuTreeViewModel.cs @@ -120,7 +120,12 @@ namespace YY.Admin.ViewModels.Control // 已实现页面:密炼物料信息 ["MixerMaterialListView"] = "MixerMaterialListView", ["/xslmes/mesMixerMaterial"] = "MixerMaterialListView", - ["mesMixerMaterial"] = "MixerMaterialListView" + ["mesMixerMaterial"] = "MixerMaterialListView", + + // 已实现页面:原料入场记录 + ["RawMaterialEntryListView"] = "RawMaterialEntryListView", + ["/xslmes/mesXslRawMaterialEntry"] = "RawMaterialEntryListView", + ["mesXslRawMaterialEntry"] = "RawMaterialEntryListView" }; private MenuItem? _selectedMenuItem; diff --git a/yy-admin-master/YY.Admin/ViewModels/RawMaterialEntry/RawMaterialEntryEditDialogViewModel.cs b/yy-admin-master/YY.Admin/ViewModels/RawMaterialEntry/RawMaterialEntryEditDialogViewModel.cs new file mode 100644 index 0000000..7add1d7 --- /dev/null +++ b/yy-admin-master/YY.Admin/ViewModels/RawMaterialEntry/RawMaterialEntryEditDialogViewModel.cs @@ -0,0 +1,278 @@ +using HandyControl.Controls; +using HandyControl.Tools.Extension; +using System.Collections.ObjectModel; +using YY.Admin.Core; +using YY.Admin.Core.Entity; +using YY.Admin.Core.Services; +using YY.Admin.Services.Service; + +namespace YY.Admin.ViewModels.RawMaterialEntry; + +public class RawMaterialEntryEditDialogViewModel : BaseViewModel, IDialogResultable +{ + private readonly IRawMaterialEntryService _entryService; + private readonly IJeecgDictSyncService _dictSyncService; + private readonly IMixerMaterialService _mixerMaterialService; + + // 加载完物料后用于回填 Edit 模式选中项 + private string? _pendingMaterialId; + + private MesXslRawMaterialEntry? _entry; + public MesXslRawMaterialEntry? Entry + { + get => _entry; + set => SetProperty(ref _entry, value); + } + + private MesMixerMaterial? _selectedMaterial; + public MesMixerMaterial? SelectedMaterial + { + get => _selectedMaterial; + set + { + if (!SetProperty(ref _selectedMaterial, value) || value == null || Entry == null) return; + Entry.MaterialId = value.Id; + Entry.MaterialCode = value.MaterialCode; + Entry.MaterialName = value.MaterialName; + RaisePropertyChanged(nameof(Entry)); + + // 新增模式自动生成条码/批次号 + if (IsAddMode && !string.IsNullOrEmpty(value.MaterialCode)) + _ = AutoGenerateBarcodeAsync(value.MaterialCode); + } + } + + private bool _isGenerating; + public bool IsGenerating + { + get => _isGenerating; + set => SetProperty(ref _isGenerating, value); + } + + public bool IsAddMode => string.IsNullOrWhiteSpace(Entry?.Id); + public string DialogTitle => IsAddMode ? "新增原料入场记录" : "编辑原料入场记录"; + + public ObservableCollection MaterialOptions { get; } = new(); + public ObservableCollection> TestResultOptions { get; } = new(); + public ObservableCollection> TestStatusOptions { get; } = new(); + public ObservableCollection> PrintFlagOptions { get; } = new(); + public ObservableCollection> StockBalanceOptions { get; } = new(); + public ObservableCollection> IsSpecialAdoptionOptions { get; } = new(); + public ObservableCollection> StatusOptions { get; } = new(); + + private bool _result; + public bool Result { get => _result; set => SetProperty(ref _result, value); } + public Action? CloseAction { get; set; } + + public DelegateCommand SaveCommand { get; } + public DelegateCommand CancelCommand { get; } + + public RawMaterialEntryEditDialogViewModel( + IRawMaterialEntryService entryService, + IJeecgDictSyncService dictSyncService, + IMixerMaterialService mixerMaterialService, + IContainerExtension container, + IRegionManager regionManager) : base(container, regionManager) + { + _entryService = entryService; + _dictSyncService = dictSyncService; + _mixerMaterialService = mixerMaterialService; + SaveCommand = new DelegateCommand(async () => await SaveAsync()); + CancelCommand = new DelegateCommand(() => CloseAction?.Invoke()); + _ = LoadAllAsync(); + } + + private async Task LoadAllAsync() + { + await Task.WhenAll(LoadDictOptionsAsync(), LoadMaterialOptionsAsync()); + } + + private async Task LoadMaterialOptionsAsync() + { + try + { + // 每次打开弹窗都主动拉一次,确保直接写库的数据也能同步到 + await _mixerMaterialService.SyncFromRemoteAsync(); + var result = await _mixerMaterialService.PageAsync(1, 2000); + MaterialOptions.Clear(); + foreach (var m in result.Records) + MaterialOptions.Add(m); + + // Edit 模式:物料加载完后回填选中项 + if (_pendingMaterialId != null) + { + var match = MaterialOptions.FirstOrDefault(m => + string.Equals(m.Id, _pendingMaterialId, StringComparison.OrdinalIgnoreCase)); + if (match != null) + SelectedMaterial = match; + _pendingMaterialId = null; + } + } + catch { /* 无法加载物料列表时不阻断表单 */ } + } + + private async Task LoadDictOptionsAsync() + { + try + { + var testResultOpts = await _dictSyncService.GetDictOptionsAsync("xslmes_test_result"); + var testStatusOpts = await _dictSyncService.GetDictOptionsAsync("xslmes_test_status"); + var printFlagOpts = await _dictSyncService.GetDictOptionsAsync("xslmes_print_flag"); + var ynOpts = await _dictSyncService.GetDictOptionsAsync("yn"); + var statusOpts = await _dictSyncService.GetDictOptionsAsync("xslmes_entry_status"); + + PopulateOptions(TestResultOptions, testResultOpts, new[] + { + new KeyValuePair("未检", "0"), + new KeyValuePair("合格", "1"), + new KeyValuePair("不合格", "2"), + }); + PopulateOptions(TestStatusOptions, testStatusOpts, new[] + { + new KeyValuePair("送样", "0"), + new KeyValuePair("已批准", "1"), + }); + PopulateOptions(PrintFlagOptions, printFlagOpts, new[] + { + new KeyValuePair("未打印", "0"), + new KeyValuePair("已打印", "1"), + }); + PopulateOptions(StockBalanceOptions, ynOpts, new[] + { + new KeyValuePair("否", "0"), + new KeyValuePair("是", "1"), + }); + PopulateOptions(IsSpecialAdoptionOptions, ynOpts, new[] + { + new KeyValuePair("否", "0"), + new KeyValuePair("是", "1"), + }); + PopulateOptions(StatusOptions, statusOpts, Array.Empty>()); + } + catch { FillFallbackOptions(); } + } + + private static void PopulateOptions( + ObservableCollection> target, + IEnumerable> items, + IEnumerable> fallback) + { + target.Clear(); + var list = items.ToList(); + foreach (var item in list.Count > 0 ? list : fallback.ToList()) + target.Add(item); + } + + private void FillFallbackOptions() + { + PopulateOptions(TestResultOptions, Array.Empty>(), new[] + { + new KeyValuePair("未检", "0"), + new KeyValuePair("合格", "1"), + new KeyValuePair("不合格", "2"), + }); + PopulateOptions(TestStatusOptions, Array.Empty>(), new[] + { + new KeyValuePair("送样", "0"), + new KeyValuePair("已批准", "1"), + }); + PopulateOptions(PrintFlagOptions, Array.Empty>(), new[] + { + new KeyValuePair("未打印", "0"), + new KeyValuePair("已打印", "1"), + }); + var ynDefault = new[] { new KeyValuePair("否", "0"), new KeyValuePair("是", "1") }; + PopulateOptions(StockBalanceOptions, Array.Empty>(), ynDefault); + PopulateOptions(IsSpecialAdoptionOptions, Array.Empty>(), ynDefault); + } + + private async Task AutoGenerateBarcodeAsync(string materialCode) + { + IsGenerating = true; + try + { + var code = await _entryService.GenerateBarcodeAsync(materialCode); + if (!string.IsNullOrEmpty(code) && Entry != null) + { + Entry.Barcode = code; + Entry.BatchNo = code; + RaisePropertyChanged(nameof(Entry)); + } + } + finally { IsGenerating = false; } + } + + public void InitializeForAdd() + { + _selectedMaterial = null; + RaisePropertyChanged(nameof(SelectedMaterial)); + Entry = new MesXslRawMaterialEntry + { + TestResult = "0", TestStatus = "0", PrintFlag = "0", + StockBalance = "0", IsSpecialAdoption = "0" + }; + RaisePropertyChanged(nameof(IsAddMode)); + RaisePropertyChanged(nameof(DialogTitle)); + } + + public void InitializeForEdit(MesXslRawMaterialEntry entry) + { + Entry = new MesXslRawMaterialEntry + { + Id = entry.Id, Barcode = entry.Barcode, BatchNo = entry.BatchNo, EntryTime = entry.EntryTime, + WeightRecordId = entry.WeightRecordId, BillNo = entry.BillNo, + MaterialId = entry.MaterialId, MaterialCode = entry.MaterialCode, MaterialName = entry.MaterialName, + SupplyCustomer = entry.SupplyCustomer, SupplierId = entry.SupplierId, SupplierName = entry.SupplierName, + ManufacturerMaterialName = entry.ManufacturerMaterialName, + ShelfLife = entry.ShelfLife, TotalWeight = entry.TotalWeight, TotalPortions = entry.TotalPortions, + PortionWeight = entry.PortionWeight, PortionPackages = entry.PortionPackages, + TestResult = entry.TestResult, TestStatus = entry.TestStatus, PrintFlag = entry.PrintFlag, + StockBalance = entry.StockBalance, WarehouseLocation = entry.WarehouseLocation, + UnloadOperator = entry.UnloadOperator, IsSpecialAdoption = entry.IsSpecialAdoption, + SpecialAdoptionOperator = entry.SpecialAdoptionOperator, SpecialAdoptionTime = entry.SpecialAdoptionTime, + SpecialAdoptionReason = entry.SpecialAdoptionReason, Status = entry.Status, Remark = entry.Remark, + TenantId = entry.TenantId, + }; + + // 若物料列表已加载则直接回填,否则记录 pending 等加载完后回填 + if (MaterialOptions.Count > 0) + { + _selectedMaterial = MaterialOptions.FirstOrDefault(m => + string.Equals(m.Id, entry.MaterialId, StringComparison.OrdinalIgnoreCase)); + RaisePropertyChanged(nameof(SelectedMaterial)); + } + else + { + _pendingMaterialId = entry.MaterialId; + } + + RaisePropertyChanged(nameof(IsAddMode)); + RaisePropertyChanged(nameof(DialogTitle)); + } + + private async Task SaveAsync() + { + if (Entry == null) return; + try + { + bool ok; + if (IsAddMode) + { + ok = await _entryService.AddAsync(Entry); + if (ok) HandyControl.Controls.MessageBox.Success("新增成功!"); + else { HandyControl.Controls.MessageBox.Error("新增失败!"); return; } + } + else + { + ok = await _entryService.EditAsync(Entry); + if (!ok) { HandyControl.Controls.MessageBox.Error("编辑失败!"); return; } + } + Result = ok; + CloseAction?.Invoke(); + } + catch (Exception ex) + { + HandyControl.Controls.MessageBox.Error($"操作失败:{ex.Message}"); + } + } +} diff --git a/yy-admin-master/YY.Admin/ViewModels/RawMaterialEntry/RawMaterialEntryListViewModel.cs b/yy-admin-master/YY.Admin/ViewModels/RawMaterialEntry/RawMaterialEntryListViewModel.cs new file mode 100644 index 0000000..4aa656b --- /dev/null +++ b/yy-admin-master/YY.Admin/ViewModels/RawMaterialEntry/RawMaterialEntryListViewModel.cs @@ -0,0 +1,204 @@ +using HandyControl.Controls; +using HandyControl.Tools.Extension; +using Prism.Events; +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Windows; +using YY.Admin.Core; +using YY.Admin.Core.Events; +using YY.Admin.Core.Helper; +using YY.Admin.Core.Entity; +using YY.Admin.Core.Services; +using YY.Admin.Services.Service; +using YY.Admin.Views.RawMaterialEntry; + +namespace YY.Admin.ViewModels.RawMaterialEntry; + +public class RawMaterialEntryListViewModel : BaseViewModel +{ + private readonly IRawMaterialEntryService _entryService; + private readonly IJeecgDictSyncService _dictSyncService; + private SubscriptionToken? _entryChangedToken; + private SubscriptionToken? _syncConflictToken; + + private ObservableCollection _entries = new(); + public ObservableCollection Entries + { + get => _entries; + set => SetProperty(ref _entries, value); + } + + private long _total; + public long Total { get => _total; set => SetProperty(ref _total, value); } + + private int _pageNo = 1; + public int PageNo { get => _pageNo; set => SetProperty(ref _pageNo, value); } + + private int _pageSize = 20; + public int PageSize { get => _pageSize; set => SetProperty(ref _pageSize, value); } + + private string? _filterBarcode; + public string? FilterBarcode { get => _filterBarcode; set => SetProperty(ref _filterBarcode, value); } + + private string? _filterBatchNo; + public string? FilterBatchNo { get => _filterBatchNo; set => SetProperty(ref _filterBatchNo, value); } + + private string? _filterBillNo; + public string? FilterBillNo { get => _filterBillNo; set => SetProperty(ref _filterBillNo, value); } + + private string? _filterMaterialName; + public string? FilterMaterialName { get => _filterMaterialName; set => SetProperty(ref _filterMaterialName, value); } + + private string? _filterSupplierName; + public string? FilterSupplierName { get => _filterSupplierName; set => SetProperty(ref _filterSupplierName, value); } + + public DelegateCommand SearchCommand { get; } + public DelegateCommand ResetCommand { get; } + public DelegateCommand AddCommand { get; } + public DelegateCommand EditCommand { get; } + public DelegateCommand DeleteCommand { get; } + public DelegateCommand PrevPageCommand { get; } + public DelegateCommand NextPageCommand { get; } + + public RawMaterialEntryListViewModel( + IRawMaterialEntryService entryService, + IJeecgDictSyncService dictSyncService, + IContainerExtension container, + IRegionManager regionManager) : base(container, regionManager) + { + _entryService = entryService; + _dictSyncService = dictSyncService; + + SearchCommand = new DelegateCommand(async () => { PageNo = 1; await LoadAsync(); }); + ResetCommand = new DelegateCommand(async () => + { + FilterBarcode = null; FilterBatchNo = null; FilterBillNo = null; + FilterMaterialName = null; FilterSupplierName = null; + PageNo = 1; + await LoadAsync(); + }); + AddCommand = new DelegateCommand(async () => await ShowAddDialogAsync()); + EditCommand = new DelegateCommand(async e => await ShowEditDialogAsync(e)); + DeleteCommand = new DelegateCommand(async e => await DeleteAsync(e)); + PrevPageCommand = new DelegateCommand(async () => { if (PageNo > 1) { PageNo--; await LoadAsync(); } }); + NextPageCommand = new DelegateCommand(async () => { if ((long)PageNo * PageSize < Total) { PageNo++; await LoadAsync(); } }); + + _entryChangedToken = _eventAggregator + .GetEvent() + .Subscribe(async p => await OnEntryChangedAsync(p), ThreadOption.UIThread); + + _syncConflictToken = _eventAggregator.GetEvent() + .Subscribe(OnSyncConflict, ThreadOption.UIThread); + + _ = InitializeAsync(); + } + + private async Task OnEntryChangedAsync(RawMaterialEntryChangedPayload payload) + { + if (payload.Action == "edit" && !string.IsNullOrWhiteSpace(payload.EntryId)) + await RefreshSingleAsync(payload.EntryId!); + else + await LoadAsync(); + } + + private async Task RefreshSingleAsync(string entryId) + { + try + { + var updated = await _entryService.GetByIdAsync(entryId); + if (updated == null) return; + var idx = Entries.ToList().FindIndex(e => string.Equals(e.Id, entryId, StringComparison.OrdinalIgnoreCase)); + if (idx >= 0) Entries[idx] = updated; + } + catch (Exception ex) { Debug.WriteLine($"[原料入场] 单条刷新失败: {ex.Message}"); } + } + + private void OnSyncConflict(SyncConflictPayload payload) + { + if (!string.Equals(payload.EntityName, "原料入场", StringComparison.OrdinalIgnoreCase)) return; + var parts = new List(); + if (payload.PushedCount > 0) parts.Add($"已同步 {payload.PushedCount} 条本地改动到服务器"); + if (payload.NewRecordsPushed > 0) parts.Add($"已上传 {payload.NewRecordsPushed} 条本地新增记录"); + if (payload.ConflictCount > 0) parts.Add($"{payload.ConflictCount} 条记录与服务器版本冲突,已保留服务器版本"); + if (parts.Count == 0) return; + var message = string.Join("\n", parts); + if (payload.ConflictCount > 0) Growl.Warning(message); else Growl.Success(message); + } + + private async Task InitializeAsync() + { + try + { + await UIHelper.WaitForRenderAsync(); + await LoadAsync(); + } + catch (Exception ex) { Debug.WriteLine($"原料入场列表初始化失败: {ex.Message}"); } + } + + public async Task LoadAsync() + { + try + { + IsLoading = true; + var result = await _entryService.PageAsync(PageNo, PageSize, + FilterBarcode, FilterBatchNo, FilterBillNo, FilterMaterialName, FilterSupplierName); + Entries = new ObservableCollection(result.Records); + Total = result.Total; + } + catch (Exception ex) { Growl.Error($"加载原料入场记录失败:{ex.Message}"); } + finally { IsLoading = false; } + } + + private async Task ShowAddDialogAsync() + { + try + { + var result = await HandyControl.Controls.Dialog.Show() + .Initialize(vm => vm.InitializeForAdd()) + .GetResultAsync(); + if (result) await LoadAsync(); + } + catch (Exception ex) { Growl.Error($"打开新增对话框失败:{ex.Message}"); } + } + + private async Task ShowEditDialogAsync(MesXslRawMaterialEntry entry) + { + if (entry == null) return; + try + { + var result = await HandyControl.Controls.Dialog.Show() + .Initialize(vm => vm.InitializeForEdit(entry)) + .GetResultAsync(); + if (result) await LoadAsync(); + } + catch (Exception ex) { Growl.Error($"打开编辑对话框失败:{ex.Message}"); } + } + + private async Task DeleteAsync(MesXslRawMaterialEntry entry) + { + if (entry?.Id == null) return; + var confirm = System.Windows.MessageBox.Show( + $"确定删除该原料入场记录(条码:{entry.Barcode})?此操作不可恢复!", + "确认删除", MessageBoxButton.OKCancel, MessageBoxImage.Question); + if (confirm != System.Windows.MessageBoxResult.OK) return; + + var ok = await _entryService.DeleteAsync(entry.Id); + if (ok) { Growl.Success("删除成功!"); await LoadAsync(); } + else Growl.Error("删除失败!"); + } + + protected override void CleanUp() + { + base.CleanUp(); + if (_entryChangedToken != null) + { + _eventAggregator.GetEvent().Unsubscribe(_entryChangedToken); + _entryChangedToken = null; + } + if (_syncConflictToken != null) + { + _eventAggregator.GetEvent().Unsubscribe(_syncConflictToken); + _syncConflictToken = null; + } + } +} diff --git a/yy-admin-master/YY.Admin/Views/RawMaterialEntry/RawMaterialEntryEditDialogView.xaml b/yy-admin-master/YY.Admin/Views/RawMaterialEntry/RawMaterialEntryEditDialogView.xaml new file mode 100644 index 0000000..8eab901 --- /dev/null +++ b/yy-admin-master/YY.Admin/Views/RawMaterialEntry/RawMaterialEntryEditDialogView.xaml @@ -0,0 +1,400 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +