using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Commerce; using Tiku.Application.Jobs; using Tiku.Domain.Commerce; namespace Tiku.Infrastructure.Commerce; internal sealed class CommerceAdjustmentService(CommerceAdministrationDependencies dependencies) : CommerceAdministrationServiceBase(dependencies), ICommerceAdjustmentService { public async Task GetAdjustmentVouchersAsync( CommerceAdminActor actor, CommerceAdminQuery query, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); var vouchers = commercePersistence.CommerceAdjustmentVouchers.AsNoTracking() .Where(item => item.TenantId == actor.TenantId); if (!string.IsNullOrWhiteSpace(query.Status)) vouchers = vouchers.Where(item => item.Status == ParseAdjustmentVoucherStatus(query.Status)); var items = await vouchers .OrderByDescending(item => item.CreatedAt) .Take(Math.Clamp(query.Limit ?? 50, 1, 200)) .ToArrayAsync(cancellationToken); return new TenantAdjustmentVoucherList(items); } public async Task GetAdjustmentVoucherAsync( CommerceAdminActor actor, Guid voucherId, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); return await commercePersistence.CommerceAdjustmentVouchers.AsNoTracking() .SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.Id == voucherId, cancellationToken) ?? throw new CommerceException("Adjustment voucher was not found.", "adjustment_voucher_not_found"); } public async Task CreateAdjustmentVoucherAsync( CommerceAdminActor actor, CreateAdjustmentVoucherCommand command, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); ArgumentException.ThrowIfNullOrWhiteSpace(command.Reason); await AssertOptionalReferenceAsync(commercePersistence.CommerceReconciliationIssues, actor.TenantId, command.IssueId, "reconciliation_issue_not_found", cancellationToken); await AssertOptionalReferenceAsync(commercePersistence.CommerceReconciliationBatches, actor.TenantId, command.BatchId, "reconciliation_batch_not_found", cancellationToken); await AssertOptionalReferenceAsync(commercePersistence.CommerceReconciliationItems, actor.TenantId, command.ItemId, "reconciliation_item_not_found", cancellationToken); await AssertOptionalReferenceAsync(commercePersistence.Orders, actor.TenantId, command.OrderId, "order_not_found", cancellationToken); await AssertOptionalReferenceAsync(commercePersistence.Payments, actor.TenantId, command.PaymentId, "payment_not_found", cancellationToken); await AssertOptionalReferenceAsync(commercePersistence.CommerceRefundRequests, actor.TenantId, command.RefundRequestId, "refund_not_found", cancellationToken); var voucher = new CommerceAdjustmentVoucher { TenantId = actor.TenantId, IssueId = command.IssueId, BatchId = command.BatchId, ItemId = command.ItemId, OrderId = command.OrderId, PaymentId = command.PaymentId, RefundRequestId = command.RefundRequestId, CreatedBy = actor.UserId, VoucherNo = $"ADJ{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{Random.Shared.Next(1000, 9999)}", Status = CommerceAdjustmentVoucherStatus.Draft, Direction = command.Direction, AmountCents = command.AmountCents, Currency = string.IsNullOrWhiteSpace(command.Currency) ? "CNY" : command.Currency.Trim().ToUpperInvariant(), Reason = command.Reason.Trim(), ProofAssetKey = string.IsNullOrWhiteSpace(command.ProofAssetKey) ? null : command.ProofAssetKey.Trim(), Metadata = JsonObjectOrDefault(command.Metadata) }; commercePersistence.CommerceAdjustmentVouchers.Add(voucher); commercePersistence.CommerceAdjustmentVoucherEvents.Add(new CommerceAdjustmentVoucherEvent { TenantId = actor.TenantId, VoucherId = voucher.Id, ToStatus = voucher.Status, ActorUserId = actor.UserId, Note = voucher.Reason, Details = JsonSerializer.SerializeToElement(new { voucher.Direction, voucher.AmountCents }) }); await AddAuditAsync(actor, "commerce.adjustment_voucher.created", "commerce_adjustment_vouchers", voucher.Id, new { voucher.VoucherNo, voucher.Direction, voucher.AmountCents }, cancellationToken); await unitOfWork.SaveChangesAsync(cancellationToken); return voucher; } public async Task UpdateAdjustmentVoucherStatusAsync( CommerceAdminActor actor, UpdateAdjustmentVoucherStatusCommand command, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); var voucher = await commercePersistence.CommerceAdjustmentVouchers.SingleOrDefaultAsync( item => item.TenantId == actor.TenantId && item.Id == command.VoucherId, cancellationToken) ?? throw new CommerceException("Adjustment voucher was not found.", "adjustment_voucher_not_found"); var fromStatus = voucher.Status; if (fromStatus != command.Status && !IsAllowedAdjustmentTransition(fromStatus, command.Status)) throw new CommerceException("Adjustment voucher status transition is invalid.", "invalid_adjustment_status_transition"); voucher.Status = command.Status; if (command.Status is CommerceAdjustmentVoucherStatus.Approved or CommerceAdjustmentVoucherStatus.Rejected) { voucher.ReviewedBy = actor.UserId; voucher.ReviewedAt ??= DateTimeOffset.UtcNow; } else if (command.Status is CommerceAdjustmentVoucherStatus.Closed or CommerceAdjustmentVoucherStatus.Void) { voucher.ClosedAt ??= DateTimeOffset.UtcNow; } commercePersistence.CommerceAdjustmentVoucherEvents.Add(new CommerceAdjustmentVoucherEvent { TenantId = actor.TenantId, VoucherId = voucher.Id, FromStatus = fromStatus, ToStatus = command.Status, ActorUserId = actor.UserId, Note = command.Note, Details = JsonSerializer.SerializeToElement(new { }) }); await AddAuditAsync(actor, "commerce.adjustment_voucher.status_changed", "commerce_adjustment_vouchers", voucher.Id, new { voucher.VoucherNo, From = fromStatus, To = command.Status }, cancellationToken); await unitOfWork.SaveChangesAsync(cancellationToken); return voucher; } public async Task GetAdjustmentVoucherEventsAsync( CommerceAdminActor actor, Guid voucherId, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); var exists = await commercePersistence.CommerceAdjustmentVouchers.AnyAsync( item => item.TenantId == actor.TenantId && item.Id == voucherId, cancellationToken); if (!exists) throw new CommerceException("Adjustment voucher was not found.", "adjustment_voucher_not_found"); var events = await commercePersistence.CommerceAdjustmentVoucherEvents.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.VoucherId == voucherId) .OrderBy(item => item.CreatedAt) .ToArrayAsync(cancellationToken); return new TenantAdjustmentVoucherEventList(events); } public async Task GetAdjustmentReportAsync( CommerceAdminActor actor, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); return new TenantAdjustmentReport( await commercePersistence.CommerceAdjustmentVouchers.CountAsync( item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Draft, cancellationToken), await commercePersistence.CommerceAdjustmentVouchers.CountAsync( item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.PendingReview, cancellationToken), await commercePersistence.CommerceAdjustmentVouchers.CountAsync( item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Approved, cancellationToken), await commercePersistence.CommerceAdjustmentVouchers.CountAsync( item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Closed, cancellationToken), await commercePersistence.CommerceAdjustmentVouchers .Where(item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Approved && item.Direction == CommerceAdjustmentDirection.IncreaseRevenue) .SumAsync(item => item.AmountCents, cancellationToken), await commercePersistence.CommerceAdjustmentVouchers .Where(item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Approved && item.Direction == CommerceAdjustmentDirection.DecreaseRevenue) .SumAsync(item => item.AmountCents, cancellationToken)); } public async Task GetAnomalySummaryAsync( CommerceAdminActor actor, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); var openRefunds = await commercePersistence.CommerceRefundRequests.CountAsync( item => item.TenantId == actor.TenantId && item.Status == CommerceRefundStatus.Requested, cancellationToken); var processingRefunds = await commercePersistence.CommerceRefundRequests.CountAsync( item => item.TenantId == actor.TenantId && item.Status == CommerceRefundStatus.Processing, cancellationToken); var openIssues = await commercePersistence.CommerceReconciliationIssues.CountAsync( item => item.TenantId == actor.TenantId && item.Status != ReconciliationIssueStatus.Resolved && item.Status != ReconciliationIssueStatus.Ignored, cancellationToken); var failedBatches = await commercePersistence.CommerceReconciliationBatches.CountAsync( item => item.TenantId == actor.TenantId && item.Status == ReconciliationBatchStatus.Failed, cancellationToken); var pendingPayments = await commercePersistence.Payments.CountAsync( item => item.TenantId == actor.TenantId && item.Status == PaymentStatus.Pending, cancellationToken); var mismatchCount = await commercePersistence.CommerceReconciliationItems.CountAsync( item => item.TenantId == actor.TenantId && (item.MatchStatus == ReconciliationMatchStatus.AmountMismatch || item.MatchStatus == ReconciliationMatchStatus.StatusMismatch), cancellationToken); return new TenantCommerceAnomalySummary( openRefunds, processingRefunds, openIssues, failedBatches, pendingPayments, mismatchCount); } }