222 lines
12 KiB
C#
222 lines
12 KiB
C#
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<TenantAdjustmentVoucherList> GetAdjustmentVouchersAsync(
|
|
CommerceAdminActor actor,
|
|
CommerceAdminQuery query,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
await AssertAdminAsync(actor, cancellationToken);
|
|
var vouchers = dbContext.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<CommerceAdjustmentVoucher> GetAdjustmentVoucherAsync(
|
|
CommerceAdminActor actor,
|
|
Guid voucherId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
await AssertAdminAsync(actor, cancellationToken);
|
|
return await dbContext.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<CommerceAdjustmentVoucher> CreateAdjustmentVoucherAsync(
|
|
CommerceAdminActor actor,
|
|
CreateAdjustmentVoucherCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
await AssertAdminAsync(actor, cancellationToken);
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(command.Reason);
|
|
await AssertOptionalReferenceAsync(dbContext.CommerceReconciliationIssues, actor.TenantId, command.IssueId,
|
|
"reconciliation_issue_not_found", cancellationToken);
|
|
await AssertOptionalReferenceAsync(dbContext.CommerceReconciliationBatches, actor.TenantId, command.BatchId,
|
|
"reconciliation_batch_not_found", cancellationToken);
|
|
await AssertOptionalReferenceAsync(dbContext.CommerceReconciliationItems, actor.TenantId, command.ItemId,
|
|
"reconciliation_item_not_found", cancellationToken);
|
|
await AssertOptionalReferenceAsync(dbContext.Orders, actor.TenantId, command.OrderId, "order_not_found",
|
|
cancellationToken);
|
|
await AssertOptionalReferenceAsync(dbContext.Payments, actor.TenantId, command.PaymentId, "payment_not_found",
|
|
cancellationToken);
|
|
await AssertOptionalReferenceAsync(dbContext.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)
|
|
};
|
|
dbContext.CommerceAdjustmentVouchers.Add(voucher);
|
|
dbContext.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 dbContext.SaveChangesAsync(cancellationToken);
|
|
return voucher;
|
|
}
|
|
|
|
public async Task<CommerceAdjustmentVoucher> UpdateAdjustmentVoucherStatusAsync(
|
|
CommerceAdminActor actor,
|
|
UpdateAdjustmentVoucherStatusCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
await AssertAdminAsync(actor, cancellationToken);
|
|
var voucher = await dbContext.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;
|
|
}
|
|
|
|
dbContext.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 dbContext.SaveChangesAsync(cancellationToken);
|
|
return voucher;
|
|
}
|
|
|
|
public async Task<TenantAdjustmentVoucherEventList> GetAdjustmentVoucherEventsAsync(
|
|
CommerceAdminActor actor,
|
|
Guid voucherId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
await AssertAdminAsync(actor, cancellationToken);
|
|
var exists = await dbContext.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 dbContext.CommerceAdjustmentVoucherEvents.AsNoTracking()
|
|
.Where(item => item.TenantId == actor.TenantId && item.VoucherId == voucherId)
|
|
.OrderBy(item => item.CreatedAt)
|
|
.ToArrayAsync(cancellationToken);
|
|
return new TenantAdjustmentVoucherEventList(events);
|
|
}
|
|
|
|
public async Task<TenantAdjustmentReport> GetAdjustmentReportAsync(
|
|
CommerceAdminActor actor,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
await AssertAdminAsync(actor, cancellationToken);
|
|
return new TenantAdjustmentReport(
|
|
await dbContext.CommerceAdjustmentVouchers.CountAsync(
|
|
item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Draft,
|
|
cancellationToken),
|
|
await dbContext.CommerceAdjustmentVouchers.CountAsync(
|
|
item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.PendingReview,
|
|
cancellationToken),
|
|
await dbContext.CommerceAdjustmentVouchers.CountAsync(
|
|
item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Approved,
|
|
cancellationToken),
|
|
await dbContext.CommerceAdjustmentVouchers.CountAsync(
|
|
item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Closed,
|
|
cancellationToken),
|
|
await dbContext.CommerceAdjustmentVouchers
|
|
.Where(item =>
|
|
item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Approved &&
|
|
item.Direction == CommerceAdjustmentDirection.IncreaseRevenue)
|
|
.SumAsync(item => item.AmountCents, cancellationToken),
|
|
await dbContext.CommerceAdjustmentVouchers
|
|
.Where(item =>
|
|
item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Approved &&
|
|
item.Direction == CommerceAdjustmentDirection.DecreaseRevenue)
|
|
.SumAsync(item => item.AmountCents, cancellationToken));
|
|
}
|
|
|
|
public async Task<TenantCommerceAnomalySummary> GetAnomalySummaryAsync(
|
|
CommerceAdminActor actor,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
await AssertAdminAsync(actor, cancellationToken);
|
|
var openRefunds = await dbContext.CommerceRefundRequests.CountAsync(
|
|
item => item.TenantId == actor.TenantId && item.Status == CommerceRefundStatus.Requested,
|
|
cancellationToken);
|
|
var processingRefunds = await dbContext.CommerceRefundRequests.CountAsync(
|
|
item => item.TenantId == actor.TenantId && item.Status == CommerceRefundStatus.Processing,
|
|
cancellationToken);
|
|
var openIssues = await dbContext.CommerceReconciliationIssues.CountAsync(
|
|
item => item.TenantId == actor.TenantId && item.Status != ReconciliationIssueStatus.Resolved &&
|
|
item.Status != ReconciliationIssueStatus.Ignored,
|
|
cancellationToken);
|
|
var failedBatches = await dbContext.CommerceReconciliationBatches.CountAsync(
|
|
item => item.TenantId == actor.TenantId && item.Status == ReconciliationBatchStatus.Failed,
|
|
cancellationToken);
|
|
var pendingPayments = await dbContext.Payments.CountAsync(
|
|
item => item.TenantId == actor.TenantId && item.Status == PaymentStatus.Pending,
|
|
cancellationToken);
|
|
var mismatchCount = await dbContext.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);
|
|
}
|
|
}
|