fix: 国联民生收益互换支持审批前生成交易确认书

- 仅国联民生收益互换支持在新增待确认、修改待确认、审批中生成交易确认书,期权、其他公司及其他交易状态保持原限制
- 交易确认书页面同步开放上述交易的“生成确认书”按钮,后端批量生成链路同步放开状态过滤
- 审批前生成仅校验客户、标的、初始持仓等模板必需核心数据;不以银行账户、联系人、保证金等非模板必填资料阻断
- 审批前已生成确认书的交易被修改时,使该交易原确认书关联失效,重新生成仍按原覆盖逻辑处理
- 审批拒绝时清理该交易审批前生成的确认书关联、OA 结果及用印队列;无其他有效引用时删除确认书数据,避免遗留无效确认书
- 保持原交易编号逻辑:自动生成和手工填写均不复用已拒绝交易编号
- 新增审批前适用范围、确认书关联及 OA 清理谓词专项单测
This commit is contained in:
tengyufan
2026-07-30 11:10:48 +08:00
parent c519dcf44f
commit b3484c3618
7 changed files with 335 additions and 7 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
};
}
}
}
@@ -657,8 +657,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("初始标的持仓缺少标的代码");
}
}
}
}
@@ -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) {