新增原料入场记录功能,包含免密接口和数据同步,更新相关控制器、实体和服务,支持条码/批次号生成及管理,优化用户体验和系统实时数据处理能力。

This commit is contained in:
geht
2026-05-09 15:55:11 +08:00
parent 64e978a618
commit 16bb22a113
38 changed files with 2398 additions and 10 deletions

View File

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

View File

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

View File

@@ -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");
// 系统分类字典免密接口(供桌面端调用)

View File

@@ -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<IPage<MesXslRawMaterialEntry>> rawMaterialEntryAnonList(
MesXslRawMaterialEntry entity,
@RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo,
@RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize,
HttpServletRequest req) {
QueryWrapper<MesXslRawMaterialEntry> qw = QueryGenerator.initQueryWrapper(entity, req.getParameterMap());
qw.orderByDesc("create_time");
IPage<MesXslRawMaterialEntry> page = rawMaterialEntryService.page(new Page<>(pageNo, pageSize), qw);
return Result.OK(page);
}
@Operation(summary = "原料入场记录-免密通过id查询")
@GetMapping("/xslmes/mesXslRawMaterialEntry/anon/queryById")
public Result<MesXslRawMaterialEntry> 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<String> rawMaterialEntryAnonGenerateBarcode(
@RequestParam(name = "materialCode", defaultValue = "") String materialCode) {
return Result.OK(rawMaterialEntryService.generateBarcode(materialCode));
}
@Operation(summary = "原料入场记录-免密添加")
@PostMapping("/xslmes/mesXslRawMaterialEntry/anon/add")
public Result<String> 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<String> 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<String> 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<String> rawMaterialEntryAnonDeleteBatch(@RequestParam(name = "ids") String ids) {
rawMaterialEntryService.removeByIds(Arrays.asList(ids.split(",")));
stompNotify.publishRawMaterialEntryChanged("batchDelete", ids);
return Result.OK("批量删除成功!");
}
// ─────────────────────────── 车辆私有辅助 ────────────────────────────
private void applyWeightBillType(MesXslWeightRecord record) {

View File

@@ -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<MesXslRawM
@Autowired
private IMesXslRawMaterialEntryService mesXslRawMaterialEntryService;
@Autowired
private MesXslStompNotifyService stompNotify;
@Operation(summary = "原料入场记录-分页列表查询")
@GetMapping(value = "/list")
@@ -55,6 +58,7 @@ public class MesXslRawMaterialEntryController extends JeecgController<MesXslRawM
@PostMapping(value = "/add")
public Result<String> add(@RequestBody MesXslRawMaterialEntry mesXslRawMaterialEntry) {
mesXslRawMaterialEntryService.save(mesXslRawMaterialEntry);
stompNotify.publishRawMaterialEntryChanged("add", mesXslRawMaterialEntry.getId());
return Result.OK("添加成功!");
}
@@ -64,6 +68,7 @@ public class MesXslRawMaterialEntryController extends JeecgController<MesXslRawM
@RequestMapping(value = "/edit", method = {RequestMethod.PUT, RequestMethod.POST})
public Result<String> edit(@RequestBody MesXslRawMaterialEntry mesXslRawMaterialEntry) {
mesXslRawMaterialEntryService.updateById(mesXslRawMaterialEntry);
stompNotify.publishRawMaterialEntryChanged("edit", mesXslRawMaterialEntry.getId());
return Result.OK("编辑成功!");
}
@@ -73,6 +78,7 @@ public class MesXslRawMaterialEntryController extends JeecgController<MesXslRawM
@DeleteMapping(value = "/delete")
public Result<String> 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<MesXslRawM
@DeleteMapping(value = "/deleteBatch")
public Result<String> deleteBatch(@RequestParam(name = "ids", required = true) String ids) {
this.mesXslRawMaterialEntryService.removeByIds(Arrays.asList(ids.split(",")));
stompNotify.publishRawMaterialEntryChanged("batchDelete", ids);
return Result.OK("批量删除成功!");
}

View File

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

View File

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

View File

@@ -10,4 +10,13 @@ import com.baomidou.mybatisplus.extension.service.IService;
* @Version: V1.0
*/
public interface IMesXslRawMaterialEntryService extends IService<MesXslRawMaterialEntry> {
/**
* 生成条码/批次号
* 格式QH + 物料编码 + 日期(yyMMdd) + 当日序号(001起)
*
* @param materialCode 物料编码
* @return 生成的条码字符串
*/
String generateBarcode(String materialCode);
}

View File

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

View File

@@ -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<MesXslRawMaterialEntryMapper, MesXslRawMaterialEntry> implements IMesXslRawMaterialEntryService {
public class MesXslRawMaterialEntryServiceImpl
extends ServiceImpl<MesXslRawMaterialEntryMapper, MesXslRawMaterialEntry>
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<MesXslRawMaterialEntry> qw = new LambdaQueryWrapper<>();
qw.likeRight(MesXslRawMaterialEntry::getBarcode, prefix);
long count = this.count(qw);
return prefix + String.format("%03d", count + 1);
}
}

View File

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

View File

@@ -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<RawMaterialEntryChangedPayload>
{
}

View File

@@ -0,0 +1,26 @@
using YY.Admin.Core.Entity;
namespace YY.Admin.Core.Services;
public record RawMaterialEntryPageResult(List<MesXslRawMaterialEntry> Records, long Total, int PageNo, int PageSize);
public interface IRawMaterialEntryService
{
Task<RawMaterialEntryPageResult> PageAsync(
int pageNo, int pageSize,
string? barcode = null,
string? batchNo = null,
string? billNo = null,
string? materialName = null,
string? supplierName = null,
CancellationToken ct = default);
Task<MesXslRawMaterialEntry?> GetByIdAsync(string id, CancellationToken ct = default);
Task<bool> AddAsync(MesXslRawMaterialEntry entry, CancellationToken ct = default);
Task<bool> EditAsync(MesXslRawMaterialEntry entry, CancellationToken ct = default);
Task<bool> DeleteAsync(string id, CancellationToken ct = default);
Task<bool> DeleteBatchAsync(string ids, CancellationToken ct = default);
/// <summary>调用后端接口生成条码/批次号格式QH+物料编码+yyMMdd+序号)</summary>
Task<string?> GenerateBarcodeAsync(string materialCode, CancellationToken ct = default);
}

View File

