Compare commits

...

3 Commits

Author SHA1 Message Date
c79e9bd6e8 feat(finance): 完成发票管理模块后端实现 2026-03-04 21:13:33 +08:00
21a689edec Merge pull request #5 from msumshk/feature/finance-cost-1to1
feat(finance): add cost management backend module
2026-03-04 16:15:02 +08:00
fa6e376b86 feat(finance): add cost management backend module 2026-03-04 16:07:16 +08:00
55 changed files with 5971 additions and 0 deletions

View File

@@ -0,0 +1,384 @@
namespace TakeoutSaaS.TenantApi.Contracts.Finance;
/// <summary>
/// 成本模块通用作用域请求。
/// </summary>
public class FinanceCostScopeRequest
{
/// <summary>
/// 维度tenant/store
/// </summary>
public string? Dimension { get; set; }
/// <summary>
/// 门店标识(门店维度必填)。
/// </summary>
public string? StoreId { get; set; }
/// <summary>
/// 月份yyyy-MM
/// </summary>
public string? Month { get; set; }
}
/// <summary>
/// 成本录入查询请求。
/// </summary>
public sealed class FinanceCostEntryRequest : FinanceCostScopeRequest;
/// <summary>
/// 成本分析查询请求。
/// </summary>
public sealed class FinanceCostAnalysisRequest : FinanceCostScopeRequest
{
/// <summary>
/// 趋势月份数量。
/// </summary>
public int TrendMonthCount { get; set; } = 6;
}
/// <summary>
/// 成本录入保存请求。
/// </summary>
public sealed class SaveFinanceCostEntryRequest : FinanceCostScopeRequest
{
/// <summary>
/// 分类列表。
/// </summary>
public List<SaveFinanceCostCategoryRequest> Categories { get; set; } = [];
}
/// <summary>
/// 成本分类保存项请求。
/// </summary>
public sealed class SaveFinanceCostCategoryRequest
{
/// <summary>
/// 分类编码food/labor/fixed/packaging
/// </summary>
public string Category { get; set; } = string.Empty;
/// <summary>
/// 分类总金额。
/// </summary>
public decimal TotalAmount { get; set; }
/// <summary>
/// 分类明细。
/// </summary>
public List<SaveFinanceCostDetailRequest> Items { get; set; } = [];
}
/// <summary>
/// 成本明细保存项请求。
/// </summary>
public sealed class SaveFinanceCostDetailRequest
{
/// <summary>
/// 明细标识(可空)。
/// </summary>
public string? ItemId { get; set; }
/// <summary>
/// 明细名称。
/// </summary>
public string ItemName { get; set; } = string.Empty;
/// <summary>
/// 明细金额。
/// </summary>
public decimal Amount { get; set; }
/// <summary>
/// 数量(人工类可用)。
/// </summary>
public decimal? Quantity { get; set; }
/// <summary>
/// 单价(人工类可用)。
/// </summary>
public decimal? UnitPrice { get; set; }
/// <summary>
/// 排序值。
/// </summary>
public int SortOrder { get; set; }
}
/// <summary>
/// 成本录入响应。
/// </summary>
public sealed class FinanceCostEntryResponse
{
/// <summary>
/// 维度编码。
/// </summary>
public string Dimension { get; set; } = "tenant";
/// <summary>
/// 门店标识(门店维度时有值)。
/// </summary>
public string? StoreId { get; set; }
/// <summary>
/// 月份yyyy-MM
/// </summary>
public string Month { get; set; } = string.Empty;
/// <summary>
/// 本月营业额。
/// </summary>
public decimal MonthRevenue { get; set; }
/// <summary>
/// 本月总成本。
/// </summary>
public decimal TotalCost { get; set; }
/// <summary>
/// 本月成本率(%)。
/// </summary>
public decimal CostRate { get; set; }
/// <summary>
/// 分类数据。
/// </summary>
public List<FinanceCostEntryCategoryResponse> Categories { get; set; } = [];
}
/// <summary>
/// 成本分类响应。
/// </summary>
public sealed class FinanceCostEntryCategoryResponse
{
/// <summary>
/// 分类编码。
/// </summary>
public string Category { get; set; } = string.Empty;
/// <summary>
/// 分类文案。
/// </summary>
public string CategoryText { get; set; } = string.Empty;
/// <summary>
/// 分类总金额。
/// </summary>
public decimal TotalAmount { get; set; }
/// <summary>
/// 分类占比(%)。
/// </summary>
public decimal Percentage { get; set; }
/// <summary>
/// 明细数据。
/// </summary>
public List<FinanceCostEntryDetailResponse> Items { get; set; } = [];
}
/// <summary>
/// 成本明细响应。
/// </summary>
public sealed class FinanceCostEntryDetailResponse
{
/// <summary>
/// 明细标识。
/// </summary>
public string? ItemId { get; set; }
/// <summary>
/// 明细名称。
/// </summary>
public string ItemName { get; set; } = string.Empty;
/// <summary>
/// 明细金额。
/// </summary>
public decimal Amount { get; set; }
/// <summary>
/// 数量(人工类可用)。
/// </summary>
public decimal? Quantity { get; set; }
/// <summary>
/// 单价(人工类可用)。
/// </summary>
public decimal? UnitPrice { get; set; }
/// <summary>
/// 排序值。
/// </summary>
public int SortOrder { get; set; }
}
/// <summary>
/// 成本分析响应。
/// </summary>
public sealed class FinanceCostAnalysisResponse
{
/// <summary>
/// 维度编码。
/// </summary>
public string Dimension { get; set; } = "tenant";
/// <summary>
/// 门店标识(门店维度时有值)。
/// </summary>
public string? StoreId { get; set; }
/// <summary>
/// 月份yyyy-MM
/// </summary>
public string Month { get; set; } = string.Empty;
/// <summary>
/// 统计卡。
/// </summary>
public FinanceCostAnalysisStatsResponse Stats { get; set; } = new();
/// <summary>
/// 趋势数据。
/// </summary>
public List<FinanceCostTrendPointResponse> Trend { get; set; } = [];
/// <summary>
/// 构成数据。
/// </summary>
public List<FinanceCostCompositionResponse> Composition { get; set; } = [];
/// <summary>
/// 明细表数据。
/// </summary>
public List<FinanceCostMonthlyDetailResponse> DetailRows { get; set; } = [];
}
/// <summary>
/// 成本分析统计卡响应。
/// </summary>
public sealed class FinanceCostAnalysisStatsResponse
{
/// <summary>
/// 本月总成本。
/// </summary>
public decimal TotalCost { get; set; }
/// <summary>
/// 食材成本率(%)。
/// </summary>
public decimal FoodCostRate { get; set; }
/// <summary>
/// 单均成本。
/// </summary>
public decimal AverageCostPerPaidOrder { get; set; }
/// <summary>
/// 环比变化(%)。
/// </summary>
public decimal MonthOnMonthChangeRate { get; set; }
/// <summary>
/// 本月营业额。
/// </summary>
public decimal Revenue { get; set; }
/// <summary>
/// 本月支付成功订单数。
/// </summary>
public int PaidOrderCount { get; set; }
}
/// <summary>
/// 成本趋势点响应。
/// </summary>
public sealed class FinanceCostTrendPointResponse
{
/// <summary>
/// 月份yyyy-MM
/// </summary>
public string Month { get; set; } = string.Empty;
/// <summary>
/// 月度总成本。
/// </summary>
public decimal TotalCost { get; set; }
/// <summary>
/// 月度营业额。
/// </summary>
public decimal Revenue { get; set; }
/// <summary>
/// 月度成本率(%)。
/// </summary>
public decimal CostRate { get; set; }
}
/// <summary>
/// 成本构成响应。
/// </summary>
public sealed class FinanceCostCompositionResponse
{
/// <summary>
/// 分类编码。
/// </summary>
public string Category { get; set; } = string.Empty;
/// <summary>
/// 分类文案。
/// </summary>
public string CategoryText { get; set; } = string.Empty;
/// <summary>
/// 金额。
/// </summary>
public decimal Amount { get; set; }
/// <summary>
/// 占比(%)。
/// </summary>
public decimal Percentage { get; set; }
}
/// <summary>
/// 成本分析明细表行响应。
/// </summary>
public sealed class FinanceCostMonthlyDetailResponse
{
/// <summary>
/// 月份yyyy-MM
/// </summary>
public string Month { get; set; } = string.Empty;
/// <summary>
/// 食材成本。
/// </summary>
public decimal FoodAmount { get; set; }
/// <summary>
/// 人工成本。
/// </summary>
public decimal LaborAmount { get; set; }
/// <summary>
/// 固定费用。
/// </summary>
public decimal FixedAmount { get; set; }
/// <summary>
/// 包装耗材。
/// </summary>
public decimal PackagingAmount { get; set; }
/// <summary>
/// 总成本。
/// </summary>
public decimal TotalCost { get; set; }
/// <summary>
/// 成本率(%)。
/// </summary>
public decimal CostRate { get; set; }
}

View File

@@ -0,0 +1,533 @@
namespace TakeoutSaaS.TenantApi.Contracts.Finance;
/// <summary>
/// 保存发票设置请求。
/// </summary>
public sealed class FinanceInvoiceSettingSaveRequest
{
/// <summary>
/// 企业名称。
/// </summary>
public string CompanyName { get; set; } = string.Empty;
/// <summary>
/// 纳税人识别号。
/// </summary>
public string TaxpayerNumber { get; set; } = string.Empty;
/// <summary>
/// 注册地址。
/// </summary>
public string? RegisteredAddress { get; set; }
/// <summary>
/// 注册电话。
/// </summary>
public string? RegisteredPhone { get; set; }
/// <summary>
/// 开户银行。
/// </summary>
public string? BankName { get; set; }
/// <summary>
/// 银行账号。
/// </summary>
public string? BankAccount { get; set; }
/// <summary>
/// 是否启用电子普通发票。
/// </summary>
public bool EnableElectronicNormalInvoice { get; set; } = true;
/// <summary>
/// 是否启用电子专用发票。
/// </summary>
public bool EnableElectronicSpecialInvoice { get; set; }
/// <summary>
/// 是否启用自动开票。
/// </summary>
public bool EnableAutoIssue { get; set; }
/// <summary>
/// 自动开票单张最大金额。
/// </summary>
public decimal AutoIssueMaxAmount { get; set; } = 10_000m;
}
/// <summary>
/// 发票记录列表请求。
/// </summary>
public sealed class FinanceInvoiceRecordListRequest
{
/// <summary>
/// 开始日期yyyy-MM-dd
/// </summary>
public string? StartDate { get; set; }
/// <summary>
/// 结束日期yyyy-MM-dd
/// </summary>
public string? EndDate { get; set; }
/// <summary>
/// 状态pending/issued/voided
/// </summary>
public string? Status { get; set; }
/// <summary>
/// 类型normal/special
/// </summary>
public string? InvoiceType { get; set; }
/// <summary>
/// 关键词(发票号/公司名/申请人)。
/// </summary>
public string? Keyword { get; set; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; set; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; set; } = 10;
}
/// <summary>
/// 发票记录详情请求。
/// </summary>
public sealed class FinanceInvoiceRecordDetailRequest
{
/// <summary>
/// 发票记录 ID。
/// </summary>
public string RecordId { get; set; } = string.Empty;
}
/// <summary>
/// 发票开票请求。
/// </summary>
public sealed class FinanceInvoiceRecordIssueRequest
{
/// <summary>
/// 发票记录 ID。
/// </summary>
public string RecordId { get; set; } = string.Empty;
/// <summary>
/// 接收邮箱(可选)。
/// </summary>
public string? ContactEmail { get; set; }
/// <summary>
/// 开票备注。
/// </summary>
public string? IssueRemark { get; set; }
}
/// <summary>
/// 发票作废请求。
/// </summary>
public sealed class FinanceInvoiceRecordVoidRequest
{
/// <summary>
/// 发票记录 ID。
/// </summary>
public string RecordId { get; set; } = string.Empty;
/// <summary>
/// 作废原因。
/// </summary>
public string VoidReason { get; set; } = string.Empty;
}
/// <summary>
/// 发票申请请求。
/// </summary>
public sealed class FinanceInvoiceRecordApplyRequest
{
/// <summary>
/// 申请人。
/// </summary>
public string ApplicantName { get; set; } = string.Empty;
/// <summary>
/// 开票抬头(公司名)。
/// </summary>
public string CompanyName { get; set; } = string.Empty;
/// <summary>
/// 纳税人识别号。
/// </summary>
public string? TaxpayerNumber { get; set; }
/// <summary>
/// 发票类型normal/special
/// </summary>
public string InvoiceType { get; set; } = "normal";
/// <summary>
/// 开票金额。
/// </summary>
public decimal Amount { get; set; }
/// <summary>
/// 关联订单号。
/// </summary>
public string OrderNo { get; set; } = string.Empty;
/// <summary>
/// 接收邮箱。
/// </summary>
public string? ContactEmail { get; set; }
/// <summary>
/// 联系电话。
/// </summary>
public string? ContactPhone { get; set; }
/// <summary>
/// 申请备注。
/// </summary>
public string? ApplyRemark { get; set; }
/// <summary>
/// 申请时间(可空)。
/// </summary>
public DateTime? AppliedAt { get; set; }
}
/// <summary>
/// 发票设置响应。
/// </summary>
public sealed class FinanceInvoiceSettingResponse
{
/// <summary>
/// 企业名称。
/// </summary>
public string CompanyName { get; set; } = string.Empty;
/// <summary>
/// 纳税人识别号。
/// </summary>
public string TaxpayerNumber { get; set; } = string.Empty;
/// <summary>
/// 注册地址。
/// </summary>
public string? RegisteredAddress { get; set; }
/// <summary>
/// 注册电话。
/// </summary>
public string? RegisteredPhone { get; set; }
/// <summary>
/// 开户银行。
/// </summary>
public string? BankName { get; set; }
/// <summary>
/// 银行账号。
/// </summary>
public string? BankAccount { get; set; }
/// <summary>
/// 是否启用电子普通发票。
/// </summary>
public bool EnableElectronicNormalInvoice { get; set; }
/// <summary>
/// 是否启用电子专用发票。
/// </summary>
public bool EnableElectronicSpecialInvoice { get; set; }
/// <summary>
/// 是否启用自动开票。
/// </summary>
public bool EnableAutoIssue { get; set; }
/// <summary>
/// 自动开票单张最大金额。
/// </summary>
public decimal AutoIssueMaxAmount { get; set; }
}
/// <summary>
/// 发票统计响应。
/// </summary>
public sealed class FinanceInvoiceStatsResponse
{
/// <summary>
/// 本月已开票金额。
/// </summary>
public decimal CurrentMonthIssuedAmount { get; set; }
/// <summary>
/// 本月已开票张数。
/// </summary>
public int CurrentMonthIssuedCount { get; set; }
/// <summary>
/// 待开票数量。
/// </summary>
public int PendingCount { get; set; }
/// <summary>
/// 已作废数量。
/// </summary>
public int VoidedCount { get; set; }
}
/// <summary>
/// 发票记录列表项响应。
/// </summary>
public sealed class FinanceInvoiceRecordResponse
{
/// <summary>
/// 记录 ID。
/// </summary>
public string RecordId { get; set; } = string.Empty;
/// <summary>
/// 发票号码。
/// </summary>
public string InvoiceNo { get; set; } = string.Empty;
/// <summary>
/// 申请人。
/// </summary>
public string ApplicantName { get; set; } = string.Empty;
/// <summary>
/// 开票抬头(公司名)。
/// </summary>
public string CompanyName { get; set; } = string.Empty;
/// <summary>
/// 发票类型编码。
/// </summary>
public string InvoiceType { get; set; } = string.Empty;
/// <summary>
/// 发票类型文案。
/// </summary>
public string InvoiceTypeText { get; set; } = string.Empty;
/// <summary>
/// 金额。
/// </summary>
public decimal Amount { get; set; }
/// <summary>
/// 关联订单号。
/// </summary>
public string OrderNo { get; set; } = string.Empty;
/// <summary>
/// 状态编码。
/// </summary>
public string Status { get; set; } = string.Empty;
/// <summary>
/// 状态文案。
/// </summary>
public string StatusText { get; set; } = string.Empty;
/// <summary>
/// 申请时间(本地显示字符串)。
/// </summary>
public string AppliedAt { get; set; } = string.Empty;
}
/// <summary>
/// 发票记录详情响应。
/// </summary>
public sealed class FinanceInvoiceRecordDetailResponse
{
/// <summary>
/// 记录 ID。
/// </summary>
public string RecordId { get; set; } = string.Empty;
/// <summary>
/// 发票号码。
/// </summary>
public string InvoiceNo { get; set; } = string.Empty;
/// <summary>
/// 申请人。
/// </summary>
public string ApplicantName { get; set; } = string.Empty;
/// <summary>
/// 开票抬头(公司名)。
/// </summary>
public string CompanyName { get; set; } = string.Empty;
/// <summary>
/// 纳税人识别号。
/// </summary>
public string? TaxpayerNumber { get; set; }
/// <summary>
/// 发票类型编码。
/// </summary>
public string InvoiceType { get; set; } = string.Empty;
/// <summary>
/// 发票类型文案。
/// </summary>
public string InvoiceTypeText { get; set; } = string.Empty;
/// <summary>
/// 金额。
/// </summary>
public decimal Amount { get; set; }
/// <summary>
/// 关联订单号。
/// </summary>
public string OrderNo { get; set; } = string.Empty;
/// <summary>
/// 接收邮箱。
/// </summary>
public string? ContactEmail { get; set; }
/// <summary>
/// 联系电话。
/// </summary>
public string? ContactPhone { get; set; }
/// <summary>
/// 申请备注。
/// </summary>
public string? ApplyRemark { get; set; }
/// <summary>
/// 状态编码。
/// </summary>
public string Status { get; set; } = string.Empty;
/// <summary>
/// 状态文案。
/// </summary>
public string StatusText { get; set; } = string.Empty;
/// <summary>
/// 申请时间(本地显示字符串)。
/// </summary>
public string AppliedAt { get; set; } = string.Empty;
/// <summary>
/// 开票时间(本地显示字符串)。
/// </summary>
public string? IssuedAt { get; set; }
/// <summary>
/// 开票人 ID。
/// </summary>
public string? IssuedByUserId { get; set; }
/// <summary>
/// 开票备注。
/// </summary>
public string? IssueRemark { get; set; }
/// <summary>
/// 作废时间(本地显示字符串)。
/// </summary>
public string? VoidedAt { get; set; }
/// <summary>
/// 作废人 ID。
/// </summary>
public string? VoidedByUserId { get; set; }
/// <summary>
/// 作废原因。
/// </summary>
public string? VoidReason { get; set; }
}
/// <summary>
/// 发票开票结果响应。
/// </summary>
public sealed class FinanceInvoiceIssueResultResponse
{
/// <summary>
/// 记录 ID。
/// </summary>
public string RecordId { get; set; } = string.Empty;
/// <summary>
/// 发票号码。
/// </summary>
public string InvoiceNo { get; set; } = string.Empty;
/// <summary>
/// 开票抬头。
/// </summary>
public string CompanyName { get; set; } = string.Empty;
/// <summary>
/// 金额。
/// </summary>
public decimal Amount { get; set; }
/// <summary>
/// 接收邮箱。
/// </summary>
public string? ContactEmail { get; set; }
/// <summary>
/// 开票时间(本地显示字符串)。
/// </summary>
public string IssuedAt { get; set; } = string.Empty;
/// <summary>
/// 状态编码。
/// </summary>
public string Status { get; set; } = string.Empty;
/// <summary>
/// 状态文案。
/// </summary>
public string StatusText { get; set; } = string.Empty;
}
/// <summary>
/// 发票记录分页响应。
/// </summary>
public sealed class FinanceInvoiceRecordListResultResponse
{
/// <summary>
/// 列表项。
/// </summary>
public List<FinanceInvoiceRecordResponse> Items { get; set; } = [];
/// <summary>
/// 页码。
/// </summary>
public int Page { get; set; }
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; set; }
/// <summary>
/// 总条数。
/// </summary>
public int TotalCount { get; set; }
/// <summary>
/// 统计。
/// </summary>
public FinanceInvoiceStatsResponse Stats { get; set; } = new();
}

View File

