using ComplianceDAL.CustomCode.CanonicalBuilder; using ComplianceDAL.DTO.CanonicalInvoice; using GB5Shared.DTO.Framework.Login; using Microsoft.Extensions.Logging; using System.Runtime.CompilerServices; using System.Text.RegularExpressions; namespace ComplianceBLL.CanonicalBuilder; public class GSTCanonicalBuilderBLL : ICanonicalBuilderBLL { private readonly ICanonicalBuilderDAL _dal; private readonly ILogger _logger; private static readonly Regex GstinRegex = new(@"^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$", RegexOptions.Compiled); private static readonly Regex PincodeRegex = new(@"^\d{6}$", RegexOptions.Compiled); public GSTCanonicalBuilderBLL(ICanonicalBuilderDAL dal, ILogger logger) { _dal = dal; _logger = logger; } public async Task BuildAsync(int documentId, LoginDTO login, CancellationToken ct) { try { var invoice = await _dal.GetCanonicalInvoice(documentId, login, ct).ConfigureAwait(false); if (invoice is null) _logger.LogWarning("No ERP data for DocumentId {DocumentId}", documentId); return invoice; } catch (Exception ex) { _logger.LogError(ex, "Canonical builder: BuildAsync failed for DocumentId {DocumentId}", documentId); throw; } } public IAsyncEnumerable BuildBatchAsync( DateTime fromDate, DateTime toDate, int ouId, int companyId, LoginDTO login, CancellationToken ct) => DriveBatch(_dal.GetCanonicalInvoicesBatch(fromDate, toDate, ouId, companyId, login, ct), "BuildBatchAsync", fromDate, toDate, ouId, ct); public IAsyncEnumerable BuildPurchaseBatchAsync( DateTime fromDate, DateTime toDate, int ouId, int companyId, LoginDTO login, CancellationToken ct) => DriveBatch(_dal.GetCanonicalPurchaseDocumentsBatch(fromDate, toDate, ouId, companyId, login, ct), "BuildPurchaseBatchAsync", fromDate, toDate, ouId, ct); // yield return is not permitted inside a try block that has a catch clause, so the enumerator is // driven manually: MoveNextAsync (where the DAL can throw) stays inside try/catch, and yield // return stays outside it in the try/finally below. Shared by both batch directions. private async IAsyncEnumerable DriveBatch( IAsyncEnumerable source, string callerName, DateTime fromDate, DateTime toDate, int ouId, [EnumeratorCancellation] CancellationToken ct) { var enumerator = source.GetAsyncEnumerator(ct); try { while (true) { CanonicalInvoiceDTO current; try { if (!await enumerator.MoveNextAsync().ConfigureAwait(false)) break; current = enumerator.Current; } catch (Exception ex) { _logger.LogError(ex, "Canonical builder: {Caller} failed for OuId {OuId}, FromDate {FromDate}, ToDate {ToDate}", callerName, ouId, fromDate, toDate); throw; } yield return current; } } finally { await enumerator.DisposeAsync().ConfigureAwait(false); } } // Pre-validation: catches common portal rejection causes locally before any API call. public List ValidateCanonicalInvoice(CanonicalInvoiceDTO invoice) { try { var errors = new List(); if (string.IsNullOrWhiteSpace(invoice.Supplier.GSTIN)) errors.Add("Supplier GSTIN is required."); else if (!GstinRegex.IsMatch(invoice.Supplier.GSTIN)) errors.Add($"Supplier GSTIN '{invoice.Supplier.GSTIN}' is not in valid 15-character format."); if (!string.IsNullOrWhiteSpace(invoice.Receiver.GSTIN) && !GstinRegex.IsMatch(invoice.Receiver.GSTIN)) errors.Add($"Receiver GSTIN '{invoice.Receiver.GSTIN}' is not in valid 15-character format."); if (!string.IsNullOrWhiteSpace(invoice.Receiver.Pincode) && !PincodeRegex.IsMatch(invoice.Receiver.Pincode)) errors.Add($"Receiver pincode '{invoice.Receiver.Pincode}' must be 6 digits."); if (string.IsNullOrWhiteSpace(invoice.Receiver.StateCode)) errors.Add("Receiver state code is required."); if (invoice.IsExport && string.IsNullOrWhiteSpace(invoice.ShippingBillNumber)) errors.Add("Shipping bill number is required for export invoices."); foreach (var line in invoice.Lines) { if (string.IsNullOrWhiteSpace(line.HSNCode)) errors.Add($"Line {line.LineNumber}: HSN/SAC code is required."); else if (line.HSNCode.Length is not (4 or 6 or 8)) errors.Add($"Line {line.LineNumber}: HSN code must be 4, 6, or 8 digits."); if (line.TaxableValue < 0) errors.Add($"Line {line.LineNumber}: Taxable value cannot be negative."); var expectedTax = Math.Round(line.TaxableValue * (line.IGSTRate + line.CGSTRate + line.SGSTRate + line.CessRate) / 100, 2); var actualTax = line.IGSTAmount + line.CGSTAmount + line.SGSTAmount + line.CessAmount; if (Math.Abs(expectedTax - actualTax) > 1m) errors.Add($"Line {line.LineNumber}: Tax amount mismatch (expected {expectedTax}, found {actualTax})."); } return errors; } catch (Exception ex) { _logger.LogError(ex, "Canonical builder: ValidateCanonicalInvoice failed for DocumentNumber {DocumentNumber}", invoice.DocumentNumber); throw; } } }