Files
KArtSell.Aegis/src/KArtSell.Modules.ModelOperations/Features/ApprovalWorkflow/Sql.cs
T
kjh2064 136665c616 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>
2026-08-07 16:33:28 +09:00

143 lines
5.6 KiB
C#

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