@@ -0,0 +1,270 @@
using System.Globalization;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TakeoutSaaS.Application.App.Finance.Cost.Commands;
using TakeoutSaaS.Application.App.Finance.Cost.Dto;
using TakeoutSaaS.Application.App.Finance.Cost.Queries;
using TakeoutSaaS.Application.App.Stores.Services;
using TakeoutSaaS.Domain.Finance.Enums;
using TakeoutSaaS.Infrastructure.App.Persistence;
using TakeoutSaaS.Module.Authorization.Attributes;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Web.Api;
using TakeoutSaaS.TenantApi.Contracts.Finance;
namespace TakeoutSaaS.TenantApi.Controllers;
/// <summary>
/// 财务中心成本管理。
/// </summary>
[ApiVersion("1.0")]
[Authorize]
[Route("api/tenant/v{version:apiVersion}/finance/cost")]
public sealed class FinanceCostController(
IMediator mediator,
TakeoutAppDbContext dbContext,
StoreContextService storeContextService)
: BaseApiController
{
private const string ViewPermission = "tenant:finance:cost:view";
private const string ManagePermission = "tenant:finance:cost:manage";
/// <summary>
/// 查询成本录入数据。
/// </summary>
[HttpGet("entry")]
[PermissionAuthorize(ViewPermission, ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<FinanceCostEntryResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<FinanceCostEntryResponse>> Entry(
[FromQuery] FinanceCostEntryRequest request,
CancellationToken cancellationToken)
{
// 1. 解析维度与作用域。
var scope = await ParseScopeAsync(request, cancellationToken);
// 2. 查询录入数据并映射响应。
var result = await mediator.Send(new GetFinanceCostEntryQuery
{
Dimension = scope.Dimension,
StoreId = scope.StoreId,
CostMonth = scope.CostMonth
}, cancellationToken);
return ApiResponse<FinanceCostEntryResponse>.Ok(MapEntry(result));
}
/// <summary>
/// 保存成本录入数据。
/// </summary>
[HttpPost("entry/save")]
[PermissionAuthorize(ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<FinanceCostEntryResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<FinanceCostEntryResponse>> SaveEntry(
[FromBody] SaveFinanceCostEntryRequest request,
CancellationToken cancellationToken)
{
// 1. 解析维度与作用域。
var scope = await ParseScopeAsync(request, cancellationToken);
// 2. 发起保存命令并映射响应。
var result = await mediator.Send(new SaveFinanceCostEntryCommand
{
Dimension = scope.Dimension,
StoreId = scope.StoreId,
CostMonth = scope.CostMonth,
Categories = (request.Categories ?? [])
.Select(MapSaveCategory)
.ToList()
}, cancellationToken);
return ApiResponse<FinanceCostEntryResponse>.Ok(MapEntry(result));
}
/// <summary>
/// 查询成本分析数据。
/// </summary>
[HttpGet("analysis")]
[PermissionAuthorize(ViewPermission, ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<FinanceCostAnalysisResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<FinanceCostAnalysisResponse>> Analysis(
[FromQuery] FinanceCostAnalysisRequest request,
CancellationToken cancellationToken)
{
// 1. 解析维度与作用域。
var scope = await ParseScopeAsync(request, cancellationToken);
// 2. 查询分析数据并映射响应。
var result = await mediator.Send(new GetFinanceCostAnalysisQuery
{
Dimension = scope.Dimension,
StoreId = scope.StoreId,
CostMonth = scope.CostMonth,
TrendMonthCount = Math.Clamp(request.TrendMonthCount, 3, 12)
}, cancellationToken);
return ApiResponse<FinanceCostAnalysisResponse>.Ok(MapAnalysis(result));
}
private async Task<(FinanceCostDimension Dimension, long? StoreId, DateTime CostMonth)> ParseScopeAsync(
FinanceCostScopeRequest request,
CancellationToken cancellationToken)
{
var dimension = ParseDimension(request.Dimension);
var costMonth = ParseMonthOrDefault(request.Month);
if (dimension == FinanceCostDimension.Tenant)
{
return (dimension, null, costMonth);
}
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
return (dimension, storeId, costMonth);
}
private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
{
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
}
private static FinanceCostDimension ParseDimension(string? value)
{
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
return normalized switch
{
"" or "tenant" => FinanceCostDimension.Tenant,
"store" => FinanceCostDimension.Store,
_ => throw new BusinessException(ErrorCodes.BadRequest, "dimension 非法")
};
}
private static DateTime ParseMonthOrDefault(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
var utcNow = DateTime.UtcNow;
return new DateTime(utcNow.Year, utcNow.Month, 1, 0, 0, 0, DateTimeKind.Utc);
}
if (DateTime.TryParseExact(
value.Trim(),
"yyyy-MM",
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out var parsed))
{
return new DateTime(parsed.Year, parsed.Month, 1, 0, 0, 0, DateTimeKind.Utc);
}
throw new BusinessException(ErrorCodes.BadRequest, "month 格式必须为 yyyy-MM");
}
private static FinanceCostCategory ParseCategory(string? value)
{
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
return normalized switch
{
"food" => FinanceCostCategory.FoodMaterial,
"labor" => FinanceCostCategory.Labor,
"fixed" => FinanceCostCategory.FixedExpense,
"packaging" => FinanceCostCategory.PackagingConsumable,
_ => throw new BusinessException(ErrorCodes.BadRequest, "category 非法")
};
}
private static SaveFinanceCostCategoryCommandItem MapSaveCategory(SaveFinanceCostCategoryRequest source)
{
return new SaveFinanceCostCategoryCommandItem
{
Category = ParseCategory(source.Category),
TotalAmount = source.TotalAmount,
Items = (source.Items ?? [])
.Select(item => new SaveFinanceCostDetailCommandItem
{
ItemId = StoreApiHelpers.ParseSnowflakeOrNull(item.ItemId),
ItemName = item.ItemName,
Amount = item.Amount,
Quantity = item.Quantity,
UnitPrice = item.UnitPrice,
SortOrder = item.SortOrder
})
.ToList()
};
}
private static FinanceCostEntryResponse MapEntry(FinanceCostEntryDto source)
{
return new FinanceCostEntryResponse
{
Dimension = source.Dimension,
StoreId = source.StoreId,
Month = source.Month,
MonthRevenue = source.MonthRevenue,
TotalCost = source.TotalCost,
CostRate = source.CostRate,
Categories = source.Categories.Select(category => new FinanceCostEntryCategoryResponse
{
Category = category.Category,
CategoryText = category.CategoryText,
TotalAmount = category.TotalAmount,
Percentage = category.Percentage,
Items = category.Items.Select(item => new FinanceCostEntryDetailResponse
{
ItemId = item.ItemId,
ItemName = item.ItemName,
Amount = item.Amount,
Quantity = item.Quantity,
UnitPrice = item.UnitPrice,
SortOrder = item.SortOrder
}).ToList()
}).ToList()
};
}
private static FinanceCostAnalysisResponse MapAnalysis(FinanceCostAnalysisDto source)
{
return new FinanceCostAnalysisResponse
{
Dimension = source.Dimension,
StoreId = source.StoreId,
Month = source.Month,
Stats = new FinanceCostAnalysisStatsResponse
{
TotalCost = source.Stats.TotalCost,
FoodCostRate = source.Stats.FoodCostRate,
AverageCostPerPaidOrder = source.Stats.AverageCostPerPaidOrder,
MonthOnMonthChangeRate = source.Stats.MonthOnMonthChangeRate,
Revenue = source.Stats.Revenue,
PaidOrderCount = source.Stats.PaidOrderCount
},
Trend = source.Trend.Select(item => new FinanceCostTrendPointResponse
{
Month = item.Month,
TotalCost = item.TotalCost,
Revenue = item.Revenue,
CostRate = item.CostRate
}).ToList(),
Composition = source.Composition.Select(item => new FinanceCostCompositionResponse
{
Category = item.Category,
CategoryText = item.CategoryText,
Amount = item.Amount,
Percentage = item.Percentage
}).ToList(),
DetailRows = source.DetailRows.Select(item => new FinanceCostMonthlyDetailResponse
{
Month = item.Month,
FoodAmount = item.FoodAmount,
LaborAmount = item.LaborAmount,
FixedAmount = item.FixedAmount,
PackagingAmount = item.PackagingAmount,
TotalCost = item.TotalCost,
CostRate = item.CostRate
}).ToList()
};
}
}

View File

@@ -0,0 +1,308 @@
using System.Globalization;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TakeoutSaaS.Application.App.Finance.Invoice.Commands;
using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
using TakeoutSaaS.Application.App.Finance.Invoice.Queries;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Module.Authorization.Attributes;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Web.Api;
using TakeoutSaaS.TenantApi.Contracts.Finance;
namespace TakeoutSaaS.TenantApi.Controllers;
/// <summary>
/// 财务中心发票管理。
/// </summary>
[ApiVersion("1.0")]
[Authorize]
[Route("api/tenant/v{version:apiVersion}/finance/invoice")]
public sealed class FinanceInvoiceController(IMediator mediator) : BaseApiController
{
private const string ViewPermission = "tenant:finance:invoice:view";
private const string IssuePermission = "tenant:finance:invoice:issue";
private const string VoidPermission = "tenant:finance:invoice:void";
private const string SettingsPermission = "tenant:finance:invoice:settings";
/// <summary>
/// 查询发票设置详情。
/// </summary>
[HttpGet("settings/detail")]
[PermissionAuthorize(ViewPermission, SettingsPermission)]
[ProducesResponseType(typeof(ApiResponse<FinanceInvoiceSettingResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<FinanceInvoiceSettingResponse>> SettingsDetail(CancellationToken cancellationToken)
{
var result = await mediator.Send(new GetFinanceInvoiceSettingDetailQuery(), cancellationToken);
return ApiResponse<FinanceInvoiceSettingResponse>.Ok(MapSetting(result));
}
/// <summary>
/// 保存发票设置。
/// </summary>
[HttpPost("settings/save")]
[PermissionAuthorize(SettingsPermission)]
[ProducesResponseType(typeof(ApiResponse<FinanceInvoiceSettingResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<FinanceInvoiceSettingResponse>> SettingsSave(
[FromBody] FinanceInvoiceSettingSaveRequest request,
CancellationToken cancellationToken)
{
var result = await mediator.Send(new SaveFinanceInvoiceSettingCommand
{
CompanyName = request.CompanyName,
TaxpayerNumber = request.TaxpayerNumber,
RegisteredAddress = request.RegisteredAddress,
RegisteredPhone = request.RegisteredPhone,
BankName = request.BankName,
BankAccount = request.BankAccount,
EnableElectronicNormalInvoice = request.EnableElectronicNormalInvoice,
EnableElectronicSpecialInvoice = request.EnableElectronicSpecialInvoice,
EnableAutoIssue = request.EnableAutoIssue,
AutoIssueMaxAmount = request.AutoIssueMaxAmount
}, cancellationToken);
return ApiResponse<FinanceInvoiceSettingResponse>.Ok(MapSetting(result));
}
/// <summary>
/// 查询发票记录分页。
/// </summary>
[HttpGet("record/list")]
[PermissionAuthorize(ViewPermission, IssuePermission, VoidPermission)]
[ProducesResponseType(typeof(ApiResponse<FinanceInvoiceRecordListResultResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<FinanceInvoiceRecordListResultResponse>> RecordList(
[FromQuery] FinanceInvoiceRecordListRequest request,
CancellationToken cancellationToken)
{
var result = await mediator.Send(new GetFinanceInvoiceRecordListQuery
{
StartDateUtc = ParseDateOrNull(request.StartDate, nameof(request.StartDate)),
EndDateUtc = ParseDateOrNull(request.EndDate, nameof(request.EndDate)),
Status = ParseStatusOrNull(request.Status),
InvoiceType = ParseInvoiceTypeOrNull(request.InvoiceType),
Keyword = request.Keyword,
Page = request.Page,
PageSize = request.PageSize
}, cancellationToken);
return ApiResponse<FinanceInvoiceRecordListResultResponse>.Ok(new FinanceInvoiceRecordListResultResponse
{
Items = result.Items.Select(MapRecord).ToList(),
Page = result.Page,
PageSize = result.PageSize,
TotalCount = result.TotalCount,
Stats = new FinanceInvoiceStatsResponse
{
CurrentMonthIssuedAmount = result.Stats.CurrentMonthIssuedAmount,
CurrentMonthIssuedCount = result.Stats.CurrentMonthIssuedCount,
PendingCount = result.Stats.PendingCount,
VoidedCount = result.Stats.VoidedCount
}
});
}
/// <summary>
/// 查询发票记录详情。
/// </summary>
[HttpGet("record/detail")]
[PermissionAuthorize(ViewPermission, IssuePermission, VoidPermission)]
[ProducesResponseType(typeof(ApiResponse<FinanceInvoiceRecordDetailResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<FinanceInvoiceRecordDetailResponse>> RecordDetail(
[FromQuery] FinanceInvoiceRecordDetailRequest request,
CancellationToken cancellationToken)
{
var result = await mediator.Send(new GetFinanceInvoiceRecordDetailQuery
{
RecordId = StoreApiHelpers.ParseRequiredSnowflake(request.RecordId, nameof(request.RecordId))
}, cancellationToken);
return ApiResponse<FinanceInvoiceRecordDetailResponse>.Ok(MapRecordDetail(result));
}
/// <summary>
/// 发票开票。
/// </summary>
[HttpPost("record/issue")]
[PermissionAuthorize(IssuePermission)]
[ProducesResponseType(typeof(ApiResponse<FinanceInvoiceIssueResultResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<FinanceInvoiceIssueResultResponse>> RecordIssue(
[FromBody] FinanceInvoiceRecordIssueRequest request,
CancellationToken cancellationToken)
{
var result = await mediator.Send(new IssueFinanceInvoiceRecordCommand
{
RecordId = StoreApiHelpers.ParseRequiredSnowflake(request.RecordId, nameof(request.RecordId)),
ContactEmail = request.ContactEmail,
IssueRemark = request.IssueRemark
}, cancellationToken);
return ApiResponse<FinanceInvoiceIssueResultResponse>.Ok(MapIssueResult(result));
}
/// <summary>
/// 作废发票。
/// </summary>
[HttpPost("record/void")]
[PermissionAuthorize(VoidPermission)]
[ProducesResponseType(typeof(ApiResponse<FinanceInvoiceRecordDetailResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<FinanceInvoiceRecordDetailResponse>> RecordVoid(
[FromBody] FinanceInvoiceRecordVoidRequest request,
CancellationToken cancellationToken)
{
var result = await mediator.Send(new VoidFinanceInvoiceRecordCommand
{
RecordId = StoreApiHelpers.ParseRequiredSnowflake(request.RecordId, nameof(request.RecordId)),
VoidReason = request.VoidReason
}, cancellationToken);
return ApiResponse<FinanceInvoiceRecordDetailResponse>.Ok(MapRecordDetail(result));
}
/// <summary>
/// 申请发票。
/// </summary>
[HttpPost("record/apply")]
[PermissionAuthorize(ViewPermission, IssuePermission)]
[ProducesResponseType(typeof(ApiResponse<FinanceInvoiceRecordDetailResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<FinanceInvoiceRecordDetailResponse>> RecordApply(
[FromBody] FinanceInvoiceRecordApplyRequest request,
CancellationToken cancellationToken)
{
var result = await mediator.Send(new ApplyFinanceInvoiceRecordCommand
{
ApplicantName = request.ApplicantName,
CompanyName = request.CompanyName,
TaxpayerNumber = request.TaxpayerNumber,
InvoiceType = request.InvoiceType,
Amount = request.Amount,
OrderNo = request.OrderNo,
ContactEmail = request.ContactEmail,
ContactPhone = request.ContactPhone,
ApplyRemark = request.ApplyRemark,
AppliedAt = request.AppliedAt
}, cancellationToken);
return ApiResponse<FinanceInvoiceRecordDetailResponse>.Ok(MapRecordDetail(result));
}
private static DateTime? ParseDateOrNull(string? value, string fieldName)
{
return string.IsNullOrWhiteSpace(value)
? null
: StoreApiHelpers.ParseDateOnly(value, fieldName);
}
private static TenantInvoiceStatus? ParseStatusOrNull(string? value)
{
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(normalized))
{
return null;
}
return normalized switch
{
"pending" => TenantInvoiceStatus.Pending,
"issued" => TenantInvoiceStatus.Issued,
"voided" => TenantInvoiceStatus.Voided,
_ => throw new BusinessException(ErrorCodes.BadRequest, "status 参数不合法")
};
}
private static TenantInvoiceType? ParseInvoiceTypeOrNull(string? value)
{
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(normalized))
{
return null;
}
return normalized switch
{
"normal" => TenantInvoiceType.Normal,
"special" => TenantInvoiceType.Special,
_ => throw new BusinessException(ErrorCodes.BadRequest, "invoiceType 参数不合法")
};
}
private static FinanceInvoiceSettingResponse MapSetting(FinanceInvoiceSettingDto source)
{
return new FinanceInvoiceSettingResponse
{
CompanyName = source.CompanyName,
TaxpayerNumber = source.TaxpayerNumber,
RegisteredAddress = source.RegisteredAddress,
RegisteredPhone = source.RegisteredPhone,
BankName = source.BankName,
BankAccount = source.BankAccount,
EnableElectronicNormalInvoice = source.EnableElectronicNormalInvoice,
EnableElectronicSpecialInvoice = source.EnableElectronicSpecialInvoice,
EnableAutoIssue = source.EnableAutoIssue,
AutoIssueMaxAmount = source.AutoIssueMaxAmount
};
}
private static FinanceInvoiceRecordResponse MapRecord(FinanceInvoiceRecordDto source)
{
return new FinanceInvoiceRecordResponse
{
RecordId = source.RecordId.ToString(),
InvoiceNo = source.InvoiceNo,
ApplicantName = source.ApplicantName,
CompanyName = source.CompanyName,
InvoiceType = source.InvoiceType,
InvoiceTypeText = source.InvoiceTypeText,
Amount = source.Amount,
OrderNo = source.OrderNo,
Status = source.Status,
StatusText = source.StatusText,
AppliedAt = source.AppliedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
};
}
private static FinanceInvoiceRecordDetailResponse MapRecordDetail(FinanceInvoiceRecordDetailDto source)
{
return new FinanceInvoiceRecordDetailResponse
{
RecordId = source.RecordId.ToString(),
InvoiceNo = source.InvoiceNo,
ApplicantName = source.ApplicantName,
CompanyName = source.CompanyName,
TaxpayerNumber = source.TaxpayerNumber,
InvoiceType = source.InvoiceType,
InvoiceTypeText = source.InvoiceTypeText,
Amount = source.Amount,
OrderNo = source.OrderNo,
ContactEmail = source.ContactEmail,
ContactPhone = source.ContactPhone,
ApplyRemark = source.ApplyRemark,
Status = source.Status,
StatusText = source.StatusText,
AppliedAt = source.AppliedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
IssuedAt = source.IssuedAt?.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
IssuedByUserId = source.IssuedByUserId?.ToString(),
IssueRemark = source.IssueRemark,
VoidedAt = source.VoidedAt?.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
VoidedByUserId = source.VoidedByUserId?.ToString(),
VoidReason = source.VoidReason
};
}
private static FinanceInvoiceIssueResultResponse MapIssueResult(FinanceInvoiceIssueResultDto source)
{
return new FinanceInvoiceIssueResultResponse
{
RecordId = source.RecordId.ToString(),
InvoiceNo = source.InvoiceNo,
CompanyName = source.CompanyName,
Amount = source.Amount,
ContactEmail = source.ContactEmail,
IssuedAt = source.IssuedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
Status = source.Status,
StatusText = source.StatusText
};
}
}

View File

@@ -0,0 +1,88 @@
using MediatR;
using TakeoutSaaS.Application.App.Finance.Cost.Dto;
using TakeoutSaaS.Domain.Finance.Enums;
namespace TakeoutSaaS.Application.App.Finance.Cost.Commands;
/// <summary>
/// 保存成本录入数据。
/// </summary>
public sealed class SaveFinanceCostEntryCommand : IRequest<FinanceCostEntryDto>
{
/// <summary>
/// 统计维度。
/// </summary>
public FinanceCostDimension Dimension { get; init; } = FinanceCostDimension.Tenant;
/// <summary>
/// 门店标识(门店维度必填)。
/// </summary>
public long? StoreId { get; init; }
/// <summary>
/// 目标月份UTC 每月第一天)。
/// </summary>
public DateTime CostMonth { get; init; } = DateTime.UtcNow;
/// <summary>
/// 成本分类列表。
/// </summary>
public IReadOnlyList<SaveFinanceCostCategoryCommandItem> Categories { get; init; } = [];
}
/// <summary>
/// 成本分类保存项。
/// </summary>
public sealed class SaveFinanceCostCategoryCommandItem
{
/// <summary>
/// 成本分类。
/// </summary>
public FinanceCostCategory Category { get; init; }
/// <summary>
/// 分类总金额。
/// </summary>
public decimal TotalAmount { get; init; }
/// <summary>
/// 分类明细项。
/// </summary>
public IReadOnlyList<SaveFinanceCostDetailCommandItem> Items { get; init; } = [];
}
/// <summary>
/// 成本明细保存项。
/// </summary>
public sealed class SaveFinanceCostDetailCommandItem
{
/// <summary>
/// 明细标识(编辑时透传,可为空)。
/// </summary>
public long? ItemId { get; init; }
/// <summary>
/// 明细名称。
/// </summary>
public string ItemName { get; init; } = string.Empty;
/// <summary>
/// 金额。
/// </summary>
public decimal Amount { get; init; }
/// <summary>
/// 数量(人工类可用)。
/// </summary>
public decimal? Quantity { get; init; }
/// <summary>
/// 单价(人工类可用)。
/// </summary>
public decimal? UnitPrice { get; init; }
/// <summary>
/// 排序值。
/// </summary>
public int SortOrder { get; init; }
}

View File

@@ -0,0 +1,279 @@
namespace TakeoutSaaS.Application.App.Finance.Cost.Dto;
/// <summary>
/// 成本录入明细项 DTO。
/// </summary>
public sealed class FinanceCostEntryDetailDto
{
/// <summary>
/// 明细标识。
/// </summary>
public string? ItemId { get; set; }
/// <summary>
/// 明细名称。
/// </summary>
public string ItemName { get; set; } = string.Empty;
/// <summary>
/// 金额。
/// </summary>
public decimal Amount { get; set; }
/// <summary>
/// 数量(人工类可用)。
/// </summary>
public decimal? Quantity { get; set; }
/// <summary>
/// 单价(人工类可用)。
/// </summary>
public decimal? UnitPrice { get; set; }
/// <summary>
/// 排序值。
/// </summary>
public int SortOrder { get; set; }
}
/// <summary>
/// 成本录入分类 DTO。
/// </summary>
public sealed class FinanceCostEntryCategoryDto
{
/// <summary>
/// 分类编码。
/// </summary>
public string Category { get; set; } = string.Empty;
/// <summary>
/// 分类文案。
/// </summary>
public string CategoryText { get; set; } = string.Empty;
/// <summary>
/// 分类总金额。
/// </summary>
public decimal TotalAmount { get; set; }
/// <summary>
/// 分类占比(%)。
/// </summary>
public decimal Percentage { get; set; }
/// <summary>
/// 分类明细项。
/// </summary>
public List<FinanceCostEntryDetailDto> Items { get; set; } = [];
}
/// <summary>
/// 成本录入页 DTO。
/// </summary>
public sealed class FinanceCostEntryDto
{
/// <summary>
/// 维度编码。
/// </summary>
public string Dimension { get; set; } = "tenant";
/// <summary>
/// 门店标识(门店维度时有值)。
/// </summary>
public string? StoreId { get; set; }
/// <summary>
/// 月份yyyy-MM
/// </summary>
public string Month { get; set; } = string.Empty;
/// <summary>
/// 本月营业额。
/// </summary>
public decimal MonthRevenue { get; set; }
/// <summary>
/// 本月总成本。
/// </summary>
public decimal TotalCost { get; set; }
/// <summary>
/// 本月成本率(%)。
/// </summary>
public decimal CostRate { get; set; }
/// <summary>
/// 分类集合。
/// </summary>
public List<FinanceCostEntryCategoryDto> Categories { get; set; } = [];
}
/// <summary>
/// 成本分析统计卡 DTO。
/// </summary>
public sealed class FinanceCostAnalysisStatsDto
{
/// <summary>
/// 本月总成本。
/// </summary>
public decimal TotalCost { get; set; }
/// <summary>
/// 食材成本率(%)。
/// </summary>
public decimal FoodCostRate { get; set; }
/// <summary>
/// 单均成本。
/// </summary>
public decimal AverageCostPerPaidOrder { get; set; }
/// <summary>
/// 环比变化(%)。
/// </summary>
public decimal MonthOnMonthChangeRate { get; set; }
/// <summary>
/// 本月营业额。
/// </summary>
public decimal Revenue { get; set; }
/// <summary>
/// 本月支付成功订单数。
/// </summary>
public int PaidOrderCount { get; set; }
}
/// <summary>
/// 成本趋势点 DTO。
/// </summary>
public sealed class FinanceCostTrendPointDto
{
/// <summary>
/// 月份yyyy-MM
/// </summary>
public string Month { get; set; } = string.Empty;
/// <summary>
/// 月度总成本。
/// </summary>
public decimal TotalCost { get; set; }
/// <summary>
/// 月度营业额。
/// </summary>
public decimal Revenue { get; set; }
/// <summary>
/// 月度成本率(%)。
/// </summary>
public decimal CostRate { get; set; }
}
/// <summary>
/// 成本构成项 DTO。
/// </summary>
public sealed class FinanceCostCompositionItemDto
{
/// <summary>
/// 分类编码。
/// </summary>
public string Category { get; set; } = string.Empty;
/// <summary>
/// 分类文案。
/// </summary>
public string CategoryText { get; set; } = string.Empty;
/// <summary>
/// 金额。
/// </summary>
public decimal Amount { get; set; }
/// <summary>
/// 占比(%)。
/// </summary>
public decimal Percentage { get; set; }
}
/// <summary>
/// 成本分析明细表行 DTO。
/// </summary>
public sealed class FinanceCostMonthlyDetailRowDto
{
/// <summary>
/// 月份yyyy-MM
/// </summary>
public string Month { get; set; } = string.Empty;
/// <summary>
/// 食材成本。
/// </summary>
public decimal FoodAmount { get; set; }
/// <summary>
/// 人工成本。
/// </summary>
public decimal LaborAmount { get; set; }
/// <summary>
/// 固定费用。
/// </summary>
public decimal FixedAmount { get; set; }
/// <summary>
/// 包装耗材。
/// </summary>
public decimal PackagingAmount { get; set; }
/// <summary>
/// 总成本。
/// </summary>
public decimal TotalCost { get; set; }
/// <summary>
/// 成本率(%)。
/// </summary>
public decimal CostRate { get; set; }
}
/// <summary>
/// 成本分析页 DTO。
/// </summary>
public sealed class FinanceCostAnalysisDto
{
/// <summary>
/// 维度编码。
/// </summary>
public string Dimension { get; set; } = "tenant";
/// <summary>
/// 门店标识(门店维度时有值)。
/// </summary>
public string? StoreId { get; set; }
/// <summary>
/// 月份yyyy-MM
/// </summary>
public string Month { get; set; } = string.Empty;
/// <summary>
/// 统计卡。
/// </summary>
public FinanceCostAnalysisStatsDto Stats { get; set; } = new();
/// <summary>
/// 趋势数据。
/// </summary>
public List<FinanceCostTrendPointDto> Trend { get; set; } = [];
/// <summary>
/// 构成数据。
/// </summary>
public List<FinanceCostCompositionItemDto> Composition { get; set; } = [];
/// <summary>
/// 明细表数据。
/// </summary>
public List<FinanceCostMonthlyDetailRowDto> DetailRows { get; set; } = [];
}

View File

@@ -0,0 +1,248 @@
using System.Globalization;
using TakeoutSaaS.Application.App.Finance.Cost.Dto;
using TakeoutSaaS.Domain.Finance.Enums;
using TakeoutSaaS.Domain.Finance.Models;
namespace TakeoutSaaS.Application.App.Finance.Cost.Handlers;
/// <summary>
/// 成本模块映射与文案转换。
/// </summary>
internal static class FinanceCostMapping
{
/// <summary>
/// 维度编码转枚举。
/// </summary>
public static FinanceCostDimension ParseDimensionCode(string? value)
{
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
{
"store" => FinanceCostDimension.Store,
_ => FinanceCostDimension.Tenant
};
}
/// <summary>
/// 维度枚举转编码。
/// </summary>
public static string ToDimensionCode(FinanceCostDimension value)
{
return value == FinanceCostDimension.Store ? "store" : "tenant";
}
/// <summary>
/// 分类编码转枚举。
/// </summary>
public static FinanceCostCategory ParseCategoryCode(string? value)
{
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
{
"food" => FinanceCostCategory.FoodMaterial,
"labor" => FinanceCostCategory.Labor,
"fixed" => FinanceCostCategory.FixedExpense,
"packaging" => FinanceCostCategory.PackagingConsumable,
_ => FinanceCostCategory.FoodMaterial
};
}
/// <summary>
/// 分类枚举转编码。
/// </summary>
public static string ToCategoryCode(FinanceCostCategory value)
{
return value switch
{
FinanceCostCategory.FoodMaterial => "food",
FinanceCostCategory.Labor => "labor",
FinanceCostCategory.FixedExpense => "fixed",
FinanceCostCategory.PackagingConsumable => "packaging",
_ => "food"
};
}
/// <summary>
/// 分类文案。
/// </summary>
public static string ToCategoryText(FinanceCostCategory value)
{
return value switch
{
FinanceCostCategory.FoodMaterial => "食材原料",
FinanceCostCategory.Labor => "人工成本",
FinanceCostCategory.FixedExpense => "固定费用",
FinanceCostCategory.PackagingConsumable => "包装耗材",
_ => "食材原料"
};
}
/// <summary>
/// 格式化月份字符串yyyy-MM
/// </summary>
public static string ToMonthText(DateTime month)
{
return month.ToString("yyyy-MM", CultureInfo.InvariantCulture);
}
/// <summary>
/// 归一化金额精度。
/// </summary>
public static decimal RoundAmount(decimal value)
{
return decimal.Round(value, 2, MidpointRounding.AwayFromZero);
}
/// <summary>
/// 构建录入页 DTO。
/// </summary>
public static FinanceCostEntryDto ToEntryDto(FinanceCostMonthSnapshot snapshot)
{
// 1. 计算总成本与成本率。
var totalCost = RoundAmount(snapshot.Categories.Sum(item => item.TotalAmount));
var costRate = snapshot.MonthRevenue > 0
? RoundAmount(totalCost / snapshot.MonthRevenue * 100m)
: 0m;
// 2. 映射分类与明细。
var categories = snapshot.Categories.Select(category =>
{
var percentage = totalCost > 0
? RoundAmount(category.TotalAmount / totalCost * 100m)
: 0m;
return new FinanceCostEntryCategoryDto
{
Category = ToCategoryCode(category.Category),
CategoryText = ToCategoryText(category.Category),
TotalAmount = RoundAmount(category.TotalAmount),
Percentage = percentage,
Items = category.Items
.OrderBy(item => item.SortOrder)
.ThenBy(item => item.ItemName)
.Select(item => new FinanceCostEntryDetailDto
{
ItemId = item.ItemId?.ToString(CultureInfo.InvariantCulture),
ItemName = item.ItemName,
Amount = RoundAmount(item.Amount),
Quantity = item.Quantity.HasValue ? RoundAmount(item.Quantity.Value) : null,
UnitPrice = item.UnitPrice.HasValue ? RoundAmount(item.UnitPrice.Value) : null,
SortOrder = item.SortOrder
})
.ToList()
};
}).ToList();
return new FinanceCostEntryDto
{
Dimension = ToDimensionCode(snapshot.Dimension),
StoreId = snapshot.StoreId?.ToString(CultureInfo.InvariantCulture),
Month = ToMonthText(snapshot.CostMonth),
MonthRevenue = RoundAmount(snapshot.MonthRevenue),
TotalCost = totalCost,
CostRate = costRate,
Categories = categories
};
}
/// <summary>
/// 构建分析页 DTO。
/// </summary>
public static FinanceCostAnalysisDto ToAnalysisDto(FinanceCostAnalysisSnapshot snapshot)
{
// 1. 计算统计指标。
var averageCostPerPaidOrder = snapshot.CurrentPaidOrderCount > 0
? RoundAmount(snapshot.CurrentTotalCost / snapshot.CurrentPaidOrderCount)
: 0m;
var foodCostRate = snapshot.CurrentRevenue > 0
? RoundAmount(snapshot.CurrentFoodAmount / snapshot.CurrentRevenue * 100m)
: 0m;
// 2. 映射趋势与明细表。
var trend = snapshot.Trends
.OrderBy(item => item.MonthStartUtc)
.Select(item =>
{
var costRate = item.Revenue > 0
? RoundAmount(item.TotalCost / item.Revenue * 100m)
: 0m;
return new FinanceCostTrendPointDto
{
Month = ToMonthText(item.MonthStartUtc),
TotalCost = RoundAmount(item.TotalCost),
Revenue = RoundAmount(item.Revenue),
CostRate = costRate
};
})
.ToList();
var detailRows = snapshot.DetailRows
.OrderByDescending(item => item.MonthStartUtc)
.Select(item =>
{
var costRate = item.Revenue > 0
? RoundAmount(item.TotalCost / item.Revenue * 100m)
: 0m;
return new FinanceCostMonthlyDetailRowDto
{
Month = ToMonthText(item.MonthStartUtc),
FoodAmount = RoundAmount(item.FoodAmount),
LaborAmount = RoundAmount(item.LaborAmount),
FixedAmount = RoundAmount(item.FixedAmount),
PackagingAmount = RoundAmount(item.PackagingAmount),
TotalCost = RoundAmount(item.TotalCost),
CostRate = costRate
};
})
.ToList();
// 3. 构建成本构成。
var totalCost = RoundAmount(snapshot.CurrentTotalCost);
var composition = snapshot.CurrentCategories
.OrderBy(item => item.Category)
.Select(item => new FinanceCostCompositionItemDto
{
Category = ToCategoryCode(item.Category),
CategoryText = ToCategoryText(item.Category),
Amount = RoundAmount(item.TotalAmount),
Percentage = totalCost > 0
? RoundAmount(item.TotalAmount / totalCost * 100m)
: 0m
})
.ToList();
return new FinanceCostAnalysisDto
{
Dimension = ToDimensionCode(snapshot.Dimension),
StoreId = snapshot.StoreId?.ToString(CultureInfo.InvariantCulture),
Month = ToMonthText(snapshot.CostMonth),
Stats = new FinanceCostAnalysisStatsDto
{
TotalCost = totalCost,
FoodCostRate = foodCostRate,
AverageCostPerPaidOrder = averageCostPerPaidOrder,
MonthOnMonthChangeRate = RoundAmount(snapshot.MonthOnMonthChangeRate),
Revenue = RoundAmount(snapshot.CurrentRevenue),
PaidOrderCount = snapshot.CurrentPaidOrderCount
},
Trend = trend,
Composition = composition,
DetailRows = detailRows
};
}
/// <summary>
/// 归一化为月份起始 UTC 时间。
/// </summary>
public static DateTime NormalizeMonthStart(DateTime value)
{
var utcValue = value.Kind switch
{
DateTimeKind.Utc => value,
DateTimeKind.Local => value.ToUniversalTime(),
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
};
return new DateTime(utcValue.Year, utcValue.Month, 1, 0, 0, 0, DateTimeKind.Utc);
}
}

View File

@@ -0,0 +1,36 @@
using MediatR;
using TakeoutSaaS.Application.App.Finance.Cost.Dto;
using TakeoutSaaS.Application.App.Finance.Cost.Queries;
using TakeoutSaaS.Domain.Finance.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Finance.Cost.Handlers;
/// <summary>
/// 成本分析查询处理器。
/// </summary>
public sealed class GetFinanceCostAnalysisQueryHandler(
IFinanceCostRepository financeCostRepository,
ITenantProvider tenantProvider)
: IRequestHandler<GetFinanceCostAnalysisQuery, FinanceCostAnalysisDto>
{
/// <inheritdoc />
public async Task<FinanceCostAnalysisDto> Handle(
GetFinanceCostAnalysisQuery request,
CancellationToken cancellationToken)
{
// 1. 读取租户上下文并查询分析快照。
var tenantId = tenantProvider.GetCurrentTenantId();
var normalizedMonth = FinanceCostMapping.NormalizeMonthStart(request.CostMonth);
var snapshot = await financeCostRepository.GetAnalysisSnapshotAsync(
tenantId,
request.Dimension,
request.StoreId,
normalizedMonth,
request.TrendMonthCount,
cancellationToken);
// 2. 映射 DTO 返回。
return FinanceCostMapping.ToAnalysisDto(snapshot);
}
}

View File

@@ -0,0 +1,35 @@
using MediatR;
using TakeoutSaaS.Application.App.Finance.Cost.Dto;
using TakeoutSaaS.Application.App.Finance.Cost.Queries;
using TakeoutSaaS.Domain.Finance.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Finance.Cost.Handlers;
/// <summary>
/// 成本录入查询处理器。
/// </summary>
public sealed class GetFinanceCostEntryQueryHandler(
IFinanceCostRepository financeCostRepository,
ITenantProvider tenantProvider)
: IRequestHandler<GetFinanceCostEntryQuery, FinanceCostEntryDto>
{
/// <inheritdoc />
public async Task<FinanceCostEntryDto> Handle(
GetFinanceCostEntryQuery request,
CancellationToken cancellationToken)
{
// 1. 读取租户上下文并查询月度快照。
var tenantId = tenantProvider.GetCurrentTenantId();
var normalizedMonth = FinanceCostMapping.NormalizeMonthStart(request.CostMonth);
var snapshot = await financeCostRepository.GetMonthSnapshotAsync(
tenantId,
request.Dimension,
request.StoreId,
normalizedMonth,
cancellationToken);
// 2. 映射 DTO 返回。
return FinanceCostMapping.ToEntryDto(snapshot);
}
}

View File

@@ -0,0 +1,66 @@
using MediatR;
using TakeoutSaaS.Application.App.Finance.Cost.Commands;
using TakeoutSaaS.Application.App.Finance.Cost.Dto;
using TakeoutSaaS.Domain.Finance.Models;
using TakeoutSaaS.Domain.Finance.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Finance.Cost.Handlers;
/// <summary>
/// 成本录入保存处理器。
/// </summary>
public sealed class SaveFinanceCostEntryCommandHandler(
IFinanceCostRepository financeCostRepository,
ITenantProvider tenantProvider)
: IRequestHandler<SaveFinanceCostEntryCommand, FinanceCostEntryDto>
{
/// <inheritdoc />
public async Task<FinanceCostEntryDto> Handle(
SaveFinanceCostEntryCommand request,
CancellationToken cancellationToken)
{
// 1. 归一化入参并组装仓储快照模型。
var tenantId = tenantProvider.GetCurrentTenantId();
var normalizedMonth = FinanceCostMapping.NormalizeMonthStart(request.CostMonth);
var categories = request.Categories.Select(category => new FinanceCostCategorySnapshot
{
Category = category.Category,
TotalAmount = FinanceCostMapping.RoundAmount(category.TotalAmount),
Items = (category.Items ?? [])
.Select(item => new FinanceCostDetailItemSnapshot
{
ItemId = item.ItemId,
ItemName = item.ItemName,
Amount = FinanceCostMapping.RoundAmount(item.Amount),
Quantity = item.Quantity.HasValue
? FinanceCostMapping.RoundAmount(item.Quantity.Value)
: null,
UnitPrice = item.UnitPrice.HasValue
? FinanceCostMapping.RoundAmount(item.UnitPrice.Value)
: null,
SortOrder = item.SortOrder
})
.ToList()
}).ToList();
// 2. 持久化保存并重新查询最新快照。
await financeCostRepository.SaveMonthSnapshotAsync(
tenantId,
request.Dimension,
request.StoreId,
normalizedMonth,
categories,
cancellationToken);
var snapshot = await financeCostRepository.GetMonthSnapshotAsync(
tenantId,
request.Dimension,
request.StoreId,
normalizedMonth,
cancellationToken);
// 3. 映射为页面 DTO 返回。
return FinanceCostMapping.ToEntryDto(snapshot);
}
}

View File

@@ -0,0 +1,31 @@
using MediatR;
using TakeoutSaaS.Application.App.Finance.Cost.Dto;
using TakeoutSaaS.Domain.Finance.Enums;
namespace TakeoutSaaS.Application.App.Finance.Cost.Queries;
/// <summary>
/// 查询成本分析页数据。
/// </summary>
public sealed class GetFinanceCostAnalysisQuery : IRequest<FinanceCostAnalysisDto>
{
/// <summary>
/// 统计维度。
/// </summary>
public FinanceCostDimension Dimension { get; init; } = FinanceCostDimension.Tenant;
/// <summary>
/// 门店标识(门店维度必填)。
/// </summary>
public long? StoreId { get; init; }
/// <summary>
/// 目标月份UTC 每月第一天)。
/// </summary>
public DateTime CostMonth { get; init; } = DateTime.UtcNow;
/// <summary>
/// 趋势月份数量。
/// </summary>
public int TrendMonthCount { get; init; } = 6;
}

View File

@@ -0,0 +1,26 @@
using MediatR;
using TakeoutSaaS.Application.App.Finance.Cost.Dto;
using TakeoutSaaS.Domain.Finance.Enums;
namespace TakeoutSaaS.Application.App.Finance.Cost.Queries;
/// <summary>
/// 查询成本录入页数据。
/// </summary>
public sealed class GetFinanceCostEntryQuery : IRequest<FinanceCostEntryDto>
{
/// <summary>
/// 统计维度。
/// </summary>
public FinanceCostDimension Dimension { get; init; } = FinanceCostDimension.Tenant;
/// <summary>
/// 门店标识(门店维度必填)。
/// </summary>
public long? StoreId { get; init; }
/// <summary>
/// 目标月份UTC 每月第一天)。
/// </summary>
public DateTime CostMonth { get; init; } = DateTime.UtcNow;
}

View File

@@ -0,0 +1,46 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Finance.Cost.Queries;
using TakeoutSaaS.Domain.Finance.Enums;
namespace TakeoutSaaS.Application.App.Finance.Cost.Validators;
/// <summary>
/// 成本分析查询验证器。
/// </summary>
public sealed class GetFinanceCostAnalysisQueryValidator : AbstractValidator<GetFinanceCostAnalysisQuery>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public GetFinanceCostAnalysisQueryValidator()
{
RuleFor(x => x.Dimension)
.Must(value => value is FinanceCostDimension.Tenant or FinanceCostDimension.Store)
.WithMessage("dimension 非法");
RuleFor(x => x)
.Must(query =>
query.Dimension != FinanceCostDimension.Store ||
(query.StoreId.HasValue && query.StoreId.Value > 0))
.WithMessage("storeId 非法");
RuleFor(x => x.CostMonth)
.Must(IsMonthInExpectedRange)
.WithMessage("month 非法");
RuleFor(x => x.TrendMonthCount)
.InclusiveBetween(3, 12);
}
private static bool IsMonthInExpectedRange(DateTime value)
{
var month = value.Kind switch
{
DateTimeKind.Utc => value,
DateTimeKind.Local => value.ToUniversalTime(),
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
};
return month.Year is >= 2000 and <= 2100;
}
}

View File

@@ -0,0 +1,43 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Finance.Cost.Queries;
using TakeoutSaaS.Domain.Finance.Enums;
namespace TakeoutSaaS.Application.App.Finance.Cost.Validators;
/// <summary>
/// 成本录入查询验证器。
/// </summary>
public sealed class GetFinanceCostEntryQueryValidator : AbstractValidator<GetFinanceCostEntryQuery>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public GetFinanceCostEntryQueryValidator()
{
RuleFor(x => x.Dimension)
.Must(value => value is FinanceCostDimension.Tenant or FinanceCostDimension.Store)
.WithMessage("dimension 非法");
RuleFor(x => x)
.Must(query =>
query.Dimension != FinanceCostDimension.Store ||
(query.StoreId.HasValue && query.StoreId.Value > 0))
.WithMessage("storeId 非法");
RuleFor(x => x.CostMonth)
.Must(IsMonthInExpectedRange)
.WithMessage("month 非法");
}
private static bool IsMonthInExpectedRange(DateTime value)
{
var month = value.Kind switch
{
DateTimeKind.Utc => value,
DateTimeKind.Local => value.ToUniversalTime(),
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
};
return month.Year is >= 2000 and <= 2100;
}
}

View File

@@ -0,0 +1,120 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Finance.Cost.Commands;
using TakeoutSaaS.Domain.Finance.Enums;
namespace TakeoutSaaS.Application.App.Finance.Cost.Validators;
/// <summary>
/// 成本录入保存验证器。
/// </summary>
public sealed class SaveFinanceCostEntryCommandValidator : AbstractValidator<SaveFinanceCostEntryCommand>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public SaveFinanceCostEntryCommandValidator()
{
RuleFor(x => x.Dimension)
.Must(value => value is FinanceCostDimension.Tenant or FinanceCostDimension.Store)
.WithMessage("dimension 非法");
RuleFor(x => x)
.Must(command =>
command.Dimension != FinanceCostDimension.Store ||
(command.StoreId.HasValue && command.StoreId.Value > 0))
.WithMessage("storeId 非法");
RuleFor(x => x.CostMonth)
.Must(IsMonthInExpectedRange)
.WithMessage("month 非法");
RuleFor(x => x.Categories)
.NotNull()
.Must(categories => categories.Count > 0)
.WithMessage("categories 不能为空");
RuleFor(x => x.Categories)
.Must(HaveDistinctCategories)
.WithMessage("分类重复");
RuleForEach(x => x.Categories)
.SetValidator(new SaveFinanceCostCategoryCommandItemValidator());
}
private static bool HaveDistinctCategories(IReadOnlyList<SaveFinanceCostCategoryCommandItem> categories)
{
var normalized = (categories ?? [])
.Select(item => item.Category)
.Where(value => value != default)
.ToList();
return normalized.Count == normalized.Distinct().Count();
}
private static bool IsMonthInExpectedRange(DateTime value)
{
var month = value.Kind switch
{
DateTimeKind.Utc => value,
DateTimeKind.Local => value.ToUniversalTime(),
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
};
return month.Year is >= 2000 and <= 2100;
}
}
/// <summary>
/// 成本分类保存项验证器。
/// </summary>
public sealed class SaveFinanceCostCategoryCommandItemValidator : AbstractValidator<SaveFinanceCostCategoryCommandItem>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public SaveFinanceCostCategoryCommandItemValidator()
{
RuleFor(x => x.Category)
.Must(value => value is FinanceCostCategory.FoodMaterial
or FinanceCostCategory.Labor
or FinanceCostCategory.FixedExpense
or FinanceCostCategory.PackagingConsumable)
.WithMessage("category 非法");
RuleFor(x => x.TotalAmount)
.GreaterThanOrEqualTo(0);
RuleForEach(x => x.Items)
.SetValidator(new SaveFinanceCostDetailCommandItemValidator());
}
}
/// <summary>
/// 成本明细保存项验证器。
/// </summary>
public sealed class SaveFinanceCostDetailCommandItemValidator : AbstractValidator<SaveFinanceCostDetailCommandItem>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public SaveFinanceCostDetailCommandItemValidator()
{
RuleFor(x => x.ItemName)
.NotEmpty()
.MaximumLength(64);
RuleFor(x => x.Amount)
.GreaterThanOrEqualTo(0);
RuleFor(x => x.Quantity)
.GreaterThanOrEqualTo(0)
.When(x => x.Quantity.HasValue);
RuleFor(x => x.UnitPrice)
.GreaterThanOrEqualTo(0)
.When(x => x.UnitPrice.HasValue);
RuleFor(x => x.SortOrder)
.GreaterThanOrEqualTo(0);
}
}

View File

@@ -0,0 +1,60 @@
using MediatR;
using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
namespace TakeoutSaaS.Application.App.Finance.Invoice.Commands;
/// <summary>
/// 申请发票记录命令。
/// </summary>
public sealed class ApplyFinanceInvoiceRecordCommand : IRequest<FinanceInvoiceRecordDetailDto>
{
/// <summary>
/// 申请人。
/// </summary>
public string ApplicantName { get; init; } = string.Empty;
/// <summary>
/// 开票抬头(公司名)。
/// </summary>
public string CompanyName { get; init; } = string.Empty;
/// <summary>
/// 纳税人识别号。
/// </summary>
public string? TaxpayerNumber { get; init; }
/// <summary>
/// 发票类型normal/special
/// </summary>
public string InvoiceType { get; init; } = "normal";
/// <summary>
/// 开票金额。
/// </summary>
public decimal Amount { get; init; }
/// <summary>
/// 关联订单号。
/// </summary>
public string OrderNo { get; init; } = string.Empty;
/// <summary>
/// 接收邮箱。
/// </summary>
public string? ContactEmail { get; init; }
/// <summary>
/// 联系电话。
/// </summary>
public string? ContactPhone { get; init; }
/// <summary>
/// 申请备注。
/// </summary>
public string? ApplyRemark { get; init; }
/// <summary>
/// 申请时间(可空,默认当前 UTC
/// </summary>
public DateTime? AppliedAt { get; init; }
}

View File

@@ -0,0 +1,25 @@
using MediatR;
using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
namespace TakeoutSaaS.Application.App.Finance.Invoice.Commands;
/// <summary>
/// 开票命令。
/// </summary>
public sealed class IssueFinanceInvoiceRecordCommand : IRequest<FinanceInvoiceIssueResultDto>
{
/// <summary>
/// 发票记录 ID。
/// </summary>
public long RecordId { get; init; }
/// <summary>
/// 接收邮箱(可选,传入会覆盖原值)。
/// </summary>
public string? ContactEmail { get; init; }
/// <summary>
/// 开票备注。
/// </summary>
public string? IssueRemark { get; init; }
}

View File

@@ -0,0 +1,60 @@
using MediatR;
using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
namespace TakeoutSaaS.Application.App.Finance.Invoice.Commands;
/// <summary>
/// 保存发票设置命令。
/// </summary>
public sealed class SaveFinanceInvoiceSettingCommand : IRequest<FinanceInvoiceSettingDto>
{
/// <summary>
/// 企业名称。
/// </summary>
public string CompanyName { get; init; } = string.Empty;
/// <summary>
/// 纳税人识别号。
/// </summary>
public string TaxpayerNumber { get; init; } = string.Empty;
/// <summary>
/// 注册地址。
/// </summary>
public string? RegisteredAddress { get; init; }
/// <summary>
/// 注册电话。
/// </summary>
public string? RegisteredPhone { get; init; }
/// <summary>
/// 开户银行。
/// </summary>
public string? BankName { get; init; }
/// <summary>
/// 银行账号。
/// </summary>
public string? BankAccount { get; init; }
/// <summary>
/// 是否启用电子普通发票。
/// </summary>
public bool EnableElectronicNormalInvoice { get; init; }
/// <summary>
/// 是否启用电子专用发票。
/// </summary>
public bool EnableElectronicSpecialInvoice { get; init; }
/// <summary>
/// 是否启用自动开票。
/// </summary>
public bool EnableAutoIssue { get; init; }
/// <summary>
/// 自动开票单张最大金额。
/// </summary>
public decimal AutoIssueMaxAmount { get; init; }
}

View File

@@ -0,0 +1,20 @@
using MediatR;
using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
namespace TakeoutSaaS.Application.App.Finance.Invoice.Commands;
/// <summary>
/// 作废发票命令。
/// </summary>
public sealed class VoidFinanceInvoiceRecordCommand : IRequest<FinanceInvoiceRecordDetailDto>
{
/// <summary>
/// 发票记录 ID。
/// </summary>
public long RecordId { get; init; }
/// <summary>
/// 作废原因。
/// </summary>
public string VoidReason { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,47 @@
namespace TakeoutSaaS.Application.App.Finance.Invoice.Dto;
/// <summary>
/// 发票开票结果 DTO。
/// </summary>
public sealed class FinanceInvoiceIssueResultDto
{
/// <summary>
/// 记录 ID。
/// </summary>
public long RecordId { get; set; }
/// <summary>
/// 发票号码。
/// </summary>
public string InvoiceNo { get; set; } = string.Empty;
/// <summary>
/// 开票抬头。
/// </summary>
public string CompanyName { get; set; } = string.Empty;
/// <summary>
/// 金额。
/// </summary>
public decimal Amount { get; set; }
/// <summary>
/// 接收邮箱。
/// </summary>
public string? ContactEmail { get; set; }
/// <summary>
/// 开票时间UTC
/// </summary>
public DateTime IssuedAt { get; set; }
/// <summary>
/// 状态编码。
/// </summary>
public string Status { get; set; } = string.Empty;
/// <summary>
/// 状态文案。
/// </summary>
public string StatusText { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,112 @@
namespace TakeoutSaaS.Application.App.Finance.Invoice.Dto;
/// <summary>
/// 发票记录详情 DTO。
/// </summary>
public sealed class FinanceInvoiceRecordDetailDto
{
/// <summary>
/// 记录 ID。
/// </summary>
public long RecordId { get; set; }
/// <summary>
/// 发票号码。
/// </summary>
public string InvoiceNo { get; set; } = string.Empty;
/// <summary>
/// 申请人。
/// </summary>
public string ApplicantName { get; set; } = string.Empty;
/// <summary>
/// 开票抬头(公司名)。
/// </summary>
public string CompanyName { get; set; } = string.Empty;
/// <summary>
/// 纳税人识别号。
/// </summary>
public string? TaxpayerNumber { get; set; }
/// <summary>
/// 发票类型编码。
/// </summary>
public string InvoiceType { get; set; } = string.Empty;
/// <summary>
/// 发票类型文案。
/// </summary>
public string InvoiceTypeText { get; set; } = string.Empty;
/// <summary>
/// 金额。
/// </summary>
public decimal Amount { get; set; }
/// <summary>
/// 关联订单号。
/// </summary>
public string OrderNo { get; set; } = string.Empty;
/// <summary>
/// 接收邮箱。
/// </summary>
public string? ContactEmail { get; set; }
/// <summary>
/// 联系电话。
/// </summary>
public string? ContactPhone { get; set; }
/// <summary>
/// 申请备注。
/// </summary>
public string? ApplyRemark { get; set; }
/// <summary>
/// 状态编码。
/// </summary>
public string Status { get; set; } = string.Empty;
/// <summary>
/// 状态文案。
/// </summary>
public string StatusText { get; set; } = string.Empty;
/// <summary>
/// 申请时间UTC
/// </summary>
public DateTime AppliedAt { get; set; }
/// <summary>
/// 开票时间UTC
/// </summary>
public DateTime? IssuedAt { get; set; }
/// <summary>
/// 开票人 ID。
/// </summary>
public long? IssuedByUserId { get; set; }
/// <summary>
/// 开票备注。
/// </summary>
public string? IssueRemark { get; set; }
/// <summary>
/// 作废时间UTC
/// </summary>
public DateTime? VoidedAt { get; set; }
/// <summary>
/// 作废人 ID。
/// </summary>
public long? VoidedByUserId { get; set; }
/// <summary>
/// 作废原因。
/// </summary>
public string? VoidReason { get; set; }
}

View File

@@ -0,0 +1,62 @@
namespace TakeoutSaaS.Application.App.Finance.Invoice.Dto;
/// <summary>
/// 发票记录列表项 DTO。
/// </summary>
public sealed class FinanceInvoiceRecordDto
{
/// <summary>
/// 记录 ID。
/// </summary>
public long RecordId { get; set; }
/// <summary>
/// 发票号码。
/// </summary>
public string InvoiceNo { get; set; } = string.Empty;
/// <summary>
/// 申请人。
/// </summary>
public string ApplicantName { get; set; } = string.Empty;
/// <summary>
/// 开票抬头(公司名)。
/// </summary>
public string CompanyName { get; set; } = string.Empty;
/// <summary>
/// 发票类型编码。
/// </summary>
public string InvoiceType { get; set; } = string.Empty;
/// <summary>
/// 发票类型文案。
/// </summary>
public string InvoiceTypeText { get; set; } = string.Empty;
/// <summary>
/// 金额。
/// </summary>
public decimal Amount { get; set; }
/// <summary>
/// 关联订单号。
/// </summary>
public string OrderNo { get; set; } = string.Empty;
/// <summary>
/// 状态编码。
/// </summary>
public string Status { get; set; } = string.Empty;
/// <summary>
/// 状态文案。
/// </summary>
public string StatusText { get; set; } = string.Empty;
/// <summary>
/// 申请时间UTC
/// </summary>
public DateTime AppliedAt { get; set; }
}

View File

@@ -0,0 +1,32 @@
namespace TakeoutSaaS.Application.App.Finance.Invoice.Dto;
/// <summary>
/// 发票记录分页结果 DTO。
/// </summary>
public sealed class FinanceInvoiceRecordListResultDto
{
/// <summary>
/// 列表项。
/// </summary>
public List<FinanceInvoiceRecordDto> Items { get; set; } = [];
/// <summary>
/// 页码。
/// </summary>
public int Page { get; set; }
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; set; }
/// <summary>
/// 总条数。
/// </summary>
public int TotalCount { get; set; }
/// <summary>
/// 统计。
/// </summary>
public FinanceInvoiceStatsDto Stats { get; set; } = new();
}

View File

@@ -0,0 +1,57 @@
namespace TakeoutSaaS.Application.App.Finance.Invoice.Dto;
/// <summary>
/// 发票设置 DTO。
/// </summary>
public sealed class FinanceInvoiceSettingDto
{
/// <summary>
/// 企业名称。
/// </summary>
public string CompanyName { get; set; } = string.Empty;
/// <summary>
/// 纳税人识别号。
/// </summary>
public string TaxpayerNumber { get; set; } = string.Empty;
/// <summary>
/// 注册地址。
/// </summary>
public string? RegisteredAddress { get; set; }
/// <summary>
/// 注册电话。
/// </summary>
public string? RegisteredPhone { get; set; }
/// <summary>
/// 开户银行。
/// </summary>
public string? BankName { get; set; }
/// <summary>
/// 银行账号。
/// </summary>
public string? BankAccount { get; set; }
/// <summary>
/// 是否启用电子普通发票。
/// </summary>
public bool EnableElectronicNormalInvoice { get; set; }
/// <summary>
/// 是否启用电子专用发票。
/// </summary>
public bool EnableElectronicSpecialInvoice { get; set; }
/// <summary>
/// 是否启用自动开票。
/// </summary>
public bool EnableAutoIssue { get; set; }
/// <summary>
/// 自动开票单张最大金额。
/// </summary>
public decimal AutoIssueMaxAmount { get; set; }
}

View File

@@ -0,0 +1,27 @@
namespace TakeoutSaaS.Application.App.Finance.Invoice.Dto;
/// <summary>
/// 发票统计 DTO。
/// </summary>
public sealed class FinanceInvoiceStatsDto
{
/// <summary>
/// 本月已开票金额。
/// </summary>
public decimal CurrentMonthIssuedAmount { get; set; }
/// <summary>
/// 本月已开票张数。
/// </summary>
public int CurrentMonthIssuedCount { get; set; }
/// <summary>
/// 待开票数量。
/// </summary>
public int PendingCount { get; set; }
/// <summary>
/// 已作废数量。
/// </summary>
public int VoidedCount { get; set; }
}

View File

@@ -0,0 +1,199 @@
using TakeoutSaaS.Application.App.Finance.Invoice.Commands;
using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
namespace TakeoutSaaS.Application.App.Finance.Invoice;
/// <summary>
/// 发票模块 DTO 构造器。
/// </summary>
internal static class FinanceInvoiceDtoFactory
{
public static FinanceInvoiceSettingDto CreateDefaultSettingDto()
{
return new FinanceInvoiceSettingDto
{
CompanyName = string.Empty,
TaxpayerNumber = string.Empty,
RegisteredAddress = null,
RegisteredPhone = null,
BankName = null,
BankAccount = null,
EnableElectronicNormalInvoice = true,
EnableElectronicSpecialInvoice = false,
EnableAutoIssue = false,
AutoIssueMaxAmount = 10_000m
};
}
public static FinanceInvoiceSettingDto ToSettingDto(TenantInvoiceSetting source)
{
return new FinanceInvoiceSettingDto
{
CompanyName = source.CompanyName,
TaxpayerNumber = source.TaxpayerNumber,
RegisteredAddress = source.RegisteredAddress,
RegisteredPhone = source.RegisteredPhone,
BankName = source.BankName,
BankAccount = source.BankAccount,
EnableElectronicNormalInvoice = source.EnableElectronicNormalInvoice,
EnableElectronicSpecialInvoice = source.EnableElectronicSpecialInvoice,
EnableAutoIssue = source.EnableAutoIssue,
AutoIssueMaxAmount = decimal.Round(source.AutoIssueMaxAmount, 2, MidpointRounding.AwayFromZero)
};
}
public static TenantInvoiceSetting CreateSettingEntity(
SaveFinanceInvoiceSettingCommand request,
string companyName,
string taxpayerNumber,
string? registeredAddress,
string? registeredPhone,
string? bankName,
string? bankAccount,
decimal autoIssueMaxAmount)
{
return new TenantInvoiceSetting
{
CompanyName = companyName,
TaxpayerNumber = taxpayerNumber,
RegisteredAddress = registeredAddress,
RegisteredPhone = registeredPhone,
BankName = bankName,
BankAccount = bankAccount,
EnableElectronicNormalInvoice = request.EnableElectronicNormalInvoice,
EnableElectronicSpecialInvoice = request.EnableElectronicSpecialInvoice,
EnableAutoIssue = request.EnableAutoIssue,
AutoIssueMaxAmount = autoIssueMaxAmount
};
}
public static void ApplySettingChanges(
TenantInvoiceSetting entity,
SaveFinanceInvoiceSettingCommand request,
string companyName,
string taxpayerNumber,
string? registeredAddress,
string? registeredPhone,
string? bankName,
string? bankAccount,
decimal autoIssueMaxAmount)
{
entity.CompanyName = companyName;
entity.TaxpayerNumber = taxpayerNumber;
entity.RegisteredAddress = registeredAddress;
entity.RegisteredPhone = registeredPhone;
entity.BankName = bankName;
entity.BankAccount = bankAccount;
entity.EnableElectronicNormalInvoice = request.EnableElectronicNormalInvoice;
entity.EnableElectronicSpecialInvoice = request.EnableElectronicSpecialInvoice;
entity.EnableAutoIssue = request.EnableAutoIssue;
entity.AutoIssueMaxAmount = autoIssueMaxAmount;
}
public static FinanceInvoiceStatsDto ToStatsDto(TenantInvoiceRecordStatsSnapshot source)
{
return new FinanceInvoiceStatsDto
{
CurrentMonthIssuedAmount = decimal.Round(source.CurrentMonthIssuedAmount, 2, MidpointRounding.AwayFromZero),
CurrentMonthIssuedCount = source.CurrentMonthIssuedCount,
PendingCount = source.PendingCount,
VoidedCount = source.VoidedCount
};
}
public static FinanceInvoiceRecordDto ToRecordDto(TenantInvoiceRecord source)
{
return new FinanceInvoiceRecordDto
{
RecordId = source.Id,
InvoiceNo = source.InvoiceNo,
ApplicantName = source.ApplicantName,
CompanyName = source.CompanyName,
InvoiceType = FinanceInvoiceMapping.ToInvoiceTypeText(source.InvoiceType),
InvoiceTypeText = FinanceInvoiceMapping.ToInvoiceTypeDisplayText(source.InvoiceType),
Amount = decimal.Round(source.Amount, 2, MidpointRounding.AwayFromZero),
OrderNo = source.OrderNo,
Status = FinanceInvoiceMapping.ToStatusText(source.Status),
StatusText = FinanceInvoiceMapping.ToStatusDisplayText(source.Status),
AppliedAt = source.AppliedAt
};
}
public static FinanceInvoiceRecordDetailDto ToRecordDetailDto(TenantInvoiceRecord source)
{
return new FinanceInvoiceRecordDetailDto
{
RecordId = source.Id,
InvoiceNo = source.InvoiceNo,
ApplicantName = source.ApplicantName,
CompanyName = source.CompanyName,
TaxpayerNumber = source.TaxpayerNumber,
InvoiceType = FinanceInvoiceMapping.ToInvoiceTypeText(source.InvoiceType),
InvoiceTypeText = FinanceInvoiceMapping.ToInvoiceTypeDisplayText(source.InvoiceType),
Amount = decimal.Round(source.Amount, 2, MidpointRounding.AwayFromZero),
OrderNo = source.OrderNo,
ContactEmail = source.ContactEmail,
ContactPhone = source.ContactPhone,
ApplyRemark = source.ApplyRemark,
Status = FinanceInvoiceMapping.ToStatusText(source.Status),
StatusText = FinanceInvoiceMapping.ToStatusDisplayText(source.Status),
AppliedAt = source.AppliedAt,
IssuedAt = source.IssuedAt,
IssuedByUserId = source.IssuedByUserId,
IssueRemark = source.IssueRemark,
VoidedAt = source.VoidedAt,
VoidedByUserId = source.VoidedByUserId,
VoidReason = source.VoidReason
};
}
public static FinanceInvoiceIssueResultDto ToIssueResultDto(TenantInvoiceRecord source)
{
return new FinanceInvoiceIssueResultDto
{
RecordId = source.Id,
InvoiceNo = source.InvoiceNo,
CompanyName = source.CompanyName,
Amount = decimal.Round(source.Amount, 2, MidpointRounding.AwayFromZero),
ContactEmail = source.ContactEmail,
IssuedAt = source.IssuedAt ?? DateTime.UtcNow,
Status = FinanceInvoiceMapping.ToStatusText(source.Status),
StatusText = FinanceInvoiceMapping.ToStatusDisplayText(source.Status)
};
}
public static TenantInvoiceRecord CreateRecordEntity(
long tenantId,
string invoiceNo,
string applicantName,
string companyName,
string? taxpayerNumber,
TenantInvoiceType invoiceType,
decimal amount,
string orderNo,
string? contactEmail,
string? contactPhone,
string? applyRemark,
DateTime appliedAt)
{
return new TenantInvoiceRecord
{
TenantId = tenantId,
InvoiceNo = invoiceNo,
ApplicantName = applicantName,
CompanyName = companyName,
TaxpayerNumber = taxpayerNumber,
InvoiceType = invoiceType,
Amount = amount,
OrderNo = orderNo,
ContactEmail = contactEmail,
ContactPhone = contactPhone,
ApplyRemark = applyRemark,
Status = TenantInvoiceStatus.Pending,
AppliedAt = appliedAt
};
}
}

View File

@@ -0,0 +1,252 @@
using System.Net.Mail;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
namespace TakeoutSaaS.Application.App.Finance.Invoice;
/// <summary>
/// 发票模块映射与参数标准化。
/// </summary>
internal static class FinanceInvoiceMapping
{
public static TenantInvoiceType ParseInvoiceTypeRequired(string? value)
{
return ParseInvoiceTypeOptional(value)
?? throw new BusinessException(ErrorCodes.BadRequest, "invoiceType 参数不合法");
}
public static TenantInvoiceType? ParseInvoiceTypeOptional(string? value)
{
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(normalized))
{
return null;
}
return normalized switch
{
"normal" => TenantInvoiceType.Normal,
"special" => TenantInvoiceType.Special,
_ => throw new BusinessException(ErrorCodes.BadRequest, "invoiceType 参数不合法")
};
}
public static TenantInvoiceStatus? ParseStatusOptional(string? value)
{
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(normalized))
{
return null;
}
return normalized switch
{
"pending" => TenantInvoiceStatus.Pending,
"issued" => TenantInvoiceStatus.Issued,
"voided" => TenantInvoiceStatus.Voided,
_ => throw new BusinessException(ErrorCodes.BadRequest, "status 参数不合法")
};
}
public static string ToInvoiceTypeText(TenantInvoiceType value)
{
return value switch
{
TenantInvoiceType.Normal => "normal",
TenantInvoiceType.Special => "special",
_ => "normal"
};
}
public static string ToInvoiceTypeDisplayText(TenantInvoiceType value)
{
return value switch
{
TenantInvoiceType.Normal => "普票",
TenantInvoiceType.Special => "专票",
_ => "普票"
};
}
public static string ToStatusText(TenantInvoiceStatus value)
{
return value switch
{
TenantInvoiceStatus.Pending => "pending",
TenantInvoiceStatus.Issued => "issued",
TenantInvoiceStatus.Voided => "voided",
_ => "pending"
};
}
public static string ToStatusDisplayText(TenantInvoiceStatus value)
{
return value switch
{
TenantInvoiceStatus.Pending => "待开票",
TenantInvoiceStatus.Issued => "已开票",
TenantInvoiceStatus.Voided => "已作废",
_ => "待开票"
};
}
public static string NormalizeCompanyName(string? value)
{
return NormalizeRequiredText(value, "companyName", 128);
}
public static string NormalizeApplicantName(string? value)
{
return NormalizeRequiredText(value, "applicantName", 64);
}
public static string NormalizeOrderNo(string? value)
{
return NormalizeRequiredText(value, "orderNo", 32);
}
public static string NormalizeTaxpayerNumber(string? value)
{
return NormalizeRequiredText(value, "taxpayerNumber", 64);
}
public static string? NormalizeOptionalTaxpayerNumber(string? value)
{
return NormalizeOptionalText(value, "taxpayerNumber", 64);
}
public static string? NormalizeOptionalKeyword(string? value)
{
return NormalizeOptionalText(value, "keyword", 64);
}
public static string? NormalizeOptionalEmail(string? value)
{
var normalized = NormalizeOptionalText(value, "contactEmail", 128);
if (normalized is null)
{
return null;
}
try
{
_ = new MailAddress(normalized);
return normalized;
}
catch (FormatException)
{
throw new BusinessException(ErrorCodes.BadRequest, "contactEmail 参数不合法");
}
}
public static string? NormalizeOptionalPhone(string? value)
{
return NormalizeOptionalText(value, "contactPhone", 32);
}
public static string? NormalizeOptionalRemark(string? value, string fieldName, int maxLength = 256)
{
return NormalizeOptionalText(value, fieldName, maxLength);
}
public static string NormalizeVoidReason(string? value)
{
return NormalizeRequiredText(value, "voidReason", 256);
}
public static decimal NormalizeAmount(decimal value)
{
if (value <= 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "amount 参数不合法");
}
return decimal.Round(value, 2, MidpointRounding.AwayFromZero);
}
public static decimal NormalizeAutoIssueMaxAmount(decimal value)
{
if (value <= 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "autoIssueMaxAmount 参数不合法");
}
return decimal.Round(value, 2, MidpointRounding.AwayFromZero);
}
public static (DateTime? StartUtc, DateTime? EndUtc) NormalizeDateRange(DateTime? startUtc, DateTime? endUtc)
{
DateTime? normalizedStart = null;
DateTime? normalizedEnd = null;
if (startUtc.HasValue)
{
var utcValue = NormalizeUtc(startUtc.Value);
normalizedStart = new DateTime(utcValue.Year, utcValue.Month, utcValue.Day, 0, 0, 0, DateTimeKind.Utc);
}
if (endUtc.HasValue)
{
var utcValue = NormalizeUtc(endUtc.Value);
normalizedEnd = new DateTime(utcValue.Year, utcValue.Month, utcValue.Day, 0, 0, 0, DateTimeKind.Utc)
.AddDays(1)
.AddTicks(-1);
}
if (normalizedStart.HasValue && normalizedEnd.HasValue && normalizedStart > normalizedEnd)
{
throw new BusinessException(ErrorCodes.BadRequest, "开始日期不能晚于结束日期");
}
return (normalizedStart, normalizedEnd);
}
public static DateTime NormalizeUtc(DateTime value)
{
return value.Kind switch
{
DateTimeKind.Utc => value,
DateTimeKind.Local => value.ToUniversalTime(),
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
};
}
public static string BuildInvoiceNo(DateTime nowUtc)
{
var utcNow = NormalizeUtc(nowUtc);
return $"INV{utcNow:yyyyMMddHHmmssfff}{Random.Shared.Next(100, 999)}";
}
private static string NormalizeRequiredText(string? value, string fieldName, int maxLength)
{
var normalized = (value ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalized))
{
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 参数不合法");
}
if (normalized.Length > maxLength)
{
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 长度不能超过 {maxLength}");
}
return normalized;
}
private static string? NormalizeOptionalText(string? value, string fieldName, int maxLength)
{
var normalized = (value ?? string.Empty).Trim();
if (normalized.Length == 0)
{
return null;
}
if (normalized.Length > maxLength)
{
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 长度不能超过 {maxLength}");
}
return normalized;
}
}

View File

@@ -0,0 +1,107 @@
using MediatR;
using TakeoutSaaS.Application.App.Finance.Invoice.Commands;
using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Security;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Finance.Invoice.Handlers;
/// <summary>
/// 申请发票处理器。
/// </summary>
public sealed class ApplyFinanceInvoiceRecordCommandHandler(
ITenantInvoiceRepository repository,
ITenantProvider tenantProvider,
ICurrentUserAccessor currentUserAccessor)
: IRequestHandler<ApplyFinanceInvoiceRecordCommand, FinanceInvoiceRecordDetailDto>
{
/// <inheritdoc />
public async Task<FinanceInvoiceRecordDetailDto> Handle(
ApplyFinanceInvoiceRecordCommand request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var invoiceType = FinanceInvoiceMapping.ParseInvoiceTypeRequired(request.InvoiceType);
var applicantName = FinanceInvoiceMapping.NormalizeApplicantName(request.ApplicantName);
var companyName = FinanceInvoiceMapping.NormalizeCompanyName(request.CompanyName);
var taxpayerNumber = FinanceInvoiceMapping.NormalizeOptionalTaxpayerNumber(request.TaxpayerNumber);
var amount = FinanceInvoiceMapping.NormalizeAmount(request.Amount);
var orderNo = FinanceInvoiceMapping.NormalizeOrderNo(request.OrderNo);
var contactEmail = FinanceInvoiceMapping.NormalizeOptionalEmail(request.ContactEmail);
var contactPhone = FinanceInvoiceMapping.NormalizeOptionalPhone(request.ContactPhone);
var applyRemark = FinanceInvoiceMapping.NormalizeOptionalRemark(request.ApplyRemark, "applyRemark");
var appliedAt = request.AppliedAt.HasValue
? FinanceInvoiceMapping.NormalizeUtc(request.AppliedAt.Value)
: DateTime.UtcNow;
if (invoiceType == TenantInvoiceType.Special && string.IsNullOrWhiteSpace(taxpayerNumber))
{
throw new BusinessException(ErrorCodes.BadRequest, "专票必须填写纳税人识别号");
}
var setting = await repository.GetSettingAsync(tenantId, cancellationToken)
?? throw new BusinessException(ErrorCodes.BadRequest, "请先完成发票设置");
EnsureTypeEnabled(setting, invoiceType);
var invoiceNo = await GenerateInvoiceNoAsync(tenantId, cancellationToken);
var entity = FinanceInvoiceDtoFactory.CreateRecordEntity(
tenantId,
invoiceNo,
applicantName,
companyName,
taxpayerNumber,
invoiceType,
amount,
orderNo,
contactEmail,
contactPhone,
applyRemark,
appliedAt);
if (setting.EnableAutoIssue && amount <= setting.AutoIssueMaxAmount)
{
entity.Status = TenantInvoiceStatus.Issued;
entity.IssuedAt = DateTime.UtcNow;
entity.IssuedByUserId = currentUserAccessor.IsAuthenticated ? currentUserAccessor.UserId : null;
entity.IssueRemark = "系统自动开票";
}
await repository.AddRecordAsync(entity, cancellationToken);
await repository.SaveChangesAsync(cancellationToken);
return FinanceInvoiceDtoFactory.ToRecordDetailDto(entity);
}
private static void EnsureTypeEnabled(TenantInvoiceSetting setting, TenantInvoiceType type)
{
if (type == TenantInvoiceType.Normal && !setting.EnableElectronicNormalInvoice)
{
throw new BusinessException(ErrorCodes.BadRequest, "电子普通发票未启用");
}
if (type == TenantInvoiceType.Special && !setting.EnableElectronicSpecialInvoice)
{
throw new BusinessException(ErrorCodes.BadRequest, "电子专用发票未启用");
}
}
private async Task<string> GenerateInvoiceNoAsync(long tenantId, CancellationToken cancellationToken)
{
for (var index = 0; index < 10; index += 1)
{
var invoiceNo = FinanceInvoiceMapping.BuildInvoiceNo(DateTime.UtcNow);
var exists = await repository.ExistsInvoiceNoAsync(tenantId, invoiceNo, cancellationToken);
if (!exists)
{
return invoiceNo;
}
}
throw new BusinessException(ErrorCodes.BadRequest, "生成发票号码失败,请稍后重试");
}
}

View File

@@ -0,0 +1,30 @@
using MediatR;
using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
using TakeoutSaaS.Application.App.Finance.Invoice.Queries;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Finance.Invoice.Handlers;
/// <summary>
/// 发票记录详情查询处理器。
/// </summary>
public sealed class GetFinanceInvoiceRecordDetailQueryHandler(
ITenantInvoiceRepository repository,
ITenantProvider tenantProvider)
: IRequestHandler<GetFinanceInvoiceRecordDetailQuery, FinanceInvoiceRecordDetailDto>
{
/// <inheritdoc />
public async Task<FinanceInvoiceRecordDetailDto> Handle(
GetFinanceInvoiceRecordDetailQuery request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var record = await repository.FindRecordByIdAsync(tenantId, request.RecordId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "发票记录不存在");
return FinanceInvoiceDtoFactory.ToRecordDetailDto(record);
}
}

View File

@@ -0,0 +1,50 @@
using MediatR;
using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
using TakeoutSaaS.Application.App.Finance.Invoice.Queries;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Finance.Invoice.Handlers;
/// <summary>
/// 发票记录分页查询处理器。
/// </summary>
public sealed class GetFinanceInvoiceRecordListQueryHandler(
ITenantInvoiceRepository repository,
ITenantProvider tenantProvider)
: IRequestHandler<GetFinanceInvoiceRecordListQuery, FinanceInvoiceRecordListResultDto>
{
/// <inheritdoc />
public async Task<FinanceInvoiceRecordListResultDto> Handle(
GetFinanceInvoiceRecordListQuery request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var keyword = FinanceInvoiceMapping.NormalizeOptionalKeyword(request.Keyword);
var (startUtc, endUtc) = FinanceInvoiceMapping.NormalizeDateRange(request.StartDateUtc, request.EndDateUtc);
var page = Math.Max(1, request.Page);
var pageSize = Math.Clamp(request.PageSize, 1, 200);
var (items, totalCount) = await repository.SearchRecordsAsync(
tenantId,
startUtc,
endUtc,
request.Status,
request.InvoiceType,
keyword,
page,
pageSize,
cancellationToken);
var statsSnapshot = await repository.GetStatsAsync(tenantId, DateTime.UtcNow, cancellationToken);
return new FinanceInvoiceRecordListResultDto
{
Items = items.Select(FinanceInvoiceDtoFactory.ToRecordDto).ToList(),
Page = page,
PageSize = pageSize,
TotalCount = totalCount,
Stats = FinanceInvoiceDtoFactory.ToStatsDto(statsSnapshot)
};
}
}

View File

@@ -0,0 +1,29 @@
using MediatR;
using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
using TakeoutSaaS.Application.App.Finance.Invoice.Queries;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Finance.Invoice.Handlers;
/// <summary>
/// 发票设置详情查询处理器。
/// </summary>
public sealed class GetFinanceInvoiceSettingDetailQueryHandler(
ITenantInvoiceRepository repository,
ITenantProvider tenantProvider)
: IRequestHandler<GetFinanceInvoiceSettingDetailQuery, FinanceInvoiceSettingDto>
{
/// <inheritdoc />
public async Task<FinanceInvoiceSettingDto> Handle(
GetFinanceInvoiceSettingDetailQuery request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var setting = await repository.GetSettingAsync(tenantId, cancellationToken);
return setting is null
? FinanceInvoiceDtoFactory.CreateDefaultSettingDto()
: FinanceInvoiceDtoFactory.ToSettingDto(setting);
}
}

View File

@@ -0,0 +1,65 @@
using MediatR;
using TakeoutSaaS.Application.App.Finance.Invoice.Commands;
using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Security;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Finance.Invoice.Handlers;
/// <summary>
/// 发票开票处理器。
/// </summary>
public sealed class IssueFinanceInvoiceRecordCommandHandler(
ITenantInvoiceRepository repository,
ITenantProvider tenantProvider,
ICurrentUserAccessor currentUserAccessor)
: IRequestHandler<IssueFinanceInvoiceRecordCommand, FinanceInvoiceIssueResultDto>
{
/// <inheritdoc />
public async Task<FinanceInvoiceIssueResultDto> Handle(
IssueFinanceInvoiceRecordCommand request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var record = await repository.FindRecordByIdAsync(tenantId, request.RecordId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "发票记录不存在");
if (record.Status != TenantInvoiceStatus.Pending)
{
throw new BusinessException(ErrorCodes.BadRequest, "仅待开票记录允许开票");
}
var setting = await repository.GetSettingAsync(tenantId, cancellationToken)
?? throw new BusinessException(ErrorCodes.BadRequest, "请先完成发票设置");
EnsureTypeEnabled(setting, record.InvoiceType);
record.ContactEmail = FinanceInvoiceMapping.NormalizeOptionalEmail(request.ContactEmail) ?? record.ContactEmail;
record.IssueRemark = FinanceInvoiceMapping.NormalizeOptionalRemark(request.IssueRemark, "issueRemark");
record.Status = TenantInvoiceStatus.Issued;
record.IssuedAt = DateTime.UtcNow;
record.IssuedByUserId = currentUserAccessor.IsAuthenticated ? currentUserAccessor.UserId : null;
await repository.UpdateRecordAsync(record, cancellationToken);
await repository.SaveChangesAsync(cancellationToken);
return FinanceInvoiceDtoFactory.ToIssueResultDto(record);
}
private static void EnsureTypeEnabled(TenantInvoiceSetting setting, TenantInvoiceType type)
{
if (type == TenantInvoiceType.Normal && !setting.EnableElectronicNormalInvoice)
{
throw new BusinessException(ErrorCodes.BadRequest, "电子普通发票未启用");
}
if (type == TenantInvoiceType.Special && !setting.EnableElectronicSpecialInvoice)
{
throw new BusinessException(ErrorCodes.BadRequest, "电子专用发票未启用");
}
}
}

View File

@@ -0,0 +1,72 @@
using MediatR;
using TakeoutSaaS.Application.App.Finance.Invoice.Commands;
using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Finance.Invoice.Handlers;
/// <summary>
/// 保存发票设置处理器。
/// </summary>
public sealed class SaveFinanceInvoiceSettingCommandHandler(
ITenantInvoiceRepository repository,
ITenantProvider tenantProvider)
: IRequestHandler<SaveFinanceInvoiceSettingCommand, FinanceInvoiceSettingDto>
{
/// <inheritdoc />
public async Task<FinanceInvoiceSettingDto> Handle(
SaveFinanceInvoiceSettingCommand request,
CancellationToken cancellationToken)
{
if (!request.EnableElectronicNormalInvoice && !request.EnableElectronicSpecialInvoice)
{
throw new BusinessException(ErrorCodes.BadRequest, "至少启用一种发票类型");
}
var tenantId = tenantProvider.GetCurrentTenantId();
var companyName = FinanceInvoiceMapping.NormalizeCompanyName(request.CompanyName);
var taxpayerNumber = FinanceInvoiceMapping.NormalizeTaxpayerNumber(request.TaxpayerNumber);
var registeredAddress = FinanceInvoiceMapping.NormalizeOptionalRemark(request.RegisteredAddress, "registeredAddress", 256);
var registeredPhone = FinanceInvoiceMapping.NormalizeOptionalPhone(request.RegisteredPhone);
var bankName = FinanceInvoiceMapping.NormalizeOptionalRemark(request.BankName, "bankName", 128);
var bankAccount = FinanceInvoiceMapping.NormalizeOptionalRemark(request.BankAccount, "bankAccount", 64);
var autoIssueMaxAmount = FinanceInvoiceMapping.NormalizeAutoIssueMaxAmount(request.AutoIssueMaxAmount);
var setting = await repository.GetSettingAsync(tenantId, cancellationToken);
if (setting is null)
{
setting = FinanceInvoiceDtoFactory.CreateSettingEntity(
request,
companyName,
taxpayerNumber,
registeredAddress,
registeredPhone,
bankName,
bankAccount,
autoIssueMaxAmount);
await repository.AddSettingAsync(setting, cancellationToken);
}
else
{
FinanceInvoiceDtoFactory.ApplySettingChanges(
setting,
request,
companyName,
taxpayerNumber,
registeredAddress,
registeredPhone,
bankName,
bankAccount,
autoIssueMaxAmount);
await repository.UpdateSettingAsync(setting, cancellationToken);
}
await repository.SaveChangesAsync(cancellationToken);
return FinanceInvoiceDtoFactory.ToSettingDto(setting);
}
}

View File

@@ -0,0 +1,46 @@
using MediatR;
using TakeoutSaaS.Application.App.Finance.Invoice.Commands;
using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Security;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Finance.Invoice.Handlers;
/// <summary>
/// 发票作废处理器。
/// </summary>
public sealed class VoidFinanceInvoiceRecordCommandHandler(
ITenantInvoiceRepository repository,
ITenantProvider tenantProvider,
ICurrentUserAccessor currentUserAccessor)
: IRequestHandler<VoidFinanceInvoiceRecordCommand, FinanceInvoiceRecordDetailDto>
{
/// <inheritdoc />
public async Task<FinanceInvoiceRecordDetailDto> Handle(
VoidFinanceInvoiceRecordCommand request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var record = await repository.FindRecordByIdAsync(tenantId, request.RecordId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "发票记录不存在");
if (record.Status != TenantInvoiceStatus.Issued)
{
throw new BusinessException(ErrorCodes.BadRequest, "仅已开票记录允许作废");
}
record.Status = TenantInvoiceStatus.Voided;
record.VoidReason = FinanceInvoiceMapping.NormalizeVoidReason(request.VoidReason);
record.VoidedAt = DateTime.UtcNow;
record.VoidedByUserId = currentUserAccessor.IsAuthenticated ? currentUserAccessor.UserId : null;
await repository.UpdateRecordAsync(record, cancellationToken);
await repository.SaveChangesAsync(cancellationToken);
return FinanceInvoiceDtoFactory.ToRecordDetailDto(record);
}
}

View File

@@ -0,0 +1,15 @@
using MediatR;
using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
namespace TakeoutSaaS.Application.App.Finance.Invoice.Queries;
/// <summary>
/// 查询发票记录详情。
/// </summary>
public sealed class GetFinanceInvoiceRecordDetailQuery : IRequest<FinanceInvoiceRecordDetailDto>
{
/// <summary>
/// 发票记录 ID。
/// </summary>
public long RecordId { get; init; }
}

View File

@@ -0,0 +1,46 @@
using MediatR;
using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
using TakeoutSaaS.Domain.Tenants.Enums;
namespace TakeoutSaaS.Application.App.Finance.Invoice.Queries;
/// <summary>
/// 查询发票记录分页。
/// </summary>
public sealed class GetFinanceInvoiceRecordListQuery : IRequest<FinanceInvoiceRecordListResultDto>
{
/// <summary>
/// 开始日期UTC
/// </summary>
public DateTime? StartDateUtc { get; init; }
/// <summary>
/// 结束日期UTC
/// </summary>
public DateTime? EndDateUtc { get; init; }
/// <summary>
/// 状态筛选。
/// </summary>
public TenantInvoiceStatus? Status { get; init; }
/// <summary>
/// 类型筛选。
/// </summary>
public TenantInvoiceType? InvoiceType { get; init; }
/// <summary>
/// 关键词。
/// </summary>
public string? Keyword { get; init; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 20;
}

View File

@@ -0,0 +1,11 @@
using MediatR;
using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
namespace TakeoutSaaS.Application.App.Finance.Invoice.Queries;
/// <summary>
/// 查询发票设置详情。
/// </summary>
public sealed class GetFinanceInvoiceSettingDetailQuery : IRequest<FinanceInvoiceSettingDto>
{
}

View File

@@ -0,0 +1,35 @@
using TakeoutSaaS.Domain.Finance.Enums;
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Finance.Entities;
/// <summary>
/// 成本录入月度汇总实体(按维度 + 分类)。
/// </summary>
public sealed class FinanceCostEntry : MultiTenantEntityBase
{
/// <summary>
/// 统计维度。
/// </summary>
public FinanceCostDimension Dimension { get; set; } = FinanceCostDimension.Tenant;
/// <summary>
/// 门店标识(租户汇总维度为空)。
/// </summary>
public long? StoreId { get; set; }
/// <summary>
/// 成本月份(统一存储为 UTC 每月第一天 00:00:00
/// </summary>
public DateTime CostMonth { get; set; }
/// <summary>
/// 成本分类。
/// </summary>
public FinanceCostCategory Category { get; set; }
/// <summary>
/// 分类总金额。
/// </summary>
public decimal TotalAmount { get; set; }
}

View File

@@ -0,0 +1,60 @@
using TakeoutSaaS.Domain.Finance.Enums;
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Finance.Entities;
/// <summary>
/// 成本录入明细项实体。
/// </summary>
public sealed class FinanceCostEntryItem : MultiTenantEntityBase
{
/// <summary>
/// 关联汇总行标识。
/// </summary>
public long EntryId { get; set; }
/// <summary>
/// 统计维度。
/// </summary>
public FinanceCostDimension Dimension { get; set; } = FinanceCostDimension.Tenant;
/// <summary>
/// 门店标识(租户汇总维度为空)。
/// </summary>
public long? StoreId { get; set; }
/// <summary>
/// 成本月份(统一存储为 UTC 每月第一天 00:00:00
/// </summary>
public DateTime CostMonth { get; set; }
/// <summary>
/// 成本分类。
/// </summary>
public FinanceCostCategory Category { get; set; }
/// <summary>
/// 明细名称。
/// </summary>
public string ItemName { get; set; } = string.Empty;
/// <summary>
/// 明细金额。
/// </summary>
public decimal Amount { get; set; }
/// <summary>
/// 数量(人工类可用)。
/// </summary>
public decimal? Quantity { get; set; }
/// <summary>
/// 单价(人工类可用)。
/// </summary>
public decimal? UnitPrice { get; set; }
/// <summary>
/// 排序值。
/// </summary>
public int SortOrder { get; set; } = 100;
}

View File

@@ -0,0 +1,27 @@
namespace TakeoutSaaS.Domain.Finance.Enums;
/// <summary>
/// 成本分类。
/// </summary>
public enum FinanceCostCategory
{
/// <summary>
/// 食材原料。
/// </summary>
FoodMaterial = 1,
/// <summary>
/// 人工成本。
/// </summary>
Labor = 2,
/// <summary>
/// 固定费用。
/// </summary>
FixedExpense = 3,
/// <summary>
/// 包装耗材。
/// </summary>
PackagingConsumable = 4
}

View File

@@ -0,0 +1,17 @@
namespace TakeoutSaaS.Domain.Finance.Enums;
/// <summary>
/// 成本统计维度。
/// </summary>
public enum FinanceCostDimension
{
/// <summary>
/// 租户汇总维度。
/// </summary>
Tenant = 1,
/// <summary>
/// 门店维度。
/// </summary>
Store = 2
}

View File

@@ -0,0 +1,214 @@
using TakeoutSaaS.Domain.Finance.Enums;
namespace TakeoutSaaS.Domain.Finance.Models;
/// <summary>
/// 成本明细项快照。
/// </summary>
public sealed record FinanceCostDetailItemSnapshot
{
/// <summary>
/// 明细标识。
/// </summary>
public long? ItemId { get; init; }
/// <summary>
/// 明细名称。
/// </summary>
public required string ItemName { get; init; }
/// <summary>
/// 明细金额。
/// </summary>
public decimal Amount { get; init; }
/// <summary>
/// 数量(人工类可用)。
/// </summary>
public decimal? Quantity { get; init; }
/// <summary>
/// 单价(人工类可用)。
/// </summary>
public decimal? UnitPrice { get; init; }
/// <summary>
/// 排序值。
/// </summary>
public int SortOrder { get; init; }
}
/// <summary>
/// 成本分类快照。
/// </summary>
public sealed record FinanceCostCategorySnapshot
{
/// <summary>
/// 成本分类。
/// </summary>
public required FinanceCostCategory Category { get; init; }
/// <summary>
/// 分类总金额。
/// </summary>
public decimal TotalAmount { get; init; }
/// <summary>
/// 分类明细。
/// </summary>
public IReadOnlyList<FinanceCostDetailItemSnapshot> Items { get; init; } = [];
}
/// <summary>
/// 成本录入页快照。
/// </summary>
public sealed record FinanceCostMonthSnapshot
{
/// <summary>
/// 统计维度。
/// </summary>
public required FinanceCostDimension Dimension { get; init; }
/// <summary>
/// 门店标识(租户维度为空)。
/// </summary>
public long? StoreId { get; init; }
/// <summary>
/// 成本月份。
/// </summary>
public required DateTime CostMonth { get; init; }
/// <summary>
/// 本月营业额。
/// </summary>
public decimal MonthRevenue { get; init; }
/// <summary>
/// 成本分类集合。
/// </summary>
public IReadOnlyList<FinanceCostCategorySnapshot> Categories { get; init; } = [];
}
/// <summary>
/// 月度趋势行。
/// </summary>
public sealed record FinanceCostTrendSnapshot
{
/// <summary>
/// 月份起始时间UTC
/// </summary>
public required DateTime MonthStartUtc { get; init; }
/// <summary>
/// 月度总成本。
/// </summary>
public decimal TotalCost { get; init; }
/// <summary>
/// 月度营业额。
/// </summary>
public decimal Revenue { get; init; }
}
/// <summary>
/// 月度成本明细表行。
/// </summary>
public sealed record FinanceCostMonthlyDetailSnapshot
{
/// <summary>
/// 月份起始时间UTC
/// </summary>
public required DateTime MonthStartUtc { get; init; }
/// <summary>
/// 食材成本。
/// </summary>
public decimal FoodAmount { get; init; }
/// <summary>
/// 人工成本。
/// </summary>
public decimal LaborAmount { get; init; }
/// <summary>
/// 固定费用。
/// </summary>
public decimal FixedAmount { get; init; }
/// <summary>
/// 包装耗材。
/// </summary>
public decimal PackagingAmount { get; init; }
/// <summary>
/// 月度总成本。
/// </summary>
public decimal TotalCost { get; init; }
/// <summary>
/// 月度营业额。
/// </summary>
public decimal Revenue { get; init; }
}
/// <summary>
/// 成本分析快照。
/// </summary>
public sealed record FinanceCostAnalysisSnapshot
{
/// <summary>
/// 统计维度。
/// </summary>
public required FinanceCostDimension Dimension { get; init; }
/// <summary>
/// 门店标识(租户维度为空)。
/// </summary>
public long? StoreId { get; init; }
/// <summary>
/// 当前月份。
/// </summary>
public required DateTime CostMonth { get; init; }
/// <summary>
/// 当前月总成本。
/// </summary>
public decimal CurrentTotalCost { get; init; }
/// <summary>
/// 当前月食材成本。
/// </summary>
public decimal CurrentFoodAmount { get; init; }
/// <summary>
/// 当前月营业额。
/// </summary>
public decimal CurrentRevenue { get; init; }
/// <summary>
/// 当前月支付成功订单数。
/// </summary>
public int CurrentPaidOrderCount { get; init; }
/// <summary>
/// 环比变化率(%)。
/// </summary>
public decimal MonthOnMonthChangeRate { get; init; }
/// <summary>
/// 分类构成。
/// </summary>
public IReadOnlyList<FinanceCostCategorySnapshot> CurrentCategories { get; init; } = [];
/// <summary>
/// 近 N 月趋势。
/// </summary>
public IReadOnlyList<FinanceCostTrendSnapshot> Trends { get; init; } = [];
/// <summary>
/// 明细表数据。
/// </summary>
public IReadOnlyList<FinanceCostMonthlyDetailSnapshot> DetailRows { get; init; } = [];
}

View File

@@ -0,0 +1,42 @@
using TakeoutSaaS.Domain.Finance.Enums;
using TakeoutSaaS.Domain.Finance.Models;
namespace TakeoutSaaS.Domain.Finance.Repositories;
/// <summary>
/// 成本管理仓储契约。
/// </summary>
public interface IFinanceCostRepository
{
/// <summary>
/// 获取成本录入页月度快照。
/// </summary>
Task<FinanceCostMonthSnapshot> GetMonthSnapshotAsync(
long tenantId,
FinanceCostDimension dimension,
long? storeId,
DateTime costMonth,
CancellationToken cancellationToken = default);
/// <summary>
/// 保存月度成本录入快照。
/// </summary>
Task SaveMonthSnapshotAsync(
long tenantId,
FinanceCostDimension dimension,
long? storeId,
DateTime costMonth,
IReadOnlyList<FinanceCostCategorySnapshot> categories,
CancellationToken cancellationToken = default);
/// <summary>
/// 获取成本分析页快照。
/// </summary>
Task<FinanceCostAnalysisSnapshot> GetAnalysisSnapshotAsync(
long tenantId,
FinanceCostDimension dimension,
long? storeId,
DateTime costMonth,
int trendMonthCount,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,100 @@
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Tenants.Entities;
/// <summary>
/// 租户发票记录。
/// </summary>
public sealed class TenantInvoiceRecord : MultiTenantEntityBase
{
/// <summary>
/// 发票号码。
/// </summary>
public string InvoiceNo { get; set; } = string.Empty;
/// <summary>
/// 申请人。
/// </summary>
public string ApplicantName { get; set; } = string.Empty;
/// <summary>
/// 开票抬头(公司名)。
/// </summary>
public string CompanyName { get; set; } = string.Empty;
/// <summary>
/// 纳税人识别号快照。
/// </summary>
public string? TaxpayerNumber { get; set; }
/// <summary>
/// 发票类型。
/// </summary>
public TenantInvoiceType InvoiceType { get; set; } = TenantInvoiceType.Normal;
/// <summary>
/// 开票金额。
/// </summary>
public decimal Amount { get; set; }
/// <summary>
/// 关联订单号。
/// </summary>
public string OrderNo { get; set; } = string.Empty;
/// <summary>
/// 接收邮箱。
/// </summary>
public string? ContactEmail { get; set; }
/// <summary>
/// 联系电话。
/// </summary>
public string? ContactPhone { get; set; }
/// <summary>
/// 申请备注。
/// </summary>
public string? ApplyRemark { get; set; }
/// <summary>
/// 发票状态。
/// </summary>
public TenantInvoiceStatus Status { get; set; } = TenantInvoiceStatus.Pending;
/// <summary>
/// 申请时间UTC
/// </summary>
public DateTime AppliedAt { get; set; } = DateTime.UtcNow;
/// <summary>
/// 开票时间UTC
/// </summary>
public DateTime? IssuedAt { get; set; }
/// <summary>
/// 开票人 ID。
/// </summary>
public long? IssuedByUserId { get; set; }
/// <summary>
/// 开票备注。
/// </summary>
public string? IssueRemark { get; set; }
/// <summary>
/// 作废时间UTC
/// </summary>
public DateTime? VoidedAt { get; set; }
/// <summary>
/// 作废人 ID。
/// </summary>
public long? VoidedByUserId { get; set; }
/// <summary>
/// 作废原因。
/// </summary>
public string? VoidReason { get; set; }
}

View File

@@ -0,0 +1,59 @@
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Tenants.Entities;
/// <summary>
/// 租户发票开票基础设置。
/// </summary>
public sealed class TenantInvoiceSetting : MultiTenantEntityBase
{
/// <summary>
/// 企业名称。
/// </summary>
public string CompanyName { get; set; } = string.Empty;
/// <summary>
/// 纳税人识别号。
/// </summary>
public string TaxpayerNumber { get; set; } = string.Empty;
/// <summary>
/// 注册地址。
/// </summary>
public string? RegisteredAddress { get; set; }
/// <summary>
/// 注册电话。
/// </summary>
public string? RegisteredPhone { get; set; }
/// <summary>
/// 开户银行。
/// </summary>
public string? BankName { get; set; }
/// <summary>
/// 银行账号。
/// </summary>
public string? BankAccount { get; set; }
/// <summary>
/// 是否启用电子普通发票。
/// </summary>
public bool EnableElectronicNormalInvoice { get; set; } = true;
/// <summary>
/// 是否启用电子专用发票。
/// </summary>
public bool EnableElectronicSpecialInvoice { get; set; }
/// <summary>
/// 是否启用自动开票。
/// </summary>
public bool EnableAutoIssue { get; set; }
/// <summary>
/// 自动开票单张最大金额。
/// </summary>
public decimal AutoIssueMaxAmount { get; set; } = 10_000m;
}

View File

@@ -0,0 +1,22 @@
namespace TakeoutSaaS.Domain.Tenants.Enums;
/// <summary>
/// 租户发票状态。
/// </summary>
public enum TenantInvoiceStatus
{
/// <summary>
/// 待开票。
/// </summary>
Pending = 1,
/// <summary>
/// 已开票。
/// </summary>
Issued = 2,
/// <summary>
/// 已作废。
/// </summary>
Voided = 3
}

View File

@@ -0,0 +1,17 @@
namespace TakeoutSaaS.Domain.Tenants.Enums;
/// <summary>
/// 租户发票类型。
/// </summary>
public enum TenantInvoiceType
{
/// <summary>
/// 电子普通发票。
/// </summary>
Normal = 1,
/// <summary>
/// 电子专用发票。
/// </summary>
Special = 2
}

View File

@@ -0,0 +1,104 @@
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
namespace TakeoutSaaS.Domain.Tenants.Repositories;
/// <summary>
/// 租户发票仓储契约。
/// </summary>
public interface ITenantInvoiceRepository
{
/// <summary>
/// 查询租户发票设置。
/// </summary>
Task<TenantInvoiceSetting?> GetSettingAsync(long tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// 新增发票设置。
/// </summary>
Task AddSettingAsync(TenantInvoiceSetting entity, CancellationToken cancellationToken = default);
/// <summary>
/// 更新发票设置。
/// </summary>
Task UpdateSettingAsync(TenantInvoiceSetting entity, CancellationToken cancellationToken = default);
/// <summary>
/// 分页查询发票记录。
/// </summary>
Task<(IReadOnlyList<TenantInvoiceRecord> Items, int TotalCount)> SearchRecordsAsync(
long tenantId,
DateTime? startUtc,
DateTime? endUtc,
TenantInvoiceStatus? status,
TenantInvoiceType? invoiceType,
string? keyword,
int page,
int pageSize,
CancellationToken cancellationToken = default);
/// <summary>
/// 获取发票页统计。
/// </summary>
Task<TenantInvoiceRecordStatsSnapshot> GetStatsAsync(
long tenantId,
DateTime nowUtc,
CancellationToken cancellationToken = default);
/// <summary>
/// 根据标识查询发票记录。
/// </summary>
Task<TenantInvoiceRecord?> FindRecordByIdAsync(
long tenantId,
long recordId,
CancellationToken cancellationToken = default);
/// <summary>
/// 判断租户下发票号码是否已存在。
/// </summary>
Task<bool> ExistsInvoiceNoAsync(
long tenantId,
string invoiceNo,
CancellationToken cancellationToken = default);
/// <summary>
/// 新增发票记录。
/// </summary>
Task AddRecordAsync(TenantInvoiceRecord entity, CancellationToken cancellationToken = default);
/// <summary>
/// 更新发票记录。
/// </summary>
Task UpdateRecordAsync(TenantInvoiceRecord entity, CancellationToken cancellationToken = default);
/// <summary>
/// 持久化变更。
/// </summary>
Task SaveChangesAsync(CancellationToken cancellationToken = default);
}
/// <summary>
/// 发票页面统计快照。
/// </summary>
public sealed record TenantInvoiceRecordStatsSnapshot
{
/// <summary>
/// 本月已开票金额。
/// </summary>
public decimal CurrentMonthIssuedAmount { get; init; }
/// <summary>
/// 本月已开票张数。
/// </summary>
public int CurrentMonthIssuedCount { get; init; }
/// <summary>
/// 待开票张数。
/// </summary>
public int PendingCount { get; init; }
/// <summary>
/// 已作废张数。
/// </summary>
public int VoidedCount { get; init; }
}

View File

@@ -55,6 +55,7 @@ public static class AppServiceCollectionExtensions
services.AddScoped<IMemberMessageReachRepository, EfMemberMessageReachRepository>();
services.AddScoped<IStoredCardRepository, EfStoredCardRepository>();
services.AddScoped<IOrderRepository, EfOrderRepository>();
services.AddScoped<IFinanceCostRepository, EfFinanceCostRepository>();
services.AddScoped<IFinanceTransactionRepository, EfFinanceTransactionRepository>();
services.AddScoped<IPaymentRepository, EfPaymentRepository>();
services.AddScoped<IDeliveryRepository, EfDeliveryRepository>();
@@ -68,6 +69,7 @@ public static class AppServiceCollectionExtensions
services.AddScoped<ITenantQuotaUsageRepository, EfTenantQuotaUsageRepository>();
services.AddScoped<ITenantQuotaUsageHistoryRepository, EfTenantQuotaUsageHistoryRepository>();
services.AddScoped<ITenantVisibilityRoleRuleRepository, TenantVisibilityRoleRuleRepository>();
services.AddScoped<ITenantInvoiceRepository, EfTenantInvoiceRepository>();
services.AddScoped<IInventoryRepository, EfInventoryRepository>();
services.AddScoped<IQuotaPackageRepository, EfQuotaPackageRepository>();
services.AddScoped<IStatisticsRepository, EfStatisticsRepository>();

View File

@@ -8,6 +8,7 @@ using TakeoutSaaS.Domain.Deliveries.Entities;
using TakeoutSaaS.Domain.Distribution.Entities;
using TakeoutSaaS.Domain.Common.Enums;
using TakeoutSaaS.Domain.Engagement.Entities;
using TakeoutSaaS.Domain.Finance.Entities;
using TakeoutSaaS.Domain.GroupBuying.Entities;
using TakeoutSaaS.Domain.Inventory.Entities;
using TakeoutSaaS.Domain.Membership.Entities;
@@ -94,6 +95,22 @@ public sealed class TakeoutAppDbContext(
/// </summary>
public DbSet<TenantVisibilityRoleRule> TenantVisibilityRoleRules => Set<TenantVisibilityRoleRule>();
/// <summary>
/// 租户发票设置。
/// </summary>
public DbSet<TenantInvoiceSetting> TenantInvoiceSettings => Set<TenantInvoiceSetting>();
/// <summary>
/// 租户发票记录。
/// </summary>
public DbSet<TenantInvoiceRecord> TenantInvoiceRecords => Set<TenantInvoiceRecord>();
/// <summary>
/// 成本录入汇总。
/// </summary>
public DbSet<FinanceCostEntry> FinanceCostEntries => Set<FinanceCostEntry>();
/// <summary>
/// 成本录入明细。
/// </summary>
public DbSet<FinanceCostEntryItem> FinanceCostEntryItems => Set<FinanceCostEntryItem>();
/// <summary>
/// 配额包定义。
/// </summary>
public DbSet<QuotaPackage> QuotaPackages => Set<QuotaPackage>();
@@ -525,6 +542,10 @@ public sealed class TakeoutAppDbContext(
ConfigureTenantAnnouncementRead(modelBuilder.Entity<TenantAnnouncementRead>());
ConfigureTenantVerificationProfile(modelBuilder.Entity<TenantVerificationProfile>());
ConfigureTenantVisibilityRoleRule(modelBuilder.Entity<TenantVisibilityRoleRule>());
ConfigureTenantInvoiceSetting(modelBuilder.Entity<TenantInvoiceSetting>());
ConfigureTenantInvoiceRecord(modelBuilder.Entity<TenantInvoiceRecord>());
ConfigureFinanceCostEntry(modelBuilder.Entity<FinanceCostEntry>());
ConfigureFinanceCostEntryItem(modelBuilder.Entity<FinanceCostEntryItem>());
ConfigureQuotaPackage(modelBuilder.Entity<QuotaPackage>());
ConfigureTenantQuotaPackagePurchase(modelBuilder.Entity<TenantQuotaPackagePurchase>());
ConfigureMerchantDocument(modelBuilder.Entity<MerchantDocument>());
@@ -1042,6 +1063,92 @@ public sealed class TakeoutAppDbContext(
builder.HasIndex(x => x.TenantId).IsUnique();
}
private static void ConfigureTenantInvoiceSetting(EntityTypeBuilder<TenantInvoiceSetting> builder)
{
builder.ToTable("finance_invoice_settings");
builder.HasKey(x => x.Id);
builder.Property(x => x.TenantId).IsRequired();
builder.Property(x => x.CompanyName).HasMaxLength(128).IsRequired();
builder.Property(x => x.TaxpayerNumber).HasMaxLength(64).IsRequired();
builder.Property(x => x.RegisteredAddress).HasMaxLength(256);
builder.Property(x => x.RegisteredPhone).HasMaxLength(32);
builder.Property(x => x.BankName).HasMaxLength(128);
builder.Property(x => x.BankAccount).HasMaxLength(64);
builder.Property(x => x.EnableElectronicNormalInvoice).IsRequired();
builder.Property(x => x.EnableElectronicSpecialInvoice).IsRequired();
builder.Property(x => x.EnableAutoIssue).IsRequired();
builder.Property(x => x.AutoIssueMaxAmount).HasPrecision(18, 2).IsRequired();
builder.HasIndex(x => x.TenantId).IsUnique();
}
private static void ConfigureTenantInvoiceRecord(EntityTypeBuilder<TenantInvoiceRecord> builder)
{
builder.ToTable("finance_invoice_records");
builder.HasKey(x => x.Id);
builder.Property(x => x.TenantId).IsRequired();
builder.Property(x => x.InvoiceNo).HasMaxLength(32).IsRequired();
builder.Property(x => x.ApplicantName).HasMaxLength(64).IsRequired();
builder.Property(x => x.CompanyName).HasMaxLength(128).IsRequired();
builder.Property(x => x.TaxpayerNumber).HasMaxLength(64);
builder.Property(x => x.InvoiceType).HasConversion<int>().IsRequired();
builder.Property(x => x.Amount).HasPrecision(18, 2).IsRequired();
builder.Property(x => x.OrderNo).HasMaxLength(32).IsRequired();
builder.Property(x => x.ContactEmail).HasMaxLength(128);
builder.Property(x => x.ContactPhone).HasMaxLength(32);
builder.Property(x => x.ApplyRemark).HasMaxLength(256);
builder.Property(x => x.Status).HasConversion<int>().IsRequired();
builder.Property(x => x.AppliedAt).IsRequired();
builder.Property(x => x.IssueRemark).HasMaxLength(256);
builder.Property(x => x.VoidReason).HasMaxLength(256);
builder.HasIndex(x => new { x.TenantId, x.InvoiceNo }).IsUnique();
builder.HasIndex(x => new { x.TenantId, x.OrderNo });
builder.HasIndex(x => new { x.TenantId, x.Status, x.AppliedAt });
builder.HasIndex(x => new { x.TenantId, x.Status, x.IssuedAt });
builder.HasIndex(x => new { x.TenantId, x.InvoiceType, x.AppliedAt });
}
private static void ConfigureFinanceCostEntry(EntityTypeBuilder<FinanceCostEntry> builder)
{
builder.ToTable("finance_cost_entries");
builder.HasKey(x => x.Id);
builder.Property(x => x.TenantId).IsRequired();
builder.Property(x => x.Dimension).HasConversion<int>().IsRequired();
builder.Property(x => x.StoreId);
builder.Property(x => x.CostMonth).IsRequired();
builder.Property(x => x.Category).HasConversion<int>().IsRequired();
builder.Property(x => x.TotalAmount).HasPrecision(18, 2);
builder.HasIndex(x => new { x.TenantId, x.Dimension, x.StoreId, x.CostMonth, x.Category }).IsUnique();
builder.HasIndex(x => new { x.TenantId, x.Dimension, x.StoreId, x.CostMonth });
}
private static void ConfigureFinanceCostEntryItem(EntityTypeBuilder<FinanceCostEntryItem> builder)
{
builder.ToTable("finance_cost_entry_items");
builder.HasKey(x => x.Id);
builder.Property(x => x.TenantId).IsRequired();
builder.Property(x => x.EntryId).IsRequired();
builder.Property(x => x.Dimension).HasConversion<int>().IsRequired();
builder.Property(x => x.StoreId);
builder.Property(x => x.CostMonth).IsRequired();
builder.Property(x => x.Category).HasConversion<int>().IsRequired();
builder.Property(x => x.ItemName).HasMaxLength(64).IsRequired();
builder.Property(x => x.Amount).HasPrecision(18, 2);
builder.Property(x => x.Quantity).HasPrecision(18, 2);
builder.Property(x => x.UnitPrice).HasPrecision(18, 2);
builder.Property(x => x.SortOrder).HasDefaultValue(100);
builder.HasOne<FinanceCostEntry>()
.WithMany()
.HasForeignKey(x => x.EntryId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(x => x.EntryId);
builder.HasIndex(x => new { x.TenantId, x.Dimension, x.StoreId, x.CostMonth, x.Category, x.SortOrder });
}
private static void ConfigureTenantAnnouncement(EntityTypeBuilder<TenantAnnouncement> builder)
{
builder.ToTable("tenant_announcements");

View File

@@ -0,0 +1,527 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Finance.Entities;
using TakeoutSaaS.Domain.Finance.Enums;
using TakeoutSaaS.Domain.Finance.Models;
using TakeoutSaaS.Domain.Finance.Repositories;
using TakeoutSaaS.Domain.Payments.Enums;
using TakeoutSaaS.Infrastructure.App.Persistence;
namespace TakeoutSaaS.Infrastructure.App.Repositories;
/// <summary>
/// 成本管理 EF Core 仓储实现。
/// </summary>
public sealed class EfFinanceCostRepository(TakeoutAppDbContext context) : IFinanceCostRepository
{
private static readonly FinanceCostCategory[] CategoryOrder =
[
FinanceCostCategory.FoodMaterial,
FinanceCostCategory.Labor,
FinanceCostCategory.FixedExpense,
FinanceCostCategory.PackagingConsumable
];
/// <inheritdoc />
public async Task<FinanceCostMonthSnapshot> GetMonthSnapshotAsync(
long tenantId,
FinanceCostDimension dimension,
long? storeId,
DateTime costMonth,
CancellationToken cancellationToken = default)
{
// 1. 归一化月份并加载分类快照。
var normalizedMonth = NormalizeMonthStart(costMonth);
var normalizedStoreId = NormalizeStoreId(dimension, storeId);
var categories = await GetCategorySnapshotsAsync(
tenantId,
dimension,
normalizedStoreId,
normalizedMonth,
cancellationToken);
// 2. 读取本月营业额(真实订单与支付记录聚合)。
var monthRevenue = await GetRevenueByMonthAsync(
tenantId,
dimension,
normalizedStoreId,
normalizedMonth,
cancellationToken);
return new FinanceCostMonthSnapshot
{
Dimension = dimension,
StoreId = normalizedStoreId,
CostMonth = normalizedMonth,
MonthRevenue = monthRevenue,
Categories = categories
};
}
/// <inheritdoc />
public async Task SaveMonthSnapshotAsync(
long tenantId,
FinanceCostDimension dimension,
long? storeId,
DateTime costMonth,
IReadOnlyList<FinanceCostCategorySnapshot> categories,
CancellationToken cancellationToken = default)
{
// 1. 归一化入参与分类数据。
var normalizedMonth = NormalizeMonthStart(costMonth);
var normalizedStoreId = NormalizeStoreId(dimension, storeId);
var normalizedCategories = NormalizeCategoriesForSave(categories);
// 2. 删除同维度同月份历史记录(先删明细,再删汇总)。
var existingEntries = await context.FinanceCostEntries
.Where(item =>
item.TenantId == tenantId &&
item.Dimension == dimension &&
item.CostMonth == normalizedMonth &&
((dimension == FinanceCostDimension.Store && item.StoreId == normalizedStoreId) ||
(dimension == FinanceCostDimension.Tenant && item.StoreId == null)))
.ToListAsync(cancellationToken);
var existingEntryIds = existingEntries.Select(item => item.Id).ToList();
if (existingEntryIds.Count > 0)
{
var existingItems = await context.FinanceCostEntryItems
.Where(item => existingEntryIds.Contains(item.EntryId))
.ToListAsync(cancellationToken);
context.FinanceCostEntryItems.RemoveRange(existingItems);
}
context.FinanceCostEntries.RemoveRange(existingEntries);
await context.SaveChangesAsync(cancellationToken);
// 3. 新增汇总行并持久化,拿到主键。
var newEntries = normalizedCategories
.Select(item => new FinanceCostEntry
{
TenantId = tenantId,
Dimension = dimension,
StoreId = normalizedStoreId,
CostMonth = normalizedMonth,
Category = item.Category,
TotalAmount = RoundAmount(item.TotalAmount)
})
.ToList();
if (newEntries.Count > 0)
{
await context.FinanceCostEntries.AddRangeAsync(newEntries, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
}
// 4. 写入明细项并持久化。
var entryIdMap = newEntries.ToDictionary(item => item.Category, item => item.Id);
var newItems = new List<FinanceCostEntryItem>();
foreach (var category in normalizedCategories)
{
if (!entryIdMap.TryGetValue(category.Category, out var entryId))
{
continue;
}
foreach (var detail in category.Items.OrderBy(item => item.SortOrder).ThenBy(item => item.ItemName))
{
newItems.Add(new FinanceCostEntryItem
{
TenantId = tenantId,
EntryId = entryId,
Dimension = dimension,
StoreId = normalizedStoreId,
CostMonth = normalizedMonth,
Category = category.Category,
ItemName = detail.ItemName.Trim(),
Amount = RoundAmount(detail.Amount),
Quantity = detail.Quantity.HasValue ? RoundAmount(detail.Quantity.Value) : null,
UnitPrice = detail.UnitPrice.HasValue ? RoundAmount(detail.UnitPrice.Value) : null,
SortOrder = detail.SortOrder
});
}
}
if (newItems.Count > 0)
{
await context.FinanceCostEntryItems.AddRangeAsync(newItems, cancellationToken);
}
await context.SaveChangesAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<FinanceCostAnalysisSnapshot> GetAnalysisSnapshotAsync(
long tenantId,
FinanceCostDimension dimension,
long? storeId,
DateTime costMonth,
int trendMonthCount,
CancellationToken cancellationToken = default)
{
// 1. 归一化参数并生成趋势月份序列。
var normalizedMonth = NormalizeMonthStart(costMonth);
var normalizedStoreId = NormalizeStoreId(dimension, storeId);
var normalizedTrendCount = Math.Clamp(trendMonthCount, 3, 12);
var trendMonths = BuildTrendMonths(normalizedMonth, normalizedTrendCount);
// 2. 读取当前月分类、营业额、已支付订单量。
var currentCategories = await GetCategorySnapshotsAsync(
tenantId,
dimension,
normalizedStoreId,
normalizedMonth,
cancellationToken);
var currentTotalCost = RoundAmount(currentCategories.Sum(item => item.TotalAmount));
var currentFoodAmount = RoundAmount(currentCategories
.FirstOrDefault(item => item.Category == FinanceCostCategory.FoodMaterial)
?.TotalAmount ?? 0m);
var currentRevenue = await GetRevenueByMonthAsync(
tenantId,
dimension,
normalizedStoreId,
normalizedMonth,
cancellationToken);
var currentPaidOrderCount = await GetPaidOrderCountByMonthAsync(
tenantId,
dimension,
normalizedStoreId,
normalizedMonth,
cancellationToken);
// 3. 计算环比变化(与上月总成本对比)。
var previousMonth = normalizedMonth.AddMonths(-1);
var previousCategories = await GetCategorySnapshotsAsync(
tenantId,
dimension,
normalizedStoreId,
previousMonth,
cancellationToken);
var previousTotalCost = RoundAmount(previousCategories.Sum(item => item.TotalAmount));
var monthOnMonthChangeRate = CalculateMonthOnMonthChangeRate(currentTotalCost, previousTotalCost);
// 4. 组装趋势与明细表行。
var trends = new List<FinanceCostTrendSnapshot>(trendMonths.Count);
var detailRows = new List<FinanceCostMonthlyDetailSnapshot>(trendMonths.Count);
foreach (var month in trendMonths)
{
var monthCategories = await GetCategorySnapshotsAsync(
tenantId,
dimension,
normalizedStoreId,
month,
cancellationToken);
var monthRevenue = await GetRevenueByMonthAsync(
tenantId,
dimension,
normalizedStoreId,
month,
cancellationToken);
var foodAmount = RoundAmount(monthCategories
.FirstOrDefault(item => item.Category == FinanceCostCategory.FoodMaterial)
?.TotalAmount ?? 0m);
var laborAmount = RoundAmount(monthCategories
.FirstOrDefault(item => item.Category == FinanceCostCategory.Labor)
?.TotalAmount ?? 0m);
var fixedAmount = RoundAmount(monthCategories
.FirstOrDefault(item => item.Category == FinanceCostCategory.FixedExpense)
?.TotalAmount ?? 0m);
var packagingAmount = RoundAmount(monthCategories
.FirstOrDefault(item => item.Category == FinanceCostCategory.PackagingConsumable)
?.TotalAmount ?? 0m);
var monthTotalCost = RoundAmount(foodAmount + laborAmount + fixedAmount + packagingAmount);
trends.Add(new FinanceCostTrendSnapshot
{
MonthStartUtc = month,
TotalCost = monthTotalCost,
Revenue = monthRevenue
});
detailRows.Add(new FinanceCostMonthlyDetailSnapshot
{
MonthStartUtc = month,
FoodAmount = foodAmount,
LaborAmount = laborAmount,
FixedAmount = fixedAmount,
PackagingAmount = packagingAmount,
TotalCost = monthTotalCost,
Revenue = monthRevenue
});
}
return new FinanceCostAnalysisSnapshot
{
Dimension = dimension,
StoreId = normalizedStoreId,
CostMonth = normalizedMonth,
CurrentTotalCost = currentTotalCost,
CurrentFoodAmount = currentFoodAmount,
CurrentRevenue = currentRevenue,
CurrentPaidOrderCount = currentPaidOrderCount,
MonthOnMonthChangeRate = monthOnMonthChangeRate,
CurrentCategories = currentCategories,
Trends = trends,
DetailRows = detailRows
};
}
private async Task<IReadOnlyList<FinanceCostCategorySnapshot>> GetCategorySnapshotsAsync(
long tenantId,
FinanceCostDimension dimension,
long? storeId,
DateTime month,
CancellationToken cancellationToken)
{
// 1. 读取当月汇总与明细。
var entryQuery = context.FinanceCostEntries
.AsNoTracking()
.Where(item =>
item.TenantId == tenantId &&
item.Dimension == dimension &&
item.CostMonth == month &&
((dimension == FinanceCostDimension.Store && item.StoreId == storeId) ||
(dimension == FinanceCostDimension.Tenant && item.StoreId == null)));
var entries = await entryQuery.ToListAsync(cancellationToken);
var entryIds = entries.Select(item => item.Id).ToList();
var items = entryIds.Count == 0
? []
: await context.FinanceCostEntryItems
.AsNoTracking()
.Where(item => entryIds.Contains(item.EntryId))
.OrderBy(item => item.SortOrder)
.ThenBy(item => item.Id)
.ToListAsync(cancellationToken);
// 2. 按分类聚合,补齐默认分类顺序。
var entryMap = entries.ToDictionary(item => item.Category, item => item);
var itemGroupMap = items
.GroupBy(item => item.Category)
.ToDictionary(group => group.Key, group => group.ToList());
return CategoryOrder.Select(category =>
{
var totalAmount = entryMap.TryGetValue(category, out var entry)
? RoundAmount(entry.TotalAmount)
: 0m;
var details = itemGroupMap.TryGetValue(category, out var group)
? group.Select(detail => new FinanceCostDetailItemSnapshot
{
ItemId = detail.Id,
ItemName = detail.ItemName,
Amount = RoundAmount(detail.Amount),
Quantity = detail.Quantity.HasValue ? RoundAmount(detail.Quantity.Value) : null,
UnitPrice = detail.UnitPrice.HasValue ? RoundAmount(detail.UnitPrice.Value) : null,
SortOrder = detail.SortOrder
}).ToList()
: [];
return new FinanceCostCategorySnapshot
{
Category = category,
TotalAmount = totalAmount,
Items = details
};
}).ToList();
}
private async Task<decimal> GetRevenueByMonthAsync(
long tenantId,
FinanceCostDimension dimension,
long? storeId,
DateTime monthStartUtc,
CancellationToken cancellationToken)
{
var monthEnd = monthStartUtc.AddMonths(1);
// 1. 聚合支付成功金额。
var paidQuery =
from payment in context.PaymentRecords.AsNoTracking()
join order in context.Orders.AsNoTracking()
on payment.OrderId equals order.Id
where payment.TenantId == tenantId
&& order.TenantId == tenantId
&& payment.Status == PaymentStatus.Paid
&& (payment.PaidAt ?? payment.CreatedAt) >= monthStartUtc
&& (payment.PaidAt ?? payment.CreatedAt) < monthEnd
select new
{
order.StoreId,
payment.Amount
};
if (dimension == FinanceCostDimension.Store && storeId.HasValue)
{
paidQuery = paidQuery.Where(item => item.StoreId == storeId.Value);
}
var totalPaidAmount = await paidQuery
.Select(item => item.Amount)
.DefaultIfEmpty(0m)
.SumAsync(cancellationToken);
// 2. 聚合退款成功金额。
var refundQuery =
from refund in context.PaymentRefundRecords.AsNoTracking()
join order in context.Orders.AsNoTracking()
on refund.OrderId equals order.Id
where refund.TenantId == tenantId
&& order.TenantId == tenantId
&& refund.Status == PaymentRefundStatus.Succeeded
&& (refund.CompletedAt ?? refund.RequestedAt) >= monthStartUtc
&& (refund.CompletedAt ?? refund.RequestedAt) < monthEnd
select new
{
order.StoreId,
refund.Amount
};
if (dimension == FinanceCostDimension.Store && storeId.HasValue)
{
refundQuery = refundQuery.Where(item => item.StoreId == storeId.Value);
}
var totalRefundAmount = await refundQuery
.Select(item => item.Amount)
.DefaultIfEmpty(0m)
.SumAsync(cancellationToken);
return RoundAmount(totalPaidAmount - totalRefundAmount);
}
private async Task<int> GetPaidOrderCountByMonthAsync(
long tenantId,
FinanceCostDimension dimension,
long? storeId,
DateTime monthStartUtc,
CancellationToken cancellationToken)
{
var monthEnd = monthStartUtc.AddMonths(1);
var paidOrderQuery =
from payment in context.PaymentRecords.AsNoTracking()
join order in context.Orders.AsNoTracking()
on payment.OrderId equals order.Id
where payment.TenantId == tenantId
&& order.TenantId == tenantId
&& payment.Status == PaymentStatus.Paid
&& (payment.PaidAt ?? payment.CreatedAt) >= monthStartUtc
&& (payment.PaidAt ?? payment.CreatedAt) < monthEnd
select new
{
order.StoreId,
payment.OrderId
};
if (dimension == FinanceCostDimension.Store && storeId.HasValue)
{
paidOrderQuery = paidOrderQuery.Where(item => item.StoreId == storeId.Value);
}
return await paidOrderQuery
.Select(item => item.OrderId)
.Distinct()
.CountAsync(cancellationToken);
}
private static IReadOnlyList<FinanceCostCategorySnapshot> NormalizeCategoriesForSave(
IReadOnlyList<FinanceCostCategorySnapshot> categories)
{
var source = categories ?? [];
var map = source
.GroupBy(item => item.Category)
.ToDictionary(group => group.Key, group => group.First());
return CategoryOrder.Select((category, index) =>
{
if (!map.TryGetValue(category, out var current))
{
return new FinanceCostCategorySnapshot
{
Category = category,
TotalAmount = 0m,
Items = []
};
}
var normalizedItems = (current.Items ?? [])
.Where(item => !string.IsNullOrWhiteSpace(item.ItemName))
.Select((item, itemIndex) => new FinanceCostDetailItemSnapshot
{
ItemId = item.ItemId,
ItemName = item.ItemName.Trim(),
Amount = RoundAmount(item.Amount),
Quantity = item.Quantity.HasValue ? RoundAmount(item.Quantity.Value) : null,
UnitPrice = item.UnitPrice.HasValue ? RoundAmount(item.UnitPrice.Value) : null,
SortOrder = item.SortOrder <= 0 ? itemIndex + 1 : item.SortOrder
})
.OrderBy(item => item.SortOrder)
.ThenBy(item => item.ItemName)
.ToList();
var totalAmount = current.TotalAmount > 0
? RoundAmount(current.TotalAmount)
: RoundAmount(normalizedItems.Sum(item => item.Amount));
return new FinanceCostCategorySnapshot
{
Category = category,
TotalAmount = totalAmount,
Items = normalizedItems
};
}).ToList();
}
private static DateTime NormalizeMonthStart(DateTime value)
{
var utcValue = value.Kind switch
{
DateTimeKind.Utc => value,
DateTimeKind.Local => value.ToUniversalTime(),
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
};
return new DateTime(utcValue.Year, utcValue.Month, 1, 0, 0, 0, DateTimeKind.Utc);
}
private static long? NormalizeStoreId(FinanceCostDimension dimension, long? storeId)
{
if (dimension == FinanceCostDimension.Tenant)
{
return null;
}
return storeId.HasValue && storeId.Value > 0
? storeId.Value
: null;
}
private static decimal RoundAmount(decimal value)
{
return decimal.Round(value, 2, MidpointRounding.AwayFromZero);
}
private static decimal CalculateMonthOnMonthChangeRate(decimal currentValue, decimal previousValue)
{
if (previousValue <= 0)
{
return currentValue <= 0 ? 0m : 100m;
}
var rate = (currentValue - previousValue) / previousValue * 100m;
return RoundAmount(rate);
}
private static List<DateTime> BuildTrendMonths(DateTime currentMonth, int trendMonthCount)
{
var startMonth = currentMonth.AddMonths(0 - Math.Max(1, trendMonthCount) + 1);
var result = new List<DateTime>(trendMonthCount);
for (var index = 0; index < trendMonthCount; index++)
{
result.Add(startMonth.AddMonths(index));
}
return result;
}
}

View File

@@ -0,0 +1,215 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Infrastructure.App.Persistence;
namespace TakeoutSaaS.Infrastructure.App.Repositories;
/// <summary>
/// 租户发票仓储 EF Core 实现。
/// </summary>
public sealed class EfTenantInvoiceRepository(TakeoutAppDbContext context) : ITenantInvoiceRepository
{
/// <inheritdoc />
public Task<TenantInvoiceSetting?> GetSettingAsync(long tenantId, CancellationToken cancellationToken = default)
{
return context.TenantInvoiceSettings
.Where(item => item.TenantId == tenantId)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public Task AddSettingAsync(TenantInvoiceSetting entity, CancellationToken cancellationToken = default)
{
return context.TenantInvoiceSettings.AddAsync(entity, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateSettingAsync(TenantInvoiceSetting entity, CancellationToken cancellationToken = default)
{
context.TenantInvoiceSettings.Update(entity);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task<(IReadOnlyList<TenantInvoiceRecord> Items, int TotalCount)> SearchRecordsAsync(
long tenantId,
DateTime? startUtc,
DateTime? endUtc,
TenantInvoiceStatus? status,
TenantInvoiceType? invoiceType,
string? keyword,
int page,
int pageSize,
CancellationToken cancellationToken = default)
{
var normalizedPage = Math.Max(1, page);
var normalizedPageSize = Math.Clamp(pageSize, 1, 500);
var query = BuildRecordQuery(tenantId, startUtc, endUtc, status, invoiceType, keyword);
var totalCount = await query.CountAsync(cancellationToken);
if (totalCount == 0)
{
return ([], 0);
}
var items = await query
.OrderByDescending(item => item.AppliedAt)
.ThenByDescending(item => item.Id)
.Skip((normalizedPage - 1) * normalizedPageSize)
.Take(normalizedPageSize)
.ToListAsync(cancellationToken);
return (items, totalCount);
}
/// <inheritdoc />
public async Task<TenantInvoiceRecordStatsSnapshot> GetStatsAsync(
long tenantId,
DateTime nowUtc,
CancellationToken cancellationToken = default)
{
var utcNow = NormalizeUtc(nowUtc);
var monthStart = new DateTime(utcNow.Year, utcNow.Month, 1, 0, 0, 0, DateTimeKind.Utc);
var summary = await context.TenantInvoiceRecords
.AsNoTracking()
.Where(item => item.TenantId == tenantId)
.GroupBy(_ => 1)
.Select(group => new
{
CurrentMonthIssuedAmount = group
.Where(item =>
item.Status == TenantInvoiceStatus.Issued &&
item.IssuedAt.HasValue &&
item.IssuedAt.Value >= monthStart &&
item.IssuedAt.Value <= utcNow)
.Sum(item => item.Amount),
CurrentMonthIssuedCount = group
.Count(item =>
item.Status == TenantInvoiceStatus.Issued &&
item.IssuedAt.HasValue &&
item.IssuedAt.Value >= monthStart &&
item.IssuedAt.Value <= utcNow),
PendingCount = group.Count(item => item.Status == TenantInvoiceStatus.Pending),
VoidedCount = group.Count(item => item.Status == TenantInvoiceStatus.Voided)
})
.FirstOrDefaultAsync(cancellationToken);
if (summary is null)
{
return new TenantInvoiceRecordStatsSnapshot();
}
return new TenantInvoiceRecordStatsSnapshot
{
CurrentMonthIssuedAmount = summary.CurrentMonthIssuedAmount,
CurrentMonthIssuedCount = summary.CurrentMonthIssuedCount,
PendingCount = summary.PendingCount,
VoidedCount = summary.VoidedCount
};
}
/// <inheritdoc />
public Task<TenantInvoiceRecord?> FindRecordByIdAsync(
long tenantId,
long recordId,
CancellationToken cancellationToken = default)
{
return context.TenantInvoiceRecords
.Where(item => item.TenantId == tenantId && item.Id == recordId)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public Task<bool> ExistsInvoiceNoAsync(
long tenantId,
string invoiceNo,
CancellationToken cancellationToken = default)
{
return context.TenantInvoiceRecords
.AsNoTracking()
.AnyAsync(
item => item.TenantId == tenantId && item.InvoiceNo == invoiceNo,
cancellationToken);
}
/// <inheritdoc />
public Task AddRecordAsync(TenantInvoiceRecord entity, CancellationToken cancellationToken = default)
{
return context.TenantInvoiceRecords.AddAsync(entity, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateRecordAsync(TenantInvoiceRecord entity, CancellationToken cancellationToken = default)
{
context.TenantInvoiceRecords.Update(entity);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
return context.SaveChangesAsync(cancellationToken);
}
private IQueryable<TenantInvoiceRecord> BuildRecordQuery(
long tenantId,
DateTime? startUtc,
DateTime? endUtc,
TenantInvoiceStatus? status,
TenantInvoiceType? invoiceType,
string? keyword)
{
var query = context.TenantInvoiceRecords
.AsNoTracking()
.Where(item => item.TenantId == tenantId);
if (startUtc.HasValue)
{
var normalizedStart = NormalizeUtc(startUtc.Value);
query = query.Where(item => item.AppliedAt >= normalizedStart);
}
if (endUtc.HasValue)
{
var normalizedEnd = NormalizeUtc(endUtc.Value);
query = query.Where(item => item.AppliedAt <= normalizedEnd);
}
if (status.HasValue)
{
query = query.Where(item => item.Status == status.Value);
}
if (invoiceType.HasValue)
{
query = query.Where(item => item.InvoiceType == invoiceType.Value);
}
var normalizedKeyword = (keyword ?? string.Empty).Trim();
if (!string.IsNullOrWhiteSpace(normalizedKeyword))
{
var like = $"%{normalizedKeyword}%";
query = query.Where(item =>
EF.Functions.ILike(item.InvoiceNo, like) ||
EF.Functions.ILike(item.CompanyName, like) ||
EF.Functions.ILike(item.ApplicantName, like) ||
EF.Functions.ILike(item.OrderNo, like));
}
return query;
}
private static DateTime NormalizeUtc(DateTime value)
{
return value.Kind switch
{
DateTimeKind.Utc => value,
DateTimeKind.Local => value.ToUniversalTime(),
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
};
}
}

View File

@@ -0,0 +1,112 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using TakeoutSaaS.Infrastructure.App.Persistence;
#nullable disable
namespace TakeoutSaaS.Infrastructure.Migrations;
/// <summary>
/// 新增财务中心成本管理表结构。
/// </summary>
[DbContext(typeof(TakeoutAppDbContext))]
[Migration("20260305010000_AddFinanceCostModule")]
public sealed class AddFinanceCostModule : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "finance_cost_entries",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Dimension = table.Column<int>(type: "integer", nullable: false, comment: "统计维度。"),
StoreId = table.Column<long>(type: "bigint", nullable: true, comment: "门店标识(租户汇总维度为空)。"),
CostMonth = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "成本月份UTC 每月第一天)。"),
Category = table.Column<int>(type: "integer", nullable: false, comment: "成本分类。"),
TotalAmount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "分类总金额。"),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间UTC。"),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间UTC从未更新时为 null。"),
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间UTC未删除时为 null。"),
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
},
constraints: table =>
{
table.PrimaryKey("PK_finance_cost_entries", x => x.Id);
},
comment: "财务成本录入月度汇总。");
migrationBuilder.CreateTable(
name: "finance_cost_entry_items",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
EntryId = table.Column<long>(type: "bigint", nullable: false, comment: "关联汇总标识。"),
Dimension = table.Column<int>(type: "integer", nullable: false, comment: "统计维度。"),
StoreId = table.Column<long>(type: "bigint", nullable: true, comment: "门店标识(租户汇总维度为空)。"),
CostMonth = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "成本月份UTC 每月第一天)。"),
Category = table.Column<int>(type: "integer", nullable: false, comment: "成本分类。"),
ItemName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false, comment: "明细名称。"),
Amount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "明细金额。"),
Quantity = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: true, comment: "数量(人工类可用)。"),
UnitPrice = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: true, comment: "单价(人工类可用)。"),
SortOrder = table.Column<int>(type: "integer", nullable: false, defaultValue: 100, comment: "排序值。"),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间UTC。"),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间UTC从未更新时为 null。"),
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间UTC未删除时为 null。"),
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
},
constraints: table =>
{
table.PrimaryKey("PK_finance_cost_entry_items", x => x.Id);
table.ForeignKey(
name: "FK_finance_cost_entry_items_finance_cost_entries_EntryId",
column: x => x.EntryId,
principalTable: "finance_cost_entries",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
},
comment: "财务成本录入明细项。");
migrationBuilder.CreateIndex(
name: "IX_finance_cost_entries_TenantId_Dimension_StoreId_CostMonth",
table: "finance_cost_entries",
columns: new[] { "TenantId", "Dimension", "StoreId", "CostMonth" });
migrationBuilder.CreateIndex(
name: "IX_finance_cost_entries_TenantId_Dimension_StoreId_CostMonth_C~",
table: "finance_cost_entries",
columns: new[] { "TenantId", "Dimension", "StoreId", "CostMonth", "Category" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_finance_cost_entry_items_EntryId",
table: "finance_cost_entry_items",
column: "EntryId");
migrationBuilder.CreateIndex(
name: "IX_finance_cost_entry_items_TenantId_Dimension_StoreId_CostMon~",
table: "finance_cost_entry_items",
columns: new[] { "TenantId", "Dimension", "StoreId", "CostMonth", "Category", "SortOrder" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "finance_cost_entry_items");
migrationBuilder.DropTable(
name: "finance_cost_entries");
}
}

View File

@@ -0,0 +1,131 @@
using System;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using TakeoutSaaS.Infrastructure.App.Persistence;
#nullable disable
namespace TakeoutSaaS.Infrastructure.Migrations;
/// <summary>
/// 新增财务中心发票管理表结构。
/// </summary>
[DbContext(typeof(TakeoutAppDbContext))]
[Migration("20260305103000_AddFinanceInvoiceModule")]
public sealed class AddFinanceInvoiceModule : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "finance_invoice_records",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
InvoiceNo = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false, comment: "发票号码。"),
ApplicantName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false, comment: "申请人。"),
CompanyName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false, comment: "开票抬头(公司名)。"),
TaxpayerNumber = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true, comment: "纳税人识别号快照。"),
InvoiceType = table.Column<int>(type: "integer", nullable: false, comment: "发票类型。"),
Amount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "开票金额。"),
OrderNo = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false, comment: "关联订单号。"),
ContactEmail = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true, comment: "接收邮箱。"),
ContactPhone = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true, comment: "联系电话。"),
ApplyRemark = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true, comment: "申请备注。"),
Status = table.Column<int>(type: "integer", nullable: false, comment: "发票状态。"),
AppliedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "申请时间UTC。"),
IssuedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "开票时间UTC。"),
IssuedByUserId = table.Column<long>(type: "bigint", nullable: true, comment: "开票人 ID。"),
IssueRemark = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true, comment: "开票备注。"),
VoidedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "作废时间UTC。"),
VoidedByUserId = table.Column<long>(type: "bigint", nullable: true, comment: "作废人 ID。"),
VoidReason = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true, comment: "作废原因。"),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间UTC。"),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间UTC从未更新时为 null。"),
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间UTC未删除时为 null。"),
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
},
constraints: table =>
{
table.PrimaryKey("PK_finance_invoice_records", x => x.Id);
},
comment: "租户发票记录。");
migrationBuilder.CreateTable(
name: "finance_invoice_settings",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
CompanyName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false, comment: "企业名称。"),
TaxpayerNumber = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false, comment: "纳税人识别号。"),
RegisteredAddress = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true, comment: "注册地址。"),
RegisteredPhone = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true, comment: "注册电话。"),
BankName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true, comment: "开户银行。"),
BankAccount = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true, comment: "银行账号。"),
EnableElectronicNormalInvoice = table.Column<bool>(type: "boolean", nullable: false, comment: "是否启用电子普通发票。"),
EnableElectronicSpecialInvoice = table.Column<bool>(type: "boolean", nullable: false, comment: "是否启用电子专用发票。"),
EnableAutoIssue = table.Column<bool>(type: "boolean", nullable: false, comment: "是否启用自动开票。"),
AutoIssueMaxAmount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "自动开票单张最大金额。"),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间UTC。"),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间UTC从未更新时为 null。"),
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间UTC未删除时为 null。"),
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
},
constraints: table =>
{
table.PrimaryKey("PK_finance_invoice_settings", x => x.Id);
},
comment: "租户发票开票基础设置。");
migrationBuilder.CreateIndex(
name: "IX_finance_invoice_records_TenantId_InvoiceNo",
table: "finance_invoice_records",
columns: new[] { "TenantId", "InvoiceNo" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_finance_invoice_records_TenantId_InvoiceType_AppliedAt",
table: "finance_invoice_records",
columns: new[] { "TenantId", "InvoiceType", "AppliedAt" });
migrationBuilder.CreateIndex(
name: "IX_finance_invoice_records_TenantId_OrderNo",
table: "finance_invoice_records",
columns: new[] { "TenantId", "OrderNo" });
migrationBuilder.CreateIndex(
name: "IX_finance_invoice_records_TenantId_Status_AppliedAt",
table: "finance_invoice_records",
columns: new[] { "TenantId", "Status", "AppliedAt" });
migrationBuilder.CreateIndex(
name: "IX_finance_invoice_records_TenantId_Status_IssuedAt",
table: "finance_invoice_records",
columns: new[] { "TenantId", "Status", "IssuedAt" });
migrationBuilder.CreateIndex(
name: "IX_finance_invoice_settings_TenantId",
table: "finance_invoice_settings",
column: "TenantId",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "finance_invoice_records");
migrationBuilder.DropTable(
name: "finance_invoice_settings");
}
}

