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>
143 lines
5.6 KiB
C#
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
|
|
});
|
|
}
|
|
}
|