Workstream G: Implement AEG-X-009 P1-P6 (KRX/OpenDart/KIS API integration)

- 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>
This commit is contained in:
2026-08-07 16:33:28 +09:00
parent 2b2841671c
commit 136665c616
16 changed files with 1986 additions and 0 deletions
@@ -0,0 +1,99 @@
namespace KArtSell.Modules.ModelOperations.Features.ApprovalWorkflow;
using FastEndpoints;
using KArtSell.Modules.ModelOperations.Domain.ApprovalWorkflow;
public record CreateApprovalRequest(Guid ModelId, DateOnly EffectiveAt, string Justification);
public record CreateApprovalResponse(Guid Id, string Status, DateTime CreatedAt);
public class CreateApprovalEndpoint : EndpointWithoutRequests<CreateApprovalResponse>
{
private readonly CreateApprovalProposalHandler _handler;
private readonly ApprovalWorkflowSql _sql;
public CreateApprovalEndpoint(CreateApprovalProposalHandler handler, ApprovalWorkflowSql sql)
{
_handler = handler;
_sql = sql;
}
public override void Configure()
{
Post("/approvals");
AllowAnonymous();
}
public override async Task HandleAsync(CancellationToken ct)
{
var request = await HttpContext.Request.ReadFromJsonAsync<CreateApprovalRequest>(cancellationToken: ct);
var userEmail = HttpContext.User.FindFirst("email")?.Value ?? "anonymous";
var userRole = HttpContext.User.FindFirst("role")?.Value ?? "Guest";
var proposalId = await _handler.Handle(userEmail, userRole, request!.ModelId, request.EffectiveAt, request.Justification, Guid.NewGuid(), ct);
var proposal = await _sql.GetProposalAsync(proposalId, ct);
await SendCreatedAtAsync<CreateApprovalEndpoint>(new { id = proposalId }, new CreateApprovalResponse(proposalId, "DRAFT", proposal!.CreatedAt), cancellation: ct);
}
}
public record GetApprovalsRequest(string? Status, Guid? ModelId, int Limit = 50, int Offset = 0);
public record ApprovalDto(Guid Id, Guid ModelId, string Status, string CreatedBy, DateTime CreatedAt, string Justification);
public record GetApprovalsResponse(List<ApprovalDto> Items, int Total, int Pages);
public class GetApprovalsEndpoint : Endpoint<GetApprovalsRequest, GetApprovalsResponse>
{
private readonly ApprovalWorkflowSql _sql;
public GetApprovalsEndpoint(ApprovalWorkflowSql sql) => _sql = sql;
public override void Configure()
{
Get("/approvals");
AllowAnonymous();
}
public override async Task HandleAsync(GetApprovalsRequest req, CancellationToken ct)
{
var status = req.Status != null ? Enum.Parse<ApprovalStatus>(req.Status, ignoreCase: true) : null;
var proposals = await _sql.ListProposalsAsync(status, req.ModelId, req.Limit, req.Offset, ct);
var items = proposals.Select(p => new ApprovalDto(p.Id, p.ModelId, p.Status.ToString(), p.CreatedBy, p.CreatedAt, p.Justification)).ToList();
await SendAsync(new GetApprovalsResponse(items, items.Count, (items.Count + req.Limit - 1) / req.Limit), cancellation: ct);
}
}
public record ApproveApprovalRequest(string ApprovalNotes, List<EvidenceDto> Evidence);
public record EvidenceDto(string Type, string Url, string? Comment);
public record ApproveApprovalResponse(Guid Id, string Status, DateTime ApprovedAt);
public class ApproveApprovalEndpoint : Endpoint<ApproveApprovalRequest, ApproveApprovalResponse>
{
private readonly ApproveApprovalHandler _handler;
private readonly ApprovalWorkflowSql _sql;
public ApproveApprovalEndpoint(ApproveApprovalHandler handler, ApprovalWorkflowSql sql)
{
_handler = handler;
_sql = sql;
}
public override void Configure()
{
Post("/approvals/{id}/approve");
AllowAnonymous();
}
public override async Task HandleAsync(ApproveApprovalRequest req, CancellationToken ct)
{
var proposalId = Route<Guid>("id");
var userEmail = HttpContext.User.FindFirst("email")?.Value ?? "anonymous";
var userRole = HttpContext.User.FindFirst("role")?.Value ?? "Guest";
var evidence = req.Evidence.Select(e => (e.Type, e.Url, e.Comment)).ToList();
await _handler.Handle(proposalId, userEmail, userRole, req.ApprovalNotes, evidence, Guid.NewGuid(), ct);
var proposal = await _sql.GetProposalAsync(proposalId, ct);
await SendAsync(new ApproveApprovalResponse(proposalId, "APPROVED", proposal!.ApprovedAt ?? DateTime.UtcNow), cancellation: ct);
}
}