View File

@@ -0,0 +1,243 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using TakeoutSaaS.Infrastructure.Identity.Persistence;
#nullable disable
namespace TakeoutSaaS.Infrastructure.Migrations.IdentityDb;
/// <summary>
/// 写入成本管理菜单与权限定义。
/// </summary>
[DbContext(typeof(IdentityDbContext))]
[Migration("20260305013000_SeedFinanceCostMenuAndPermissions")]
public sealed class SeedFinanceCostMenuAndPermissions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
DO $$
DECLARE
v_parent_permission_id bigint;
v_view_permission_id bigint;
v_manage_permission_id bigint;
v_parent_menu_id bigint;
v_cost_menu_id bigint;
v_permission_seed_base bigint := 840100000000000000;
v_menu_seed_base bigint := 850100000000000000;
BEGIN
-- 1.
SELECT "Id"
INTO v_parent_permission_id
FROM public.permissions
WHERE "Code" = 'group:tenant:finance'
ORDER BY "Id"
LIMIT 1;
IF v_parent_permission_id IS NULL THEN
v_parent_permission_id := v_permission_seed_base + 1;
INSERT INTO public.permissions (
"Id", "Name", "Code", "Description",
"CreatedAt", "UpdatedAt", "DeletedAt",
"CreatedBy", "UpdatedBy", "DeletedBy",
"ParentId", "SortOrder", "Type", "Portal")
VALUES (
v_parent_permission_id, '', 'group:tenant:finance', '',
NOW(), NULL, NULL,
NULL, NULL, NULL,
0, 5000, 'group', 1)
ON CONFLICT ("Code") DO NOTHING;
END IF;
-- 2. Upsert
INSERT INTO public.permissions (
"Id", "Name", "Code", "Description",
"CreatedAt", "UpdatedAt", "DeletedAt",
"CreatedBy", "UpdatedBy", "DeletedBy",
"ParentId", "SortOrder", "Type", "Portal")
VALUES (
v_permission_seed_base + 11, '', 'tenant:finance:cost:view', '',
NOW(), NULL, NULL,
NULL, NULL, NULL,
v_parent_permission_id, 5110, 'leaf', 1)
ON CONFLICT ("Code") DO UPDATE
SET "Name" = EXCLUDED."Name",
"Description" = EXCLUDED."Description",
"ParentId" = EXCLUDED."ParentId",
"SortOrder" = EXCLUDED."SortOrder",
"Type" = EXCLUDED."Type",
"Portal" = EXCLUDED."Portal",
"DeletedAt" = NULL,
"DeletedBy" = NULL,
"UpdatedAt" = NOW();
-- 3. Upsert
INSERT INTO public.permissions (
"Id", "Name", "Code", "Description",
"CreatedAt", "UpdatedAt", "DeletedAt",
"CreatedBy", "UpdatedBy", "DeletedBy",
"ParentId", "SortOrder", "Type", "Portal")
VALUES (
v_permission_seed_base + 12, '', 'tenant:finance:cost:manage', '',
NOW(), NULL, NULL,
NULL, NULL, NULL,
v_parent_permission_id, 5120, 'leaf', 1)
ON CONFLICT ("Code") DO UPDATE
SET "Name" = EXCLUDED."Name",
"Description" = EXCLUDED."Description",
"ParentId" = EXCLUDED."ParentId",
"SortOrder" = EXCLUDED."SortOrder",
"Type" = EXCLUDED."Type",
"Portal" = EXCLUDED."Portal",
"DeletedAt" = NULL,
"DeletedBy" = NULL,
"UpdatedAt" = NOW();
-- 4. ID
SELECT "Id" INTO v_view_permission_id FROM public.permissions WHERE "Code" = 'tenant:finance:cost:view' LIMIT 1;
SELECT "Id" INTO v_manage_permission_id FROM public.permissions WHERE "Code" = 'tenant:finance:cost:manage' LIMIT 1;
-- 5.
SELECT "Id"
INTO v_parent_menu_id
FROM public.menu_definitions
WHERE "Portal" = 1 AND "Path" = '/finance' AND "DeletedAt" IS NULL
ORDER BY "Id"
LIMIT 1;
IF v_parent_menu_id IS NULL THEN
v_parent_menu_id := v_menu_seed_base + 1;
INSERT INTO public.menu_definitions (
"Id", "ParentId", "Name", "Path", "Component", "Title", "Icon",
"IsIframe", "Link", "KeepAlive", "SortOrder",
"RequiredPermissions", "MetaPermissions", "MetaRoles", "AuthListJson",
"CreatedAt", "UpdatedAt", "DeletedAt", "CreatedBy", "UpdatedBy", "DeletedBy", "Portal")
VALUES (
v_parent_menu_id, 0, 'Finance', '/finance', 'BasicLayout', '', 'lucide:wallet',
FALSE, NULL, FALSE, 500,
'', '', '', NULL,
NOW(), NULL, NULL, NULL, NULL, NULL, 1)
ON CONFLICT ("Id") DO NOTHING;
END IF;
-- 6. Upsert
SELECT "Id"
INTO v_cost_menu_id
FROM public.menu_definitions
WHERE "Portal" = 1
AND ("Path" = '/finance/cost' OR ("Path" = 'cost' AND "Component" = '/finance/cost/index'))
ORDER BY "DeletedAt" NULLS FIRST, "Id"
LIMIT 1;
IF v_cost_menu_id IS NULL THEN
v_cost_menu_id := v_menu_seed_base + 11;
INSERT INTO public.menu_definitions (
"Id", "ParentId", "Name", "Path", "Component", "Title", "Icon",
"IsIframe", "Link", "KeepAlive", "SortOrder",
"RequiredPermissions", "MetaPermissions", "MetaRoles", "AuthListJson",
"CreatedAt", "UpdatedAt", "DeletedAt", "CreatedBy", "UpdatedBy", "DeletedBy", "Portal")
VALUES (
v_cost_menu_id, v_parent_menu_id, 'CostManagement', '/finance/cost', '/finance/cost/index', '', 'lucide:circle-dollar-sign',
FALSE, NULL, TRUE, 520,
'tenant:finance:cost:view', 'tenant:finance:cost:view,tenant:finance:cost:manage', '', NULL,
NOW(), NULL, NULL, NULL, NULL, NULL, 1)
ON CONFLICT ("Id") DO NOTHING;
ELSE
UPDATE public.menu_definitions
SET "ParentId" = v_parent_menu_id,
"Name" = 'CostManagement',
"Path" = '/finance/cost',
"Component" = '/finance/cost/index',
"Title" = '',
"Icon" = 'lucide:circle-dollar-sign',
"IsIframe" = FALSE,
"Link" = NULL,
"KeepAlive" = TRUE,
"SortOrder" = 520,
"RequiredPermissions" = 'tenant:finance:cost:view',
"MetaPermissions" = 'tenant:finance:cost:view,tenant:finance:cost:manage',
"MetaRoles" = '',
"DeletedAt" = NULL,
"DeletedBy" = NULL,
"UpdatedAt" = NOW(),
"Portal" = 1
WHERE "Id" = v_cost_menu_id;
END IF;
-- 7. tenant-admin
INSERT INTO public.role_permissions (
"Id", "RoleId", "PermissionId", "CreatedAt", "UpdatedAt", "DeletedAt",
"CreatedBy", "UpdatedBy", "DeletedBy", "TenantId", "Portal")
SELECT
ABS(HASHTEXTEXTENDED('tenant-admin:cost:' || role."Id"::text || ':' || permission_id::text, 0)),
role."Id",
permission_id,
NOW(), NULL, NULL,
NULL, NULL, NULL,
role."TenantId",
1
FROM public.roles role
CROSS JOIN LATERAL (
SELECT UNNEST(ARRAY[v_view_permission_id, v_manage_permission_id]) AS permission_id
) item
WHERE role."Code" = 'tenant-admin'
AND role."DeletedAt" IS NULL
AND item.permission_id IS NOT NULL
ON CONFLICT ("RoleId", "PermissionId") DO UPDATE
SET "DeletedAt" = NULL,
"DeletedBy" = NULL,
"UpdatedAt" = NOW(),
"Portal" = 1;
-- 8. tenant-admin
INSERT INTO public.role_template_permissions (
"Id", "RoleTemplateId", "PermissionCode",
"CreatedAt", "UpdatedAt", "DeletedAt",
"CreatedBy", "UpdatedBy", "DeletedBy")
SELECT
ABS(HASHTEXTEXTENDED('template-cost:' || template."Id"::text || ':' || item.permission_code, 0)),
template."Id",
item.permission_code,
NOW(), NULL, NULL,
NULL, NULL, NULL
FROM public.role_templates template
CROSS JOIN LATERAL (
SELECT UNNEST(ARRAY['tenant:finance:cost:view', 'tenant:finance:cost:manage']) AS permission_code
) item
WHERE template."TemplateCode" = 'tenant-admin'
AND template."DeletedAt" IS NULL
ON CONFLICT ("RoleTemplateId", "PermissionCode") DO UPDATE
SET "DeletedAt" = NULL,
"DeletedBy" = NULL,
"UpdatedAt" = NOW();
END $$;
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
DO $$
BEGIN
DELETE FROM public.role_permissions
WHERE "PermissionId" IN (
SELECT "Id"
FROM public.permissions
WHERE "Code" IN ('tenant:finance:cost:view', 'tenant:finance:cost:manage'));
DELETE FROM public.role_template_permissions
WHERE "PermissionCode" IN ('tenant:finance:cost:view', 'tenant:finance:cost:manage');
DELETE FROM public.menu_definitions
WHERE "Portal" = 1 AND "Path" = '/finance/cost';
DELETE FROM public.permissions
WHERE "Code" IN ('tenant:finance:cost:view', 'tenant:finance:cost:manage');
END $$;
""");
}
}