不可篡改专业工时计费与审计追踪 (Timekeeping & Billing Architecture)
在律所、会计师事务所、咨询机构等专业服务行业中,工时(Time Entry)即资产。客户不仅要求按分钟精确记录服务耗时,还要求账单具备极高的防篡改性与审计溯源性。
BitzOrcas.Platform.Timekeeping 提供了工业级的专业工时管理中心。它支持律师/顾问多级费率矩阵、计时单生命周期状态机流转、开票锁定保护以及严格的不可篡改审计追踪链。
1. 工时生命周期状态机
每一个工时条目(TimeEntry)遵循严格的状态流转约束,一旦进入开票流程即永久锁定修改权:
2. 核心聚合根与费率矩阵:TimeEntry.cs
工时聚合根位于 BitzOrcas.Platform.Timekeeping.Contracts(或对应 Domain 空间),继承 TenantAggregateRoot<string>,使用适配器中立的 [BitzTable] 与 [BitzColumn] 声明元数据,对外部完全封闭属性修改:
using System;using System.ComponentModel;using BitzOrcas.Domain.Contracts;using BitzOrcas.Domain.Entities;using BitzOrcas.Domain.Results;using BitzOrcas.Domain.Tenancy;using BitzOrcas.Persistence.Metadata;using BitzOrcas.Platform.Timekeeping.Contracts;
namespace BitzOrcas.Platform.Timekeeping.Domain;
/// <summary>/// 工时条目状态枚举/// </summary>public enum TimeEntryStatus{ Draft = 1, Submitted = 2, Approved = 3, Billed = 4, WrittenOff = 5}
/// <summary>/// 专业工时聚合根/// </summary>/// <remarks>/// 声明适配器中立元数据;雪花标识、租户隔离、审计追踪与软删除由基类统一定义。/// </remarks>[BitzTable("TimekeepingEntry", IsTenant = true, IsSoftDelete = true, Description = "专业工时记录")]public sealed class TimeEntry : TenantAggregateRoot<string>{ /// <summary> /// 关联合同或案件项目 ID /// </summary> [BitzColumn(Length = 64, IsRequired = true)] public string MatterId { get; private set; } = string.Empty;
/// <summary> /// 执行专业人员用户 ID /// </summary> [BitzColumn(Length = 64, IsRequired = true)] public string StaffUserId { get; private set; } = string.Empty;
/// <summary> /// 服务发生日期 /// </summary> [BitzColumn(IsRequired = true)] public DateOnly WorkDate { get; private set; }
/// <summary> /// 投入工时(单位:小时,支持 0.1 小时即 6 分钟粒度) /// </summary> [BitzColumn(Precision = 8, Scale = 2, IsRequired = true)] public decimal Hours { get; private set; }
/// <summary> /// 适用的小时收费单价(元/小时) /// </summary> [BitzColumn(Precision = 18, Scale = 2, IsRequired = true)] public decimal HourlyRate { get; private set; }
/// <summary> /// 算定应收金额 = Hours * HourlyRate /// </summary> [BitzColumn(Precision = 18, Scale = 2, IsRequired = true)] public decimal TotalFee { get; private set; }
/// <summary> /// 工作内容详细描述 /// </summary> [BitzColumn(Length = 2000, IsRequired = true)] public string Narrative { get; private set; } = string.Empty;
/// <summary> /// 当前工时单状态 /// </summary> [BitzColumn(IsRequired = true)] public TimeEntryStatus Status { get; private set; } = TimeEntryStatus.Draft;
/// <summary> /// 关联的发票流水号(仅在 Billed 状态下有值) /// </summary> [BitzColumn(Length = 64, IsRequired = false)] public string? InvoiceNumber { get; private set; }
/// <summary> /// 仅供 ORM 持久化物化使用的无参构造 /// </summary> [Obsolete("For ORM materialization only. Use Create.", error: true)] [EditorBrowsable(EditorBrowsableState.Never)] public TimeEntry() : base("0") { }
private TimeEntry( string id, string tenantId, string matterId, string staffUserId, DateOnly workDate, decimal hours, decimal hourlyRate, string narrative) : base(id) { TenantId = tenantId; MatterId = matterId; StaffUserId = staffUserId; WorkDate = workDate; Hours = hours; HourlyRate = hourlyRate; TotalFee = hours * hourlyRate; Narrative = narrative; Status = TimeEntryStatus.Draft; }
/// <summary> /// 创建工时条目聚合根 /// </summary> public static Result<TimeEntry> Create( string? tenantId, string matterId, string staffUserId, DateOnly workDate, decimal hours, decimal hourlyRate, string narrative) { if (string.IsNullOrWhiteSpace(tenantId) || !TenancyDefaults.IsValid(tenantId)) { return Result<TimeEntry>.Failure(TimekeepingErrors.TenantRequired); }
if (string.IsNullOrWhiteSpace(matterId)) { return Result<TimeEntry>.Failure(TimekeepingErrors.MatterIdRequired); }
if (string.IsNullOrWhiteSpace(staffUserId)) { return Result<TimeEntry>.Failure(TimekeepingErrors.StaffUserIdRequired); }
if (hours <= 0 || hours > 24) { return Result<TimeEntry>.Failure(TimekeepingErrors.HoursInvalid); }
if (hourlyRate < 0) { return Result<TimeEntry>.Failure(TimekeepingErrors.HourlyRateInvalid); }
if (string.IsNullOrWhiteSpace(narrative)) { return Result<TimeEntry>.Failure(TimekeepingErrors.NarrativeRequired); }
var entry = new TimeEntry( "0", tenantId, matterId.Trim(), staffUserId.Trim(), workDate, hours, hourlyRate, narrative.Trim());
return Result<TimeEntry>.Success(entry); }
/// <summary> /// 更新工时与服务叙述(严格守卫未开票状态) /// </summary> public Result Update(decimal newHours, string newNarrative) { // 核心锁定防线:已开票状态物理禁止任何形式的变更 if (Status == TimeEntryStatus.Billed) { return Result.Failure(TimekeepingErrors.AlreadyBilledImmutable); }
if (newHours <= 0 || newHours > 24) { return Result.Failure(TimekeepingErrors.HoursInvalid); }
if (string.IsNullOrWhiteSpace(newNarrative)) { return Result.Failure(TimekeepingErrors.NarrativeRequired); }
Hours = newHours; Narrative = newNarrative.Trim(); TotalFee = Hours * HourlyRate;
return Result.Success(); }
/// <summary> /// 锁定并关联发票流水 /// </summary> public Result MarkAsBilled(string invoiceNumber) { if (Status == TimeEntryStatus.Billed) { return Result.Failure(TimekeepingErrors.AlreadyBilledImmutable); }
if (string.IsNullOrWhiteSpace(invoiceNumber)) { return Result.Failure(TimekeepingErrors.InvoiceNumberRequired); }
Status = TimeEntryStatus.Billed; InvoiceNumber = invoiceNumber.Trim(); return Result.Success(); }}3. 防篡改锁定与开票联动
为了防范“已向客户开票收款后、内部人员私自篡改工时记录”的道德风险,在 Timekeeping 的修改 Handler 中,写操作通过 ORM 中立的窄仓储 ICommandRepository<TimeEntry, string> 恢复聚合,将变更守卫下推至领域聚合根内部:
using BitzOrcas.Domain.Abstractions;using BitzOrcas.Domain.Results;using BitzOrcas.Platform.Timekeeping.Contracts;using BitzOrcas.Platform.Timekeeping.Domain;using Mediator;
namespace BitzOrcas.Platform.Timekeeping.Application.Commands;
/// <summary>/// 更新工时单命令契约/// </summary>public sealed record UpdateTimeEntryCommand( string Id, decimal Hours, string Narrative) : ICommand<Result>;
/// <summary>/// 更新工时单命令处理器/// </summary>public sealed class UpdateTimeEntryCommandHandler( ICommandRepository<TimeEntry, string> repository) : ICommandHandler<UpdateTimeEntryCommand, Result>{ public async ValueTask<Result> Handle( UpdateTimeEntryCommand command, CancellationToken cancellationToken) { // 1. 从命令仓储加载聚合根 var entryResult = await repository.FindAsync(command.Id, cancellationToken); if (entryResult.IsFailure) { return Result.Failure(TimekeepingErrors.EntryNotFound); }
var entry = entryResult.GetValueOrThrow();
// 2. 状态守卫与费用重算全部内聚在聚合方法内 var updateResult = entry.Update(command.Hours, command.Narrative); if (updateResult.IsFailure) { return updateResult; }
// 3. 提交持久化(事务与审计由管道接管) var saveResult = await repository.SaveAsync(entry, cancellationToken); return saveResult.IsFailure ? Result.Failure(saveResult.Error) : Result.Success(); }}稳定的模块错误契约:TimekeepingErrors.cs
namespace BitzOrcas.Platform.Timekeeping.Contracts;
using BitzOrcas.Domain.Results;
/// <summary>/// Timekeeping 模块强类型错误字典/// </summary>public static class TimekeepingErrors{ public static readonly Error EntryNotFound = Error.NotFound("Timekeeping.Entry.NotFound"); public static readonly Error AlreadyBilledImmutable = Error.Conflict("Timekeeping.Entry.AlreadyBilledImmutable"); public static readonly Error HoursInvalid = Error.Validation("Timekeeping.Entry.HoursInvalid"); public static readonly Error HourlyRateInvalid = Error.Validation("Timekeeping.Entry.HourlyRateInvalid"); public static readonly Error NarrativeRequired = Error.Validation("Timekeeping.Entry.NarrativeRequired"); public static readonly Error MatterIdRequired = Error.Validation("Timekeeping.Entry.MatterIdRequired"); public static readonly Error StaffUserIdRequired = Error.Validation("Timekeeping.Entry.StaffUserIdRequired"); public static readonly Error TenantRequired = Error.Unauthorized("Timekeeping.Tenant.Required"); public static readonly Error InvoiceNumberRequired = Error.Validation("Timekeeping.Entry.InvoiceNumberRequired");}4. 相关架构决策与进阶推荐 (Related Deep Dives)
- 开票与支付:账单与支付回调处理规范
- 费率精算:法律专业费率与利息计算引擎
- 决策溯源:ADR 0002:生产就绪门禁与质量守卫