using System;
using System.Data.Common;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Data.SqlClient;
using GB5Shared.DTO.Framework.Login;
using GB5Shared.DTO.Framework.VoucherNumber;
using GB5Shared.Query.VoucherNumber;
using GB5Shared.QueryExecutor;
namespace GB5Shared.GenerateAutoNumber
{
///
/// Dapper-based equivalent of GB4's VnoGenerationDLL.GetNextVnoWithSameSession.
/// Generates formatted voucher numbers by atomically incrementing the MBIZTRANSACTIONKEYS
/// counter for the applicable period slot (yearly / monthly / daily / continuous).
///
/// Period sentinel values stored in MBIZTRANSACTIONKEYS.YEAR:
/// -2 → Yearly (MONTH = 0, DAY = 0)
/// -1 → Continuous (MONTH = 0, DAY = 0)
/// actual year → Monthly or Daily
///
/// Number format: SystemPrefix(date) + Prefix + PaddedLastNo + Suffix + SystemSuffix(date)
/// where SystemPrefix/Suffix are .NET date-format strings (e.g. "yy" → "25").
///
/// Registration: services.AddScoped<VoucherNumberService>()
///
public class VoucherNumberService
{
private readonly IQueryExecutor _queryExecutor;
private readonly AutoNumber _autoNumber;
public VoucherNumberService(IQueryExecutor queryExecutor, AutoNumber autoNumber)
{
_queryExecutor = queryExecutor;
_autoNumber = autoNumber;
}
///
/// Returns the next formatted voucher number for the given BizTransactionTypeId,
/// or null when NOGenerationType == 1 (Manual) — in that case the caller
/// should keep the user-supplied number as-is.
///
/// BizTransactionType primary key.
/// The document / voucher date used for period bucketing and SystemPrefix/Suffix formatting.
/// Current user session.
///
/// Active DB transaction. Both the MBIZTRANSACTIONKEYS UPDATE/INSERT and the
/// entity INSERT must share the same transaction so that a rollback reverts the
/// counter increment along with the entity row.
///
/// Cancellation token.
///
/// When supplied (including ""), replaces config.Prefix in the formatted output.
/// Pass "" from TaskBLL so BuildTaskNumber provides the sole type-letter prefix,
/// matching GB4's pattern: Task.Number = 'T' + VnoGenerationDLL.GetNextVno(...)
///
// Overload for callers that still pass DateTime (other services not yet migrated)
public Task GetNextVoucherNumberAsync(
int bizTransactionTypeId,
DateTime voucherDate,
LoginDTO loginDTO,
DbTransaction? transaction = null,
CancellationToken ct = default,
string? prefixOverride = null)
=> GetNextVoucherNumberAsync(
bizTransactionTypeId,
DateOnly.FromDateTime(voucherDate),
loginDTO, transaction, ct, prefixOverride);
public async Task GetNextVoucherNumberAsync(
int bizTransactionTypeId,
DateOnly voucherDate,
LoginDTO loginDTO,
DbTransaction? transaction = null,
CancellationToken ct = default,
string? prefixOverride = null)
{
// ── Step 1: load BizTransactionType config ─────────────────────────────────
var configs = (await _queryExecutor.QueryAsync(
loginDTO,
VoucherNumberQB.GET_BIZTRANSACTIONTYPE_CONFIG,
new { BizTransactionTypeId = bizTransactionTypeId })).ToList();
if (configs.Count == 0)
throw new InvalidOperationException(
$"BizTransactionType (Id={bizTransactionTypeId}) not found in MBIZTRANSACTIONTYPE. " +
"Contact Administrator.");
var config = configs[0];
// Manual generation — caller keeps user-supplied number unchanged
if (config.NOGenerationType == 1)
return null;
// ── Step 2: resolve period-slot sentinel values ────────────────────────────
BuildPeriodValues(config.NOGenerationPeriodType, voucherDate,
out int yearVal, out int monthVal, out int dayVal);
// ── Steps 3+4: atomically increment the counter ───────────────────────────
// UPDATE ... OUTPUT INSERTED.LASTNO returns the new value in one statement.
// SQL Server serialises concurrent UPDATEs on the same row so no two requests
// can receive the same LASTNO. If 0 rows returned the period slot does not
// exist yet — INSERT it with LASTNO = 1. A retry handles the narrow race
// where two requests both see 0 rows and race to INSERT.
var keyParam = new
{
BizTransactionTypeId = bizTransactionTypeId,
Year = yearVal,
Month = monthVal,
Day = dayVal
};
int lastNo = await IncrementOrInsertAsync(
loginDTO, config, keyParam, bizTransactionTypeId,
yearVal, monthVal, dayVal, transaction, ct);
// ── Step 5: format and return ──────────────────────────────────────────────
return FormatVoucherNumber(config, lastNo, voucherDate, prefixOverride);
}
// ── Private helpers ───────────────────────────────────────────────────────────
///
/// Atomically increments MBIZTRANSACTIONKEYS.LASTNO for the given period slot and
/// returns the new value. On the very first use of a period the row is created with
/// LASTNO = 1. A retry handles the rare race where two concurrent requests both
/// see a missing row and race to INSERT.
///
private async Task IncrementOrInsertAsync(
LoginDTO loginDTO,
VoucherNumberConfigDTO config,
object keyParam,
int bizTransactionTypeId,
int yearVal, int monthVal, int dayVal,
DbTransaction? transaction,
CancellationToken ct)
{
// Attempt 1: atomic increment on an existing row.
int lastNo = (await _queryExecutor.QueryAsync(
loginDTO,
VoucherNumberQB.INCREMENT_LASTNO,
keyParam,
transaction)).FirstOrDefault();
if (lastNo > 0)
return lastNo;
// Row doesn't exist yet — create it with LASTNO = 1.
// Two concurrent requests can both reach this branch; catch the unique-key
// violation the loser gets and retry the UPDATE (which now finds the winner's row).
try
{
var newKeyId = await _autoNumber.GetNumberAsync(1, "BIZTRANSACTIONKEYS", loginDTO);
await _queryExecutor.ExecuteAsync(
loginDTO,
VoucherNumberQB.INSERT_BIZTRANSACTIONKEYS,
new
{
BizTransactionKeysId = newKeyId.StartNumber,
BizTransactionTypeId = bizTransactionTypeId,
Year = yearVal,
Month = monthVal,
Day = dayVal,
Prefix = config.Prefix,
Suffix = config.Suffix,
SystemPrefix = config.SystemPrefix
},
transaction);
return 1;
}
catch (SqlException ex) when (ex.Number == 2627 || ex.Number == 2601)
{
// Another request inserted the row first. Retry the atomic increment.
lastNo = (await _queryExecutor.QueryAsync(
loginDTO,
VoucherNumberQB.INCREMENT_LASTNO,
keyParam,
transaction)).FirstOrDefault();
if (lastNo > 0)
return lastNo;
throw new InvalidOperationException(
$"Failed to generate voucher number for BizTransactionTypeId={bizTransactionTypeId} " +
$"after INSERT collision. Contact Administrator.", ex);
}
}
///
/// Converts NOGenerationPeriodType to the sentinel Year/Month/Day values that
/// MBIZTRANSACTIONKEYS uses to partition counters by period.
///
private static void BuildPeriodValues(
byte periodType, DateOnly date,
out int year, out int month, out int day)
{
switch (periodType)
{
case 0: // Yearly — single row per BizTransactionType, YEAR sentinel = -2
year = -2;
month = 0;
day = 0;
break;
case 1: // Monthly — one row per year+month
year = date.Year;
month = date.Month;
day = 0;
break;
case 2: // Daily — one row per year+month+day
year = date.Year;
month = date.Month;
day = date.Day;
break;
case 3: // Continuous — single ever-incrementing row, YEAR sentinel = -1
default:
year = -1;
month = 0;
day = 0;
break;
}
}
///
/// Builds the formatted number string:
/// SystemPrefix(date-formatted) + Prefix + ZeroPaddedLastNo + Suffix + SystemSuffix(date-formatted)
///
/// The numeric portion is left-padded with zeros so the total string length equals
/// NumberSize. Throws if the fixed parts already exceed NumberSize.
///
private static string FormatVoucherNumber(
VoucherNumberConfigDTO config, int lastNo, DateOnly voucherDate,
string? prefixOverride = null)
{
string systemPrefix = FormatDatePattern(config.SystemPrefix, voucherDate, nameof(config.SystemPrefix));
string systemSuffix = FormatDatePattern(config.SystemSuffix, voucherDate, nameof(config.SystemSuffix));
// prefixOverride: when supplied (including ""), replaces config.Prefix.
// This lets callers (e.g. TaskBLL) skip the DB-configured prefix and apply
// their own type letter via BuildTaskNumber, matching GB4's pattern of
// Task.Number = 'T' + VnoGenerationDLL.GetNextVnoWithSameSession(...)
string prefix = prefixOverride ?? (config.Prefix ?? string.Empty);
string suffix = config.Suffix ?? string.Empty;
string numberStr = lastNo.ToString();
if (config.NumberSize > 0)
{
int fixedLength = systemPrefix.Length + prefix.Length + suffix.Length + systemSuffix.Length;
int availableWidth = config.NumberSize - fixedLength;
if (availableWidth < numberStr.Length)
throw new InvalidOperationException(
$"Generated voucher number ({numberStr.Length} digits) exceeds the available " +
$"numeric width ({availableWidth}) within NumberSize={config.NumberSize}. " +
"Increase NumberSize in BizTransactionType settings.");
numberStr = numberStr.PadLeft(availableWidth, '0');
}
return $"{systemPrefix}{prefix}{numberStr}{suffix}{systemSuffix}";
}
///
/// Formats a date using the given pattern (e.g. "yy" → "25", "yyyyMM" → "202501").
/// Returns empty string if the pattern is null or empty.
/// Throws a descriptive exception on invalid format strings.
///
private static string FormatDatePattern(string? pattern, DateOnly date, string fieldName)
{
if (string.IsNullOrEmpty(pattern))
return string.Empty;
try
{
return date.ToString(pattern, CultureInfo.GetCultureInfo("en-US"));
}
catch (FormatException ex)
{
throw new InvalidOperationException(
$"Invalid date-format pattern '{pattern}' in BizTransactionType '{fieldName}'. " +
"Use standard .NET date-format strings (e.g. \"yy\", \"yyyyMM\"). " +
"Contact Administrator.", ex);
}
}
}
}