Files
tiku-backend.net/Tiku.Infrastructure/Commerce/Adjustments/CommerceAdjustmentService.cs
xiong 33375a38d7
Some checks failed
ci / release-gate (push) Has been cancelled
refactor(architecture): harden module boundaries
2026-08-04 12:10:36 +08:00

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 = 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<CommerceAdjustmentVoucher> 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<CommerceAdjustmentVoucher> 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<CommerceAdjustmentVoucher> 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<TenantAdjustmentVoucherEventList> 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<TenantAdjustmentReport> 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<TenantCommerceAnomalySummary> 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);
}
}