136665c616
- P1: KRX OpenAPI service (indices, stocks, OHLCV data) - P2: OpenDart API service (company disclosures, quarterly financials) - P3: KIS API service (trading orders, portfolio holdings) - P4-P6: Daily scheduling, error classification, SLA tracking, LKG fallback - Schema: market_data schema with append-only import logs - Error handling: transient/permanent classification + exponential backoff - Idempotency: correlation_id deduplication for safe replay - Services: 3 independent data services with caching, retry logic - Handler: Centralized import orchestration with logging - Job: Hangfire daily scheduler (q-evaluation queue, 16:30-20:30 KST window) - Tests: Unit & integration scenarios for import execution - AGENTS.md v16.0 13/13 compliance ✅ Closes workstream G (Phase 2 preparation). Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
102 lines
4.2 KiB
C#
102 lines
4.2 KiB
C#
namespace KArtSell.Modules.ModelOperations.Features.ApprovalWorkflow;
|
|
|
|
using KArtSell.Modules.ModelOperations.Domain.ApprovalWorkflow;
|
|
|
|
public class CreateApprovalProposalHandler
|
|
{
|
|
private readonly ApprovalWorkflowSql _sql;
|
|
|
|
public CreateApprovalProposalHandler(ApprovalWorkflowSql sql) => _sql = sql;
|
|
|
|
public async Task<Guid> Handle(string userEmail, string userRole, Guid modelId, DateOnly effectiveAt, string justification, Guid correlationId, CancellationToken ct = default)
|
|
{
|
|
if (!ApprovalWorkflowPolicy.CanCreateProposal(userEmail, userRole))
|
|
throw new UnauthorizedAccessException("Only Maker role can create proposals");
|
|
|
|
var proposal = new ApprovalProposal
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
ModelId = modelId,
|
|
Status = ApprovalStatus.Draft,
|
|
CreatedBy = userEmail,
|
|
CreatedAt = DateTime.UtcNow,
|
|
Justification = justification,
|
|
EffectiveAt = effectiveAt,
|
|
PublishedAt = DateTime.UtcNow,
|
|
Revision = 1,
|
|
CorrelationId = correlationId
|
|
};
|
|
|
|
var proposalId = await _sql.InsertProposalAsync(proposal, ct);
|
|
|
|
var createEvent = ApprovalWorkflowPolicy.CreateStateChangeEvent(proposalId, ApprovalStatus.Draft, userEmail, correlationId);
|
|
await _sql.InsertEventAsync(createEvent, ct);
|
|
|
|
return proposalId;
|
|
}
|
|
}
|
|
|
|
public class ApproveApprovalHandler
|
|
{
|
|
private readonly ApprovalWorkflowSql _sql;
|
|
|
|
public ApproveApprovalHandler(ApprovalWorkflowSql sql) => _sql = sql;
|
|
|
|
public async Task Handle(Guid proposalId, string userEmail, string userRole, string approvalNotes, List<(string Type, string Url, string? Comment)> evidence, Guid correlationId, CancellationToken ct = default)
|
|
{
|
|
var proposal = await _sql.GetProposalAsync(proposalId, ct)
|
|
?? throw new KeyNotFoundException($"Proposal {proposalId} not found");
|
|
|
|
if (!ApprovalWorkflowPolicy.CanApprove(proposal, userEmail, userRole))
|
|
throw new UnauthorizedAccessException("Only Checker role (different from Maker) can approve proposals");
|
|
|
|
ApprovalWorkflowPolicy.ValidateProposalState(proposal.Status, ApprovalStatus.Approved);
|
|
|
|
await _sql.UpdateProposalStatusAsync(proposalId, ApprovalStatus.Approved, userEmail, approvalNotes, ct);
|
|
|
|
foreach (var (type, url, comment) in evidence)
|
|
{
|
|
var evt = new ApprovalEvidence
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
ApprovalProposalId = proposalId,
|
|
EvidenceType = type,
|
|
EvidenceUrl = url,
|
|
ReviewerComment = comment,
|
|
PublishedAt = DateTime.UtcNow,
|
|
CorrelationId = correlationId
|
|
};
|
|
|
|
await _sql.InsertEvidenceAsync(evt, ct);
|
|
}
|
|
|
|
var approvalEvent = ApprovalWorkflowPolicy.CreateStateChangeEvent(proposalId, ApprovalStatus.Approved, userEmail, correlationId,
|
|
new Dictionary<string, object> { { "notes", approvalNotes } });
|
|
await _sql.InsertEventAsync(approvalEvent, ct);
|
|
}
|
|
}
|
|
|
|
public class ActivateModelHandler
|
|
{
|
|
private readonly ApprovalWorkflowSql _sql;
|
|
|
|
public ActivateModelHandler(ApprovalWorkflowSql sql) => _sql = sql;
|
|
|
|
public async Task Handle(Guid proposalId, string userEmail, string userRole, Guid correlationId, CancellationToken ct = default)
|
|
{
|
|
var proposal = await _sql.GetProposalAsync(proposalId, ct)
|
|
?? throw new KeyNotFoundException($"Proposal {proposalId} not found");
|
|
|
|
if (!ApprovalWorkflowPolicy.CanActivate(proposal, userEmail, userRole))
|
|
throw new UnauthorizedAccessException("Only SRE role can activate approved proposals");
|
|
|
|
ApprovalWorkflowPolicy.ValidateProposalState(proposal.Status, ApprovalStatus.Active);
|
|
|
|
await _sql.UpdateProposalStatusAsync(proposalId, ApprovalStatus.Active, userEmail, "Model activated by SRE", ct);
|
|
|
|
var activateEvent = ApprovalWorkflowPolicy.CreateStateChangeEvent(proposalId, ApprovalStatus.Active, userEmail, correlationId,
|
|
new Dictionary<string, object> { { "effectiveAt", proposal.EffectiveAt.ToString("O") } });
|
|
await _sql.InsertEventAsync(activateEvent, ct);
|
|
}
|
|
}
|