feat:商户管理
This commit is contained in:
@@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using TakeoutSaaS.Domain.Deliveries.Repositories;
|
||||
using TakeoutSaaS.Domain.Inventory.Repositories;
|
||||
using TakeoutSaaS.Domain.Merchants.Repositories;
|
||||
using TakeoutSaaS.Domain.Merchants.Services;
|
||||
using TakeoutSaaS.Domain.Orders.Repositories;
|
||||
using TakeoutSaaS.Domain.Payments.Repositories;
|
||||
using TakeoutSaaS.Domain.Products.Repositories;
|
||||
@@ -63,6 +64,7 @@ public static class AppServiceCollectionExtensions
|
||||
// 1. 账单领域/导出服务
|
||||
services.AddScoped<IBillingDomainService, BillingDomainService>();
|
||||
services.AddScoped<IBillingExportService, BillingExportService>();
|
||||
services.AddScoped<IMerchantExportService, MerchantExportService>();
|
||||
|
||||
services.AddOptions<AppSeedOptions>()
|
||||
.Bind(configuration.GetSection(AppSeedOptions.SectionName))
|
||||
|
||||
@@ -469,6 +469,7 @@ public sealed class TakeoutAppDbContext(
|
||||
builder.Property(x => x.Industry).HasMaxLength(64);
|
||||
builder.Property(x => x.LogoUrl).HasColumnType("text");
|
||||
builder.Property(x => x.Remarks).HasMaxLength(512);
|
||||
builder.Property(x => x.OperatingMode).HasConversion<int>();
|
||||
builder.HasIndex(x => x.Code).IsUnique();
|
||||
builder.HasIndex(x => x.ContactPhone).IsUnique();
|
||||
}
|
||||
@@ -533,7 +534,17 @@ public sealed class TakeoutAppDbContext(
|
||||
builder.Property(x => x.District).HasMaxLength(64);
|
||||
builder.Property(x => x.Address).HasMaxLength(256);
|
||||
builder.Property(x => x.ReviewRemarks).HasMaxLength(512);
|
||||
builder.Property(x => x.OperatingMode).HasConversion<int>();
|
||||
builder.Property(x => x.IsFrozen).HasDefaultValue(false);
|
||||
builder.Property(x => x.FrozenReason).HasMaxLength(500);
|
||||
builder.Property(x => x.ClaimedByName).HasMaxLength(100);
|
||||
builder.Property(x => x.RowVersion)
|
||||
.IsRowVersion()
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("bytea");
|
||||
builder.HasIndex(x => x.TenantId);
|
||||
builder.HasIndex(x => new { x.TenantId, x.Status });
|
||||
builder.HasIndex(x => x.ClaimedBy);
|
||||
}
|
||||
|
||||
private static void ConfigureStore(EntityTypeBuilder<Store> builder)
|
||||
@@ -544,6 +555,10 @@ public sealed class TakeoutAppDbContext(
|
||||
builder.Property(x => x.Name).HasMaxLength(128).IsRequired();
|
||||
builder.Property(x => x.Phone).HasMaxLength(32);
|
||||
builder.Property(x => x.ManagerName).HasMaxLength(64);
|
||||
builder.Property(x => x.BusinessLicenseNumber).HasMaxLength(50);
|
||||
builder.Property(x => x.LegalRepresentative).HasMaxLength(100);
|
||||
builder.Property(x => x.RegisteredAddress).HasMaxLength(500);
|
||||
builder.Property(x => x.BusinessLicenseImageUrl).HasMaxLength(500);
|
||||
builder.Property(x => x.Province).HasMaxLength(64);
|
||||
builder.Property(x => x.City).HasMaxLength(64);
|
||||
builder.Property(x => x.District).HasMaxLength(64);
|
||||
@@ -553,6 +568,9 @@ public sealed class TakeoutAppDbContext(
|
||||
builder.Property(x => x.DeliveryRadiusKm).HasPrecision(6, 2);
|
||||
builder.HasIndex(x => new { x.TenantId, x.MerchantId });
|
||||
builder.HasIndex(x => new { x.TenantId, x.Code }).IsUnique();
|
||||
builder.HasIndex(x => new { x.MerchantId, x.BusinessLicenseNumber })
|
||||
.IsUnique()
|
||||
.HasFilter("\"BusinessLicenseNumber\" IS NOT NULL AND \"Status\" <> 3");
|
||||
}
|
||||
|
||||
private static void ConfigureProductCategory(EntityTypeBuilder<ProductCategory> builder)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Common.Enums;
|
||||
using TakeoutSaaS.Domain.Merchants.Entities;
|
||||
using TakeoutSaaS.Domain.Merchants.Enums;
|
||||
using TakeoutSaaS.Domain.Merchants.Repositories;
|
||||
@@ -24,6 +25,26 @@ public sealed class EfMerchantRepository(TakeoutAppDbContext context, TakeoutLog
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Merchant?> FindByIdAsync(long merchantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.Merchants
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.Id == merchantId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Merchant?> FindByTenantIdAsync(long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.Merchants
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Merchant>> SearchAsync(long tenantId, MerchantStatus? status, CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -208,9 +229,79 @@ public sealed class EfMerchantRepository(TakeoutAppDbContext context, TakeoutLog
|
||||
public async Task<IReadOnlyList<MerchantAuditLog>> GetAuditLogsAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await logsContext.MerchantAuditLogs
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.MerchantId == merchantId)
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Merchant>> SearchAsync(
|
||||
long? tenantId,
|
||||
MerchantStatus? status,
|
||||
OperatingMode? operatingMode,
|
||||
string? keyword,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = context.Merchants
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.AsQueryable();
|
||||
|
||||
if (tenantId.HasValue && tenantId.Value > 0)
|
||||
{
|
||||
query = query.Where(x => x.TenantId == tenantId.Value);
|
||||
}
|
||||
|
||||
if (status.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.Status == status.Value);
|
||||
}
|
||||
|
||||
if (operatingMode.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.OperatingMode == operatingMode.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
var normalized = keyword.Trim();
|
||||
query = query.Where(x =>
|
||||
EF.Functions.ILike(x.BrandName, $"%{normalized}%") ||
|
||||
EF.Functions.ILike(x.BusinessLicenseNumber ?? string.Empty, $"%{normalized}%"));
|
||||
}
|
||||
|
||||
return await query
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddChangeLogAsync(MerchantChangeLog log, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return logsContext.MerchantChangeLogs.AddAsync(log, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<MerchantChangeLog>> GetChangeLogsAsync(
|
||||
long merchantId,
|
||||
long tenantId,
|
||||
string? fieldName = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = logsContext.MerchantChangeLogs
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.MerchantId == merchantId);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(fieldName))
|
||||
{
|
||||
query = query.Where(x => x.FieldName == fieldName);
|
||||
}
|
||||
|
||||
return await query
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,16 @@ public sealed class EfStoreRepository(TakeoutAppDbContext context) : IStoreRepos
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Store>> GetByMerchantIdAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await context.Stores
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.MerchantId == merchantId)
|
||||
.OrderBy(x => x.Name)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Store>> SearchAsync(long tenantId, StoreStatus? status, CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -44,6 +54,31 @@ public sealed class EfStoreRepository(TakeoutAppDbContext context) : IStoreRepos
|
||||
return stores;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Dictionary<long, int>> GetStoreCountsAsync(long? tenantId, IReadOnlyCollection<long> merchantIds, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (merchantIds.Count == 0)
|
||||
{
|
||||
return new Dictionary<long, int>();
|
||||
}
|
||||
|
||||
var query = context.Stores.AsNoTracking();
|
||||
if (!tenantId.HasValue || tenantId.Value <= 0)
|
||||
{
|
||||
query = query.IgnoreQueryFilters();
|
||||
}
|
||||
else
|
||||
{
|
||||
query = query.Where(x => x.TenantId == tenantId.Value);
|
||||
}
|
||||
|
||||
return await query
|
||||
.Where(x => merchantIds.Contains(x.MerchantId))
|
||||
.GroupBy(x => x.MerchantId)
|
||||
.Select(group => new { group.Key, Count = group.Count() })
|
||||
.ToDictionaryAsync(x => x.Key, x => x.Count, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<StoreBusinessHour>> GetBusinessHoursAsync(long storeId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
using QuestPDF.Fluent;
|
||||
using QuestPDF.Helpers;
|
||||
using QuestPDF.Infrastructure;
|
||||
using System.Globalization;
|
||||
using TakeoutSaaS.Domain.Common.Enums;
|
||||
using TakeoutSaaS.Domain.Merchants.Entities;
|
||||
using TakeoutSaaS.Domain.Merchants.Enums;
|
||||
using TakeoutSaaS.Domain.Merchants.Services;
|
||||
using TakeoutSaaS.Domain.Stores.Entities;
|
||||
using TakeoutSaaS.Domain.Stores.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 商户导出服务实现(PDF)。
|
||||
/// </summary>
|
||||
public sealed class MerchantExportService : IMerchantExportService
|
||||
{
|
||||
public MerchantExportService()
|
||||
{
|
||||
QuestPDF.Settings.License = LicenseType.Community;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<byte[]> ExportToPdfAsync(
|
||||
Merchant merchant,
|
||||
string? tenantName,
|
||||
IReadOnlyList<Store> stores,
|
||||
IReadOnlyList<MerchantAuditLog> auditLogs,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(merchant);
|
||||
|
||||
var safeStores = stores ?? Array.Empty<Store>();
|
||||
var safeAuditLogs = auditLogs ?? Array.Empty<MerchantAuditLog>();
|
||||
|
||||
var document = Document.Create(container =>
|
||||
{
|
||||
container.Page(page =>
|
||||
{
|
||||
page.Size(PageSizes.A4);
|
||||
page.Margin(24);
|
||||
page.DefaultTextStyle(x => x.FontSize(10));
|
||||
|
||||
page.Content().Column(column =>
|
||||
{
|
||||
column.Spacing(10);
|
||||
column.Item().Text("Merchant Export").FontSize(16).SemiBold();
|
||||
|
||||
column.Item().Element(section => BuildBasicSection(section, merchant, tenantName));
|
||||
column.Item().Element(section => BuildStoresSection(section, safeStores, cancellationToken));
|
||||
column.Item().Element(section => BuildAuditSection(section, safeAuditLogs, cancellationToken));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return Task.FromResult(document.GeneratePdf());
|
||||
}
|
||||
|
||||
private static void BuildBasicSection(IContainer container, Merchant merchant, string? tenantName)
|
||||
{
|
||||
container.Border(1).BorderColor(Colors.Grey.Lighten2).Padding(10).Column(column =>
|
||||
{
|
||||
column.Spacing(4);
|
||||
column.Item().Text("Basic Information").SemiBold();
|
||||
column.Item().Text($"Merchant: {merchant.BrandName}");
|
||||
column.Item().Text($"Tenant: {tenantName ?? "-"} (ID: {merchant.TenantId})");
|
||||
column.Item().Text($"Operating Mode: {ResolveOperatingMode(merchant.OperatingMode)}");
|
||||
column.Item().Text($"Status: {merchant.Status}");
|
||||
column.Item().Text($"Frozen: {(merchant.IsFrozen ? "Yes" : "No")}");
|
||||
column.Item().Text($"License Number: {merchant.BusinessLicenseNumber ?? "-"}");
|
||||
column.Item().Text($"Legal Representative: {merchant.LegalPerson ?? "-"}");
|
||||
column.Item().Text($"Registered Address: {merchant.Address ?? "-"}");
|
||||
column.Item().Text($"Contact Phone: {merchant.ContactPhone}");
|
||||
column.Item().Text($"Contact Email: {merchant.ContactEmail ?? "-"}");
|
||||
column.Item().Text($"Approved At: {FormatDateTime(merchant.ApprovedAt)}");
|
||||
column.Item().Text($"Approved By: {merchant.ApprovedBy?.ToString() ?? "-"}");
|
||||
});
|
||||
}
|
||||
|
||||
private static void BuildStoresSection(IContainer container, IReadOnlyList<Store> stores, CancellationToken cancellationToken)
|
||||
{
|
||||
container.Border(1).BorderColor(Colors.Grey.Lighten2).Padding(10).Column(column =>
|
||||
{
|
||||
column.Spacing(4);
|
||||
column.Item().Text("Stores").SemiBold();
|
||||
|
||||
if (stores.Count == 0)
|
||||
{
|
||||
column.Item().Text("No stores.");
|
||||
return;
|
||||
}
|
||||
|
||||
for (var i = 0; i < stores.Count; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var store = stores[i];
|
||||
column.Item().Text($"{i + 1}. {store.Name} | {ResolveStoreStatus(store.Status)} | {store.Address ?? "-"} | {store.Phone ?? "-"}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void BuildAuditSection(IContainer container, IReadOnlyList<MerchantAuditLog> auditLogs, CancellationToken cancellationToken)
|
||||
{
|
||||
container.Border(1).BorderColor(Colors.Grey.Lighten2).Padding(10).Column(column =>
|
||||
{
|
||||
column.Spacing(4);
|
||||
column.Item().Text("Audit History").SemiBold();
|
||||
|
||||
if (auditLogs.Count == 0)
|
||||
{
|
||||
column.Item().Text("No audit records.");
|
||||
return;
|
||||
}
|
||||
|
||||
for (var i = 0; i < auditLogs.Count; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var log = auditLogs[i];
|
||||
var title = string.IsNullOrWhiteSpace(log.Title) ? log.Action.ToString() : log.Title;
|
||||
column.Item().Text($"{i + 1}. {title} | {log.OperatorName ?? "-"} | {FormatDateTime(log.CreatedAt)}");
|
||||
if (!string.IsNullOrWhiteSpace(log.Description))
|
||||
{
|
||||
column.Item().Text($" {log.Description}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static string ResolveOperatingMode(OperatingMode? mode)
|
||||
=> mode switch
|
||||
{
|
||||
OperatingMode.SameEntity => "SameEntity",
|
||||
OperatingMode.DifferentEntity => "DifferentEntity",
|
||||
_ => "-"
|
||||
};
|
||||
|
||||
private static string ResolveStoreStatus(StoreStatus status)
|
||||
=> status switch
|
||||
{
|
||||
StoreStatus.Closed => "Closed",
|
||||
StoreStatus.Preparing => "Preparing",
|
||||
StoreStatus.Operating => "Operating",
|
||||
StoreStatus.Suspended => "Suspended",
|
||||
_ => status.ToString()
|
||||
};
|
||||
|
||||
private static string FormatDateTime(DateTime? value)
|
||||
=> value.HasValue ? value.Value.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) : "-";
|
||||
}
|
||||
Reference in New Issue
Block a user