Compare commits

...

12 Commits

Author SHA1 Message Date
63eb02f1a5 Merge pull request 'feat(member): add message reach backend module and docs seeds' (#9) from feature/member-points-mall-1to1 into dev
Some checks failed
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Failing after 1m58s
Reviewed-on: #9
2026-03-04 09:01:47 +00:00
7e1712ed13 Merge pull request 'feat(finance): 完成发票管理模块后端实现' (#7) from feature/finance-invoice-1to1 into dev
Some checks failed
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Failing after 2m7s
Reviewed-on: #7
2026-03-04 08:57:13 +00:00
8d170ba3f9 feat(finance): 完成发票管理模块后端实现 2026-03-04 16:54:30 +08:00
fa7b006373 Merge pull request 'feat(finance): add cost management backend module' (#6) from feature/finance-cost-1to1 into dev
Some checks failed
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Failing after 2m15s
Reviewed-on: #6
2026-03-04 08:16:34 +00:00
c8359c5fc3 Merge pull request 'feature/finance-report-1to1' (#5) from feature/finance-report-1to1 into dev
Some checks failed
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Failing after 2m0s
Reviewed-on: #5
2026-03-04 08:12:46 +00:00
59ebe70ed3 Merge remote-tracking branch 'gitea/dev' into feature/finance-report-1to1 2026-03-04 16:09:50 +08:00
76366cbc30 Merge pull request #4 from msumshk/feature/finance-report-1to1
feat(finance): add tenant settlement query backend
2026-03-04 16:00:02 +08:00
b0bb87d97c feat(finance): add tenant settlement query backend 2026-03-04 15:48:37 +08:00
b5aa060faf feat(member): add message reach backend module and docs seeds 2026-03-04 13:35:22 +08:00
1efa392f36 Merge pull request 'feat(member): implement points mall backend module' (#3) from feature/member-points-mall-1to1 into dev
Some checks failed
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Failing after 1m53s
Reviewed-on: #3
2026-03-04 04:33:26 +00:00
b57b3ab228 Merge pull request 'feat: 完成会员消息触达后端模块' (#2) from feature/member-message-reach-module into dev
Some checks failed
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Failing after 1m55s
Reviewed-on: #2
2026-03-04 04:18:14 +00:00
a88ca4056c Merge pull request 'feat: 新增财务交易流水后端模块' (#1) from feature/finance-transaction-module into dev
All checks were successful
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Successful in 2m16s
Reviewed-on: #1
2026-03-04 03:43:59 +00:00
59 changed files with 14570 additions and 2 deletions

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,247 @@
namespace TakeoutSaaS.TenantApi.Contracts.Finance;
/// <summary>
/// 到账统计请求。
/// </summary>
public sealed class FinanceSettlementStatsRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
}
/// <summary>
/// 到账筛选请求。
/// </summary>
public class FinanceSettlementFilterRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 开始日期yyyy-MM-dd
/// </summary>
public string? StartDate { get; set; }
/// <summary>
/// 结束日期yyyy-MM-dd
/// </summary>
public string? EndDate { get; set; }
/// <summary>
/// 渠道wechat/alipay
/// </summary>
public string? Channel { get; set; }
}
/// <summary>
/// 到账列表请求。
/// </summary>
public sealed class FinanceSettlementListRequest : FinanceSettlementFilterRequest
{
/// <summary>
/// 页码。
/// </summary>
public int Page { get; set; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; set; } = 20;
}
/// <summary>
/// 到账明细请求。
/// </summary>
public sealed class FinanceSettlementDetailRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 到账日期yyyy-MM-dd
/// </summary>
public string ArrivedDate { get; set; } = string.Empty;
/// <summary>
/// 渠道wechat/alipay
/// </summary>
public string Channel { get; set; } = string.Empty;
}
/// <summary>
/// 到账统计响应。
/// </summary>
public sealed class FinanceSettlementStatsResponse
{
/// <summary>
/// 今日到账。
/// </summary>
public decimal TodayArrivedAmount { get; set; }
/// <summary>
/// 昨日到账。
/// </summary>
public decimal YesterdayArrivedAmount { get; set; }
/// <summary>
/// 本月到账。
/// </summary>
public decimal CurrentMonthArrivedAmount { get; set; }
/// <summary>
/// 本月交易笔数。
/// </summary>
public int CurrentMonthTransactionCount { get; set; }
}
/// <summary>
/// 到账账户信息响应。
/// </summary>
public sealed class FinanceSettlementAccountResponse
{
/// <summary>
/// 银行名称。
/// </summary>
public string BankName { get; set; } = string.Empty;
/// <summary>
/// 开户名。
/// </summary>
public string BankAccountName { get; set; } = string.Empty;
/// <summary>
/// 脱敏银行账号。
/// </summary>
public string BankAccountNoMasked { get; set; } = string.Empty;
/// <summary>
/// 脱敏微信商户号。
/// </summary>
public string WechatMerchantNoMasked { get; set; } = string.Empty;
/// <summary>
/// 脱敏支付宝 PID。
/// </summary>
public string AlipayPidMasked { get; set; } = string.Empty;
/// <summary>
/// 结算周期文案。
/// </summary>
public string SettlementPeriodText { get; set; } = string.Empty;
}
/// <summary>
/// 到账列表行响应。
/// </summary>
public sealed class FinanceSettlementListItemResponse
{
/// <summary>
/// 到账日期。
/// </summary>
public string ArrivedDate { get; set; } = string.Empty;
/// <summary>
/// 渠道编码。
/// </summary>
public string Channel { get; set; } = string.Empty;
/// <summary>
/// 渠道文案。
/// </summary>
public string ChannelText { get; set; } = string.Empty;
/// <summary>
/// 交易笔数。
/// </summary>
public int TransactionCount { get; set; }
/// <summary>
/// 到账金额。
/// </summary>
public decimal ArrivedAmount { get; set; }
}
/// <summary>
/// 到账列表响应。
/// </summary>
public sealed class FinanceSettlementListResultResponse
{
/// <summary>
/// 列表项。
/// </summary>
public List<FinanceSettlementListItemResponse> Items { get; set; } = [];
/// <summary>
/// 总数。
/// </summary>
public int Total { get; set; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; set; }
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; set; }
}
/// <summary>
/// 到账明细行响应。
/// </summary>
public sealed class FinanceSettlementDetailItemResponse
{
/// <summary>
/// 订单号。
/// </summary>
public string OrderNo { get; set; } = string.Empty;
/// <summary>
/// 金额。
/// </summary>
public decimal Amount { get; set; }
/// <summary>
/// 支付时间。
/// </summary>
public string PaidAt { get; set; } = string.Empty;
}
/// <summary>
/// 到账明细响应。
/// </summary>
public sealed class FinanceSettlementDetailResultResponse
{
/// <summary>
/// 明细列表。
/// </summary>
public List<FinanceSettlementDetailItemResponse> Items { get; set; } = [];
}
/// <summary>
/// 到账导出响应。
/// </summary>
public sealed class FinanceSettlementExportResponse
{
/// <summary>
/// 文件名。
/// </summary>
public string FileName { get; set; } = string.Empty;
/// <summary>
/// 文件内容Base64
/// </summary>
public string FileContentBase64 { get; set; } = string.Empty;
/// <summary>
/// 导出总数。
/// </summary>
public int TotalCount { get; set; }
}

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,262 @@
using System.Globalization;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TakeoutSaaS.Application.App.Finance.Settlement.Dto;
using TakeoutSaaS.Application.App.Finance.Settlement.Queries;
using TakeoutSaaS.Application.App.Stores.Services;
using TakeoutSaaS.Domain.Payments.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/settlement")]
public sealed class FinanceSettlementController(
IMediator mediator,
TakeoutAppDbContext dbContext,
StoreContextService storeContextService) : BaseApiController
{
private const string ViewPermission = "tenant:finance:settlement:view";
private const string ExportPermission = "tenant:finance:settlement:export";
/// <summary>
/// 查询到账统计。
/// </summary>
[HttpGet("stats")]
[PermissionAuthorize(ViewPermission)]
[ProducesResponseType(typeof(ApiResponse<FinanceSettlementStatsResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<FinanceSettlementStatsResponse>> Stats(
[FromQuery] FinanceSettlementStatsRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var stats = await mediator.Send(new GetFinanceSettlementStatsQuery
{
StoreId = storeId
}, cancellationToken);
return ApiResponse<FinanceSettlementStatsResponse>.Ok(new FinanceSettlementStatsResponse
{
TodayArrivedAmount = stats.TodayArrivedAmount,
YesterdayArrivedAmount = stats.YesterdayArrivedAmount,
CurrentMonthArrivedAmount = stats.CurrentMonthArrivedAmount,
CurrentMonthTransactionCount = stats.CurrentMonthTransactionCount
});
}
/// <summary>
/// 查询到账账户信息。
/// </summary>
[HttpGet("account")]
[PermissionAuthorize(ViewPermission)]
[ProducesResponseType(typeof(ApiResponse<FinanceSettlementAccountResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<FinanceSettlementAccountResponse>> Account(
CancellationToken cancellationToken)
{
var account = await mediator.Send(new GetFinanceSettlementAccountQuery(), cancellationToken);
if (account is null)
{
return ApiResponse<FinanceSettlementAccountResponse>.Error(ErrorCodes.NotFound, "结算账户信息不存在");
}
return ApiResponse<FinanceSettlementAccountResponse>.Ok(new FinanceSettlementAccountResponse
{
BankName = account.BankName,
BankAccountName = account.BankAccountName,
BankAccountNoMasked = account.BankAccountNoMasked,
WechatMerchantNoMasked = account.WechatMerchantNoMasked,
AlipayPidMasked = account.AlipayPidMasked,
SettlementPeriodText = account.SettlementPeriodText
});
}
/// <summary>
/// 查询到账汇总列表。
/// </summary>
[HttpGet("list")]
[PermissionAuthorize(ViewPermission)]
[ProducesResponseType(typeof(ApiResponse<FinanceSettlementListResultResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<FinanceSettlementListResultResponse>> List(
[FromQuery] FinanceSettlementListRequest request,
CancellationToken cancellationToken)
{
var parsed = await ParseFilterAsync(request, cancellationToken);
var result = await mediator.Send(new SearchFinanceSettlementListQuery
{
StoreId = parsed.StoreId,
StartAt = parsed.StartAt,
EndAt = parsed.EndAt,
PaymentMethod = parsed.PaymentMethod,
Page = Math.Max(1, request.Page),
PageSize = Math.Clamp(request.PageSize, 1, 200)
}, cancellationToken);
return ApiResponse<FinanceSettlementListResultResponse>.Ok(new FinanceSettlementListResultResponse
{
Items = result.Items.Select(MapListItem).ToList(),
Total = result.Total,
Page = result.Page,
PageSize = result.PageSize
});
}
/// <summary>
/// 查询到账明细(展开行)。
/// </summary>
[HttpGet("detail")]
[PermissionAuthorize(ViewPermission)]
[ProducesResponseType(typeof(ApiResponse<FinanceSettlementDetailResultResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<FinanceSettlementDetailResultResponse>> Detail(
[FromQuery] FinanceSettlementDetailRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var arrivedDate = ParseRequiredDate(request.ArrivedDate, nameof(request.ArrivedDate));
var paymentMethod = ParseRequiredSettlementChannel(request.Channel);
var result = await mediator.Send(new GetFinanceSettlementDetailQuery
{
StoreId = storeId,
ArrivedDate = arrivedDate,
PaymentMethod = paymentMethod,
Take = 50
}, cancellationToken);
return ApiResponse<FinanceSettlementDetailResultResponse>.Ok(new FinanceSettlementDetailResultResponse
{
Items = result.Items.Select(MapDetailItem).ToList()
});
}
/// <summary>
/// 导出到账汇总 CSV。
/// </summary>
[HttpGet("export")]
[PermissionAuthorize(ExportPermission)]
[ProducesResponseType(typeof(ApiResponse<FinanceSettlementExportResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<FinanceSettlementExportResponse>> Export(
[FromQuery] FinanceSettlementFilterRequest request,
CancellationToken cancellationToken)
{
var parsed = await ParseFilterAsync(request, cancellationToken);
var result = await mediator.Send(new ExportFinanceSettlementCsvQuery
{
StoreId = parsed.StoreId,
StartAt = parsed.StartAt,
EndAt = parsed.EndAt,
PaymentMethod = parsed.PaymentMethod
}, cancellationToken);
return ApiResponse<FinanceSettlementExportResponse>.Ok(new FinanceSettlementExportResponse
{
FileName = result.FileName,
FileContentBase64 = result.FileContentBase64,
TotalCount = result.TotalCount
});
}
private async Task<(long StoreId, DateTime? StartAt, DateTime? EndAt, PaymentMethod? PaymentMethod)> ParseFilterAsync(
FinanceSettlementFilterRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var startAt = ParseDateOrNull(request.StartDate);
var endAt = ParseDateOrNull(request.EndDate)?.AddDays(1);
if (startAt.HasValue && endAt.HasValue && startAt >= endAt)
{
throw new BusinessException(ErrorCodes.BadRequest, "开始日期不能晚于结束日期");
}
return (storeId, startAt, endAt, ParseOptionalSettlementChannel(request.Channel));
}
private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
{
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
}
private static DateTime ParseRequiredDate(string? value, string parameterName)
{
return ParseDateOrNull(value)
?? throw new BusinessException(ErrorCodes.BadRequest, $"{parameterName} 必填,格式为 yyyy-MM-dd");
}
private static DateTime? ParseDateOrNull(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
if (DateTime.TryParseExact(
value,
"yyyy-MM-dd",
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out var parsed))
{
return DateTime.SpecifyKind(parsed.Date, DateTimeKind.Utc);
}
throw new BusinessException(ErrorCodes.BadRequest, "日期格式必须为 yyyy-MM-dd");
}
private static PaymentMethod ParseRequiredSettlementChannel(string? channel)
{
return ParseOptionalSettlementChannel(channel)
?? throw new BusinessException(ErrorCodes.BadRequest, "channel 必填,仅支持 wechat 或 alipay");
}
private static PaymentMethod? ParseOptionalSettlementChannel(string? channel)
{
return (channel ?? string.Empty).Trim().ToLowerInvariant() switch
{
"wechat" => PaymentMethod.WeChatPay,
"alipay" => PaymentMethod.Alipay,
"" => null,
_ => throw new BusinessException(ErrorCodes.BadRequest, "channel 仅支持 wechat 或 alipay")
};
}
private static FinanceSettlementListItemResponse MapListItem(FinanceSettlementListItemDto source)
{
return new FinanceSettlementListItemResponse
{
ArrivedDate = source.ArrivedDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
Channel = source.Channel,
ChannelText = source.ChannelText,
TransactionCount = source.TransactionCount,
ArrivedAmount = source.ArrivedAmount
};
}
private static FinanceSettlementDetailItemResponse MapDetailItem(FinanceSettlementDetailItemDto source)
{
return new FinanceSettlementDetailItemResponse
{
OrderNo = source.OrderNo,
Amount = source.Amount,
PaidAt = source.PaidAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
};
}
}

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,173 @@
namespace TakeoutSaaS.Application.App.Finance.Settlement.Dto;
/// <summary>
/// 到账查询汇总行 DTO。
/// </summary>
public sealed class FinanceSettlementListItemDto
{
/// <summary>
/// 到账日期UTC 日期)。
/// </summary>
public DateTime ArrivedDate { get; set; }
/// <summary>
/// 渠道编码wechat/alipay
/// </summary>
public string Channel { get; set; } = string.Empty;
/// <summary>
/// 渠道文案。
/// </summary>
public string ChannelText { get; set; } = string.Empty;
/// <summary>
/// 交易笔数。
/// </summary>
public int TransactionCount { get; set; }
/// <summary>
/// 到账金额。
/// </summary>
public decimal ArrivedAmount { get; set; }
}
/// <summary>
/// 到账查询分页结果 DTO。
/// </summary>
public sealed class FinanceSettlementListResultDto
{
/// <summary>
/// 列表项。
/// </summary>
public List<FinanceSettlementListItemDto> Items { get; set; } = [];
/// <summary>
/// 总数。
/// </summary>
public int Total { get; set; }
/// <summary>
/// 当前页码。
/// </summary>
public int Page { get; set; }
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; set; }
}
/// <summary>
/// 到账明细行 DTO。
/// </summary>
public sealed class FinanceSettlementDetailItemDto
{
/// <summary>
/// 订单号。
/// </summary>
public string OrderNo { get; set; } = string.Empty;
/// <summary>
/// 金额。
/// </summary>
public decimal Amount { get; set; }
/// <summary>
/// 支付时间UTC
/// </summary>
public DateTime PaidAt { get; set; }
}
/// <summary>
/// 到账明细结果 DTO。
/// </summary>
public sealed class FinanceSettlementDetailResultDto
{
/// <summary>
/// 明细列表。
/// </summary>
public List<FinanceSettlementDetailItemDto> Items { get; set; } = [];
}
/// <summary>
/// 到账统计 DTO。
/// </summary>
public sealed class FinanceSettlementStatsDto
{
/// <summary>
/// 今日到账金额。
/// </summary>
public decimal TodayArrivedAmount { get; set; }
/// <summary>
/// 昨日到账金额。
/// </summary>
public decimal YesterdayArrivedAmount { get; set; }
/// <summary>
/// 本月到账金额。
/// </summary>
public decimal CurrentMonthArrivedAmount { get; set; }
/// <summary>
/// 本月交易笔数。
/// </summary>
public int CurrentMonthTransactionCount { get; set; }
}
/// <summary>
/// 到账账户信息 DTO。
/// </summary>
public sealed class FinanceSettlementAccountDto
{
/// <summary>
/// 银行名称。
/// </summary>
public string BankName { get; set; } = string.Empty;
/// <summary>
/// 开户名。
/// </summary>
public string BankAccountName { get; set; } = string.Empty;
/// <summary>
/// 脱敏银行账号。
/// </summary>
public string BankAccountNoMasked { get; set; } = string.Empty;
/// <summary>
/// 脱敏微信商户号。
/// </summary>
public string WechatMerchantNoMasked { get; set; } = string.Empty;
/// <summary>
/// 脱敏支付宝 PID。
/// </summary>
public string AlipayPidMasked { get; set; } = string.Empty;
/// <summary>
/// 结算周期文案。
/// </summary>
public string SettlementPeriodText { get; set; } = string.Empty;
}
/// <summary>
/// 到账导出 DTO。
/// </summary>
public sealed class FinanceSettlementExportDto
{
/// <summary>
/// 文件名。
/// </summary>
public string FileName { get; set; } = string.Empty;
/// <summary>
/// 文件内容 Base64。
/// </summary>
public string FileContentBase64 { get; set; } = string.Empty;
/// <summary>
/// 导出总数。
/// </summary>
public int TotalCount { get; set; }
}

View File

@@ -0,0 +1,71 @@
using System.Globalization;
using System.Text;
using MediatR;
using TakeoutSaaS.Application.App.Finance.Settlement.Dto;
using TakeoutSaaS.Application.App.Finance.Settlement.Queries;
using TakeoutSaaS.Domain.Finance.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Finance.Settlement.Handlers;
/// <summary>
/// 到账汇总导出处理器。
/// </summary>
public sealed class ExportFinanceSettlementCsvQueryHandler(
IFinanceTransactionRepository financeTransactionRepository,
ITenantProvider tenantProvider)
: IRequestHandler<ExportFinanceSettlementCsvQuery, FinanceSettlementExportDto>
{
/// <inheritdoc />
public async Task<FinanceSettlementExportDto> Handle(
ExportFinanceSettlementCsvQuery request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var rows = await financeTransactionRepository.ListSettlementForExportAsync(
tenantId,
request.StoreId,
request.StartAt,
request.EndAt,
request.PaymentMethod,
cancellationToken);
var list = rows.Select(FinanceSettlementMapping.ToListItem).ToList();
var csv = BuildCsv(list);
return new FinanceSettlementExportDto
{
FileName = $"settlement-{request.StoreId}-{DateTime.UtcNow:yyyyMMddHHmmss}.csv",
FileContentBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(csv)),
TotalCount = list.Count
};
}
private static string BuildCsv(IReadOnlyList<FinanceSettlementListItemDto> rows)
{
var sb = new StringBuilder();
sb.Append('\uFEFF');
sb.AppendLine("到账日期,支付渠道,交易笔数,到账金额");
foreach (var row in rows)
{
sb.AppendLine(string.Join(',',
Escape(row.ArrivedDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)),
Escape(row.ChannelText),
Escape(row.TransactionCount.ToString(CultureInfo.InvariantCulture)),
Escape(FinanceSettlementMapping.FormatAmount(row.ArrivedAmount))));
}
return sb.ToString();
}
private static string Escape(string? value)
{
var normalized = value ?? string.Empty;
if (normalized.Contains(',') || normalized.Contains('"') || normalized.Contains('\n'))
{
return $"\"{normalized.Replace("\"", "\"\"", StringComparison.Ordinal)}\"";
}
return normalized;
}
}

View File

@@ -0,0 +1,75 @@
using System.Globalization;
using TakeoutSaaS.Application.App.Finance.Settlement.Dto;
using TakeoutSaaS.Domain.Finance.Models;
using TakeoutSaaS.Domain.Payments.Enums;
namespace TakeoutSaaS.Application.App.Finance.Settlement.Handlers;
/// <summary>
/// 到账查询映射辅助。
/// </summary>
internal static class FinanceSettlementMapping
{
/// <summary>
/// 支付方式转渠道编码。
/// </summary>
public static string ToChannelCode(PaymentMethod paymentMethod)
{
return paymentMethod switch
{
PaymentMethod.WeChatPay => "wechat",
PaymentMethod.Alipay => "alipay",
_ => "unknown"
};
}
/// <summary>
/// 支付方式转渠道文案。
/// </summary>
public static string ToChannelText(PaymentMethod paymentMethod)
{
return paymentMethod switch
{
PaymentMethod.WeChatPay => "微信支付",
PaymentMethod.Alipay => "支付宝",
_ => "未知渠道"
};
}
/// <summary>
/// 映射到账汇总行。
/// </summary>
public static FinanceSettlementListItemDto ToListItem(FinanceSettlementListItemSnapshot source)
{
return new FinanceSettlementListItemDto
{
ArrivedDate = source.ArrivedDate,
Channel = ToChannelCode(source.PaymentMethod),
ChannelText = ToChannelText(source.PaymentMethod),
TransactionCount = source.TransactionCount,
ArrivedAmount = decimal.Round(source.ArrivedAmount, 2, MidpointRounding.AwayFromZero)
};
}
/// <summary>
/// 映射到账明细行。
/// </summary>
public static FinanceSettlementDetailItemDto ToDetailItem(FinanceSettlementDetailItemSnapshot source)
{
return new FinanceSettlementDetailItemDto
{
OrderNo = source.OrderNo,
Amount = decimal.Round(source.Amount, 2, MidpointRounding.AwayFromZero),
PaidAt = source.PaidAt
};
}
/// <summary>
/// 格式化金额(导出场景)。
/// </summary>
public static string FormatAmount(decimal value)
{
return decimal.Round(value, 2, MidpointRounding.AwayFromZero)
.ToString("0.00", CultureInfo.InvariantCulture);
}
}

View File

@@ -0,0 +1,42 @@
using MediatR;
using TakeoutSaaS.Application.App.Finance.Settlement.Dto;
using TakeoutSaaS.Application.App.Finance.Settlement.Queries;
using TakeoutSaaS.Domain.Finance.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Finance.Settlement.Handlers;
/// <summary>
/// 到账账户信息查询处理器。
/// </summary>
public sealed class GetFinanceSettlementAccountQueryHandler(
IFinanceTransactionRepository financeTransactionRepository,
ITenantProvider tenantProvider)
: IRequestHandler<GetFinanceSettlementAccountQuery, FinanceSettlementAccountDto?>
{
/// <inheritdoc />
public async Task<FinanceSettlementAccountDto?> Handle(
GetFinanceSettlementAccountQuery request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var snapshot = await financeTransactionRepository.GetSettlementAccountAsync(
tenantId,
cancellationToken);
if (snapshot is null)
{
return null;
}
return new FinanceSettlementAccountDto
{
BankName = snapshot.BankName,
BankAccountName = snapshot.BankAccountName,
BankAccountNoMasked = snapshot.BankAccountNoMasked,
WechatMerchantNoMasked = snapshot.WechatMerchantNoMasked,
AlipayPidMasked = snapshot.AlipayPidMasked,
SettlementPeriodText = snapshot.SettlementPeriodText
};
}
}

View File

@@ -0,0 +1,36 @@
using MediatR;
using TakeoutSaaS.Application.App.Finance.Settlement.Dto;
using TakeoutSaaS.Application.App.Finance.Settlement.Queries;
using TakeoutSaaS.Domain.Finance.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Finance.Settlement.Handlers;
/// <summary>
/// 到账明细查询处理器。
/// </summary>
public sealed class GetFinanceSettlementDetailQueryHandler(
IFinanceTransactionRepository financeTransactionRepository,
ITenantProvider tenantProvider)
: IRequestHandler<GetFinanceSettlementDetailQuery, FinanceSettlementDetailResultDto>
{
/// <inheritdoc />
public async Task<FinanceSettlementDetailResultDto> Handle(
GetFinanceSettlementDetailQuery request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var rows = await financeTransactionRepository.GetSettlementDetailsAsync(
tenantId,
request.StoreId,
request.ArrivedDate,
request.PaymentMethod,
request.Take,
cancellationToken);
return new FinanceSettlementDetailResultDto
{
Items = rows.Select(FinanceSettlementMapping.ToDetailItem).ToList()
};
}
}

View File

@@ -0,0 +1,37 @@
using MediatR;
using TakeoutSaaS.Application.App.Finance.Settlement.Dto;
using TakeoutSaaS.Application.App.Finance.Settlement.Queries;
using TakeoutSaaS.Domain.Finance.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Finance.Settlement.Handlers;
/// <summary>
/// 到账统计查询处理器。
/// </summary>
public sealed class GetFinanceSettlementStatsQueryHandler(
IFinanceTransactionRepository financeTransactionRepository,
ITenantProvider tenantProvider)
: IRequestHandler<GetFinanceSettlementStatsQuery, FinanceSettlementStatsDto>
{
/// <inheritdoc />
public async Task<FinanceSettlementStatsDto> Handle(
GetFinanceSettlementStatsQuery request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var snapshot = await financeTransactionRepository.GetSettlementStatsAsync(
tenantId,
request.StoreId,
DateTime.UtcNow,
cancellationToken);
return new FinanceSettlementStatsDto
{
TodayArrivedAmount = snapshot.TodayArrivedAmount,
YesterdayArrivedAmount = snapshot.YesterdayArrivedAmount,
CurrentMonthArrivedAmount = snapshot.CurrentMonthArrivedAmount,
CurrentMonthTransactionCount = snapshot.CurrentMonthTransactionCount
};
}
}

View File

@@ -0,0 +1,44 @@
using MediatR;
using TakeoutSaaS.Application.App.Finance.Settlement.Dto;
using TakeoutSaaS.Application.App.Finance.Settlement.Queries;
using TakeoutSaaS.Domain.Finance.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Finance.Settlement.Handlers;
/// <summary>
/// 到账汇总分页查询处理器。
/// </summary>
public sealed class SearchFinanceSettlementListQueryHandler(
IFinanceTransactionRepository financeTransactionRepository,
ITenantProvider tenantProvider)
: IRequestHandler<SearchFinanceSettlementListQuery, FinanceSettlementListResultDto>
{
/// <inheritdoc />
public async Task<FinanceSettlementListResultDto> Handle(
SearchFinanceSettlementListQuery request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var normalizedPage = Math.Max(1, request.Page);
var normalizedPageSize = Math.Clamp(request.PageSize, 1, 200);
var snapshot = await financeTransactionRepository.SearchSettlementPageAsync(
tenantId,
request.StoreId,
request.StartAt,
request.EndAt,
request.PaymentMethod,
normalizedPage,
normalizedPageSize,
cancellationToken);
return new FinanceSettlementListResultDto
{
Items = snapshot.Items.Select(FinanceSettlementMapping.ToListItem).ToList(),
Total = snapshot.TotalCount,
Page = normalizedPage,
PageSize = normalizedPageSize
};
}
}

View File

@@ -0,0 +1,31 @@
using MediatR;
using TakeoutSaaS.Application.App.Finance.Settlement.Dto;
using TakeoutSaaS.Domain.Payments.Enums;
namespace TakeoutSaaS.Application.App.Finance.Settlement.Queries;
/// <summary>
/// 导出到账汇总 CSV。
/// </summary>
public sealed class ExportFinanceSettlementCsvQuery : IRequest<FinanceSettlementExportDto>
{
/// <summary>
/// 门店标识。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 开始时间UTC闭区间
/// </summary>
public DateTime? StartAt { get; init; }
/// <summary>
/// 结束时间UTC开区间
/// </summary>
public DateTime? EndAt { get; init; }
/// <summary>
/// 支付方式筛选。
/// </summary>
public PaymentMethod? PaymentMethod { get; init; }
}

View File

@@ -0,0 +1,11 @@
using MediatR;
using TakeoutSaaS.Application.App.Finance.Settlement.Dto;
namespace TakeoutSaaS.Application.App.Finance.Settlement.Queries;
/// <summary>
/// 查询到账账户信息。
/// </summary>
public sealed class GetFinanceSettlementAccountQuery : IRequest<FinanceSettlementAccountDto?>
{
}

View File

@@ -0,0 +1,31 @@
using MediatR;
using TakeoutSaaS.Application.App.Finance.Settlement.Dto;
using TakeoutSaaS.Domain.Payments.Enums;
namespace TakeoutSaaS.Application.App.Finance.Settlement.Queries;
/// <summary>
/// 查询到账明细。
/// </summary>
public sealed class GetFinanceSettlementDetailQuery : IRequest<FinanceSettlementDetailResultDto>
{
/// <summary>
/// 门店标识。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 到账日期UTC 日期)。
/// </summary>
public DateTime ArrivedDate { get; init; }
/// <summary>
/// 渠道(微信/支付宝)。
/// </summary>
public PaymentMethod PaymentMethod { get; init; }
/// <summary>
/// 限制条数。
/// </summary>
public int Take { get; init; } = 20;
}

View File

@@ -0,0 +1,15 @@
using MediatR;
using TakeoutSaaS.Application.App.Finance.Settlement.Dto;
namespace TakeoutSaaS.Application.App.Finance.Settlement.Queries;
/// <summary>
/// 查询到账统计。
/// </summary>
public sealed class GetFinanceSettlementStatsQuery : IRequest<FinanceSettlementStatsDto>
{
/// <summary>
/// 门店标识。
/// </summary>
public long StoreId { get; init; }
}

View File

@@ -0,0 +1,41 @@
using MediatR;
using TakeoutSaaS.Application.App.Finance.Settlement.Dto;
using TakeoutSaaS.Domain.Payments.Enums;
namespace TakeoutSaaS.Application.App.Finance.Settlement.Queries;
/// <summary>
/// 查询到账汇总分页。
/// </summary>
public sealed class SearchFinanceSettlementListQuery : IRequest<FinanceSettlementListResultDto>
{
/// <summary>
/// 门店标识。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 开始时间UTC闭区间
/// </summary>
public DateTime? StartAt { get; init; }
/// <summary>
/// 结束时间UTC开区间
/// </summary>
public DateTime? EndAt { get; init; }
/// <summary>
/// 支付方式筛选。
/// </summary>
public PaymentMethod? PaymentMethod { get; init; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 20;
}

View File

@@ -60,6 +60,16 @@ public sealed record SubmitTenantVerificationCommand : IRequest<TenantVerificati
/// </summary>
public string? BankName { get; init; }
/// <summary>
/// 微信商户号。
/// </summary>
public string? WeChatMerchantNo { get; init; }
/// <summary>
/// 支付宝 PID。
/// </summary>
public string? AlipayPid { get; init; }
/// <summary>
/// 其他补充资料 JSON。
/// </summary>

View File

@@ -71,6 +71,16 @@ public sealed class TenantVerificationDto
/// </summary>
public string? BankName { get; init; }
/// <summary>
/// 微信商户号。
/// </summary>
public string? WeChatMerchantNo { get; init; }
/// <summary>
/// 支付宝 PID。
/// </summary>
public string? AlipayPid { get; init; }
/// <summary>
/// 附加资料JSON
/// </summary>

View File

@@ -54,6 +54,8 @@ public sealed class SubmitTenantVerificationCommandHandler(
profile.BankAccountName = request.BankAccountName;
profile.BankAccountNumber = request.BankAccountNumber;
profile.BankName = request.BankName;
profile.WeChatMerchantNo = request.WeChatMerchantNo;
profile.AlipayPid = request.AlipayPid;
profile.AdditionalDataJson = request.AdditionalDataJson;
profile.Status = TenantVerificationStatus.Pending;
profile.SubmittedAt = DateTime.UtcNow;

View File

@@ -31,6 +31,8 @@ internal static class TenantMapping
BankAccountName = profile.BankAccountName,
BankAccountNumber = profile.BankAccountNumber,
BankName = profile.BankName,
WeChatMerchantNo = profile.WeChatMerchantNo,
AlipayPid = profile.AlipayPid,
AdditionalDataJson = profile.AdditionalDataJson,
SubmittedAt = profile.SubmittedAt,
ReviewRemarks = profile.ReviewRemarks,

View File

@@ -0,0 +1,128 @@
using TakeoutSaaS.Domain.Payments.Enums;
namespace TakeoutSaaS.Domain.Finance.Models;
/// <summary>
/// 到账查询汇总行。
/// </summary>
public sealed record FinanceSettlementListItemSnapshot
{
/// <summary>
/// 到账日期UTC 日期)。
/// </summary>
public required DateTime ArrivedDate { get; init; }
/// <summary>
/// 支付方式。
/// </summary>
public required PaymentMethod PaymentMethod { get; init; }
/// <summary>
/// 交易笔数。
/// </summary>
public required int TransactionCount { get; init; }
/// <summary>
/// 到账金额。
/// </summary>
public required decimal ArrivedAmount { get; init; }
}
/// <summary>
/// 到账查询明细行。
/// </summary>
public sealed record FinanceSettlementDetailItemSnapshot
{
/// <summary>
/// 订单号。
/// </summary>
public required string OrderNo { get; init; }
/// <summary>
/// 支付金额。
/// </summary>
public required decimal Amount { get; init; }
/// <summary>
/// 支付时间UTC
/// </summary>
public required DateTime PaidAt { get; init; }
}
/// <summary>
/// 到账查询分页快照。
/// </summary>
public sealed record FinanceSettlementPageSnapshot
{
/// <summary>
/// 列表项。
/// </summary>
public required IReadOnlyList<FinanceSettlementListItemSnapshot> Items { get; init; }
/// <summary>
/// 总数。
/// </summary>
public required int TotalCount { get; init; }
}
/// <summary>
/// 到账概览统计快照。
/// </summary>
public sealed record FinanceSettlementStatsSnapshot
{
/// <summary>
/// 今日到账。
/// </summary>
public required decimal TodayArrivedAmount { get; init; }
/// <summary>
/// 昨日到账。
/// </summary>
public required decimal YesterdayArrivedAmount { get; init; }
/// <summary>
/// 本月到账。
/// </summary>
public required decimal CurrentMonthArrivedAmount { get; init; }
/// <summary>
/// 本月交易笔数。
/// </summary>
public required int CurrentMonthTransactionCount { get; init; }
}
/// <summary>
/// 到账账户信息快照。
/// </summary>
public sealed record FinanceSettlementAccountSnapshot
{
/// <summary>
/// 银行名称。
/// </summary>
public required string BankName { get; init; }
/// <summary>
/// 开户名。
/// </summary>
public required string BankAccountName { get; init; }
/// <summary>
/// 脱敏银行账号。
/// </summary>
public required string BankAccountNoMasked { get; init; }
/// <summary>
/// 微信商户号(脱敏)。
/// </summary>
public required string WechatMerchantNoMasked { get; init; }
/// <summary>
/// 支付宝 PID脱敏
/// </summary>
public required string AlipayPidMasked { get; init; }
/// <summary>
/// 结算周期文案。
/// </summary>
public required string SettlementPeriodText { get; init; }
}

View File

@@ -63,4 +63,55 @@ public interface IFinanceTransactionRepository
PaymentMethod? paymentMethod,
string? keyword,
CancellationToken cancellationToken = default);
/// <summary>
/// 查询到账概览统计。
/// </summary>
Task<FinanceSettlementStatsSnapshot> GetSettlementStatsAsync(
long tenantId,
long storeId,
DateTime currentUtc,
CancellationToken cancellationToken = default);
/// <summary>
/// 查询到账账户信息。
/// </summary>
Task<FinanceSettlementAccountSnapshot?> GetSettlementAccountAsync(
long tenantId,
CancellationToken cancellationToken = default);
/// <summary>
/// 查询到账汇总分页。
/// </summary>
Task<FinanceSettlementPageSnapshot> SearchSettlementPageAsync(
long tenantId,
long storeId,
DateTime? startAt,
DateTime? endAt,
PaymentMethod? paymentMethod,
int page,
int pageSize,
CancellationToken cancellationToken = default);
/// <summary>
/// 查询到账明细。
/// </summary>
Task<IReadOnlyList<FinanceSettlementDetailItemSnapshot>> GetSettlementDetailsAsync(
long tenantId,
long storeId,
DateTime arrivedDate,
PaymentMethod paymentMethod,
int take,
CancellationToken cancellationToken = default);
/// <summary>
/// 查询到账导出数据。
/// </summary>
Task<IReadOnlyList<FinanceSettlementListItemSnapshot>> ListSettlementForExportAsync(
long tenantId,
long storeId,
DateTime? startAt,
DateTime? endAt,
PaymentMethod? paymentMethod,
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

@@ -1,3 +1,4 @@
using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Entities;
@@ -63,6 +64,18 @@ public sealed class TenantVerificationProfile : AuditableEntityBase
/// </summary>
public string? BankName { get; set; }
/// <summary>
/// 微信商户号。
/// </summary>
[MaxLength(64)]
public string? WeChatMerchantNo { get; set; }
/// <summary>
/// 支付宝 PID。
/// </summary>
[MaxLength(64)]
public string? AlipayPid { get; set; }
/// <summary>
/// 附加资料JSON
/// </summary>

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

@@ -69,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

@@ -95,6 +95,14 @@ 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>();
@@ -534,6 +542,8 @@ 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>());
@@ -1053,6 +1063,52 @@ 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");
@@ -2313,4 +2369,3 @@ public sealed class TakeoutAppDbContext(
builder.HasIndex(x => new { x.TenantId, x.QuotaPackageId, x.PurchasedAt });
}
}

View File

@@ -171,6 +171,203 @@ public sealed class EfFinanceTransactionRepository(TakeoutAppDbContext context)
return rows.Select(MapToRecord).ToList();
}
/// <inheritdoc />
public async Task<FinanceSettlementStatsSnapshot> GetSettlementStatsAsync(
long tenantId,
long storeId,
DateTime currentUtc,
CancellationToken cancellationToken = default)
{
var utcNow = NormalizeUtc(currentUtc);
var todayStart = new DateTime(utcNow.Year, utcNow.Month, utcNow.Day, 0, 0, 0, DateTimeKind.Utc);
var tomorrowStart = todayStart.AddDays(1);
var yesterdayStart = todayStart.AddDays(-1);
var monthStart = new DateTime(utcNow.Year, utcNow.Month, 1, 0, 0, 0, DateTimeKind.Utc);
var monthEnd = monthStart.AddMonths(1);
var query = BuildSettlementPaymentQuery(
tenantId,
storeId,
startAt: null,
endAt: null,
paymentMethod: null);
var summary = await query
.GroupBy(_ => 1)
.Select(group => new
{
TodayArrivedAmount = group
.Where(item => item.PaidAt >= todayStart && item.PaidAt < tomorrowStart)
.Sum(item => item.Amount),
YesterdayArrivedAmount = group
.Where(item => item.PaidAt >= yesterdayStart && item.PaidAt < todayStart)
.Sum(item => item.Amount),
CurrentMonthArrivedAmount = group
.Where(item => item.PaidAt >= monthStart && item.PaidAt < monthEnd)
.Sum(item => item.Amount),
CurrentMonthTransactionCount = group
.Count(item => item.PaidAt >= monthStart && item.PaidAt < monthEnd)
})
.FirstOrDefaultAsync(cancellationToken);
if (summary is null)
{
return new FinanceSettlementStatsSnapshot
{
TodayArrivedAmount = 0,
YesterdayArrivedAmount = 0,
CurrentMonthArrivedAmount = 0,
CurrentMonthTransactionCount = 0
};
}
return new FinanceSettlementStatsSnapshot
{
TodayArrivedAmount = decimal.Round(summary.TodayArrivedAmount, 2, MidpointRounding.AwayFromZero),
YesterdayArrivedAmount = decimal.Round(summary.YesterdayArrivedAmount, 2, MidpointRounding.AwayFromZero),
CurrentMonthArrivedAmount = decimal.Round(summary.CurrentMonthArrivedAmount, 2, MidpointRounding.AwayFromZero),
CurrentMonthTransactionCount = summary.CurrentMonthTransactionCount
};
}
/// <inheritdoc />
public async Task<FinanceSettlementAccountSnapshot?> GetSettlementAccountAsync(
long tenantId,
CancellationToken cancellationToken = default)
{
var profile = await context.TenantVerificationProfiles
.AsNoTracking()
.Where(item => item.TenantId == tenantId && item.DeletedAt == null)
.Select(item => new
{
item.BankName,
item.BankAccountName,
item.BankAccountNumber,
item.WeChatMerchantNo,
item.AlipayPid
})
.FirstOrDefaultAsync(cancellationToken);
if (profile is null)
{
return null;
}
return new FinanceSettlementAccountSnapshot
{
BankName = (profile.BankName ?? string.Empty).Trim(),
BankAccountName = (profile.BankAccountName ?? string.Empty).Trim(),
BankAccountNoMasked = MaskBankAccountNo(profile.BankAccountNumber),
WechatMerchantNoMasked = MaskWechatMerchantNo(profile.WeChatMerchantNo),
AlipayPidMasked = MaskAlipayPid(profile.AlipayPid),
SettlementPeriodText = "T+1 自动到账"
};
}
/// <inheritdoc />
public async Task<FinanceSettlementPageSnapshot> SearchSettlementPageAsync(
long tenantId,
long storeId,
DateTime? startAt,
DateTime? endAt,
PaymentMethod? paymentMethod,
int page,
int pageSize,
CancellationToken cancellationToken = default)
{
var normalizedPage = Math.Max(1, page);
var normalizedPageSize = Math.Clamp(pageSize, 1, 200);
var groupedQuery = BuildSettlementPaymentQuery(tenantId, storeId, startAt, endAt, paymentMethod)
.GroupBy(item => new { ArrivedDate = item.PaidAt.Date, item.PaymentMethod })
.Select(group => new FinanceSettlementListItemSnapshot
{
ArrivedDate = DateTime.SpecifyKind(group.Key.ArrivedDate, DateTimeKind.Utc),
PaymentMethod = group.Key.PaymentMethod,
TransactionCount = group.Count(),
ArrivedAmount = decimal.Round(group.Sum(item => item.Amount), 2, MidpointRounding.AwayFromZero)
});
var totalCount = await groupedQuery.CountAsync(cancellationToken);
if (totalCount == 0)
{
return new FinanceSettlementPageSnapshot
{
Items = [],
TotalCount = 0
};
}
var items = await groupedQuery
.OrderByDescending(item => item.ArrivedDate)
.ThenBy(item => item.PaymentMethod)
.Skip((normalizedPage - 1) * normalizedPageSize)
.Take(normalizedPageSize)
.ToListAsync(cancellationToken);
return new FinanceSettlementPageSnapshot
{
Items = items,
TotalCount = totalCount
};
}
/// <inheritdoc />
public async Task<IReadOnlyList<FinanceSettlementDetailItemSnapshot>> GetSettlementDetailsAsync(
long tenantId,
long storeId,
DateTime arrivedDate,
PaymentMethod paymentMethod,
int take,
CancellationToken cancellationToken = default)
{
var arrivedDay = NormalizeUtc(arrivedDate);
var dayStart = new DateTime(arrivedDay.Year, arrivedDay.Month, arrivedDay.Day, 0, 0, 0, DateTimeKind.Utc);
var dayEnd = dayStart.AddDays(1);
var normalizedTake = Math.Clamp(take, 1, 200);
return await BuildSettlementPaymentQuery(
tenantId,
storeId,
dayStart,
dayEnd,
paymentMethod)
.OrderByDescending(item => item.PaidAt)
.ThenByDescending(item => item.PaymentRecordId)
.Select(item => new FinanceSettlementDetailItemSnapshot
{
OrderNo = item.OrderNo,
Amount = decimal.Round(item.Amount, 2, MidpointRounding.AwayFromZero),
PaidAt = item.PaidAt
})
.Take(normalizedTake)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<FinanceSettlementListItemSnapshot>> ListSettlementForExportAsync(
long tenantId,
long storeId,
DateTime? startAt,
DateTime? endAt,
PaymentMethod? paymentMethod,
CancellationToken cancellationToken = default)
{
return await BuildSettlementPaymentQuery(tenantId, storeId, startAt, endAt, paymentMethod)
.GroupBy(item => new { ArrivedDate = item.PaidAt.Date, item.PaymentMethod })
.Select(group => new FinanceSettlementListItemSnapshot
{
ArrivedDate = DateTime.SpecifyKind(group.Key.ArrivedDate, DateTimeKind.Utc),
PaymentMethod = group.Key.PaymentMethod,
TransactionCount = group.Count(),
ArrivedAmount = decimal.Round(group.Sum(item => item.Amount), 2, MidpointRounding.AwayFromZero)
})
.OrderByDescending(item => item.ArrivedDate)
.ThenBy(item => item.PaymentMethod)
.Take(20_000)
.ToListAsync(cancellationToken);
}
private IQueryable<TransactionProjection> BuildQuery(
long tenantId,
long storeId,
@@ -385,6 +582,50 @@ public sealed class EfFinanceTransactionRepository(TakeoutAppDbContext context)
return query;
}
private IQueryable<SettlementPaymentProjection> BuildSettlementPaymentQuery(
long tenantId,
long storeId,
DateTime? startAt,
DateTime? endAt,
PaymentMethod? paymentMethod)
{
var query =
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
&& order.StoreId == storeId
&& payment.Status == PaymentStatus.Paid
&& payment.PaidAt.HasValue
&& (payment.Method == PaymentMethod.WeChatPay || payment.Method == PaymentMethod.Alipay)
select new SettlementPaymentProjection
{
PaymentRecordId = payment.Id,
OrderNo = order.OrderNo,
PaymentMethod = payment.Method,
Amount = payment.Amount,
PaidAt = payment.PaidAt!.Value
};
if (startAt.HasValue)
{
query = query.Where(item => item.PaidAt >= startAt.Value);
}
if (endAt.HasValue)
{
query = query.Where(item => item.PaidAt < endAt.Value);
}
if (paymentMethod.HasValue)
{
query = query.Where(item => item.PaymentMethod == paymentMethod.Value);
}
return query;
}
private static FinanceTransactionRecord MapToRecord(TransactionProjection source)
{
return new FinanceTransactionRecord
@@ -503,4 +744,60 @@ public sealed class EfFinanceTransactionRepository(TakeoutAppDbContext context)
public int? PointBalanceAfterChange { get; init; }
}
private sealed class SettlementPaymentProjection
{
public required long PaymentRecordId { get; init; }
public required string OrderNo { get; init; }
public required PaymentMethod PaymentMethod { get; init; }
public required decimal Amount { get; init; }
public required DateTime PaidAt { get; init; }
}
private static DateTime NormalizeUtc(DateTime value)
{
return value.Kind switch
{
DateTimeKind.Utc => value,
DateTimeKind.Local => value.ToUniversalTime(),
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
};
}
private static string MaskBankAccountNo(string? value)
{
var digits = new string((value ?? string.Empty).Where(char.IsDigit).ToArray());
if (digits.Length >= 4)
{
return $"****{digits[^4..]}";
}
return digits;
}
private static string MaskWechatMerchantNo(string? value)
{
var normalized = (value ?? string.Empty).Trim();
if (normalized.Length >= 4)
{
return $"{normalized[..2]}{new string('x', normalized.Length - 2)}";
}
return normalized;
}
private static string MaskAlipayPid(string? value)
{
var normalized = (value ?? string.Empty).Trim();
if (normalized.Length > 6)
{
return $"{normalized[..4]}{new string('x', normalized.Length - 4)}";
}
return normalized;
}
}

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,42 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TakeoutSaaS.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddTenantVerificationSettlementChannels : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "AlipayPid",
table: "tenant_verification_profiles",
type: "character varying(64)",
maxLength: 64,
nullable: true,
comment: "支付宝 PID。");
migrationBuilder.AddColumn<string>(
name: "WeChatMerchantNo",
table: "tenant_verification_profiles",
type: "character varying(64)",
maxLength: 64,
nullable: true,
comment: "微信商户号。");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AlipayPid",
table: "tenant_verification_profiles");
migrationBuilder.DropColumn(
name: "WeChatMerchantNo",
table: "tenant_verification_profiles");
}
}
}

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

@@ -9711,6 +9711,11 @@ namespace TakeoutSaaS.Infrastructure.Migrations
.HasColumnType("text")
.HasComment("附加资料JSON。");
b.Property<string>("AlipayPid")
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasComment("支付宝 PID。");
b.Property<string>("BankAccountName")
.HasMaxLength(128)
.HasColumnType("character varying(128)")
@@ -9810,6 +9815,11 @@ namespace TakeoutSaaS.Infrastructure.Migrations
.HasColumnType("bigint")
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
b.Property<string>("WeChatMerchantNo")
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasComment("微信商户号。");
b.HasKey("Id");
b.HasIndex("TenantId")