From bf984dbb25ec2f1a4879130b992f4d70e03aaef4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A9=AC=E5=86=B0=E5=86=B0?= <437394478@qq.com> Date: Thu, 27 Aug 2026 19:59:03 +0800 Subject: [PATCH 01/19] =?UTF-8?q?feat(um):=20=E6=A0=B9=E6=8D=AE=E6=A0=87?= =?UTF-8?q?=E7=9A=84=E4=BB=A3=E7=A0=81=20=E5=9F=BA=E9=87=91=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E4=BA=BA=E5=9B=9E=E6=98=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- YLErpDAL/AppManager.cs | 1 + .../FundManagerLookupService.cs | 94 +++++++++++++++++++ .../underlying_managerController.cs | 19 ++++ .../Scripts/app/underlying/underlyingedit.js | 58 ++++++++++++ 4 files changed, 172 insertions(+) create mode 100644 YLErpDAL/Modules/UnderlyingModule/FundManagerLookupService.cs diff --git a/YLErpDAL/AppManager.cs b/YLErpDAL/AppManager.cs index 0b244c2d..609c870a 100644 --- a/YLErpDAL/AppManager.cs +++ b/YLErpDAL/AppManager.cs @@ -171,6 +171,7 @@ namespace YLErp "yladmin" => _configuration.GetConnectionString("yladmin"), "ylclient" => _configuration.GetConnectionString("ylclient"), "bondoms" => _configuration.GetConnectionString("bondoms"), + "glms_bigdata" => _configuration.GetConnectionString("glms_bigdata"), "apex_oracle"=> _configuration.GetConnectionString("apex_oracle"), _ => string.Empty, }; diff --git a/YLErpDAL/Modules/UnderlyingModule/FundManagerLookupService.cs b/YLErpDAL/Modules/UnderlyingModule/FundManagerLookupService.cs new file mode 100644 index 00000000..b37d8f5e --- /dev/null +++ b/YLErpDAL/Modules/UnderlyingModule/FundManagerLookupService.cs @@ -0,0 +1,94 @@ +using Dapper; +using MySqlConnector; +using YieldChain.Helpers; +using YLErp.BLL; + +namespace YLErp.Modules.UnderlyingModule +{ + public enum FundManagerLookupStatus + { + NotFound, + Unique, + Multiple, + Unavailable + } + + public sealed class FundManagerLookupResult + { + public FundManagerLookupStatus Status { get; init; } + public string InvestAdvisorName { get; init; } + } + + /// + /// 查询上游基金档案中的基金管理人。上游不可用时返回降级结果,不阻断页面编辑。 + /// + public sealed class FundManagerLookupService + { + private sealed class FundManagerRow + { + public string InvestAdvisorCode { get; set; } + public string InvestAdvisorName { get; set; } + } + + private const string LookupSql = @" +SELECT + ia.investadvisorcode AS InvestAdvisorCode, + ia.investadvisorname AS InvestAdvisorName +FROM glms_bigdata.mf_fundarchives AS fa +INNER JOIN glms_bigdata.mf_investadvisoroutline AS ia + ON CONVERT(fa.investadvisorcode USING utf8mb4) COLLATE utf8mb4_unicode_ci = + CONVERT(ia.investadvisorcode USING utf8mb4) COLLATE utf8mb4_unicode_ci +WHERE CONVERT(fa.secucode USING utf8mb4) COLLATE utf8mb4_unicode_ci = + CONVERT(TRIM(SUBSTRING_INDEX(@UnderlyingCode, '.', 1)) USING utf8mb4) COLLATE utf8mb4_unicode_ci"; + + public FundManagerLookupResult Lookup(string underlyingCode) + { + var normalizedCode = NormalizeCode(underlyingCode); + if (string.IsNullOrEmpty(normalizedCode)) + { + return new FundManagerLookupResult { Status = FundManagerLookupStatus.NotFound }; + } + + var connectionString = AppManager.GetConnectionString("glms_bigdata"); + if (string.IsNullOrWhiteSpace(connectionString)) + { + return new FundManagerLookupResult { Status = FundManagerLookupStatus.Unavailable }; + } + + try + { + using var connection = new MySqlConnection(connectionString); + var matches = connection.Query(LookupSql, new { UnderlyingCode = normalizedCode }, commandTimeout: 10) + .Where(row => !string.IsNullOrWhiteSpace(row.InvestAdvisorName)) + .GroupBy(row => (row.InvestAdvisorCode ?? string.Empty).Trim(), StringComparer.OrdinalIgnoreCase) + .Select(group => group.Select(row => row.InvestAdvisorName.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).ToArray()) + .Where(names => names.Length > 0) + .ToArray(); + + return matches.Length switch + { + 0 => new FundManagerLookupResult { Status = FundManagerLookupStatus.NotFound }, + 1 when matches[0].Length == 1 => new FundManagerLookupResult { Status = FundManagerLookupStatus.Unique, InvestAdvisorName = matches[0][0] }, + _ => new FundManagerLookupResult { Status = FundManagerLookupStatus.Multiple } + }; + } + catch (Exception ex) + { + LogFactory.GetLogger().Error("查询基金管理人失败,代码:" + normalizedCode, ex); + return new FundManagerLookupResult { Status = FundManagerLookupStatus.Unavailable }; + } + } + + public static string NormalizeCode(string underlyingCode) + { + var trimmed = underlyingCode?.Trim(); + if (string.IsNullOrEmpty(trimmed)) + { + return null; + } + + var dotIndex = trimmed.IndexOf('.'); + return (dotIndex < 0 ? trimmed : trimmed.Substring(0, dotIndex)).TrimToNull(); + } + } +} diff --git a/YLErpWeb/Controllers/underlying_managerController.cs b/YLErpWeb/Controllers/underlying_managerController.cs index 69ba8b66..fa9e7f35 100644 --- a/YLErpWeb/Controllers/underlying_managerController.cs +++ b/YLErpWeb/Controllers/underlying_managerController.cs @@ -525,6 +525,25 @@ namespace YLErp.Web.Controllers return JsonSuccess("", underlying); } + /// + /// 查询上游基金档案中的基金管理人。查询失败或结果不唯一时返回可降级结果。 + /// + [HttpGet] + public JsonResult GetFundManager(string code, string instrumentType) + { + if (!string.Equals(instrumentType, ConsGlobal.InstrumentType.Fund, StringComparison.OrdinalIgnoreCase)) + { + return JsonSuccess("", new FundManagerLookupResult { Status = FundManagerLookupStatus.NotFound }); + } + + var result = new FundManagerLookupService().Lookup(code); + return JsonSuccess("", new + { + result.InvestAdvisorName, + IsUnique = result.Status == FundManagerLookupStatus.Unique + }); + } + /// /// 预付金参数 /// diff --git a/YLErpWeb/wwwroot/Scripts/app/underlying/underlyingedit.js b/YLErpWeb/wwwroot/Scripts/app/underlying/underlyingedit.js index dd80db28..6e66b379 100644 --- a/YLErpWeb/wwwroot/Scripts/app/underlying/underlyingedit.js +++ b/YLErpWeb/wwwroot/Scripts/app/underlying/underlyingedit.js @@ -17,6 +17,10 @@ const consSelect = ['MarketCode', 'UnderlyingInstrumentType', 'UnderlyingState', 'UnderlyingStatus', 'UpDownLimitType', 'EtfSubType']; var autoUpDownLimit, autoVariety; +var fundManagerLookupSeq = 0; +var fundManagerLookupTimer = null; +var fundManagerLookupXhr = null; +var fundManagerManualEdit = false; $(function () { @@ -46,6 +50,12 @@ $(function () { lookup: ylotc.varieties }); + $('#InvestAdvisorName').on('input', function () { + fundManagerManualEdit = true; + }); + + $('#UnderlyingCode').on('input', refreshFundManagerLookup); + for (var i = 1; i <= 5; i++) { let datas = ylotc.underlyingBlocks.filter(x => x.Group === i); let autoBlock = FastVue.autocomplete(document.getElementById('inputBlock' + i), { @@ -112,9 +122,57 @@ $(function () { break; } $('#editForm').removeClass("form-None form-Stock form-CommodityFutures form-CommoditySpot form-Bonds form-Fund").addClass("form-" + classType); + refreshFundManagerLookup(); }).trigger('change'); }); +function refreshFundManagerLookup() { + var requestSeq = ++fundManagerLookupSeq; + if (fundManagerLookupTimer) { + clearTimeout(fundManagerLookupTimer); + fundManagerLookupTimer = null; + } + if (fundManagerLookupXhr) { + fundManagerLookupXhr.abort(); + fundManagerLookupXhr = null; + } + + var codeInput = $('#UnderlyingCode'); + var typeInput = $('#UnderlyingInstrumentType'); + var managerInput = $('#InvestAdvisorName'); + if (page.Model.id > 0 || !codeInput.length || !typeInput.length || + typeInput.val() !== 'Fund' || !codeInput.val() || !managerInput.length) { + return; + } + + var codeAtRequest = codeInput.val(); + var managerAtRequest = managerInput.val() || ''; + var manualAtRequest = fundManagerManualEdit; + fundManagerLookupTimer = setTimeout(function () { + fundManagerLookupTimer = null; + fundManagerLookupXhr = $.ajax({ + url: '/underlying_manager/GetFundManager', + method: 'GET', + data: { code: codeAtRequest, instrumentType: 'Fund' } + }).done(function (resp) { + if (requestSeq !== fundManagerLookupSeq || page.Model.id > 0 || + $('#UnderlyingCode').val() !== codeAtRequest || + $('#UnderlyingInstrumentType').val() !== 'Fund' || + manualAtRequest || fundManagerManualEdit) { + return; + } + + var result = resp && resp.obj; + if (result && result.IsUnique && result.InvestAdvisorName) { + managerInput.val(result.InvestAdvisorName); + fundManagerManualEdit = false; + } + }).always(function () { + fundManagerLookupXhr = null; + }); + }, 200); +} + function saveData() { var data = $('#editForm').serializeObject(); From 9d850a7723dc2ff398c0e8fc377a8e1a70679bfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=94=A6=E9=BA=9F=20=E7=8E=8B?= Date: Thu, 27 Aug 2026 20:06:03 +0800 Subject: [PATCH 02/19] =?UTF-8?q?BugFix=201.ETF=E6=B2=A1=E6=9C=89=E6=9C=9F?= =?UTF-8?q?=E9=99=90=202.=E6=94=B6=E7=9B=98=E4=BB=B7=E5=92=8C=E5=87=80?= =?UTF-8?q?=E4=BB=B7=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DBModels/Consts/ConsMarginTerm.cs | 24 +--- .../margin_template_v2Controller.cs | 32 ++---- .../margin_template_v2ClientEdit.cshtml | 4 +- .../margin_template_v2DefaultEdit.cshtml | 4 +- .../margin_template_v2Edit.cshtml | 4 +- .../marginTemplateV2DefaultEdit.js | 108 ++++++++---------- .../marginTemplate/marginTemplateV2Edit.js | 92 +++++---------- 7 files changed, 94 insertions(+), 174 deletions(-) diff --git a/Framework/YLErp.Core/DBModels/Consts/ConsMarginTerm.cs b/Framework/YLErp.Core/DBModels/Consts/ConsMarginTerm.cs index 74779a4c..0105c168 100644 --- a/Framework/YLErp.Core/DBModels/Consts/ConsMarginTerm.cs +++ b/Framework/YLErp.Core/DBModels/Consts/ConsMarginTerm.cs @@ -10,33 +10,15 @@ namespace YLErp.DBModels /// /// 允许配置期限档(SpanConfig.BondTerm)的标的资产类型标志位。 /// 区间追保结构"按资产类型分类"时,仅这些类型允许设置非空期限档; - /// 本期仅含 利率债(TBonds=1<<4=16);ETF 子类分档能力见 TermTierEnabledEtfKinds。 + /// 仅含 利率债(TBonds=1<<4=16)。 + /// ETF 不分期限档(2026-08-27 业务裁定:ETF 没有期限概念, + /// 此前的 可转债 ETF/科创债 ETF 分档白名单 TermTierEnabledEtfKinds 已移除,如需恢复查 git 历史)。 /// public static readonly UnderlyingTypeEnum[] TermTierEnabledUnderlyingTypes = { UnderlyingTypeEnum.TBonds }; - /// - /// 允许配置期限档的 ETF 子类白名单(来自数据字典"ETF 子类")。 - /// 选择这些子类的 ETF 区块按固定4档展开(同利率债);其余子类/不区分的 ETF 区块单套参数、不分档。 - /// 取值与 underlying_manager.EtfSubType 字典项一致,由标的维护页维护。 - /// 注意:分档 ETF 子类行的 UnderlyingType 仍为 基金(32768),不走 TermTierEnabledUnderlyingTypes 位掩码(§1.2 决策:不再加枚举位)。 - /// - public static readonly List TermTierEnabledEtfKinds = new List - { - "可转债 ETF", - "科创债 ETF" - }; - - /// - /// 判断 EtfKind 是否允许分档(在 TermTierEnabledEtfKinds 白名单内)。 - /// - public static bool IsTermTierEnabledEtfKind(string etfKind) - { - return !string.IsNullOrEmpty(etfKind) && TermTierEnabledEtfKinds.Contains(etfKind); - } - /// /// 5年以下(同时也是兜底默认期限) /// diff --git a/YLErpWeb/Controllers/margin_template_v2Controller.cs b/YLErpWeb/Controllers/margin_template_v2Controller.cs index 591b8f45..ea788f5d 100644 --- a/YLErpWeb/Controllers/margin_template_v2Controller.cs +++ b/YLErpWeb/Controllers/margin_template_v2Controller.cs @@ -299,8 +299,8 @@ namespace YLErp.Web.Controllers } } - //区间追保结构 + 按资产类型分类时,期限档仅允许"允许分期限档的资产类型"(利率债)或"允许分档的 ETF 子类" - //(可转债 ETF/科创债 ETF,行 UnderlyingType=基金 + EtfKind=子类,不加枚举位)配置,防止非分档类型误配期限档; + //区间追保结构 + 按资产类型分类时,期限档仅允许利率债配置(2026-08-27 裁定:ETF 无期限概念, + //可转债 ETF/科创债 ETF 的分档白名单已移除,所有 ETF 子类/基金行一律单套参数不分档),防止非分档类型误配期限档; //该校验按行生效,与明细行数无关(单行明细同样拦截); //按严格掩码判定:行标的类型位必须全部落在可分档类型内(混合标志位如 利率债|信用债 配期限档同样拦截,与取数侧整行期限档过滤语义一致); //UnderlyingType 为空的通配行允许配期限档(取数侧期限档过滤在前、通配匹配在后,语义自洽) @@ -308,34 +308,24 @@ namespace YLErp.Web.Controllers && marginTemplate.UnderlyingSeperateType == (int)UnderlyingSeperateTypeEnum.CustomInstrumentType) { var enabledMask = ConsMarginTerm.TermTierEnabledUnderlyingTypes.Aggregate(UnderlyingTypeEnum.None, (a, t) => a | t); - var tierEnabledEtfKinds = ConsMarginTerm.TermTierEnabledEtfKinds; foreach (var detail in marginTemplate.Details) { var etfKind = detail.SpanConfig?.EtfKind; + var bondTerm = detail.SpanConfig?.BondTerm; //ETF 子类行:仅允许纯基金行(配置页子类选择器也只在纯基金区块出现) if (!string.IsNullOrEmpty(etfKind) && detail.UnderlyingType != UnderlyingTypeEnum.Fund) { throw new Exception("配置了 ETF 子类(" + etfKind + ")的参数组资产类型必须为 基金及基金专户"); } - var isTierEnabledEtfKind = tierEnabledEtfKinds.Contains(etfKind ?? ""); - if (!string.IsNullOrEmpty(detail.SpanConfig?.BondTerm) - && (detail.UnderlyingType & ~enabledMask) != UnderlyingTypeEnum.None - && !isTierEnabledEtfKind) + //ETF 子类不分期限档(对存量 4 档子类行回存给出精确报错;基金通配行由下面的掩码校验拦截) + if (!string.IsNullOrEmpty(etfKind) && !string.IsNullOrEmpty(bondTerm)) + { + throw new Exception("ETF 子类 " + etfKind + " 不分期限档,参数行不能配置期限档"); + } + if (!string.IsNullOrEmpty(bondTerm) && (detail.UnderlyingType & ~enabledMask) != UnderlyingTypeEnum.None) { throw new Exception("标的类型不允许配置期限档:" + UnderlyingTypeUtil.GetDesc(detail.UnderlyingType)); } - //分档 ETF 子类必须配期限档(区块固定4档),其余子类行禁配期限档 - if (!string.IsNullOrEmpty(etfKind)) - { - if (isTierEnabledEtfKind && string.IsNullOrEmpty(detail.SpanConfig?.BondTerm)) - { - throw new Exception("ETF 子类 " + etfKind + " 为分期限档类型,参数行必须配置期限档"); - } - if (!isTierEnabledEtfKind && !string.IsNullOrEmpty(detail.SpanConfig?.BondTerm)) - { - throw new Exception("ETF 子类 " + etfKind + " 不分期限档,参数行不能配置期限档"); - } - } } } @@ -394,8 +384,8 @@ namespace YLErp.Web.Controllers { if (marginTemplate.RuleType == (int)MarginRuleTypeEnum.区间追保结构) { - //区间追保结构:按 (利率债期限档, ETF细分) 元组分组校验(EtfKind 为预留扩展键,本期无 UI 写入,等价于仅期限档分组), - //同一分组内标的类型不允许重复,不同分组允许相同标的类型; + //区间追保结构:按 (利率债期限档, ETF细分) 元组分组校验——期限档仅利率债有值(其余恒空), + //EtfKind 为基金行的子类键;同一分组内标的类型不允许重复,不同分组允许相同标的类型; //通配行(空/全部,UnderlyingType=None/All)两两之间位与恒为 0 检不出,须单独拦截(BUG-09)—— //否则取数侧通配兜底 matched.First() 命中不确定 foreach (var termGroup in marginTemplate.Details.GroupBy(x => (x.SpanConfig?.BondTerm ?? "", x.SpanConfig?.EtfKind ?? ""))) diff --git a/YLErpWeb/Views/margin_template_v2/margin_template_v2ClientEdit.cshtml b/YLErpWeb/Views/margin_template_v2/margin_template_v2ClientEdit.cshtml index 593f2612..fcb0b1ee 100644 --- a/YLErpWeb/Views/margin_template_v2/margin_template_v2ClientEdit.cshtml +++ b/YLErpWeb/Views/margin_template_v2/margin_template_v2ClientEdit.cshtml @@ -214,10 +214,10 @@ 参数组{{blk.no}}:资产类型:{{underlyingTypeNames(blk.ut)}} @*ETF 子类(仅纯基金区块展示):选 可转债ETF/科创债ETF 分期限档(展开4档),其他子类/不区分不分档*@  ETF 子类: - (按期限分档:≤5y、(5y-10y]、(10y-30y]、>30y,每个档位独立设置) + (按期限分档:≤5y、(5y-10y]、(10y-30y]、>30y,每个档位独立设置)

-

期限档位:{{sec.termLabel}}

+

期限档位:{{sec.termLabel}}