@@ -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; }
/// <summary>检测结果0未检 1合格 2不合格</summary>
public string? TestResult { get; set; }
/// <summary>检测状态0送样 1已批准</summary>
public string? TestStatus { get; set; }
/// <summary>打印标记1已打印 0未打印</summary>
public string? PrintFlag { get; set; }
/// <summary>入库结存1是 0否</summary>
public string? StockBalance { get; set; }
public string? WarehouseLocation { get; set; }
public string? UnloadOperator { get; set; }
/// <summary>是否特采1是 0否</summary>
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 ?? ""
};
}

View File

@@ -38,6 +38,8 @@ public class SysMenuSeedData : ISqlSugarEntitySeedData<SysMenu>
new SysMenu{ Id=1300150010501, Pid=1300150000101, Title="地磅称重操作", Path="/xslmes/weightRecordOperation", Name="weightRecordOperation", Component="WeightRecordOperationView", Icon="&#xe7de;", 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="&#xe7ce;", 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="&#xe7ce;", Type=MenuTypeEnum.Menu, CreateTime=DateTime.Parse("2022-02-10 00:00:00"), OrderNo=106 },
#endregion

View File

@@ -27,6 +27,7 @@ public class SysTenantMenuSeedData : ISqlSugarEntitySeedData<SysTenantMenu>
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},

View File

@@ -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<NetworkStatusChangedEvent>()
.Subscribe(OnNetworkStatusChanged, ThreadOption.BackgroundThread);
pollManager.Register("分类字典", () => SyncAndPublishAsync("poll", null));
_logger.Information("[分类字典] CategorySyncCoordinator 已启动");
_ = Task.Run(() => SyncAndPublishAsync("startup", null));
}

View File

@@ -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<NetworkStatusChangedEvent>()
.Subscribe(OnNetworkStatusChanged, ThreadOption.BackgroundThread);
pollManager.Register("客户", () =>
{
_eventAggregator.GetEvent<CustomerChangedEvent>()
.Publish(new CustomerChangedPayload { Action = "poll" });
return Task.CompletedTask;
});
_logger.Information("[客户推送] CustomerSyncCoordinator 已启动");
}

View File

@@ -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<NetworkStatusChangedEvent>()
.Subscribe(OnNetworkStatusChanged, ThreadOption.BackgroundThread);
pollManager.Register("数据字典", () => SyncAndPublishAsync("poll", null));
_logger.Information("[数据字典] DictSyncCoordinator 已启动");
_ = Task.Run(() => SyncAndPublishAsync("startup", null));
}

View File

@@ -82,6 +82,7 @@ public class JeecgDictSyncService : IJeecgDictSyncService, ISingletonDependency
const int pageSize = 500;
var pageNo = 1;
var synced = 0;
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
while (true)
{
@@ -111,6 +112,8 @@ public class JeecgDictSyncService : IJeecgDictSyncService, ISingletonDependency
continue;
}
seenIds.Add(id);
var existing = await _dbContext.Queryable<JeecgSysDictItem>()
.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<JeecgSysDictItem>()
.Where(x => !seenList.Contains(x.Id))
.ExecuteCommandAsync();
}
return synced;
}

View File

@@ -51,6 +51,7 @@ public class MixerMaterialService : IMixerMaterialService, ISingletonDependency
}
private string BaseUrl => (_configuration.GetValue<string>("JeecgIntegration:BaseUrl") ?? "http://localhost:8080/jeecg-boot").TrimEnd('/');
private int DefaultTenantId => (int?)_configuration.GetValue<long?>("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<MesMixerMaterial>();
@@ -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)
{

View File

@@ -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<NetworkStatusChangedEvent>()
.Subscribe(OnNetworkStatusChanged, ThreadOption.BackgroundThread);
pollManager.Register("密炼物料", async () =>
{
await _mixerMaterialService.SyncFromRemoteAsync().ConfigureAwait(false);
_eventAggregator.GetEvent<MixerMaterialChangedEvent>()
.Publish(new MixerMaterialChangedPayload { Action = "poll" });
});
_logger.Information("[密炼物料推送] MixerMaterialSyncCoordinator 已启动");
_ = _mixerMaterialService.SyncFromRemoteAsync();
}

View File

@@ -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<EntryPendingOperation> _pendingOps = new();
private List<MesXslRawMaterialEntry> _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<string>("JeecgIntegration:BaseUrl") ?? "http://localhost:8080/jeecg-boot").TrimEnd('/');
private int DefaultTenantId => (int?)_configuration.GetValue<long?>("JeecgIntegration:DefaultTenantId") ?? 1002;
private HttpClient CreateClient() => _httpClientFactory.CreateClient("JeecgApi");
public async Task<RawMaterialEntryPageResult> PageAsync(
int pageNo, int pageSize,
string? barcode = null, string? batchNo = null, string? billNo = null,
string? materialName = null, string? supplierName = null,
CancellationToken ct = default)
{
List<MesXslRawMaterialEntry>? 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<MesXslRawMaterialEntry?> 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<MesXslRawMaterialEntry>(_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<bool> 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<bool> 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<bool> 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<bool> 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<string?> 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<List<MesXslRawMaterialEntry>> 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<List<MesXslRawMaterialEntry>>(_jsonOpts) ?? new();
}
private async Task<bool> 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<bool> 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<bool> 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<bool> 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<RawMaterialEntryChangedEvent>().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<SyncConflictEvent>().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<PushPendingResult> PushPendingOnReconnectAsync(CancellationToken cancellationToken)
{
if (!await _syncLock.WaitAsync(0, cancellationToken).ConfigureAwait(false))
return new PushPendingResult(0, 0, 0);
try
{
List<EntryPendingOperation> 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<PendingReplayResult> 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<MesXslRawMaterialEntry?> 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<MesXslRawMaterialEntry>(_jsonOpts);
return null;
}
catch { return null; }
}
// ─── Local cache helpers ───────────────────────────────────────────────────
private static List<MesXslRawMaterialEntry> ApplyFilters(
List<MesXslRawMaterialEntry> source,
string? barcode, string? batchNo, string? billNo,
string? materialName, string? supplierName)
{
IEnumerable<MesXslRawMaterialEntry> 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<MesXslRawMaterialEntry> ApplyPendingOpsSnapshotUnsafe(List<MesXslRawMaterialEntry> 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<List<EntryPendingOperation>>(File.ReadAllText(_pendingOpsFilePath), _jsonOpts);
_pendingOps = data ?? new();
}
catch { _pendingOps = new(); }
}
private void LoadCacheFromDisk()
{
try
{
if (!File.Exists(_cacheFilePath)) return;
var data = JsonSerializer.Deserialize<List<MesXslRawMaterialEntry>>(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<DateTime?>
{
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();
}
}
}

View File

@@ -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;
/// <summary>
/// 监听 STOMP 收到的原料入场记录变更信号,转发为桌面端 Prism 事件,触发列表刷新。
/// </summary>
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<RemoteCommandReceivedEvent>()
.Subscribe(OnRemoteCommand, ThreadOption.BackgroundThread);
_networkStatusToken = _eventAggregator
.GetEvent<NetworkStatusChangedEvent>()
.Subscribe(OnNetworkStatusChanged, ThreadOption.BackgroundThread);
pollManager.Register("原料入场", () =>
{
_eventAggregator.GetEvent<RawMaterialEntryChangedEvent>()
.Publish(new RawMaterialEntryChangedPayload { Action = "poll" });
return Task.CompletedTask;
});
_logger.Information("[原料入场] RawMaterialEntrySyncCoordinator 已启动");
}
private void OnNetworkStatusChanged(NetworkStatusChangedPayload payload)
{
if (!payload.IsOnline) return;
_logger.Information("[原料入场] 网络恢复,触发补偿刷新");
_eventAggregator.GetEvent<RawMaterialEntryChangedEvent>().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<RawMaterialEntryChangedEvent>().Publish(changedPayload);
}
catch (Exception ex)
{
_logger.Warning($"处理 STOMP 原料入场变更信号失败: {ex.Message}");
}
}
}

