diff --git a/Framework/YLErp.Core/DBModels/Consts/ConsFundTag.cs b/Framework/YLErp.Core/DBModels/Consts/ConsFundTag.cs index 2c1169fb..c5060be6 100644 --- a/Framework/YLErp.Core/DBModels/Consts/ConsFundTag.cs +++ b/Framework/YLErp.Core/DBModels/Consts/ConsFundTag.cs @@ -24,5 +24,25 @@ namespace YLErp.DBModels { return fundTag == Credit ? Credit : Cash; } + + /// + /// 腿是否按授信分配(§2.3 情形1 回退口径):腿上显式选择优先(Credit=授信、Cash=现金); + /// 腿未选(默认)时取交易级资金来源(trade.margin_fund_source,必填默认现金)——授信→按授信分配 + /// (额度不足拆单),现金→现金。交易级空值按现金(存量防御,SaveTrade 已归一)。 + /// 标签定稿(ApplyMarginFundTags)与簿记资金校验(RealtimePnlCalc.TradeCanBeConfirm) + /// 共用本口径,保证校验与定稿一致。 + /// + public static bool PreferCredit(string legFundTag, string tradeFundSource) + { + if (legFundTag == Credit) + { + return true; + } + if (legFundTag == Cash) + { + return false; + } + return string.IsNullOrEmpty(legFundTag) && tradeFundSource == Credit; + } } } diff --git a/Framework/YLErp.Core/DBModels/TradeBase.cs b/Framework/YLErp.Core/DBModels/TradeBase.cs index 7aee59a2..057999c1 100644 --- a/Framework/YLErp.Core/DBModels/TradeBase.cs +++ b/Framework/YLErp.Core/DBModels/TradeBase.cs @@ -677,6 +677,17 @@ namespace YLErp.DBModels [TradeAuditExclude] public string MarginTemplateName { get; set; } + /// + /// 保证金资金来源(收益互换,必填,默认现金):预付金腿未选资金标签(默认)时的定稿依据(§2.3 情形1)。 + /// 值与 swap_position.fund_tag 同词表:Credit=优先授信(额度不足自动拆单)、Cash=现金。 + /// 录入页必选(新交易默认 Cash),SaveTrade 对空值归一为 Cash(兜住 DMA 等绕过页面的链路); + /// 历史来源:旧版 trade.MarginTemplateName 曾以字典文本(授信保证金/现金保证金)承载该语义,模板V2迁移后由本列承接。 + /// + [DisplayName("资金来源")] + [TradeAuditExclude] + [Column("margin_fund_source")] + public string MarginFundSource { get; set; } + /// /// 预付金算法 /// diff --git a/UnitTestProject/Modules/SwapModule/FundTagCalcTest.cs b/UnitTestProject/Modules/SwapModule/FundTagCalcTest.cs index 319f40d1..0bc063a4 100644 --- a/UnitTestProject/Modules/SwapModule/FundTagCalcTest.cs +++ b/UnitTestProject/Modules/SwapModule/FundTagCalcTest.cs @@ -206,5 +206,106 @@ namespace YLErp.Modules.SwapModule Assert.AreEqual(ConsFundTag.Cash, settlements[1].Tag); Assert.AreEqual(-300m, settlements[1].MarginAmount); } + + // ================================================================ + // §2.3 情形1 交易级资金来源回退(ConsFundTag.PreferCredit): + // 腿上显式选择优先 > 交易级 margin_fund_source 回退 > 默认现金。 + // 标签定稿(ApplyMarginFundTags)与资金校验(TradeCanBeConfirm)共用本口径。 + // ================================================================ + + [TestMethod] + public void FT_031_腿选授信或现金_交易级字段不覆盖腿上显式选择() + { + Assert.IsTrue(ConsFundTag.PreferCredit(ConsFundTag.Credit, null)); + Assert.IsTrue(ConsFundTag.PreferCredit(ConsFundTag.Credit, ConsFundTag.Cash)); + Assert.IsFalse(ConsFundTag.PreferCredit(ConsFundTag.Cash, ConsFundTag.Credit)); + Assert.IsFalse(ConsFundTag.PreferCredit(ConsFundTag.Cash, null)); + } + + [TestMethod] + public void FT_032_腿未选_按交易级资金来源回退() + { + Assert.IsTrue(ConsFundTag.PreferCredit(null, ConsFundTag.Credit)); + Assert.IsTrue(ConsFundTag.PreferCredit("", ConsFundTag.Credit)); + Assert.IsFalse(ConsFundTag.PreferCredit(null, ConsFundTag.Cash)); + //交易级也未设置 → 默认现金 + Assert.IsFalse(ConsFundTag.PreferCredit(null, null)); + Assert.IsFalse(ConsFundTag.PreferCredit("", "")); + } + + // ================================================================ + // §2.3 保存前授信拆单(FundTagCalc.ApplySaveTimeSplit,2026-08-26 业务确认: + // 保存检查授信→不足拦截确认→拆完再保存;原腿=可用额度标授信、新腿=现金差额) + // ================================================================ + + private static LegAmount MarginLeg(long id, decimal fix, bool preferCredit = true) + => new() + { + Leg = new swap_position + { + id = id, + InterestDirection = 1, + InterestPrincipalFix = fix, + FundTag = preferCredit ? ConsFundTag.Credit : ConsFundTag.Cash + }, + Amount = (double)fix, + PreferCredit = preferCredit + }; + + [TestMethod] + public void FT_033_保存前拆单_额度不足_原腿授信新腿现金差额守恒() + { + var legs = new List { MarginLeg(101, 1000m) }; + var plans = FundTagCalc.AllocateByLegPreference(legs, 300, ignoreMoneyCheck: false); + var newLegs = FundTagCalc.ApplySaveTimeSplit(legs, plans); + + Assert.AreEqual(1, newLegs.Count); + //原腿保留可用额度部分并标授信 + Assert.AreEqual(300m, legs[0].Leg.InterestPrincipalFix); + Assert.AreEqual(ConsFundTag.Credit, legs[0].Leg.FundTag); + //新现金腿=差额,倒挤守恒 + Assert.AreEqual(700m, newLegs[0].InterestPrincipalFix); + Assert.AreEqual(ConsFundTag.Cash, newLegs[0].FundTag); + Assert.AreEqual(0, newLegs[0].id); + Assert.IsNull(newLegs[0].Obervation); + } + + [TestMethod] + public void FT_034_保存前拆单_可用授信为零_整腿定稿现金不拆() + { + var legs = new List { MarginLeg(101, 1000m) }; + var plans = FundTagCalc.AllocateByLegPreference(legs, 0, ignoreMoneyCheck: false); + var newLegs = FundTagCalc.ApplySaveTimeSplit(legs, plans); + + Assert.AreEqual(0, newLegs.Count); + Assert.AreEqual(1000m, legs[0].Leg.InterestPrincipalFix); + Assert.AreEqual(ConsFundTag.Cash, legs[0].Leg.FundTag); + } + + [TestMethod] + public void FT_035_保存前拆单_额度充足_全额授信定稿不拆_现金腿不动() + { + var legs = new List { MarginLeg(101, 1000m), MarginLeg(102, 500m, preferCredit: false) }; + var plans = FundTagCalc.AllocateByLegPreference(legs, 5000, ignoreMoneyCheck: false); + var newLegs = FundTagCalc.ApplySaveTimeSplit(legs, plans); + + Assert.AreEqual(0, newLegs.Count); + Assert.AreEqual(ConsFundTag.Credit, legs[0].Leg.FundTag); + Assert.AreEqual(1000m, legs[0].Leg.InterestPrincipalFix); + Assert.AreEqual(ConsFundTag.Cash, legs[1].Leg.FundTag); + } + + [TestMethod] + public void FT_036_保存前拆单_支付方向腿按方向比折算() + { + var leg = new swap_position { id = 101, InterestDirection = 2, InterestPrincipalFix = -1000m, FundTag = ConsFundTag.Credit }; + var legs = new List { new() { Leg = leg, Amount = 1000, PreferCredit = true } }; + var plans = FundTagCalc.AllocateByLegPreference(legs, 300, ignoreMoneyCheck: false); + var newLegs = FundTagCalc.ApplySaveTimeSplit(legs, plans); + + //应付额 = fix × -1(dir=2),授信部分 300 → fix = -300;现金差额倒挤 = -700 + Assert.AreEqual(-300m, leg.InterestPrincipalFix); + Assert.AreEqual(-700m, newLegs[0].InterestPrincipalFix); + } } } diff --git a/YLErpDAL/BLL/EodSettlement/RealtimePnlCalc.cs b/YLErpDAL/BLL/EodSettlement/RealtimePnlCalc.cs index d11193d8..d4b6ae4f 100644 --- a/YLErpDAL/BLL/EodSettlement/RealtimePnlCalc.cs +++ b/YLErpDAL/BLL/EodSettlement/RealtimePnlCalc.cs @@ -2327,8 +2327,9 @@ namespace YLErp.BLL.Eod if (trade.ExerciseDate.Value.Date >= valuedateBLL.ValueDate.Date) { // R4 簿记资金校验口径(2026-08-21 业务强调"走了资金的就不能占用授信"): - // 按腿的资金走向分流——走现金的部分(未选/选现金腿 + 成交金额)只认现金结存; - // 选授信的腿认 剩余可用授信(有效授信−已使用授信,授信出入表 Σ(amount)), + // 按腿的资金走向分流——走现金的部分(选现金/未选且交易级资金来源非授信 + 成交金额)只认现金结存; + // 按授信的腿(腿选授信,或腿未选回退交易级 margin_fund_source=授信,ConsFundTag.PreferCredit) + // 认 剩余可用授信(有效授信−已使用授信,授信出入表 Σ(amount)), // 授信不够覆盖的部分回落现金,同样只认现金结存。杜绝"现金腿拿授信垫付校验→现金透支"。 var marginModes = new[] { (int)InterestModeEnum.追加预付金, (int)InterestModeEnum.初始预付金 }; var legs = trade.swap_positions?.Where(x => marginModes.Contains(x.InterestMode)).ToList(); @@ -2346,7 +2347,7 @@ namespace YLErp.BLL.Eod { continue; } - if (leg.FundTag == YLErp.DBModels.ConsFundTag.Credit) + if (YLErp.DBModels.ConsFundTag.PreferCredit(leg.FundTag, trade.MarginFundSource)) { creditPayable += payable; } diff --git a/YLErpDAL/Modules/SwapModule/Margin/FundTagCalc.cs b/YLErpDAL/Modules/SwapModule/Margin/FundTagCalc.cs index 62a206f5..61f1c6d0 100644 --- a/YLErpDAL/Modules/SwapModule/Margin/FundTagCalc.cs +++ b/YLErpDAL/Modules/SwapModule/Margin/FundTagCalc.cs @@ -40,6 +40,49 @@ public static class FundTagCalc return plans; } + /// + /// 保存前授信拆单(§2.3 保存前拆单,2026-08-26 业务确认):把确认成交阶段的物理拆分前移到录入保存—— + /// 对 NeedSplit 的腿:原腿保留授信部分(InterestPrincipalFix 按可用额度折算)标 Credit, + /// 克隆一条现金差额腿(倒挤守恒)标 Cash 返回(Obervation 置空,防 SaveSwapPositions 重复插观察配置); + /// 不拆的授信偏好腿同步定稿标签:全额授信→Credit、额度为0/耗尽全额现金→Cash; + /// 现金/默认腿不动(最终定稿仍由确认成交 ApplyMarginFundTags 兜底重写)。 + /// legs 与 plans 须为 AllocateByLegPreference 的同序输入输出。占用/流水仍发生在确认成交。 + /// + public static List ApplySaveTimeSplit(List legs, List plans) + { + var newLegs = new List(); + for (var i = 0; i < plans.Count; i++) + { + if (!legs[i].PreferCredit) + { + continue; + } + var plan = plans[i]; + if (plan.NeedSplit) + { + var position = plan.Leg; + //应付额 = fix × (dir==1 ? 1 : -1),反推 fix 用同一比例(±1 自反) + var payableRatio = position.InterestDirection == 1 ? 1 : -1; + var originalFix = position.InterestPrincipalFix; + position.InterestPrincipalFix = Math.Round(Convert.ToDecimal(plan.CreditAmount) * payableRatio, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + position.FundTag = ConsFundTag.Credit; + var cashLeg = position.Clone(); + cashLeg.id = 0; + cashLeg.PositionId = 0; + cashLeg.Obervation = null; + //现金腿倒挤 = 原 fix − 授信 fix(分别独立舍入会有分位尾差,倒挤保证两腿合计与原 fix 守恒) + cashLeg.InterestPrincipalFix = originalFix - position.InterestPrincipalFix; + cashLeg.FundTag = ConsFundTag.Cash; + newLegs.Add(cashLeg); + } + else + { + plan.Leg.FundTag = plan.CreditAmount > 0 ? ConsFundTag.Credit : ConsFundTag.Cash; + } + } + return newLegs; + } + /// /// 平仓/到期返还金额按被平仓腿的 FundTag 分流(§2.4): /// Credit 腿的返还本金与返息不产生资金流水(本金写授信出入表"释放",出金方向记正数),Cash/无标签(存量)走现金。 @@ -102,8 +145,8 @@ public class LegFundPlan public double CreditAmount { get; set; } /// 现金部分金额 public double CashAmount { get; set; } - /// 拆单时新拆出的授信腿(占用记录绑定到它) - public swap_position CreditLeg { get; set; } + /// 拆单时新拆出的现金腿(授信不足的差额;占用记录绑原腿、现金流水绑它) + public swap_position CashLeg { get; set; } public bool NeedSplit => CreditAmount > 0 && CashAmount > 0; } diff --git a/YLErpDAL/Modules/SwapModule/SwapFundTagService.cs b/YLErpDAL/Modules/SwapModule/SwapFundTagService.cs index f6e188a2..f967f723 100644 --- a/YLErpDAL/Modules/SwapModule/SwapFundTagService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapFundTagService.cs @@ -10,7 +10,8 @@ namespace YLErp.Modules.SwapModule /// 标签赋值与返还两个写入口集中在本服务,授信出入表(ClientCreditInoutService)的占用/释放由此统一触发。 /// 口径:授信值取 credit.Credit 合计(已审批+日期有效+含母公司,阶段一已折算),已使用授信取授信出入表; /// 授信不进资金——授信部分不产生资金流水。 - /// 资金标签是预付金腿上的单列(swap_position.fund_tag,逐腿):录入时存用户选择(授信/现金/未选默认现金),确认成交时系统在同列定稿。 + /// 资金标签是预付金腿上的单列(swap_position.fund_tag,逐腿):录入时存用户选择(授信/现金/未选回退交易级 + /// margin_fund_source,交易级也未设默认现金),确认成交时系统在同列定稿。 /// public class SwapFundTagService : YLBaseService { @@ -50,10 +51,81 @@ namespace YLErp.Modules.SwapModule return GetEffectiveCredit(clientId, valueDate) - ClientCreditInoutService.GetUsedCredit(clientId, DbContext); } + /// + /// 保存前授信拆单(§2.3 保存前拆单,2026-08-26 业务确认:保存检查授信→不足拦截→UI 确认→拆完再保存)。 + /// 按当前剩余授信对偏好授信的预付金腿(腿选授信,或腿默认回退交易级资金来源=授信)做物理拆分: + /// 原腿=可用额度 标授信、克隆现金差额腿(插回 td.swap_positions 随保存落库);额度为0/耗尽的授信腿整体定稿现金。 + /// 有授信不足且未带确认标记(allowSplit=false)时抛 TradeMarginCreditSplitException—— + /// controller 返回 AdditionalProcessing/MarginCreditSplit 由 UI 确认后带参重提。 + /// 本方法只拆腿不定簿记:授信占用/资金流水仍在确认成交 ApplyMarginFundTags。 + /// + public void PreSplitMarginLegsByCredit(trade td, bool allowSplit) + { + var marginModes = new[] { (int)InterestModeEnum.追加预付金, (int)InterestModeEnum.初始预付金 }; + var preferLegs = (td.swap_positions ?? new List()) + .Where(x => marginModes.Contains(x.InterestMode) + //不扣本金的腿不产生预付金簿记(与 SwapTradeConfirm 同口径),不参与拆分 + && (x.Obervation == null || x.Obervation.IsDeductPrincipal) + && ConsFundTag.PreferCredit(x.FundTag, td.MarginFundSource)) + .ToList(); + if (preferLegs.Count == 0) + { + return; + } + var valueDate = td.TradeDate ?? DateTime.Now; + var creditAvailable = GetAvailableCredit(td.ClientId, valueDate); + var allocateLegs = preferLegs + .Select(x => new LegAmount + { + Leg = x, + Amount = Convert.ToDouble(x.InterestPrincipalFix * (x.InterestDirection == 1 ? 1 : -1)), + PreferCredit = true + }) + .Where(x => x.Amount > 0) + .OrderBy(x => x.Leg.HappenDate ?? DateTime.MaxValue) + .ThenBy(x => x.Leg.id) + .ToList(); + if (allocateLegs.Count == 0) + { + return; + } + var plans = FundTagCalc.AllocateByLegPreference(allocateLegs, creditAvailable, ignoreMoneyCheck: false); + //授信不足的腿 = 偏好授信但授信没覆盖全额(含额度为0/被前腿耗尽的整体转现金) + var shortPlans = plans.Where(p => p.CreditAmount < p.Amount).ToList(); + if (shortPlans.Count == 0) + { + //额度充足:全额授信腿就法定稿授信(含"默认+交易级授信"回退解析),无拆分、无拦截 + FundTagCalc.ApplySaveTimeSplit(allocateLegs, plans); + return; + } + if (!allowSplit) + { + var detail = string.Join(";", shortPlans.Select(p => p.NeedSplit + ? $"金额 {p.Amount:#,##0.00} → 授信 {p.CreditAmount:#,##0.00} + 现金 {p.CashAmount:#,##0.00}" + : $"金额 {p.Amount:#,##0.00} → 全额现金(可用授信不足)")); + throw new TradeMarginCreditSplitException( + $"预付金授信额度不足,剩余可用授信 {Math.Max(creditAvailable, 0):#,##0.00}:{detail}。" + + "确认后将按上述拆分保存(授信部分确认成交时占用授信额度、不产生资金流水;现金部分产生应付预付金)。"); + } + var newLegs = FundTagCalc.ApplySaveTimeSplit(allocateLegs, plans); + //新现金腿插回原腿之后(列表相邻,随 SaveSwapPositions 落库并分配 PosiNumber) + var splitPlans = plans.Where(p => p.NeedSplit).ToList(); + for (var i = 0; i < newLegs.Count; i++) + { + newLegs[i].OptId = UserId; + newLegs[i].OptName = UserName; + newLegs[i].OptTime = DateTime.Now; + var original = splitPlans[i].Leg; + var index = td.swap_positions.IndexOf(original); + td.swap_positions.Insert(index < 0 ? td.swap_positions.Count : index + 1, newLegs[i]); + } + } + /// /// 簿记确认时对预付金腿定稿资金标签并产生资金记录(§2.3 四种情形,逐腿)。 /// fund_tag 单列:录入时存用户选择(Credit/Cash/NULL),本方法读取选择后在同列定稿—— - /// 特批全现金;选授信按剩余额度分配(跨界腿拆单为 授信+现金 两条),未选/现金直接现金。 + /// 特批全现金;按授信分配(腿选授信,或腿未选回退交易级 margin_fund_source=授信)的腿按剩余额度占用, + /// 跨界腿拆单为 授信+现金 两条(原腿保留授信部分、差额拆出新现金腿);现金直接现金。 /// 授信腿只写授信出入表占用(占用记正数,绑定腿 position_id,冗余 trade_id),不产生资金流水; /// 现金腿走 SaveSwapTradeClientCash 幂等 upsert 产生 应付预付金 记录。 /// marginLegs 需为已过滤(IsDeductPrincipal 等)的预付金腿(InterestMode=5/6)。 @@ -74,7 +146,9 @@ namespace YLErp.Modules.SwapModule { Leg = x, Amount = Convert.ToDouble(x.InterestPrincipalFix * (x.InterestDirection == 1 ? 1 : -1)), - PreferCredit = x.FundTag == ConsFundTag.Credit + //优先级:腿上显式选择 > 交易级 margin_fund_source 回退(§2.3 情形1)> 默认现金, + //与 TradeCanBeConfirm 校验分流共用 ConsFundTag.PreferCredit 保证口径一致 + PreferCredit = ConsFundTag.PreferCredit(x.FundTag, td.MarginFundSource) }) .Where(x => x.Amount > 0) .OrderBy(x => x.Leg.HappenDate ?? DateTime.MaxValue) @@ -82,12 +156,12 @@ namespace YLErp.Modules.SwapModule .ToList(); var plans = FundTagCalc.AllocateByLegPreference(allocateLegs, creditAvailable, ignoreMoneyCheck); - //先落库拆分的新腿(需要 id 才能绑定占用记录) + //先落库拆分的新现金腿(需要 id 才能绑定现金流水) foreach (var plan in plans.Where(p => p.NeedSplit)) { - plan.CreditLeg = SplitLeg(td, plan); + plan.CashLeg = SplitLeg(td, plan); } - //标签定稿(覆盖录入选择):拆单的两条腿在 SplitLeg 内已分别标 Cash/Credit; + //标签定稿(覆盖录入选择):拆单的两条腿在 SplitLeg 内已分别定稿(原腿=授信、新腿=现金); //整腿授信→Credit、整腿现金/负应付(客户净收取)腿→Cash foreach (var leg in marginLegs) { @@ -110,26 +184,27 @@ namespace YLErp.Modules.SwapModule var happenDate = leg.HappenDate ?? td.TradeDate ?? DateTime.Now; if (plan != null && plan.CreditAmount > 0) { - //整腿授信 或 拆单后的授信部分:不产生资金流水,只写占用(拆单绑新拆出的授信腿)。 + //授信部分(整腿授信 或 拆单后保留在原腿的可用额度部分):不产生资金流水,只写占用(占用绑原腿)。 //占用记正数(BUG-01 修正:已使用授信=Σ(amount) 占用上升;2026-08-20"与资金流水同号入金负"口径已废弃) - creditService.Occupy(td.ClientId, plan.CreditLeg?.id ?? leg.id, td.id, plan.CreditAmount, happenDate, + creditService.Occupy(td.ClientId, leg.id, td.id, plan.CreditAmount, happenDate, plan.NeedSplit ? "簿记拆单授信部分" : "簿记授信占用"); } - //资金记录沿用既有符号口径(客户付钱为负 = -应付额):授信部分不产生流水,现金部分按差额产生 + //资金记录沿用既有符号口径(客户付钱为负 = -应付额):授信部分不产生流水, + //现金部分按差额产生——拆单腿的流水绑新拆出的现金腿,整腿现金/负应付腿绑原腿 var recordAmount = plan != null ? -plan.CashAmount : Convert.ToDouble(leg.InterestPrincipalFix * (leg.InterestDirection == 1 ? -1 : 1)); if (recordAmount != 0) { - cashService.SaveSwapTradeClientCash(td, recordAmount, happenDate, leg.id, ClientCashInCashOut.系统操作_应付预付金); + cashService.SaveSwapTradeClientCash(td, recordAmount, happenDate, plan?.CashLeg?.id ?? leg.id, ClientCashInCashOut.系统操作_应付预付金); } } } /// - /// 拆单:把跨界腿拆为 授信+现金 两条。原腿保留现金部分并标 Cash(资金来源同步改现金,与最终标签一致), - /// 克隆一条授信腿(InterestPrincipalFix 按授信金额折算)标 Credit,返回新腿。 - /// 拆出的腿为普通初始腿,后续编辑/回退/平仓链路按既有腿处理。 + /// 拆单:把跨界腿拆为 授信+现金 两条。原腿保留授信部分(可用额度)并标 Credit(占用记录绑原腿), + /// 克隆一条现金腿(授信不足的差额,InterestPrincipalFix 按现金金额折算)标 Cash,返回新腿 + /// (现金流水绑新腿)。拆出的腿为普通初始腿,后续编辑/回退/平仓链路按既有腿处理。 /// private swap_position SplitLeg(trade td, LegFundPlan plan) { @@ -137,22 +212,22 @@ namespace YLErp.Modules.SwapModule //应付额 = fix × (dir==1 ? 1 : -1),反推 fix 用同一比例(±1 自反) var payableRatio = leg.InterestDirection == 1 ? 1 : -1; var originalFix = leg.InterestPrincipalFix; - leg.InterestPrincipalFix = Math.Round(Convert.ToDecimal(plan.CashAmount) * payableRatio, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); - leg.FundTag = ConsFundTag.Cash; + leg.InterestPrincipalFix = Math.Round(Convert.ToDecimal(plan.CreditAmount) * payableRatio, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + leg.FundTag = ConsFundTag.Credit; - var creditLeg = leg.Clone(); - creditLeg.id = 0; - creditLeg.PositionId = 0; - //授信腿倒挤 = 原 fix − 现金 fix(BUG-20:两腿分别独立舍入会有分位尾差,倒挤保证两腿合计与原 fix 守恒) - creditLeg.InterestPrincipalFix = originalFix - leg.InterestPrincipalFix; - creditLeg.FundTag = ConsFundTag.Credit; - creditLeg.OptId = UserId; - creditLeg.OptName = UserName; - creditLeg.OptTime = DateTime.Now; - DbContext.swap_position.Add(creditLeg); + var cashLeg = leg.Clone(); + cashLeg.id = 0; + cashLeg.PositionId = 0; + //现金腿倒挤 = 原 fix − 授信 fix(两腿分别独立舍入会有分位尾差,倒挤保证两腿合计与原 fix 守恒) + cashLeg.InterestPrincipalFix = originalFix - leg.InterestPrincipalFix; + cashLeg.FundTag = ConsFundTag.Cash; + cashLeg.OptId = UserId; + cashLeg.OptName = UserName; + cashLeg.OptTime = DateTime.Now; + DbContext.swap_position.Add(cashLeg); DbContext.SaveChanges(); - creditLeg.PosiNumber = $"{td.TradeNumber}-{creditLeg.id}"; - return creditLeg; + cashLeg.PosiNumber = $"{td.TradeNumber}-{cashLeg.id}"; + return cashLeg; } /// diff --git a/YLErpDAL/Modules/SwapModule/SwapTradeService.cs b/YLErpDAL/Modules/SwapModule/SwapTradeService.cs index 7ff6a46c..62beaf46 100644 --- a/YLErpDAL/Modules/SwapModule/SwapTradeService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapTradeService.cs @@ -86,8 +86,19 @@ namespace YLErp.Modules.SwapModule /// /// /// - public trade SaveTrade(trade req, bool ignoreMoneyCheck = false) + public trade SaveTrade(trade req, bool ignoreMoneyCheck = false, bool allowMarginCreditSplit = false) { + //资金来源必填(现金/授信,默认现金):保存前归一,兜住 DMA 自动建仓等绕过录入页的链路 + if (req.TradeType == "收益互换" && string.IsNullOrWhiteSpace(req.MarginFundSource)) + { + req.MarginFundSource = ConsFundTag.Cash; + } + // §2.3 保存前授信拆单(2026-08-26 业务确认):保存检查授信→不足拦截(UI 确认)→拆完再保存。 + // 特批(ignoreMoneyCheck)语义为全现金不占授信,跳过拆单 + if (!ignoreMoneyCheck && req.TradeType == "收益互换") + { + new SwapFundTagService(this).PreSplitMarginLegsByCredit(req, allowMarginCreditSplit); + } var um = checkUnderlying(req); trade dbTrade = new trade(); //交易保存处理(PrepareInitialMargin 在此把 trade_Initial_Margin 折算进 req.InitialMargin, @@ -1709,7 +1720,7 @@ namespace YLErp.Modules.SwapModule } fundTagSvc.ApplyMarginFundTags(td, generateMarginLegs, cashSvc, false); //标签定稿(含可能的拆单)后重克隆实时持仓:TradeBack 的克隆先于定稿生成, - //重克隆使实时腿继承定稿标签、新拆出的授信腿也获得克隆(平仓返还分流查的是实时腿标签) + //重克隆使实时腿继承定稿标签、拆单新拆出的现金腿也获得克隆(平仓返还分流查的是实时腿标签) InitialPosition(td); // 合约维度盯市+无预付金腿:重建交易级(positionId=0)初始预付金记录(与 SwapTradeConfirm 一致,回退重补场景)。 // 有预付金腿的互换由上面按腿重建,不在此重复生成。 diff --git a/YLErpDAL/Modules/SwapModule/TradeMarginCreditSplitException.cs b/YLErpDAL/Modules/SwapModule/TradeMarginCreditSplitException.cs new file mode 100644 index 00000000..09671823 --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/TradeMarginCreditSplitException.cs @@ -0,0 +1,17 @@ +using BaseOUDAL; + +namespace YLErp.Modules.SwapModule +{ + /// + /// 保存交易时预付金授信不足的标记异常(§2.3 保存前拆单,2026-08-26 业务确认): + /// 保存检查授信→不足拦截→UI 确认(AdditionalProcessing/MarginCreditSplit)→带参重提后 + /// 按剩余授信物理拆分预付金腿(原腿=可用额度 标授信、新腿=差额 标现金)再保存。 + /// 属标准业务流程,不受"允许交易特批"开关控制(与 LackOfMoney 特批协议区分)。 + /// + public class TradeMarginCreditSplitException : ServiceException + { + public TradeMarginCreditSplitException(string message) : base(message) + { + } + } +} diff --git a/YLErpDAL/Modules/TradeModule/DealModule/TradeConfirmService.cs b/YLErpDAL/Modules/TradeModule/DealModule/TradeConfirmService.cs index efc6d2a6..b6503aa9 100644 --- a/YLErpDAL/Modules/TradeModule/DealModule/TradeConfirmService.cs +++ b/YLErpDAL/Modules/TradeModule/DealModule/TradeConfirmService.cs @@ -378,10 +378,11 @@ namespace YLErp.Modules.TradeModule.DealModule } var cashService = new ClientCashInCashOutService(this); cashService.SaveSwapTradeClientCash(td, td.TradePrice ?? 0, happenDate,0); - // R4 授信/现金标签:预付金腿定稿资金标签(选授信按剩余授信分配,不足跨界腿拆单), + // R4 授信/现金标签:预付金腿定稿资金标签(按授信分配的腿——腿选授信或未选回退交易级资金来源—— + // 按剩余授信分配,不足跨界腿拆单为 原腿授信+新现金腿), // 授信部分不产生资金流水(只写授信出入表占用),现金部分产生 应付预付金 记录;特批全现金。 // 必须在 InitialPosition/AddPositionEvent 之前执行:实时持仓克隆与初始事件要继承"定稿后"的标签, - // 拆单新拆出的授信腿也要被克隆、建事件(否则平仓返还分流会查到克隆腿上的旧标签/漏腿)。 + // 拆单新拆出的现金腿也要被克隆、建事件(否则平仓返还分流会查到克隆腿上的旧标签/漏腿)。 var generateMarginLegs = new List(); foreach (var marginPosition in td.swap_positions) { diff --git a/YLErpDAL/Modules/TradeModule/TradeBLL.cs b/YLErpDAL/Modules/TradeModule/TradeBLL.cs index 004baef5..261f1ef8 100644 --- a/YLErpDAL/Modules/TradeModule/TradeBLL.cs +++ b/YLErpDAL/Modules/TradeModule/TradeBLL.cs @@ -25,6 +25,11 @@ namespace YLErp.BLL /// public const string LackOfMoney = "LackOfMoney"; + /// + /// 保存交易时预付金授信不足:UI 确认后按 剩余授信+现金差额 拆分预付金腿再保存(§2.3 保存前拆单) + /// + public const string MarginCreditSplit = "MarginCreditSplit"; + public const string RiskWarningConfirm = "RiskWarningConfirm"; /// diff --git a/YLErpWeb/Controllers/SwapTrade2Controller.cs b/YLErpWeb/Controllers/SwapTrade2Controller.cs index 7dbb0d69..cabfaec8 100644 --- a/YLErpWeb/Controllers/SwapTrade2Controller.cs +++ b/YLErpWeb/Controllers/SwapTrade2Controller.cs @@ -104,6 +104,8 @@ namespace YLErp.Web.Controllers TraderId = CurUser.UserId, TraderName = CurUser.UserName, MarginTemplateName = defaultMarginTemplateName, + //资金来源必填(现金/授信),新交易默认现金 + MarginFundSource = ConsFundTag.Cash, OpponentRole = "乙方", OriginalStockEqvNotional = 0, StructureType = "普通债券类收益互换", @@ -492,18 +494,29 @@ namespace YLErp.Web.Controllers req.id = DecryptInt(req.EncryptId); } //特批放行判定与确认/审批环节同口径(processtradelogController/ApprovalService): - //系统参数 允许交易特批(SpecialOperateForTrade) 开启 且 显式带 LackOfMoney 标记重提 - var ignoreMoneyCheck = valuedateBLL.SystemDate.SpecialOperateForTrade == 1 && additionalProcessing == tradeBLL.LackOfMoney; + //系统参数 允许交易特批(SpecialOperateForTrade) 开启 且 显式带 LackOfMoney 标记重提。 + //additionalProcessing 支持逗号分隔多标记(保存前授信拆单与资金特批可链式确认): + //MarginCreditSplit=预付金授信不足拆单确认(标准流程,不受特批开关控制) + var processings = (additionalProcessing ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries).ToHashSet(); + var ignoreMoneyCheck = valuedateBLL.SystemDate.SpecialOperateForTrade == 1 && processings.Contains(tradeBLL.LackOfMoney); + var allowMarginCreditSplit = processings.Contains(tradeBLL.MarginCreditSplit); try { bool edit = req.id != 0; - var r= swapTradeService.SaveTrade(req, ignoreMoneyCheck); + var r= swapTradeService.SaveTrade(req, ignoreMoneyCheck, allowMarginCreditSplit); Task.Run(() => { RealtimePnlCalc.RealtimeSwapPosition(new OptUserInfo(0, "互换实时持仓服务", OptUserFrom.Service)); }); return JsonSuccess("更新成功", r); } + catch (TradeMarginCreditSplitException e) + { + //保存前授信拆单(§2.3):预付金授信不足,UI 确认后带 additionalProcessing=MarginCreditSplit + //重提,按 剩余授信+现金差额 物理拆腿后保存 + LogFactory.GetLogger("交易保存").Info("保存授信不足待拆单确认:" + e.Message); + return JsonSuccessData(new { proccessType = "AdditionalProcessing", type = tradeBLL.MarginCreditSplit, message = e.Message }); + } catch (TradeLackOfMoneyException e) { //保存环节资金不足:开关开启时按确认/审批同一协议返回 AdditionalProcessing/LackOfMoney, diff --git a/YLErpWeb/Views/SwapTrade2/TradeEdit.cshtml b/YLErpWeb/Views/SwapTrade2/TradeEdit.cshtml index 013c2e40..ce7eb5d6 100644 --- a/YLErpWeb/Views/SwapTrade2/TradeEdit.cshtml +++ b/YLErpWeb/Views/SwapTrade2/TradeEdit.cshtml @@ -102,6 +102,10 @@ page.Trade.StartDate = page.Trade.StartDate ? page.Trade.StartDate.substr(0, 10) : ""; page.Trade.TradeDate = page.Trade.TradeDate ? page.Trade.TradeDate.substr(0, 10) : ""; page.Trade.ExerciseDate = page.Trade.ExerciseDate ? page.Trade.ExerciseDate.substr(0, 10) : ""; + //资金来源必填(现金/授信),存量空值按默认现金归一,避免下拉空值匹配不到选项 + if (!page.Trade.MarginFundSource) { + page.Trade.MarginFundSource = "Cash"; + } @@ -289,12 +293,19 @@ 保证金模板 - 请选择 + 默认 {{ item.Text }} + + 资金来源 + + 现金 + 授信 + + @@ -332,8 +343,8 @@ - - 默认(现金) + + 默认 现金 授信 diff --git a/YLErpWeb/Views/SwapTrade2/TradeView.cshtml b/YLErpWeb/Views/SwapTrade2/TradeView.cshtml index fc33a462..b50e1912 100644 --- a/YLErpWeb/Views/SwapTrade2/TradeView.cshtml +++ b/YLErpWeb/Views/SwapTrade2/TradeView.cshtml @@ -253,7 +253,11 @@ 保证金模板 - @trade.MarginTemplateName + @(string.IsNullOrWhiteSpace(trade.MarginTemplateName) ? "默认" : trade.MarginTemplateName) + + + 资金来源 + @(trade.MarginFundSource == ConsFundTag.Credit ? "授信" : "现金") @@ -288,8 +292,8 @@ @((SwapDirectionEnum)item.InterestDirection) @((InterestModeEnum)item.InterestMode) - @*R4 资金标签(fund_tag 单列):录入时为用户选择,确认成交后为系统定稿(授信/现金)*@ - @(item.FundTag == ConsFundTag.Credit ? "授信" : "现金") + @*R4 资金标签(fund_tag 单列):录入时为用户选择(默认=取交易上的资金来源),确认成交后为系统定稿(授信/现金)*@ + @(item.FundTag == ConsFundTag.Credit ? "授信" : item.FundTag == ConsFundTag.Cash ? "现金" : "默认") @item.HappenDate.OtcFormatDate() diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js index 70d0d69e..8efa0496 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js @@ -817,17 +817,35 @@ const vue = new Vue({ "补充协议编号": $("#SupProtocolCode").val() }; this.trade.trade_extend.ExtendJson = JSON.stringify(this.trade.trade_extend.ExtendObj); - //R4 保存环节资金校验:资金不足且系统开启"允许交易特批"时,服务端按确认/审批同一协议返回 - //AdditionalProcessing/LackOfMoney——弹"交易特批"确认,带 additionalProcessing=LackOfMoney 重提放行 + //保存环节两类拦截确认(服务端按确认/审批同一协议返回 AdditionalProcessing): + //1) MarginCreditSplit 预付金授信不足——确认后按 剩余授信+现金差额 拆腿再保存(标准流程); + //2) LackOfMoney 资金不足且系统开启"允许交易特批"——弹"交易特批",特批放行。 + //两类可链式发生(拆单后现金仍不足再走特批),确认标记累积在 query 上一并带上 var thisObj = this; + var confirmedProcessings = []; var doSave = function (additionalProcessing) { var url = "/swaptrade2/tradeEditJson"; - if (!main.isEmpty(additionalProcessing)) { - url += "?additionalProcessing=" + additionalProcessing; + if (!main.isEmpty(additionalProcessing) && confirmedProcessings.indexOf(additionalProcessing) < 0) { + confirmedProcessings.push(additionalProcessing); + } + if (confirmedProcessings.length) { + url += "?additionalProcessing=" + confirmedProcessings.join(","); } main.post(url, thisObj.trade).done(function (resp) { if (resp.obj && resp.obj.proccessType == "AdditionalProcessing") { - if (resp.obj.type == "LackOfMoney") { + if (resp.obj.type == "MarginCreditSplit") { + var splitContent = '' + resp.obj.message + ''; + main.open2("提示", + splitContent, + { + area: ["460px", "260px"], + btn: ['确认拆分', '取消'], + yes: function (index, layero) { + layer.close(index); + doSave("MarginCreditSplit"); + } + }); + } else if (resp.obj.type == "LackOfMoney") { var htmlContent = '' + resp.obj.message + ''; var lackMoneyConfirmLayer = main.open2("提示", htmlContent,