Merge branch 'glms/feature/1.4.2' into glms/feature/dotnumber

This commit is contained in:
张名锐
2026-07-30 13:05:41 +08:00
8 changed files with 412 additions and 12 deletions
@@ -0,0 +1,106 @@
using YLErp.Configuration;
using YLErp.DBModels;
using YLErp.DBModels.Consts;
using YLErp.DBModels.Enums;
using YLErp.Modules.TradeModule.DocGenerateModule;
namespace YLErp.UnitTestProject.Modules.TradeModule.DocGenerateModule
{
[TestClass]
public class GuolianSwapConfirmBookScopeTest
{
[TestMethod]
public void IsPreApprovalTrade_AllAllowedStatuses_ReturnsTrue()
{
var statuses = new[]
{
ConsTrade.,
ConsTrade.,
ConsTrade.
};
foreach (var status in statuses)
{
var trade = CreateTrade(status);
Assert.IsTrue(GuolianSwapConfirmBookScope.IsPreApprovalTrade(trade, CompanyEnum.), status);
}
}
[TestMethod]
public void IsPreApprovalTrade_NonGuolianSwapOrInvalidTrade_ReturnsFalse()
{
var confirmedSwap = CreateTrade(ConsTrade.);
var optionTrade = CreateTrade(ConsTrade., "香草期权");
var invalidSwap = CreateTrade(ConsTrade.);
invalidSwap.ValidState = ConsGlobal.InValid;
Assert.IsFalse(GuolianSwapConfirmBookScope.IsPreApprovalTrade(confirmedSwap, CompanyEnum.));
Assert.IsFalse(GuolianSwapConfirmBookScope.IsPreApprovalTrade(optionTrade, CompanyEnum.));
Assert.IsFalse(GuolianSwapConfirmBookScope.IsPreApprovalTrade(CreateTrade(ConsTrade.), CompanyEnum.));
Assert.IsFalse(GuolianSwapConfirmBookScope.IsPreApprovalTrade(invalidSwap, CompanyEnum.));
}
[TestMethod]
public void OtherActiveReferencePredicate_OnlyMatchesOtherActiveTrade()
{
const string contractCode = "GLMS-TEST-20260728-FICC-04-000001";
var predicate = GuolianSwapConfirmBookCleanupService
.BuildOtherActiveReferencePredicate(contractCode, 4)
.Compile();
Assert.IsFalse(predicate(CreateRelation(4, contractCode, ContractTypeEnum.Trade, true)));
Assert.IsTrue(predicate(CreateRelation(7, contractCode, ContractTypeEnum.Trade, true)));
Assert.IsFalse(predicate(CreateRelation(7, contractCode, ContractTypeEnum.Trade, false)));
Assert.IsFalse(predicate(CreateRelation(7, "OTHER", ContractTypeEnum.Trade, true)));
Assert.IsFalse(predicate(CreateRelation(7, contractCode, ContractTypeEnum.Clearing, true)));
}
[TestMethod]
public void TradeConfirmationRelationPredicate_MatchesActiveAndInactiveRelations()
{
const string contractCode = "GLMS-TEST-20260728-FICC-04-000001";
var predicate = GuolianSwapConfirmBookCleanupService
.BuildTradeConfirmationRelationPredicate(4)
.Compile();
Assert.IsTrue(predicate(CreateRelation(4, contractCode, ContractTypeEnum.Trade, true)));
Assert.IsTrue(predicate(CreateRelation(4, contractCode, ContractTypeEnum.Trade, false)));
Assert.IsFalse(predicate(CreateRelation(7, contractCode, ContractTypeEnum.Trade, true)));
Assert.IsFalse(predicate(CreateRelation(4, contractCode, ContractTypeEnum.Clearing, true)));
}
[TestMethod]
public void ActiveOaResultPredicate_OnlyMatchesRejectedTradeActiveResult()
{
var predicate = GuolianSwapConfirmBookCleanupService
.BuildActiveOaResultPredicate(4)
.Compile();
Assert.IsTrue(predicate(new trade_contract_oa_result { trade_id = 4, is_valid = true }));
Assert.IsFalse(predicate(new trade_contract_oa_result { trade_id = 4, is_valid = false }));
Assert.IsFalse(predicate(new trade_contract_oa_result { trade_id = 7, is_valid = true }));
}
private static trade CreateTrade(string status, string tradeType = "收益互换")
{
return new trade
{
TradeType = tradeType,
TradeStatus = status,
ValidState = ConsGlobal.Valid
};
}
private static trade_contract_r CreateRelation(int tradeId, string contractCode, string type, bool isValid)
{
return new trade_contract_r
{
TradeId = tradeId,
ContractCode = contractCode,
Type = type,
IsValid = isValid
};
}
}
}
@@ -647,8 +647,15 @@ namespace YLErp.Modules.SwapModule
}
if (changeConfirmStatus)
{
//删除交易确认书 TODO
new TradeDocumentDataService(this).DeleteBooksAfterEditTrade(dbTrade);
if (GuolianSwapConfirmBookScope.IsPreApprovalTrade(dbTrade))
{
new GuolianSwapConfirmBookCleanupService(this).InvalidateAfterEdit(dbTrade);
}
else
{
//删除交易确认书 TODO
new TradeDocumentDataService(this).DeleteBooksAfterEditTrade(dbTrade);
}
}
int eventType = isAddNew ? (int)SwapEventTypeEnum. : (int)SwapEventTypeEnum.;
new SwapEventService(UserInfo).AddSwapEventDate(dbTrade.TradeDate.Value, dbTrade.id, eventType, "", 0, true, "", true);
@@ -226,9 +226,10 @@ namespace YLErp.Modules.TradeModule.DealModule
return true;
}
// 过滤掉待确认和审批中的交易,这些状态不允许生成确认书
// 仅国联收益互换允许在审批前生成确认书,其他交易保持原状态限制。
var invalidStatusList = new List<string> { ConsTrade., ConsTrade., ConsTrade. };
var invalidTrades = trades.Where(t => invalidStatusList.Contains(t.TradeStatus)).ToList();
var invalidTrades = trades.Where(t => invalidStatusList.Contains(t.TradeStatus)
&& !GuolianSwapConfirmBookScope.IsPreApprovalTrade(t)).ToList();
if (invalidTrades.Any())
{
error.AddRange(invalidTrades.Select(t => $"交易 {t.TradeNumber} 状态为【{t.TradeStatus}】,不能生成确认书"));
@@ -237,6 +238,20 @@ namespace YLErp.Modules.TradeModule.DealModule
logger.Info($"[GJGenerateConfirmBook] 过滤掉 {invalidTrades.Count} 笔待确认/审批中交易");
}
var preApprovalTrades = trades.Where(GuolianSwapConfirmBookScope.IsPreApprovalTrade).ToList();
foreach (var trade in preApprovalTrades)
{
var validationErrors = new GuolianSwapConfirmBookValidationService(this).Validate(trade);
if (!validationErrors.Any())
{
continue;
}
error.Add($"交易 {trade.TradeNumber} 无法生成确认书:{string.Join("", validationErrors)}");
tradeIds = tradeIds.Where(id => id != trade.id).ToList();
trades = trades.Where(item => item.id != trade.id).ToList();
}
if (!trades.Any())
{
logger.Info("[GJGenerateConfirmBook] 过滤后无有效交易可生成确认书");
@@ -7,6 +7,7 @@ using YLErp.Model.Enum;
using YLErp.Modules.ClientModule;
using YLErp.Modules.SwapModule;
using YLErp.Modules.TradeDalModule;
using YLErp.Modules.TradeModule.DocGenerateModule;
namespace YLErp.Modules.TradeModule.DealModule
{
@@ -295,6 +296,7 @@ namespace YLErp.Modules.TradeModule.DealModule
/// </summary>
private TradeOpenResult TradeReject(TradeOpenReqModel req, trade td)
{
var isGuolianPreApprovalTrade = GuolianSwapConfirmBookScope.IsPreApprovalTrade(td);
var oldOrderId = td.ProcessOrderId;
req.comments = FormatCommentsWithRole(oldOrderId, req.comments);
td.ProcessOrderId = Convert.ToInt32(PStatusEnum.reject);
@@ -367,6 +369,11 @@ namespace YLErp.Modules.TradeModule.DealModule
var tcDetails = DbContext.trade_cash_detail.Where(O => O.TradeCashId == tcId);
DbContext.trade_cash_detail.RemoveRange(tcDetails);
if (isGuolianPreApprovalTrade)
{
new GuolianSwapConfirmBookCleanupService(this).CleanRejectedPreApprovalTrade(td);
}
DbContext.SaveChanges();
return new TradeOpenResult(td) { RetCode = TradeOpenRetCode.Success };
@@ -75,7 +75,7 @@ namespace YLErp.Modules.TradeModule.DocGenerateModule
throw new ServiceException("没有已确认的记录信息!");
}
var tradeDic = allTrades.Where(O =>
statusList.Contains(O.TradeStatus)
(statusList.Contains(O.TradeStatus) || GuolianSwapConfirmBookScope.IsPreApprovalTrade(O))
&& O.ClientId != 0
&& O.ParentTradeId == 0)
.ToDictionary(n => n.id, n => new trade_contract_group { trade = n });
@@ -0,0 +1,192 @@
using System.Linq.Expressions;
using YLErp.Configuration;
using YLErp.DBModels;
using YLErp.DBModels.Consts;
using YLErp.DBModels.Enums;
using YLErp.Model;
namespace YLErp.Modules.TradeModule.DocGenerateModule
{
/// <summary>
/// 国联收益互换审批前生成确认书的范围与资料校验。
/// </summary>
public static class GuolianSwapConfirmBookScope
{
private static readonly HashSet<string> PreApprovalStatuses = new()
{
ConsTrade.,
ConsTrade.,
ConsTrade.
};
public static bool IsPreApprovalTrade(trade trade)
{
return IsPreApprovalTrade(trade, PS.Config.Company);
}
public static bool IsPreApprovalTrade(trade trade, CompanyEnum company)
{
return IsGuolianSwap(trade, company)
&& trade.ValidState != ConsGlobal.InValid
&& IsPreApprovalStatus(trade.TradeStatus);
}
public static bool IsGuolianSwap(trade trade)
{
return IsGuolianSwap(trade, PS.Config.Company);
}
public static bool IsGuolianSwap(trade trade, CompanyEnum company)
{
return company == CompanyEnum. && trade.TradeType == "收益互换";
}
public static bool IsPreApprovalStatus(string tradeStatus)
{
return PreApprovalStatuses.Contains(tradeStatus);
}
}
/// <summary>
/// 清理国联收益互换审批前生成的确认书,不复用按合同编号批量清理的通用逻辑。
/// </summary>
public class GuolianSwapConfirmBookCleanupService : YLBaseService
{
public GuolianSwapConfirmBookCleanupService(YLBaseService baseService) : base(baseService)
{
}
public void InvalidateAfterEdit(trade trade)
{
if (!GuolianSwapConfirmBookScope.IsGuolianSwap(trade)
|| !GuolianSwapConfirmBookScope.IsPreApprovalStatus(trade.TradeStatus))
{
return;
}
InvalidateRelations(trade.id);
}
public void CleanRejectedPreApprovalTrade(trade trade)
{
if (!GuolianSwapConfirmBookScope.IsGuolianSwap(trade))
{
return;
}
GlobalConfig.SealHandler.RemoveQueue(trade.id);
InvalidateOaResults(trade.id);
var contractCodes = InvalidateRelations(trade.id);
foreach (var contractCode in contractCodes)
{
var hasActiveReference = DbContext.trade_contract_r.Any(
BuildOtherActiveReferencePredicate(contractCode, trade.id));
if (!hasActiveReference)
{
var documents = DbContext.trade_contract_document.Where(x => x.Code == contractCode
&& x.Type == ContractTypeEnum.Trade);
DbContext.trade_contract_document.RemoveRange(documents);
}
}
}
public static Expression<Func<trade_contract_r, bool>> BuildOtherActiveReferencePredicate(
string contractCode,
int rejectedTradeId)
{
return relation => relation.ContractCode == contractCode
&& relation.Type == ContractTypeEnum.Trade
&& relation.IsValid
&& relation.TradeId != rejectedTradeId;
}
public static Expression<Func<trade_contract_r, bool>> BuildTradeConfirmationRelationPredicate(
int tradeId)
{
return relation => relation.TradeId == tradeId
&& relation.Type == ContractTypeEnum.Trade;
}
public static Expression<Func<trade_contract_oa_result, bool>> BuildActiveOaResultPredicate(
int tradeId)
{
return result => result.trade_id == tradeId && result.is_valid;
}
private void InvalidateOaResults(int tradeId)
{
var oaResults = DbContext.tradeContractOaResult
.Where(BuildActiveOaResultPredicate(tradeId))
.ToList();
oaResults.ForEach(x => x.is_valid = false);
}
private List<string> InvalidateRelations(int tradeId)
{
var relations = DbContext.trade_contract_r
.Where(BuildTradeConfirmationRelationPredicate(tradeId))
.ToList();
relations.Where(x => x.IsValid).ToList().ForEach(x => x.IsValid = false);
return relations.Select(x => x.ContractCode).Distinct().ToList();
}
}
/// <summary>
/// 仅校验模板无法正常生成或选择时必需的交易数据。
/// </summary>
public class GuolianSwapConfirmBookValidationService : YLBaseService
{
public GuolianSwapConfirmBookValidationService(YLBaseService baseService) : base(baseService)
{
}
public List<string> Validate(trade trade)
{
var errors = new List<string>();
var client = DataCacheProvider.GetClientDataSource().GetData(trade.ClientId);
var underlying = DataCacheProvider.GetUnderlyingDataSource().GetData(trade.UnderlyingCode);
if (client == null)
{
errors.Add("客户信息不存在");
return errors;
}
if (underlying == null)
{
errors.Add("标的信息不存在");
}
ValidateInitialPosition(trade, errors);
return errors;
}
private void ValidateInitialPosition(trade trade, List<string> errors)
{
var position = DbContext.swap_position.FirstOrDefault(x => x.SwapTradeId == trade.id
&& x.IsInitial
&& !x.Invalid
&& (x.PositionType == (int)PositionTypeFlag.Long || x.PositionType == (int)PositionTypeFlag.Short));
if (position == null)
{
errors.Add("缺少初始标的持仓");
return;
}
if (position.PosiDirection != (int)SwapDirectionEnum.
&& position.PosiDirection != (int)SwapDirectionEnum.)
{
errors.Add("初始标的持仓收支方向无效");
}
if (string.IsNullOrWhiteSpace(position.UnderlyingCode))
{
errors.Add("初始标的持仓缺少标的代码");
}
}
}
}
+77 -5
View File
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Html;
using System.IO;
using System.Reflection;
using YLErp.Events;
@@ -27,18 +28,44 @@ namespace YLErp
static HtmlUtil()
{
//获取bin目录中YLErp开头的文件最后修改日期作为JS和CSS文件版本
//获取bin目录中YLErp开头的文件最后修改日期作为后端构建版本
var binDir = new DirectoryInfo(AppContext.BaseDirectory);
var files = binDir.GetFiles("YLErp*");
BinFileVersion = files.Any() ? files.Max(n => n.LastWriteTime) : DateTime.MinValue;
JsVersion = BinFileVersion.ToString("yyMMddHHmmss");
//JsVersion(浏览器 ?v= 缓存戳)必须同时反映【后端 DLL】和【前端脚本】的变更:
//历史上只取 DLL mtime,导致纯前端改动(改 wwwroot/Scripts 而不重编 DLL)时
//缓存戳不变、浏览器仍加载旧版——即"发布了但不更新"(见 ?otcdebug=1 排查时的症状)。
//现纳入 wwwroot/Scripts(业务脚本)+ wwwroot/Statics/bundles(打包产物)的 mtime 取 max。
//wwwroot 定位用 Directory.GetCurrentDirectory()(与 StaticUrlMiddleware 一致,
//开发时=项目根、部署时=publish 目录),目录不存在则回退到仅 DLL mtime。
var versionClock = BinFileVersion;
var webRoot = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot");
foreach (var sub in new[] { "Scripts", Path.Combine("Statics", "bundles") })
{
var dir = Path.Combine(webRoot, sub);
if (Directory.Exists(dir))
{
foreach (var f in new DirectoryInfo(dir).GetFiles("*", SearchOption.AllDirectories))
{
if (f.LastWriteTime > versionClock) versionClock = f.LastWriteTime;
}
}
}
JsVersion = versionClock.ToString("yyMMddHHmmss");
//从 AssemblyInformationalVersion 读取 git shaSDK 编译时已嵌入,零额外依赖)
var infoVer = typeof(HtmlUtil).Assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
//格式 "1.0.0+<sha>",取 + 之后部分;无则回退到完整字符串
GitCommit = string.IsNullOrEmpty(infoVer) ? "unknown"
: (infoVer.Contains('+') ? infoVer.Substring(infoVer.LastIndexOf('+') + 1) : infoVer);
//格式 "1.0.0+<sha>",取 + 之后部分;无 +(如固定版本号 1.0.0)则回退读 .git/HEAD
if (!string.IsNullOrEmpty(infoVer) && infoVer.Contains('+'))
{
GitCommit = infoVer.Substring(infoVer.LastIndexOf('+') + 1);
}
else
{
GitCommit = ReadGitCommitFromRepo() ?? (infoVer ?? "unknown");
}
EventBus.Subscribe<DataCacheUpdateEvent>(t =>
{
@@ -46,6 +73,51 @@ namespace YLErp
});
}
/// <summary>
/// 从 .git/HEAD 读取当前 commit 短 sha,作为 AssemblyInformationalVersion 无 sha 时的回退。
/// 找不到 .git 或读取失败返回 null(不影响启动)。
/// </summary>
static string ReadGitCommitFromRepo()
{
try
{
//从 ContentRoot 向上找 .git 目录(开发时在项目根/仓库根)
var dir = new DirectoryInfo(Directory.GetCurrentDirectory());
while (dir != null)
{
var gitDir = Path.Combine(dir.FullName, ".git");
if (Directory.Exists(gitDir))
{
return ParseGitHead(gitDir);
}
dir = dir.Parent;
}
}
catch { /* 读取失败不影响启动,返回 null */ }
return null;
}
static string ParseGitHead(string gitDir)
{
var headFile = Path.Combine(gitDir, "HEAD");
if (!File.Exists(headFile)) return null;
var head = File.ReadAllText(headFile).Trim();
//HEAD 格式:"ref: refs/heads/xxx" 或 detached 时的直接 sha
if (head.StartsWith("ref: "))
{
var refPath = Path.Combine(gitDir, head.Substring(5).Replace('/', Path.DirectorySeparatorChar));
if (File.Exists(refPath))
{
return File.ReadAllText(refPath).Trim().Substring(0, 12);
}
}
else if (head.Length >= 12)
{
return head.Substring(0, 12);
}
return null;
}
/// <summary>
/// 获取基础数据JS连接
/// </summary>
@@ -926,9 +926,10 @@ function showToolName(cellValue, options, rowObject) {
var html = showDownload(cellValue, options, rowObject);
var title = page.IsAutoSealAndUploadFiles && page.IsAutoSealAfterGeneratedBook ? "生成并用印" : "生成确认书";
// 待确认和审批中的交易不允许生成确认书
// 仅国联收益互换允许在审批前生成确认书,其他交易保持原限制。
var isUnconfirmed = rowObject.TradeStatus === "新增待确认" || rowObject.TradeStatus === "修改待确认" || rowObject.TradeStatus === "审批中";
var canGenerate = page.canGenerate == true && !isUnconfirmed ? "" : "disabled";
var isGuolianSwap = page.isGuoLian === true && rowObject.TradeType === "收益互换";
var canGenerate = page.canGenerate == true && (!isUnconfirmed || isGuolianSwap) ? "" : "disabled";
var isWait = (rowObject.MetaDic.SealResult == "用印等待中");
html += "<input type=\"button\" class='wentiEdit' title='{2}' onclick=\"gJGenerateConfirmBook({0},{3},{1})\" value='{2}' {4}/>".template(rowObject.id, rowObject.MetaDic.HasGeneratedConfirmBook == "True", title, isWait, canGenerate);
if (rowObject.MetaDic.ContractDocUrl && page.canSendEmail == true) {