View File

@@ -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<NetworkStatusChangedEvent>()
.Subscribe(OnNetworkStatusChanged, ThreadOption.BackgroundThread);
pollManager.Register("供应商", () =>
{
_eventAggregator.GetEvent<SupplierChangedEvent>()
.Publish(new SupplierChangedPayload { Action = "poll" });
return Task.CompletedTask;
});
}
private async void OnNetworkStatusChanged(NetworkStatusChangedPayload payload)

View File

@@ -0,0 +1,58 @@
using YY.Admin.Core;
namespace YY.Admin.Services.Service;
/// <summary>
/// 统一后台轮询管理器。
/// 各模块通过 <see cref="Register"/> 注册轮询任务,定时器统一触发。
/// ★ 只需修改 <see cref="PollInterval"/> 即可调整所有模块的轮询间隔。
/// </summary>
public class SyncPollManager : ISingletonDependency
{
// ★ 唯一轮询间隔配置 — 修改这里即可调整所有模块的轮询频率
public static readonly TimeSpan PollInterval = TimeSpan.FromMinutes(5);
private readonly List<(string Name, Func<Task> 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} 分钟");
}
/// <summary>注册一个需要定时执行的轮询任务</summary>
/// <param name="name">任务名称,用于日志</param>
/// <param name="pollTask">异步轮询委托</param>
public void Register(string name, Func<Task> pollTask)
{
lock (_tasks)
_tasks.Add((name, pollTask));
_logger.Information($"[轮询管理器] 注册任务: {name}");
}
private void OnTick(object? _)
{
_ = Task.Run(async () =>
{
List<(string Name, Func<Task> 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}");
}
}
});
}
}

View File

@@ -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<RemoteCommandReceivedEvent>()
.Subscribe(OnRemoteCommand, ThreadOption.BackgroundThread);
// 断线重连后补拉一次,覆盖离线期间漏掉的 STOMP 事件
_networkStatusToken = _eventAggregator
.GetEvent<NetworkStatusChangedEvent>()
.Subscribe(OnNetworkStatusChanged, ThreadOption.BackgroundThread);
_logger.Information("[车辆推送] VehicleSyncCoordinator 已启动,开始监听 RemoteCommandReceivedEvent");
pollManager.Register("车辆", () =>
{
_eventAggregator.GetEvent<VehicleChangedEvent>()
.Publish(new VehicleChangedPayload { Action = "poll" });
return Task.CompletedTask;
});
_logger.Information("[车辆推送] VehicleSyncCoordinator 已启动");
}
private void OnNetworkStatusChanged(NetworkStatusChangedPayload payload)

View File

@@ -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<NetworkStatusChangedEvent>()
.Subscribe(OnNetworkStatusChanged, ThreadOption.BackgroundThread);
pollManager.Register("磅单", () =>
{
_eventAggregator.GetEvent<MesXslWeightRecordChangedEvent>()
.Publish(new MesXslWeightRecordChangedPayload { Action = "poll" });
return Task.CompletedTask;
});
_logger.Information("[磅单推送] WeightRecordSyncCoordinator 已启动");
}

View File

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

View File

@@ -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<WeightRecordOperationView>();
// 密炼物料信息
containerRegistry.RegisterForNavigation<MixerMaterialListView>();
// 原料入场记录
containerRegistry.RegisterForNavigation<RawMaterialEntryListView>();
}
}
public class DialogWindow : Window, IDialogWindow

View File

@@ -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<IMixerMaterialService, MixerMaterialService>();
containerRegistry.RegisterSingleton<MixerMaterialSyncCoordinator>();
// 原料入场记录:免密 API 直连 + STOMP 实时通知
containerRegistry.RegisterSingleton<IRawMaterialEntryService, RawMaterialEntryService>();
containerRegistry.RegisterSingleton<RawMaterialEntrySyncCoordinator>();
// 分类字典:启动同步 + 断线重连补刷
containerRegistry.RegisterSingleton<CategorySyncCoordinator>();
// 数据字典:启动同步 + 断线重连补刷
containerRegistry.RegisterSingleton<DictSyncCoordinator>();
// 统一轮询管理器(修改 SyncPollManager.PollInterval 即可调整所有模块的轮询间隔)
containerRegistry.RegisterSingleton<SyncPollManager>();
var serviceCollection = new ServiceCollection();
serviceCollection.AddTransient<DisconnectGuardHandler>();
@@ -100,6 +107,8 @@ public class SyncModule : IModule
_ = Task.Run(() => signalService.ConnectUnifiedDeviceChannelAsync(CancellationToken.None));
// 强制实例化事件订阅器(单例,构造函数内完成订阅注册)
_ = containerProvider.Resolve<SysUserEventSubscriber>();
// 统一轮询管理器必须在各协调器之前实例化,确保 Register() 能正常接收注册
_ = containerProvider.Resolve<SyncPollManager>();
// 强制实例化车辆同步协调器(构造函数内订阅 STOMP 车辆变更事件)
_ = containerProvider.Resolve<VehicleSyncCoordinator>();
// 强制实例化客户同步协调器
@@ -110,6 +119,8 @@ public class SyncModule : IModule
_ = containerProvider.Resolve<WeightRecordSyncCoordinator>();
// 强制实例化密炼物料同步协调器
_ = containerProvider.Resolve<MixerMaterialSyncCoordinator>();
// 强制实例化原料入场记录同步协调器
_ = containerProvider.Resolve<RawMaterialEntrySyncCoordinator>();
// 强制实例化分类字典同步协调器
_ = containerProvider.Resolve<CategorySyncCoordinator>();
// 强制实例化数据字典同步协调器

