Skip to content
bitzorcas
中EN

Concept

不可篡改专业工时计费与审计追踪

深入解析 BitzOrcas.Platform.Timekeeping 模块:律师与顾问工时记录、阶梯费率核算、开票锁定与防篡改审计链。

Last updated

不可篡改专业工时计费与审计追踪 (Timekeeping & Billing Architecture)

在律所、会计师事务所、咨询机构等专业服务行业中,工时(Time Entry)即资产。客户不仅要求按分钟精确记录服务耗时,还要求账单具备极高的防篡改性与审计溯源性。

BitzOrcas.Platform.Timekeeping 提供了工业级的专业工时管理中心。它支持律师/顾问多级费率矩阵、计时单生命周期状态机流转、开票锁定保护以及严格的不可篡改审计追踪链。


1. 工时生命周期状态机

每一个工时条目(TimeEntry)遵循严格的状态流转约束,一旦进入开票流程即永久锁定修改权:

顾问录入工时 (创建计时单)提交本周工时审核合伙人/项目经理审批通过审核驳回 (退回补充说明)关联发票结算(物理锁定,禁止一切修改与删除)坏账核销 / 客户折扣折让

Draft

Submitted

Approved

Billed

WrittenOff


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");
}

100%

滚轮或按钮缩放 · 放大后拖动画面 · 双击切换 100% / 200%