feat: 国联民生收益互换按簿记分类生成交易编号 簿记账户新增“簿记分类”字段,支持代客、融券,新增账户默认代客,历史账户统一初始化为代客 簿记账户分类设为必填,列表、编辑及详情页面同步展示 修改已被交易引用的簿记账户分类时增加二次确认,并提示关联交易数量 国联民生收益互换交易编号不再根据甲乙方判断,改为根据簿记账户分类生成 代客使用数字序号,融券使用字母序号,分别从已有同客户同日编号的最大序号继续递增 保留手工交易编号优先逻辑,标准格式的手工编号参与后续最大序号计算 生成候选编号后检查完整交易编号是否已存在,存在则继续递增 新增簿记分类编号格式、最大序号计算及数字字母序列隔离专项单测
273 lines
10 KiB
C#
273 lines
10 KiB
C#
using YLErp.BLL;
|
||
using YLErp.Configuration;
|
||
using YLErp.DBModels;
|
||
using YLErp.DBModels.Consts;
|
||
using YLErp.DBModels.Enums;
|
||
|
||
namespace YLErp.Modules.TradeModule.DocGenerateModule
|
||
{
|
||
/// <summary>
|
||
/// 国联交易确认书编号生成器
|
||
/// 规则:
|
||
/// - 代客交易(簿记分类为"代客"):GLMS-{对手方代码}-{成交日期(yyyyMMdd)}-FICC-{当日第N笔代客交易(数字递增)}-{标的代码},从01开始
|
||
/// 例:GLMS-IS-20260424-01-250210IB
|
||
/// - 融券交易(簿记分类为"融券"):GLMS-{对手方代码}-{成交日期(yyyyMMdd)}-FICC-{当日第N笔融券交易使用字母递增}-{标的代码},从A开始
|
||
/// 例:GLMS-IS-20260424-A-250210IB
|
||
/// </summary>
|
||
public class GuolianContractNoGenerator
|
||
{
|
||
private static readonly object _syncLock = new object();
|
||
|
||
public static bool IsGuolianSwapTrade(trade trade)
|
||
{
|
||
return IsGuolianSwapTrade(trade, PS.Config.Company);
|
||
}
|
||
|
||
public static bool IsGuolianSwapTrade(trade trade, CompanyEnum company)
|
||
{
|
||
return company == CompanyEnum.国联
|
||
&& trade?.TradeType == "收益互换";
|
||
}
|
||
|
||
public static bool ShouldGenerateTradeNumberAfterSave(trade trade, CompanyEnum company)
|
||
{
|
||
return IsGuolianSwapTrade(trade, company)
|
||
&& string.IsNullOrWhiteSpace(trade.TradeNumber);
|
||
}
|
||
|
||
public static bool CanReuseRejectedTradeNumber(trade trade, CompanyEnum company)
|
||
{
|
||
return IsGuolianSwapTrade(trade, company);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 交易首次入库后按国联交易确认书编号规则回写交易编号。
|
||
/// </summary>
|
||
/// <returns>是否生成了交易编号</returns>
|
||
public static bool TryGenerateTradeNumberAfterSave(YLContext dbContext, trade trade)
|
||
{
|
||
if (!IsGuolianSwapTrade(trade) || !string.IsNullOrWhiteSpace(trade.TradeNumber))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
if (trade.id <= 0)
|
||
{
|
||
throw new InvalidOperationException("国联收益互换交易编号必须在交易入库后生成");
|
||
}
|
||
|
||
var clientCode = DataCacheProvider.GetClientDataSource().GetData(trade.ClientId)?.Code;
|
||
if (string.IsNullOrWhiteSpace(clientCode))
|
||
{
|
||
throw new ServiceException("对手方代码缩写缺失,请联系运营组同事维护");
|
||
}
|
||
|
||
trade.TradeNumber = NormalizeTradeNumberCase(
|
||
Generate(dbContext, trade, clientCode),
|
||
PS.Config.ErpElement.UpperTradeNumber);
|
||
return true;
|
||
}
|
||
|
||
public static string NormalizeTradeNumberCase(string tradeNumber, bool upperTradeNumber)
|
||
{
|
||
return upperTradeNumber && !string.IsNullOrEmpty(tradeNumber)
|
||
? tradeNumber.ToUpperInvariant()
|
||
: tradeNumber;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 按国联确认书编号规则拼装编号(纯函数,不依赖数据库,便于单测)。
|
||
/// 格式:GLMS-{clientCode}-{成交日期(yyyyMMdd)}-FICC-{序号}-{标的代码(去点)}
|
||
/// - 代客交易:序号用两位数字,从 01 开始
|
||
/// - 融券交易:序号用字母递增,从 A 开始
|
||
/// </summary>
|
||
public static string BuildTradeNumber(trade trade, string clientCode, string bookCategory, int sequenceNo)
|
||
{
|
||
var isAgencyTrade = bookCategory == AssetUnit.BookCategoryAgency;
|
||
var underlyingCode = (trade.UnderlyingCode ?? "").Replace(".", ""); // 去掉标的代码中的点号
|
||
var tradeDateStr = (trade.TradeDate ?? DateTime.MinValue).ToString("yyyyMMdd");
|
||
var seq = isAgencyTrade ? sequenceNo.ToString("D2") : NumberToLetter(sequenceNo);
|
||
return $"GLMS-{clientCode}-{tradeDateStr}-FICC-{seq}-{underlyingCode}";
|
||
}
|
||
|
||
public static int GetNextSequenceNo(
|
||
IEnumerable<string> tradeNumbers,
|
||
string clientCode,
|
||
DateTime tradeDate,
|
||
string bookCategory)
|
||
{
|
||
if (bookCategory != AssetUnit.BookCategoryAgency &&
|
||
bookCategory != AssetUnit.BookCategorySecuritiesLending)
|
||
{
|
||
throw new ArgumentException("簿记分类无效", nameof(bookCategory));
|
||
}
|
||
|
||
var prefix = $"GLMS-{clientCode}-{tradeDate:yyyyMMdd}-FICC-";
|
||
var isAgencyTrade = bookCategory == AssetUnit.BookCategoryAgency;
|
||
var maxSequenceNo = 0;
|
||
|
||
foreach (var tradeNumber in tradeNumbers ?? Enumerable.Empty<string>())
|
||
{
|
||
if (string.IsNullOrWhiteSpace(tradeNumber) ||
|
||
!tradeNumber.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
var sequenceEndIndex = tradeNumber.IndexOf('-', prefix.Length);
|
||
if (sequenceEndIndex <= prefix.Length)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
var sequenceText = tradeNumber.Substring(
|
||
prefix.Length,
|
||
sequenceEndIndex - prefix.Length);
|
||
var sequenceNo = isAgencyTrade
|
||
? ParseNumericSequence(sequenceText)
|
||
: LetterToNumber(sequenceText);
|
||
|
||
if (sequenceNo.HasValue && sequenceNo.Value > maxSequenceNo)
|
||
{
|
||
maxSequenceNo = sequenceNo.Value;
|
||
}
|
||
}
|
||
|
||
if (maxSequenceNo == int.MaxValue)
|
||
{
|
||
throw new InvalidOperationException("国联收益互换交易编号序号已超出支持范围");
|
||
}
|
||
|
||
return maxSequenceNo + 1;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 生成国贸交易确认书编号
|
||
/// </summary>
|
||
/// <param name="dbContext">数据库上下文</param>
|
||
/// <param name="trade">交易记录</param>
|
||
/// <param name="clientCode">客户代码</param>
|
||
/// <returns>确认书编号</returns>
|
||
public static string Generate(
|
||
YLContext dbContext,
|
||
trade trade,
|
||
string clientCode)
|
||
{
|
||
var tradeDate = trade.TradeDate ?? DateTime.MinValue;
|
||
var bookCategory = dbContext.assetunit
|
||
.Where(a => a.id == trade.AssetId)
|
||
.Select(a => a.BookCategory)
|
||
.FirstOrDefault();
|
||
|
||
if (bookCategory != AssetUnit.BookCategoryAgency &&
|
||
bookCategory != AssetUnit.BookCategorySecuritiesLending)
|
||
{
|
||
throw new ServiceException("簿记账户未维护有效的簿记分类");
|
||
}
|
||
|
||
lock (_syncLock)
|
||
{
|
||
// 幂等:如果已生成过确认书编号,直接返回
|
||
var existingForTrade = dbContext.trade_contract_r
|
||
.Where(r => r.TradeId == trade.id && r.Type == ContractTypeEnum.Trade && r.IsValid)
|
||
.Select(r => r.ContractCode)
|
||
.FirstOrDefault();
|
||
|
||
if (!string.IsNullOrWhiteSpace(existingForTrade))
|
||
{
|
||
return existingForTrade;
|
||
}
|
||
|
||
// 编号中的数字/字母序列记录生成时的分类,不受簿记账户后续分类调整影响。
|
||
var tradeDateStart = tradeDate.Date;
|
||
var sameDayTradeNumbers = dbContext.trade
|
||
.Where(t => t.TradeDate >= tradeDateStart
|
||
&& t.TradeDate < tradeDateStart.AddDays(1)
|
||
&& t.ClientId == trade.ClientId
|
||
&& t.id != trade.id
|
||
&& t.TradeNumber != null
|
||
&& t.TradeNumber != "")
|
||
.Select(t => t.TradeNumber)
|
||
.ToArray();
|
||
var sequenceNo = GetNextSequenceNo(
|
||
sameDayTradeNumbers,
|
||
clientCode,
|
||
tradeDate,
|
||
bookCategory);
|
||
|
||
while (true)
|
||
{
|
||
var candidate = BuildTradeNumber(trade, clientCode, bookCategory, sequenceNo);
|
||
var isExists = dbContext.trade.Any(t => t.id != trade.id && t.TradeNumber == candidate);
|
||
if (!isExists)
|
||
{
|
||
return candidate;
|
||
}
|
||
|
||
if (sequenceNo == int.MaxValue)
|
||
{
|
||
throw new InvalidOperationException("国联收益互换交易编号序号已超出支持范围");
|
||
}
|
||
|
||
sequenceNo++;
|
||
}
|
||
}
|
||
}
|
||
|
||
private static int? ParseNumericSequence(string sequence)
|
||
{
|
||
return sequence.All(char.IsDigit) && int.TryParse(sequence, out var number) && number > 0
|
||
? number
|
||
: null;
|
||
}
|
||
|
||
private static int? LetterToNumber(string sequence)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(sequence))
|
||
{
|
||
return null;
|
||
}
|
||
|
||
var number = 0;
|
||
foreach (var value in sequence)
|
||
{
|
||
var letter = char.ToUpperInvariant(value);
|
||
if (letter < 'A' || letter > 'Z')
|
||
{
|
||
return null;
|
||
}
|
||
|
||
var letterValue = letter - 'A' + 1;
|
||
if (number > (int.MaxValue - letterValue) / 26)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
number = number * 26 + letterValue;
|
||
}
|
||
|
||
return number;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 将数字转换为字母序列(1=A, 2=B, 26=Z, 27=AA, 28=AB...)
|
||
/// </summary>
|
||
private static string NumberToLetter(int number)
|
||
{
|
||
if (number <= 0)
|
||
{
|
||
return "A";
|
||
}
|
||
|
||
string result = "";
|
||
while (number > 0)
|
||
{
|
||
number--; // 调整为从0开始
|
||
result = (char)('A' + (number % 26)) + result;
|
||
number /= 26;
|
||
}
|
||
return result;
|
||
}
|
||
}
|
||
}
|