View File

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

View File

@@ -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<bool>
{
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<MesMixerMaterial> MaterialOptions { get; } = new();
public ObservableCollection<KeyValuePair<string, string>> TestResultOptions { get; } = new();
public ObservableCollection<KeyValuePair<string, string>> TestStatusOptions { get; } = new();
public ObservableCollection<KeyValuePair<string, string>> PrintFlagOptions { get; } = new();
public ObservableCollection<KeyValuePair<string, string>> StockBalanceOptions { get; } = new();
public ObservableCollection<KeyValuePair<string, string>> IsSpecialAdoptionOptions { get; } = new();
public ObservableCollection<KeyValuePair<string, string>> 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<string, string>("未检", "0"),
new KeyValuePair<string, string>("合格", "1"),
new KeyValuePair<string, string>("不合格", "2"),
});
PopulateOptions(TestStatusOptions, testStatusOpts, new[]
{
new KeyValuePair<string, string>("送样", "0"),
new KeyValuePair<string, string>("已批准", "1"),
});
PopulateOptions(PrintFlagOptions, printFlagOpts, new[]
{
new KeyValuePair<string, string>("未打印", "0"),
new KeyValuePair<string, string>("已打印", "1"),
});
PopulateOptions(StockBalanceOptions, ynOpts, new[]
{
new KeyValuePair<string, string>("否", "0"),
new KeyValuePair<string, string>("是", "1"),
});
PopulateOptions(IsSpecialAdoptionOptions, ynOpts, new[]
{
new KeyValuePair<string, string>("否", "0"),
new KeyValuePair<string, string>("是", "1"),
});
PopulateOptions(StatusOptions, statusOpts, Array.Empty<KeyValuePair<string, string>>());
}
catch { FillFallbackOptions(); }
}
private static void PopulateOptions(
ObservableCollection<KeyValuePair<string, string>> target,
IEnumerable<KeyValuePair<string, string>> items,
IEnumerable<KeyValuePair<string, string>> 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<KeyValuePair<string, string>>(), new[]
{
new KeyValuePair<string, string>("未检", "0"),
new KeyValuePair<string, string>("合格", "1"),
new KeyValuePair<string, string>("不合格", "2"),
});
PopulateOptions(TestStatusOptions, Array.Empty<KeyValuePair<string, string>>(), new[]
{
new KeyValuePair<string, string>("送样", "0"),
new KeyValuePair<string, string>("已批准", "1"),
});
PopulateOptions(PrintFlagOptions, Array.Empty<KeyValuePair<string, string>>(), new[]
{
new KeyValuePair<string, string>("未打印", "0"),
new KeyValuePair<string, string>("已打印", "1"),
});
var ynDefault = new[] { new KeyValuePair<string, string>("否", "0"), new KeyValuePair<string, string>("是", "1") };
PopulateOptions(StockBalanceOptions, Array.Empty<KeyValuePair<string, string>>(), ynDefault);
PopulateOptions(IsSpecialAdoptionOptions, Array.Empty<KeyValuePair<string, string>>(), 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}");
}
}
}

View File

@@ -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<MesXslRawMaterialEntry> _entries = new();
public ObservableCollection<MesXslRawMaterialEntry> 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<MesXslRawMaterialEntry> EditCommand { get; }
public DelegateCommand<MesXslRawMaterialEntry> 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<MesXslRawMaterialEntry>(async e => await ShowEditDialogAsync(e));
DeleteCommand = new DelegateCommand<MesXslRawMaterialEntry>(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<RawMaterialEntryChangedEvent>()
.Subscribe(async p => await OnEntryChangedAsync(p), ThreadOption.UIThread);
_syncConflictToken = _eventAggregator.GetEvent<SyncConflictEvent>()
.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<string>();
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<MesXslRawMaterialEntry>(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<RawMaterialEntryEditDialogView>()
.Initialize<RawMaterialEntryEditDialogViewModel>(vm => vm.InitializeForAdd())
.GetResultAsync<bool>();
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<RawMaterialEntryEditDialogView>()
.Initialize<RawMaterialEntryEditDialogViewModel>(vm => vm.InitializeForEdit(entry))
.GetResultAsync<bool>();
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<RawMaterialEntryChangedEvent>().Unsubscribe(_entryChangedToken);
_entryChangedToken = null;
}
if (_syncConflictToken != null)
{
_eventAggregator.GetEvent<SyncConflictEvent>().Unsubscribe(_syncConflictToken);
_syncConflictToken = null;
}
}
}

View File

