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:
@@ -0,0 +1,142 @@
|
||||
namespace KArtSell.Modules.ModelOperations.Features.ApprovalWorkflow;
|
||||
|
||||
using Dapper;
|
||||
using KArtSell.Modules.ModelOperations.Domain.ApprovalWorkflow;
|
||||
using Npgsql;
|
||||
|
||||
public class ApprovalWorkflowSql
|
||||
{
|
||||
private readonly string _connectionString;
|
||||
|
||||
public ApprovalWorkflowSql(string connectionString) => _connectionString = connectionString;
|
||||
|
||||
public async Task<ApprovalProposal?> GetProposalAsync(Guid proposalId, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, model_id, status, created_by, created_at, justification, effective_at,
|
||||
proposed_at, approved_by, approved_at, approval_notes, activated_by, activated_at,
|
||||
published_at, revision, correlation_id
|
||||
FROM model_operations.approval_proposals
|
||||
WHERE id = @proposalId
|
||||
""";
|
||||
|
||||
using var conn = new NpgsqlConnection(_connectionString);
|
||||
return await conn.QueryFirstOrDefaultAsync<ApprovalProposal>(sql, new { proposalId });
|
||||
}
|
||||
|
||||
public async Task<List<ApprovalProposal>> ListProposalsAsync(ApprovalStatus? status = null, Guid? modelId = null, int limit = 50, int offset = 0, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, model_id, status, created_by, created_at, justification, effective_at,
|
||||
proposed_at, approved_by, approved_at, approval_notes, activated_by, activated_at,
|
||||
published_at, revision, correlation_id
|
||||
FROM model_operations.approval_proposals
|
||||
WHERE (CAST(@status AS VARCHAR) IS NULL OR status = CAST(@status AS VARCHAR))
|
||||
AND (@modelId::UUID IS NULL OR model_id = @modelId)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT @limit OFFSET @offset
|
||||
""";
|
||||
|
||||
using var conn = new NpgsqlConnection(_connectionString);
|
||||
var proposals = await conn.QueryAsync<ApprovalProposal>(sql, new
|
||||
{
|
||||
status = status?.ToString().ToUpper(),
|
||||
modelId,
|
||||
limit,
|
||||
offset
|
||||
});
|
||||
|
||||
return proposals.ToList();
|
||||
}
|
||||
|
||||
public async Task<Guid> InsertProposalAsync(ApprovalProposal proposal, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO model_operations.approval_proposals
|
||||
(id, model_id, status, created_by, created_at, justification, effective_at,
|
||||
published_at, revision, correlation_id)
|
||||
VALUES (@id, @modelId, @status, @createdBy, @createdAt, @justification, @effectiveAt,
|
||||
@publishedAt, @revision, @correlationId)
|
||||
RETURNING id
|
||||
""";
|
||||
|
||||
using var conn = new NpgsqlConnection(_connectionString);
|
||||
return await conn.QuerySingleAsync<Guid>(sql, new
|
||||
{
|
||||
proposal.Id,
|
||||
proposal.ModelId,
|
||||
status = proposal.Status.ToString().ToUpper(),
|
||||
proposal.CreatedBy,
|
||||
proposal.CreatedAt,
|
||||
proposal.Justification,
|
||||
proposal.EffectiveAt,
|
||||
proposal.PublishedAt,
|
||||
proposal.Revision,
|
||||
proposal.CorrelationId
|
||||
});
|
||||
}
|
||||
|
||||
public async Task UpdateProposalStatusAsync(Guid proposalId, ApprovalStatus newStatus, string? approvedBy = null, string? approvalNotes = null, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE model_operations.approval_proposals
|
||||
SET status = @status, approved_by = @approvedBy, approved_at = CASE WHEN @approvedBy IS NOT NULL THEN NOW() ELSE approved_at END,
|
||||
approval_notes = @approvalNotes, published_at = NOW(), revision = revision + 1
|
||||
WHERE id = @proposalId
|
||||
""";
|
||||
|
||||
using var conn = new NpgsqlConnection(_connectionString);
|
||||
await conn.ExecuteAsync(sql, new
|
||||
{
|
||||
proposalId,
|
||||
status = newStatus.ToString().ToUpper(),
|
||||
approvedBy,
|
||||
approvalNotes
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<Guid> InsertEvidenceAsync(ApprovalEvidence evidence, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO model_operations.approval_evidence
|
||||
(id, approval_proposal_id, evidence_type, evidence_url, reviewer_comment, published_at, correlation_id)
|
||||
VALUES (@id, @proposalId, @type, @url, @comment, @publishedAt, @correlationId)
|
||||
RETURNING id
|
||||
""";
|
||||
|
||||
using var conn = new NpgsqlConnection(_connectionString);
|
||||
return await conn.QuerySingleAsync<Guid>(sql, new
|
||||
{
|
||||
evidence.Id,
|
||||
proposalId = evidence.ApprovalProposalId,
|
||||
type = evidence.EvidenceType,
|
||||
url = evidence.EvidenceUrl,
|
||||
comment = evidence.ReviewerComment,
|
||||
evidence.PublishedAt,
|
||||
evidence.CorrelationId
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<Guid> InsertEventAsync(ApprovalEvent evt, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO model_operations.approval_events
|
||||
(id, approval_proposal_id, event_type, actor_email, event_at, details, published_at, correlation_id)
|
||||
VALUES (@id, @proposalId, @type, @email, @at, @details::JSONB, @publishedAt, @correlationId)
|
||||
RETURNING id
|
||||
""";
|
||||
|
||||
using var conn = new NpgsqlConnection(_connectionString);
|
||||
return await conn.QuerySingleAsync<Guid>(sql, new
|
||||
{
|
||||
evt.Id,
|
||||
proposalId = evt.ApprovalProposalId,
|
||||
type = evt.EventType,
|
||||
email = evt.ActorEmail,
|
||||
at = evt.EventAt,
|
||||
details = System.Text.Json.JsonSerializer.Serialize(evt.Details ?? new()),
|
||||
evt.PublishedAt,
|
||||
evt.CorrelationId
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user