@@ -0,0 +1,400 @@
<UserControl x:Class="YY.Admin.Views.RawMaterialEntry.RawMaterialEntryEditDialogView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:hc="https://handyorg.github.io/handycontrol"
xmlns:prism="http://prismlibrary.com/"
prism:ViewModelLocator.AutoWireViewModel="True"
mc:Ignorable="d"
Width="1280"
MinWidth="1100"
MinHeight="500">
<Grid Background="{DynamicResource ThirdlyRegionBrush}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- 标题栏 -->
<hc:SimplePanel Margin="24,20,24,12">
<TextBlock FontSize="18" Foreground="{DynamicResource PrimaryTextBrush}" Text="{Binding DialogTitle}" HorizontalAlignment="Left" VerticalAlignment="Center"/>
<Button Width="22" Height="22" Command="hc:ControlCommands.Close" Style="{StaticResource ButtonIcon}"
Foreground="{DynamicResource PrimaryBrush}" hc:IconElement.Geometry="{StaticResource ErrorGeometry}"
Padding="0" HorizontalAlignment="Right" VerticalAlignment="Center"/>
</hc:SimplePanel>
<!-- 表单区域 -->
<hc:ScrollViewer Grid.Row="1" IsInertiaEnabled="True">
<StackPanel x:Name="RootPanel" Margin="24,8,24,8">
<hc:Row Gutter="16">
<!-- 密炼物料选择(搜索下拉) -->
<hc:Col Span="16">
<DockPanel Margin="0,0,0,8" LastChildFill="True">
<TextBlock DockPanel.Dock="Left" Text="密炼物料" Width="80"
VerticalAlignment="Center"
Foreground="{DynamicResource PrimaryTextBrush}"/>
<hc:ComboBox ItemsSource="{Binding MaterialOptions}"
SelectedItem="{Binding SelectedMaterial}"
IsEditable="True"
IsTextSearchEnabled="True"
TextSearch.TextPath="MaterialCode"
Height="34"
hc:InfoElement.Placeholder="输入物料编码搜索">
<hc:ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock>
<Run Text="{Binding MaterialCode, Mode=OneWay}"/>
<Run Text=" — "/>
<Run Text="{Binding MaterialName, Mode=OneWay}"/>
</TextBlock>
</DataTemplate>
</hc:ComboBox.ItemTemplate>
</hc:ComboBox>
</DockPanel>
</hc:Col>
<!-- 物料名称(只读回显) -->
<hc:Col Span="8">
<hc:TextBox Text="{Binding Entry.MaterialName}"
Height="34"
IsReadOnly="True"
hc:InfoElement.Title="物料名称"
hc:InfoElement.TitleWidth="80"
hc:InfoElement.TitlePlacement="Left"
Foreground="{DynamicResource SecondaryTextBrush}"
Margin="0,0,0,8"/>
</hc:Col>
<!-- 条码 -->
<hc:Col Span="8">
<hc:TextBox Text="{Binding Entry.Barcode, UpdateSourceTrigger=PropertyChanged}"
Height="34"
hc:InfoElement.Title="条码"
hc:InfoElement.TitleWidth="80"
hc:InfoElement.TitlePlacement="Left"
hc:InfoElement.Placeholder="请输入条码"
hc:InfoElement.ShowClearButton="True"
Margin="0,0,0,8"/>
</hc:Col>
<!-- 批次号 -->
<hc:Col Span="8">
<hc:TextBox Text="{Binding Entry.BatchNo, UpdateSourceTrigger=PropertyChanged}"
Height="34"
hc:InfoElement.Title="批次号"
hc:InfoElement.TitleWidth="80"
hc:InfoElement.TitlePlacement="Left"
hc:InfoElement.Placeholder="请输入批次号"
hc:InfoElement.ShowClearButton="True"
Margin="0,0,0,8"/>
</hc:Col>
<!-- 入场时间 -->
<hc:Col Span="8">
<DockPanel Margin="0,0,0,8" LastChildFill="True">
<TextBlock DockPanel.Dock="Left" Text="入场时间" Width="80"
VerticalAlignment="Center"
Foreground="{DynamicResource PrimaryTextBrush}"/>
<hc:DateTimePicker SelectedDateTime="{Binding Entry.EntryTime}"
hc:InfoElement.Placeholder="请选择入场时间"
hc:InfoElement.ShowClearButton="True"
Height="34"/>
</DockPanel>
</hc:Col>
<!-- 榜单号 -->
<hc:Col Span="8">
<hc:TextBox Text="{Binding Entry.BillNo, UpdateSourceTrigger=PropertyChanged}"
Height="34"
hc:InfoElement.Title="榜单号"
hc:InfoElement.TitleWidth="80"
hc:InfoElement.TitlePlacement="Left"
hc:InfoElement.Placeholder="请输入榜单号"
hc:InfoElement.ShowClearButton="True"
Margin="0,0,0,8"/>
</hc:Col>
<!-- 供料客户 -->
<hc:Col Span="8">
<hc:TextBox Text="{Binding Entry.SupplyCustomer, UpdateSourceTrigger=PropertyChanged}"
Height="34"
hc:InfoElement.Title="供料客户"
hc:InfoElement.TitleWidth="80"
hc:InfoElement.TitlePlacement="Left"
hc:InfoElement.Placeholder="请输入供料客户"
hc:InfoElement.ShowClearButton="True"
Margin="0,0,0,8"/>
</hc:Col>
<!-- 供应商名称 -->
<hc:Col Span="8">
<hc:TextBox Text="{Binding Entry.SupplierName, UpdateSourceTrigger=PropertyChanged}"
Height="34"
hc:InfoElement.Title="供应商名称"
hc:InfoElement.TitleWidth="80"
hc:InfoElement.TitlePlacement="Left"
hc:InfoElement.Placeholder="请输入供应商名称"
hc:InfoElement.ShowClearButton="True"
Margin="0,0,0,8"/>
</hc:Col>
<!-- 厂家物料名称 -->
<hc:Col Span="8">
<hc:TextBox Text="{Binding Entry.ManufacturerMaterialName, UpdateSourceTrigger=PropertyChanged}"
Height="34"
hc:InfoElement.Title="厂家物料名称"
hc:InfoElement.TitleWidth="80"
hc:InfoElement.TitlePlacement="Left"
hc:InfoElement.Placeholder="请输入厂家物料名称"
hc:InfoElement.ShowClearButton="True"
Margin="0,0,0,8"/>
</hc:Col>
<!-- 保质期 -->
<hc:Col Span="8">
<hc:TextBox Text="{Binding Entry.ShelfLife, UpdateSourceTrigger=PropertyChanged}"
Height="34"
hc:InfoElement.Title="保质期"
hc:InfoElement.TitleWidth="80"
hc:InfoElement.TitlePlacement="Left"
hc:InfoElement.Placeholder="请输入保质期"
hc:InfoElement.ShowClearButton="True"
Margin="0,0,0,8"/>
</hc:Col>
<!-- 总重(KG) -->
<hc:Col Span="8">
<hc:NumericUpDown Value="{Binding Entry.TotalWeight}"
Minimum="0"
DecimalPlaces="2"
Height="34"
Style="{StaticResource NumericUpDownPlus}"
hc:InfoElement.Title="总重(KG)"
hc:InfoElement.TitleWidth="80"
hc:InfoElement.TitlePlacement="Left"
hc:InfoElement.Placeholder="请输入总重"
Margin="0,0,0,8"/>
</hc:Col>
<!-- 总份数 -->
<hc:Col Span="8">
<hc:NumericUpDown Value="{Binding Entry.TotalPortions}"
Minimum="0"
DecimalPlaces="0"
Height="34"
Style="{StaticResource NumericUpDownPlus}"
hc:InfoElement.Title="总份数"
hc:InfoElement.TitleWidth="80"
hc:InfoElement.TitlePlacement="Left"
hc:InfoElement.Placeholder="请输入总份数"
Margin="0,0,0,8"/>
</hc:Col>
<!-- 每份总重(KG) -->
<hc:Col Span="8">
<hc:NumericUpDown Value="{Binding Entry.PortionWeight}"
Minimum="0"
DecimalPlaces="2"
Height="34"
Style="{StaticResource NumericUpDownPlus}"
hc:InfoElement.Title="每份总重(KG)"
hc:InfoElement.TitleWidth="80"
hc:InfoElement.TitlePlacement="Left"
hc:InfoElement.Placeholder="请输入每份总重"
Margin="0,0,0,8"/>
</hc:Col>
<!-- 每份包数 -->
<hc:Col Span="8">
<hc:NumericUpDown Value="{Binding Entry.PortionPackages}"
Minimum="0"
DecimalPlaces="0"
Height="34"
Style="{StaticResource NumericUpDownPlus}"
hc:InfoElement.Title="每份包数"
hc:InfoElement.TitleWidth="80"
hc:InfoElement.TitlePlacement="Left"
hc:InfoElement.Placeholder="请输入每份包数"
Margin="0,0,0,8"/>
</hc:Col>
<!-- 检测结果 -->
<hc:Col Span="8">
<hc:ComboBox SelectedValuePath="Value"
DisplayMemberPath="Key"
ItemsSource="{Binding TestResultOptions}"
SelectedValue="{Binding Entry.TestResult}"
Height="34"
hc:InfoElement.Title="检测结果"
hc:InfoElement.TitleWidth="80"
hc:InfoElement.TitlePlacement="Left"
hc:InfoElement.Placeholder="请选择检测结果"
Margin="0,0,0,8"/>
</hc:Col>
<!-- 检测状态 -->
<hc:Col Span="8">
<hc:ComboBox SelectedValuePath="Value"
DisplayMemberPath="Key"
ItemsSource="{Binding TestStatusOptions}"
SelectedValue="{Binding Entry.TestStatus}"
Height="34"
hc:InfoElement.Title="检测状态"
hc:InfoElement.TitleWidth="80"
hc:InfoElement.TitlePlacement="Left"
hc:InfoElement.Placeholder="请选择检测状态"
Margin="0,0,0,8"/>
</hc:Col>
<!-- 打印标记 -->
<hc:Col Span="8">
<hc:ComboBox SelectedValuePath="Value"
DisplayMemberPath="Key"
ItemsSource="{Binding PrintFlagOptions}"
SelectedValue="{Binding Entry.PrintFlag}"
Height="34"
hc:InfoElement.Title="打印标记"
hc:InfoElement.TitleWidth="80"
hc:InfoElement.TitlePlacement="Left"
hc:InfoElement.Placeholder="请选择打印标记"
Margin="0,0,0,8"/>
</hc:Col>
<!-- 入库结存 -->
<hc:Col Span="8">
<hc:ComboBox SelectedValuePath="Value"
DisplayMemberPath="Key"
ItemsSource="{Binding StockBalanceOptions}"
SelectedValue="{Binding Entry.StockBalance}"
Height="34"
hc:InfoElement.Title="入库结存"
hc:InfoElement.TitleWidth="80"
hc:InfoElement.TitlePlacement="Left"
hc:InfoElement.Placeholder="请选择入库结存"
Margin="0,0,0,8"/>
</hc:Col>
<!-- 库位 -->
<hc:Col Span="8">
<hc:TextBox Text="{Binding Entry.WarehouseLocation, UpdateSourceTrigger=PropertyChanged}"
Height="34"
hc:InfoElement.Title="库位"
hc:InfoElement.TitleWidth="80"
hc:InfoElement.TitlePlacement="Left"
hc:InfoElement.Placeholder="请输入库位"
hc:InfoElement.ShowClearButton="True"
Margin="0,0,0,8"/>
</hc:Col>
<!-- 卸货人 -->
<hc:Col Span="8">
<hc:TextBox Text="{Binding Entry.UnloadOperator, UpdateSourceTrigger=PropertyChanged}"
Height="34"
hc:InfoElement.Title="卸货人"
hc:InfoElement.TitleWidth="80"
hc:InfoElement.TitlePlacement="Left"
hc:InfoElement.Placeholder="请输入卸货人"
hc:InfoElement.ShowClearButton="True"
Margin="0,0,0,8"/>
</hc:Col>
<!-- 是否特采 -->
<hc:Col Span="8">
<hc:ComboBox SelectedValuePath="Value"
DisplayMemberPath="Key"
ItemsSource="{Binding IsSpecialAdoptionOptions}"
SelectedValue="{Binding Entry.IsSpecialAdoption}"
Height="34"
hc:InfoElement.Title="是否特采"
hc:InfoElement.TitleWidth="80"
hc:InfoElement.TitlePlacement="Left"
hc:InfoElement.Placeholder="请选择是否特采"
Margin="0,0,0,8"/>
</hc:Col>
<!-- 特采操作人 -->
<hc:Col Span="8">
<hc:TextBox Text="{Binding Entry.SpecialAdoptionOperator, UpdateSourceTrigger=PropertyChanged}"
Height="34"
hc:InfoElement.Title="特采操作人"
hc:InfoElement.TitleWidth="80"
hc:InfoElement.TitlePlacement="Left"
hc:InfoElement.Placeholder="请输入特采操作人"
hc:InfoElement.ShowClearButton="True"
Margin="0,0,0,8"/>
</hc:Col>
<!-- 特采时间 -->
<hc:Col Span="8">
<DockPanel Margin="0,0,0,8" LastChildFill="True">
<TextBlock DockPanel.Dock="Left" Text="特采时间" Width="80"
VerticalAlignment="Center"
Foreground="{DynamicResource PrimaryTextBrush}"/>
<hc:DateTimePicker SelectedDateTime="{Binding Entry.SpecialAdoptionTime}"
hc:InfoElement.Placeholder="请选择特采时间"
hc:InfoElement.ShowClearButton="True"
Height="34"/>
</DockPanel>
</hc:Col>
<!-- 状态 -->
<hc:Col Span="8">
<hc:ComboBox SelectedValuePath="Value"
DisplayMemberPath="Key"
ItemsSource="{Binding StatusOptions}"
SelectedValue="{Binding Entry.Status}"
Height="34"
hc:InfoElement.Title="状态"
hc:InfoElement.TitleWidth="80"
hc:InfoElement.TitlePlacement="Left"
hc:InfoElement.Placeholder="请选择状态"
Margin="0,0,0,8"/>
</hc:Col>
<!-- 特采原因(全宽) -->
<hc:Col Span="24">
<hc:TextBox Text="{Binding Entry.SpecialAdoptionReason, UpdateSourceTrigger=PropertyChanged}"
hc:InfoElement.Title="特采原因"
hc:InfoElement.TitleWidth="80"
hc:InfoElement.TitlePlacement="Left"
hc:InfoElement.Placeholder="请输入特采原因"
hc:InfoElement.ShowClearButton="True"
TextWrapping="Wrap"
AcceptsReturn="True"
HorizontalAlignment="Stretch"
Height="64"
VerticalScrollBarVisibility="Auto"
Margin="0,0,0,8"/>
</hc:Col>
<!-- 备注(全宽) -->
<hc:Col Span="24">
<hc:TextBox Text="{Binding Entry.Remark, UpdateSourceTrigger=PropertyChanged}"
hc:InfoElement.Title="备注"
hc:InfoElement.TitleWidth="80"
hc:InfoElement.TitlePlacement="Left"
hc:InfoElement.Placeholder="请输入备注"
hc:InfoElement.ShowClearButton="True"
TextWrapping="Wrap"
AcceptsReturn="True"
HorizontalAlignment="Stretch"
Height="64"
VerticalScrollBarVisibility="Auto"
Margin="0,0,0,8"/>
</hc:Col>
</hc:Row>
</StackPanel>
</hc:ScrollViewer>
<!-- 按钮区域 -->
<StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Center" Margin="0,12,0,20">
<Button Content="取消" Command="{Binding CancelCommand}" Style="{StaticResource ButtonDefault}" Margin="0,0,15,0" Width="100"/>
<Button Content="确定" Command="{Binding SaveCommand}" Style="{StaticResource ButtonPrimary}" Width="100"/>
</StackPanel>
</Grid>
</UserControl>

View File

@@ -0,0 +1,11 @@
using System.Windows.Controls;
namespace YY.Admin.Views.RawMaterialEntry;
public partial class RawMaterialEntryEditDialogView : UserControl
{
public RawMaterialEntryEditDialogView()
{
InitializeComponent();
}
}

View File

@@ -0,0 +1,161 @@
<UserControl x:Class="YY.Admin.Views.RawMaterialEntry.RawMaterialEntryListView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:hc="https://handyorg.github.io/handycontrol"
xmlns:md="http://materialdesigninxaml.net/winfx/xaml/themes"
xmlns:prism="http://prismlibrary.com/"
prism:ViewModelLocator.AutoWireViewModel="True"
mc:Ignorable="d">
<Grid Style="{StaticResource BaseViewStyle}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- 搜索条件区域 -->
<Border Grid.Row="0" CornerRadius="4" Margin="0 0 -10 0">
<hc:Row>
<hc:Col Layout="{hc:ColLayout Xs=12, Sm=8, Md=6, Lg=4, Xl=4}">
<hc:TextBox Text="{Binding FilterBarcode, UpdateSourceTrigger=PropertyChanged}"
Margin="0 0 10 10"
hc:InfoElement.Title="条码"
hc:InfoElement.TitlePlacement="Left"
hc:InfoElement.TitleWidth="65"
hc:InfoElement.Placeholder="请输入条码"
hc:InfoElement.ShowClearButton="True"/>
</hc:Col>
<hc:Col Layout="{hc:ColLayout Xs=12, Sm=8, Md=6, Lg=4, Xl=4}">
<hc:TextBox Text="{Binding FilterBatchNo, UpdateSourceTrigger=PropertyChanged}"
Margin="0 0 10 10"
hc:InfoElement.Title="批次号"
hc:InfoElement.TitlePlacement="Left"
hc:InfoElement.TitleWidth="65"
hc:InfoElement.Placeholder="请输入批次号"
hc:InfoElement.ShowClearButton="True"/>
</hc:Col>
<hc:Col Layout="{hc:ColLayout Xs=12, Sm=8, Md=6, Lg=4, Xl=4}">
<hc:TextBox Text="{Binding FilterBillNo, UpdateSourceTrigger=PropertyChanged}"
Margin="0 0 10 10"
hc:InfoElement.Title="榜单号"
hc:InfoElement.TitlePlacement="Left"
hc:InfoElement.TitleWidth="65"
hc:InfoElement.Placeholder="请输入榜单号"
hc:InfoElement.ShowClearButton="True"/>
</hc:Col>
<hc:Col Layout="{hc:ColLayout Xs=12, Sm=8, Md=6, Lg=4, Xl=4}">
<hc:TextBox Text="{Binding FilterMaterialName, UpdateSourceTrigger=PropertyChanged}"
Margin="0 0 10 10"
hc:InfoElement.Title="物料名称"
hc:InfoElement.TitlePlacement="Left"
hc:InfoElement.TitleWidth="65"
hc:InfoElement.Placeholder="请输入物料名称"
hc:InfoElement.ShowClearButton="True"/>
</hc:Col>
<hc:Col Layout="{hc:ColLayout Xs=12, Sm=8, Md=6, Lg=4, Xl=4}">
<hc:TextBox Text="{Binding FilterSupplierName, UpdateSourceTrigger=PropertyChanged}"
Margin="0 0 10 10"
hc:InfoElement.Title="供应商"
hc:InfoElement.TitlePlacement="Left"
hc:InfoElement.TitleWidth="65"
hc:InfoElement.Placeholder="请输入供应商名称"
hc:InfoElement.ShowClearButton="True"/>
</hc:Col>
</hc:Row>
</Border>
<!-- 操作工具栏 -->
<Border Grid.Row="1" Margin="0,10">
<hc:UniformSpacingPanel Spacing="10">
<Button Style="{StaticResource ButtonPrimary}" Command="{Binding SearchCommand}">
<StackPanel Orientation="Horizontal">
<md:PackIcon Kind="Search"/>
<TextBlock Text="搜索" Style="{StaticResource IconButtonStyle}"/>
</StackPanel>
</Button>
<Button Style="{StaticResource ButtonDefault}" Command="{Binding ResetCommand}">
<StackPanel Orientation="Horizontal">
<md:PackIcon Kind="Refresh"/>
<TextBlock Text="重置" Style="{StaticResource IconButtonStyle}"/>
</StackPanel>
</Button>
<Button Style="{StaticResource ButtonSuccess}" Command="{Binding AddCommand}">
<StackPanel Orientation="Horizontal">
<md:PackIcon Kind="Plus"/>
<TextBlock Text="新增" Style="{StaticResource IconButtonStyle}"/>
</StackPanel>
</Button>
</hc:UniformSpacingPanel>
</Border>
<!-- 数据表格 -->
<DataGrid Grid.Row="2"
ItemsSource="{Binding Entries}"
AutoGenerateColumns="False"
IsReadOnly="True"
CanUserAddRows="False"
SelectionMode="Extended"
SelectionUnit="FullRow"
RowHeaderWidth="55"
GridLinesVisibility="Horizontal"
HorizontalGridLinesBrush="#FFEDEDED"
VerticalGridLinesBrush="Transparent"
HeadersVisibility="All"
ColumnHeaderStyle="{StaticResource CusDataGridColumnHeaderStyle}"
Style="{StaticResource CusDataGridStyle}"
hc:DataGridAttach.ShowSelectAllButton="True"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Auto">
<DataGrid.RowHeaderTemplate>
<DataTemplate>
<CheckBox IsChecked="{Binding IsSelected, RelativeSource={RelativeSource AncestorType=DataGridRow}}"/>
</DataTemplate>
</DataGrid.RowHeaderTemplate>
<DataGrid.Columns>
<DataGridTextColumn Header="条码" Binding="{Binding Barcode}" CellStyle="{StaticResource CusDataGridCellStyle}" Width="140"/>
<DataGridTextColumn Header="批次号" Binding="{Binding BatchNo}" CellStyle="{StaticResource CusDataGridCellStyle}" Width="120"/>
<DataGridTextColumn Header="入场时间" Binding="{Binding EntryTime, StringFormat='yyyy-MM-dd HH:mm'}" CellStyle="{StaticResource CusDataGridCellStyle}" Width="140"/>
<DataGridTextColumn Header="榜单号" Binding="{Binding BillNo}" CellStyle="{StaticResource CusDataGridCellStyle}" Width="130"/>
<DataGridTextColumn Header="物料名称" Binding="{Binding MaterialName}" CellStyle="{StaticResource CusDataGridCellStyle}" Width="140"/>
<DataGridTextColumn Header="供料客户" Binding="{Binding SupplyCustomer}" CellStyle="{StaticResource CusDataGridCellStyle}" Width="120"/>
<DataGridTextColumn Header="供应商名称" Binding="{Binding SupplierName}" CellStyle="{StaticResource CusDataGridCellStyle}" Width="140"/>
<DataGridTextColumn Header="总重(KG)" Binding="{Binding TotalWeight, StringFormat=N2}" CellStyle="{StaticResource CusDataGridCellStyle}" Width="90"/>
<DataGridTextColumn Header="总份数" Binding="{Binding TotalPortions}" CellStyle="{StaticResource CusDataGridCellStyle}" Width="70"/>
<DataGridTextColumn Header="检测结果" Binding="{Binding TestResultText}" CellStyle="{StaticResource CusDataGridCellStyle}" Width="85"/>
<DataGridTextColumn Header="检测状态" Binding="{Binding TestStatusText}" CellStyle="{StaticResource CusDataGridCellStyle}" Width="85"/>
<DataGridTextColumn Header="打印标记" Binding="{Binding PrintFlagText}" CellStyle="{StaticResource CusDataGridCellStyle}" Width="85"/>
<DataGridTextColumn Header="入库结存" Binding="{Binding StockBalanceText}" CellStyle="{StaticResource CusDataGridCellStyle}" Width="75"/>
<DataGridTextColumn Header="库位" Binding="{Binding WarehouseLocation}" CellStyle="{StaticResource CusDataGridCellStyle}" Width="90"/>
<DataGridTextColumn Header="卸货人" Binding="{Binding UnloadOperator}" CellStyle="{StaticResource CusDataGridCellStyle}" Width="80"/>
<DataGridTextColumn Header="是否特采" Binding="{Binding IsSpecialAdoptionText}" CellStyle="{StaticResource CusDataGridCellStyle}" Width="75"/>
<DataGridTextColumn Header="创建时间" Binding="{Binding CreateTime, StringFormat='yyyy-MM-dd HH:mm'}" CellStyle="{StaticResource CusDataGridCellStyle}" Width="140"/>
<DataGridTemplateColumn Header="操作" Width="140" CellStyle="{StaticResource CusDataGridCellStyle}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<hc:UniformSpacingPanel Spacing="6" HorizontalAlignment="Center">
<Button Content="编辑" Command="{Binding DataContext.EditCommand, RelativeSource={RelativeSource AncestorType=DataGrid}}"
CommandParameter="{Binding}" Style="{StaticResource ButtonInfo}" Padding="8,2" FontSize="12"/>
<Button Content="删除" Command="{Binding DataContext.DeleteCommand, RelativeSource={RelativeSource AncestorType=DataGrid}}"
CommandParameter="{Binding}" Style="{StaticResource ButtonDanger}" Padding="8,2" FontSize="12"/>
</hc:UniformSpacingPanel>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
<!-- 分页 -->
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,10,0,0">
<TextBlock Text="{Binding Total, StringFormat=共 {0} 条}" VerticalAlignment="Center" Margin="0,0,16,0"
Foreground="{DynamicResource SecondaryTextBrush}"/>
<Button Content="上一页" Command="{Binding PrevPageCommand}" Style="{StaticResource ButtonDefault}" Margin="0,0,4,0" Width="80"/>
<TextBlock Text="{Binding PageNo, StringFormat=第 {0} 页}" VerticalAlignment="Center" Margin="8,0"
Foreground="{DynamicResource PrimaryTextBrush}"/>
<Button Content="下一页" Command="{Binding NextPageCommand}" Style="{StaticResource ButtonDefault}" Width="80"/>
</StackPanel>
</Grid>
</UserControl>

View File

@@ -0,0 +1,11 @@
using System.Windows.Controls;
namespace YY.Admin.Views.RawMaterialEntry;
public partial class RawMaterialEntryListView : UserControl
{
public RawMaterialEntryListView()
{
InitializeComponent();
}
}