fix: Release build breakage + Dapper mapping bugs in VS-03/VS-04/Phase3-K
- KArtSell.Host.csproj: FrontendFiles glob was evaluated at project-load time, before pnpm build ran, so it copied stale/missing Vite-hashed filenames every Release build. Move the glob inside the target, after the build Exec. - ApprovalSql/AuditSql/TradeSql: fix live-DB integration failures never caught by unit tests: DateOnly and inet columns can't be bound/read directly through Dapper without conversion; kis_response (jsonb) read as JsonElement threw InvalidCastException; GdprRetention.RetentionEndsAt was typed DateTime against a DATE column. - TradeSql: UpdateTradeStatusAsync only ever persisted status/kis_response /error_message, silently dropping kis_order_id, executed_quantity, unit_price, total_amount, commission, net_proceeds and the execution/ settlement timestamps on every call. Changed it to take the Trade aggregate so the full state transition persists. - TradeSql: add a static ctor setting Dapper.DefaultTypeMap. MatchNamesWithUnderscores = true. The repo's [ModuleInitializer] in KArtSell.BuildingBlocks only fires once that assembly is actually loaded; TradeSql/Trade never reference a BuildingBlocks type, so under test isolation (or any host that queries a trade before touching BuildingBlocks) every snake_case column silently mapped to null/default. - Test fixes: seed the FK prerequisites (model_operations.models, sell_decisions) that ApprovalWorkflowTests/TradeExecutionTests were missing, correct a SellPriorityRanker test input to match the approved VS-10-SLICE_SPEC age-boost threshold, and fix a GDPR redaction assertion that called ToString() on a Dictionary instead of inspecting its values. 12 DbUpMigrationTests failures remain and are unrelated to this fix: the kartsell DB user isn't the owner of kartsell_migration_test, so DbUp's fresh-database rehearsal can't DROP/CREATE it. Needs a DBA grant. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,16 +4,18 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<!-- Frontend Build Target: Automatically build Vite and copy to wwwroot (dev only) -->
|
<!-- Frontend Build Target: Automatically build Vite and copy to wwwroot (dev only) -->
|
||||||
|
<!-- FrontendFiles must be globbed *after* pnpm build, inside the target: Vite emits
|
||||||
|
content-hashed filenames each build, and a top-level ItemGroup is evaluated once
|
||||||
|
at project load (before pnpm build runs), so it would copy stale/missing filenames. -->
|
||||||
<Target Name="BuildFrontend" BeforeTargets="Build" Condition="'$(CI)' != 'true' AND Exists('$(ProjectDir)../../frontend/package.json')">
|
<Target Name="BuildFrontend" BeforeTargets="Build" Condition="'$(CI)' != 'true' AND Exists('$(ProjectDir)../../frontend/package.json')">
|
||||||
<Exec Command="pnpm install --frozen-lockfile" WorkingDirectory="$(ProjectDir)../../frontend" ContinueOnError="false" />
|
<Exec Command="pnpm install --frozen-lockfile" WorkingDirectory="$(ProjectDir)../../frontend" ContinueOnError="false" />
|
||||||
<Exec Command="pnpm build" WorkingDirectory="$(ProjectDir)../../frontend" ContinueOnError="false" />
|
<Exec Command="pnpm build" WorkingDirectory="$(ProjectDir)../../frontend" ContinueOnError="false" />
|
||||||
|
<ItemGroup>
|
||||||
|
<FrontendFiles Include="../../frontend/dist/**/*" />
|
||||||
|
</ItemGroup>
|
||||||
<Copy SourceFiles="@(FrontendFiles)" DestinationFolder="$(ProjectDir)wwwroot/%(RecursiveDir)" />
|
<Copy SourceFiles="@(FrontendFiles)" DestinationFolder="$(ProjectDir)wwwroot/%(RecursiveDir)" />
|
||||||
</Target>
|
</Target>
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<FrontendFiles Include="../../frontend/dist/**/*" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="../KArtSell.BuildingBlocks/KArtSell.BuildingBlocks.csproj" />
|
<ProjectReference Include="../KArtSell.BuildingBlocks/KArtSell.BuildingBlocks.csproj" />
|
||||||
<ProjectReference Include="../KArtSell.Modules.SignalEngine/KArtSell.Modules.SignalEngine.csproj" />
|
<ProjectReference Include="../KArtSell.Modules.SignalEngine/KArtSell.Modules.SignalEngine.csproj" />
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ public class ApprovalSql
|
|||||||
const string sql = """
|
const string sql = """
|
||||||
INSERT INTO model_operations.approval_proposals
|
INSERT INTO model_operations.approval_proposals
|
||||||
(id, model_id, status, created_by, created_at, justification, effective_at, published_at, revision, correlation_id)
|
(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, 1, @correlationId)
|
VALUES (@id, @modelId, @status, @createdBy, @createdAt, @justification, @effectiveAt::date, @publishedAt, 1, @correlationId)
|
||||||
""";
|
""";
|
||||||
|
|
||||||
await conn.ExecuteAsync(sql, new
|
await conn.ExecuteAsync(sql, new
|
||||||
@@ -80,7 +80,7 @@ public class ApprovalSql
|
|||||||
createdBy,
|
createdBy,
|
||||||
createdAt = _clock.UtcNow,
|
createdAt = _clock.UtcNow,
|
||||||
justification,
|
justification,
|
||||||
effectiveAt,
|
effectiveAt = effectiveAt.ToString("yyyy-MM-dd"), // Dapper: DateOnly cannot be used as a parameter value directly
|
||||||
publishedAt,
|
publishedAt,
|
||||||
correlationId
|
correlationId
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ public class AuditSql
|
|||||||
// Get paginated results
|
// Get paginated results
|
||||||
var sql = $"""
|
var sql = $"""
|
||||||
SELECT id, event_type, entity_type, entity_id, actor_email, actor_role, event_at,
|
SELECT id, event_type, entity_type, entity_id, actor_email, actor_role, event_at,
|
||||||
result, error_message, details, evidence_links, ip_address, user_agent,
|
result, error_message, details, evidence_links, ip_address::text as ip_address, user_agent,
|
||||||
published_at, correlation_id, revision
|
published_at, correlation_id, revision
|
||||||
FROM compliance.audit_events
|
FROM compliance.audit_events
|
||||||
WHERE {whereClause}
|
WHERE {whereClause}
|
||||||
@@ -162,7 +162,7 @@ public class AuditSql
|
|||||||
{
|
{
|
||||||
const string sql = """
|
const string sql = """
|
||||||
SELECT id, event_type, entity_type, entity_id, actor_email, actor_role, event_at,
|
SELECT id, event_type, entity_type, entity_id, actor_email, actor_role, event_at,
|
||||||
result, error_message, details, evidence_links, ip_address, user_agent,
|
result, error_message, details, evidence_links, ip_address::text as ip_address, user_agent,
|
||||||
published_at, correlation_id, revision
|
published_at, correlation_id, revision
|
||||||
FROM compliance.audit_events
|
FROM compliance.audit_events
|
||||||
WHERE id = @EventId
|
WHERE id = @EventId
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ public class GdprRetention
|
|||||||
public Guid EventId { get; set; }
|
public Guid EventId { get; set; }
|
||||||
public Guid? CustomerId { get; set; }
|
public Guid? CustomerId { get; set; }
|
||||||
public string[]? DataCategories { get; set; } // PII, EMAIL, TRADING_HISTORY, PORTFOLIO_DATA, etc.
|
public string[]? DataCategories { get; set; } // PII, EMAIL, TRADING_HISTORY, PORTFOLIO_DATA, etc.
|
||||||
public DateTime RetentionEndsAt { get; set; }
|
public DateOnly RetentionEndsAt { get; set; }
|
||||||
public required string PurgeStatus { get; set; } // PENDING, PURGED, EXCEPTION
|
public required string PurgeStatus { get; set; } // PENDING, PURGED, EXCEPTION
|
||||||
public DateTime? PurgedAt { get; set; }
|
public DateTime? PurgedAt { get; set; }
|
||||||
public string? ExceptionReason { get; set; }
|
public string? ExceptionReason { get; set; }
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ public class Trade
|
|||||||
public decimal? Commission { get; set; }
|
public decimal? Commission { get; set; }
|
||||||
public decimal? NetProceeds { get; set; }
|
public decimal? NetProceeds { get; set; }
|
||||||
public string? ErrorMessage { get; set; }
|
public string? ErrorMessage { get; set; }
|
||||||
public JsonElement? KisResponse { get; set; }
|
public string? KisResponse { get; set; }
|
||||||
public DateTime? ExecutionTimestamp { get; set; }
|
public DateTime? ExecutionTimestamp { get; set; }
|
||||||
public DateTime? SettlementTimestamp { get; set; }
|
public DateTime? SettlementTimestamp { get; set; }
|
||||||
public DateTime PublishedAt { get; set; }
|
public DateTime PublishedAt { get; set; }
|
||||||
@@ -55,14 +55,14 @@ public class Trade
|
|||||||
{
|
{
|
||||||
Status = TradeStatus.Submitted;
|
Status = TradeStatus.Submitted;
|
||||||
KisOrderId = kisOrderId;
|
KisOrderId = kisOrderId;
|
||||||
KisResponse = response;
|
KisResponse = response.ToString();
|
||||||
Revision++;
|
Revision++;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void MarkAccepted(JsonElement response)
|
public void MarkAccepted(JsonElement response)
|
||||||
{
|
{
|
||||||
Status = TradeStatus.Accepted;
|
Status = TradeStatus.Accepted;
|
||||||
KisResponse = response;
|
KisResponse = response.ToString();
|
||||||
Revision++;
|
Revision++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@ public class Trade
|
|||||||
TotalAmount = executedQty * unitPrice;
|
TotalAmount = executedQty * unitPrice;
|
||||||
Status = executedQty >= Quantity ? TradeStatus.FullyFilled : TradeStatus.PartiallyFilled;
|
Status = executedQty >= Quantity ? TradeStatus.FullyFilled : TradeStatus.PartiallyFilled;
|
||||||
ExecutionTimestamp = now;
|
ExecutionTimestamp = now;
|
||||||
KisResponse = response;
|
KisResponse = response.ToString();
|
||||||
Revision++;
|
Revision++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ public class Trade
|
|||||||
public void MarkErrored(KisTradeExecutionException exception)
|
public void MarkErrored(KisTradeExecutionException exception)
|
||||||
{
|
{
|
||||||
ErrorMessage = exception.Message;
|
ErrorMessage = exception.Message;
|
||||||
KisResponse = exception.KisResponse;
|
KisResponse = exception.KisResponse?.ToString();
|
||||||
Revision++;
|
Revision++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,14 +57,7 @@ public class SubmitTradeHandler
|
|||||||
);
|
);
|
||||||
|
|
||||||
trade.MarkSubmitted(orderId, response);
|
trade.MarkSubmitted(orderId, response);
|
||||||
await _sql.UpdateTradeStatusAsync(
|
await _sql.UpdateTradeStatusAsync(trade, response, null, ct);
|
||||||
trade.Id,
|
|
||||||
TradeStatus.Submitted,
|
|
||||||
response,
|
|
||||||
null,
|
|
||||||
command.CorrelationId,
|
|
||||||
ct
|
|
||||||
);
|
|
||||||
|
|
||||||
await PublishEventAsync(
|
await PublishEventAsync(
|
||||||
"TradeSubmitted",
|
"TradeSubmitted",
|
||||||
@@ -84,14 +77,7 @@ public class SubmitTradeHandler
|
|||||||
catch (KisTradeExecutionException ex)
|
catch (KisTradeExecutionException ex)
|
||||||
{
|
{
|
||||||
trade.MarkErrored(ex);
|
trade.MarkErrored(ex);
|
||||||
await _sql.UpdateTradeStatusAsync(
|
await _sql.UpdateTradeStatusAsync(trade, ex.KisResponse, ex.Message, ct);
|
||||||
trade.Id,
|
|
||||||
trade.Status,
|
|
||||||
ex.KisResponse,
|
|
||||||
ex.Message,
|
|
||||||
command.CorrelationId,
|
|
||||||
ct
|
|
||||||
);
|
|
||||||
|
|
||||||
_logger.LogError(
|
_logger.LogError(
|
||||||
"Trade submission failed: {TradeId} {Classification}",
|
"Trade submission failed: {TradeId} {Classification}",
|
||||||
@@ -163,14 +149,7 @@ public class PollTradeStatusHandler
|
|||||||
trade.MarkFilled(executedQty, unitPrice, response, _clock.UtcNow.UtcDateTime);
|
trade.MarkFilled(executedQty, unitPrice, response, _clock.UtcNow.UtcDateTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
await _sql.UpdateTradeStatusAsync(
|
await _sql.UpdateTradeStatusAsync(trade, response, null, ct);
|
||||||
trade.Id,
|
|
||||||
trade.Status,
|
|
||||||
response,
|
|
||||||
null,
|
|
||||||
command.CorrelationId,
|
|
||||||
ct
|
|
||||||
);
|
|
||||||
|
|
||||||
if (trade.Status is TradeStatus.FullyFilled)
|
if (trade.Status is TradeStatus.FullyFilled)
|
||||||
{
|
{
|
||||||
@@ -195,14 +174,7 @@ public class PollTradeStatusHandler
|
|||||||
}
|
}
|
||||||
catch (KisTradeExecutionException ex)
|
catch (KisTradeExecutionException ex)
|
||||||
{
|
{
|
||||||
await _sql.UpdateTradeStatusAsync(
|
await _sql.UpdateTradeStatusAsync(trade, ex.KisResponse, ex.Message, ct);
|
||||||
trade.Id,
|
|
||||||
trade.Status,
|
|
||||||
ex.KisResponse,
|
|
||||||
ex.Message,
|
|
||||||
command.CorrelationId,
|
|
||||||
ct
|
|
||||||
);
|
|
||||||
|
|
||||||
_logger.LogError("Failed to poll trade status: {TradeId}", trade.Id);
|
_logger.LogError("Failed to poll trade status: {TradeId}", trade.Id);
|
||||||
}
|
}
|
||||||
@@ -262,14 +234,7 @@ public class ConfirmSettlementHandler
|
|||||||
if (success)
|
if (success)
|
||||||
{
|
{
|
||||||
trade.MarkConfirmed(_clock.UtcNow.UtcDateTime, command.Commission);
|
trade.MarkConfirmed(_clock.UtcNow.UtcDateTime, command.Commission);
|
||||||
await _sql.UpdateTradeStatusAsync(
|
await _sql.UpdateTradeStatusAsync(trade, response, null, ct);
|
||||||
trade.Id,
|
|
||||||
TradeStatus.Confirmed,
|
|
||||||
response,
|
|
||||||
null,
|
|
||||||
command.CorrelationId,
|
|
||||||
ct
|
|
||||||
);
|
|
||||||
|
|
||||||
await TradeOutboxPublisher.PublishAsync(
|
await TradeOutboxPublisher.PublishAsync(
|
||||||
_connectionFactory,
|
_connectionFactory,
|
||||||
@@ -290,14 +255,7 @@ public class ConfirmSettlementHandler
|
|||||||
}
|
}
|
||||||
catch (KisTradeExecutionException ex)
|
catch (KisTradeExecutionException ex)
|
||||||
{
|
{
|
||||||
await _sql.UpdateTradeStatusAsync(
|
await _sql.UpdateTradeStatusAsync(trade, ex.KisResponse, ex.Message, ct);
|
||||||
trade.Id,
|
|
||||||
trade.Status,
|
|
||||||
ex.KisResponse,
|
|
||||||
ex.Message,
|
|
||||||
command.CorrelationId,
|
|
||||||
ct
|
|
||||||
);
|
|
||||||
|
|
||||||
_logger.LogError("Failed to confirm settlement: {TradeId}", trade.Id);
|
_logger.LogError("Failed to confirm settlement: {TradeId}", trade.Id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ public interface ITradeSql
|
|||||||
Task<IEnumerable<Trade>> GetTradesByStatusAsync(TradeStatus status, Guid correlationId, CancellationToken ct = default);
|
Task<IEnumerable<Trade>> GetTradesByStatusAsync(TradeStatus status, Guid correlationId, CancellationToken ct = default);
|
||||||
Task<IEnumerable<Trade>> GetTradesByDecisionIdAsync(Guid sellDecisionId, Guid correlationId, CancellationToken ct = default);
|
Task<IEnumerable<Trade>> GetTradesByDecisionIdAsync(Guid sellDecisionId, Guid correlationId, CancellationToken ct = default);
|
||||||
Task InsertTradeAsync(Trade trade, CancellationToken ct = default);
|
Task InsertTradeAsync(Trade trade, CancellationToken ct = default);
|
||||||
Task UpdateTradeStatusAsync(Guid tradeId, TradeStatus newStatus, JsonElement? kisResponse, string? errorMessage, Guid correlationId, CancellationToken ct = default);
|
Task UpdateTradeStatusAsync(Trade trade, JsonElement? kisResponse, string? errorMessage, CancellationToken ct = default);
|
||||||
Task<int> CountTradesByStatusAsync(TradeStatus status, CancellationToken ct = default);
|
Task<int> CountTradesByStatusAsync(TradeStatus status, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,6 +21,16 @@ public class TradeSql : ITradeSql
|
|||||||
private readonly NpgsqlDataSource _dataSource;
|
private readonly NpgsqlDataSource _dataSource;
|
||||||
private readonly ILogger<TradeSql> _logger;
|
private readonly ILogger<TradeSql> _logger;
|
||||||
|
|
||||||
|
static TradeSql()
|
||||||
|
{
|
||||||
|
// KArtSell.BuildingBlocks.Data.DapperBootstrap sets this via [ModuleInitializer], but that
|
||||||
|
// only fires once its assembly is actually loaded into the process. Nothing in this class
|
||||||
|
// references a BuildingBlocks type, so under test isolation (or any host that queries Trade
|
||||||
|
// before touching BuildingBlocks) that assembly load - and the mapping - can be skipped,
|
||||||
|
// silently nulling out every snake_case column (kis_order_id, sell_decision_id, ...).
|
||||||
|
Dapper.DefaultTypeMap.MatchNamesWithUnderscores = true;
|
||||||
|
}
|
||||||
|
|
||||||
public TradeSql(NpgsqlDataSource dataSource, ILogger<TradeSql> logger)
|
public TradeSql(NpgsqlDataSource dataSource, ILogger<TradeSql> logger)
|
||||||
{
|
{
|
||||||
_dataSource = dataSource;
|
_dataSource = dataSource;
|
||||||
@@ -33,7 +43,7 @@ public class TradeSql : ITradeSql
|
|||||||
|
|
||||||
const string sql = """
|
const string sql = """
|
||||||
SELECT id, sell_decision_id, kis_order_id, status, quantity, executed_quantity,
|
SELECT id, sell_decision_id, kis_order_id, status, quantity, executed_quantity,
|
||||||
unit_price, total_amount, commission, net_proceeds, error_message, kis_response,
|
unit_price, total_amount, commission, net_proceeds, error_message, kis_response::text as kis_response,
|
||||||
execution_timestamp, settlement_timestamp, published_at, correlation_id, revision
|
execution_timestamp, settlement_timestamp, published_at, correlation_id, revision
|
||||||
FROM model_operations.trades
|
FROM model_operations.trades
|
||||||
WHERE id = @tradeId
|
WHERE id = @tradeId
|
||||||
@@ -61,7 +71,7 @@ public class TradeSql : ITradeSql
|
|||||||
|
|
||||||
const string sql = """
|
const string sql = """
|
||||||
SELECT id, sell_decision_id, kis_order_id, status, quantity, executed_quantity,
|
SELECT id, sell_decision_id, kis_order_id, status, quantity, executed_quantity,
|
||||||
unit_price, total_amount, commission, net_proceeds, error_message, kis_response,
|
unit_price, total_amount, commission, net_proceeds, error_message, kis_response::text as kis_response,
|
||||||
execution_timestamp, settlement_timestamp, published_at, correlation_id, revision
|
execution_timestamp, settlement_timestamp, published_at, correlation_id, revision
|
||||||
FROM model_operations.trades
|
FROM model_operations.trades
|
||||||
WHERE kis_order_id = @kisOrderId
|
WHERE kis_order_id = @kisOrderId
|
||||||
@@ -82,7 +92,7 @@ public class TradeSql : ITradeSql
|
|||||||
|
|
||||||
const string sql = """
|
const string sql = """
|
||||||
SELECT id, sell_decision_id, kis_order_id, status, quantity, executed_quantity,
|
SELECT id, sell_decision_id, kis_order_id, status, quantity, executed_quantity,
|
||||||
unit_price, total_amount, commission, net_proceeds, error_message, kis_response,
|
unit_price, total_amount, commission, net_proceeds, error_message, kis_response::text as kis_response,
|
||||||
execution_timestamp, settlement_timestamp, published_at, correlation_id, revision
|
execution_timestamp, settlement_timestamp, published_at, correlation_id, revision
|
||||||
FROM model_operations.trades
|
FROM model_operations.trades
|
||||||
WHERE status = @status
|
WHERE status = @status
|
||||||
@@ -102,7 +112,7 @@ public class TradeSql : ITradeSql
|
|||||||
|
|
||||||
const string sql = """
|
const string sql = """
|
||||||
SELECT id, sell_decision_id, kis_order_id, status, quantity, executed_quantity,
|
SELECT id, sell_decision_id, kis_order_id, status, quantity, executed_quantity,
|
||||||
unit_price, total_amount, commission, net_proceeds, error_message, kis_response,
|
unit_price, total_amount, commission, net_proceeds, error_message, kis_response::text as kis_response,
|
||||||
execution_timestamp, settlement_timestamp, published_at, correlation_id, revision
|
execution_timestamp, settlement_timestamp, published_at, correlation_id, revision
|
||||||
FROM model_operations.trades
|
FROM model_operations.trades
|
||||||
WHERE sell_decision_id = @sellDecisionId
|
WHERE sell_decision_id = @sellDecisionId
|
||||||
@@ -143,7 +153,7 @@ public class TradeSql : ITradeSql
|
|||||||
trade.Commission,
|
trade.Commission,
|
||||||
trade.NetProceeds,
|
trade.NetProceeds,
|
||||||
trade.ErrorMessage,
|
trade.ErrorMessage,
|
||||||
kisResponse = trade.KisResponse?.ToString(),
|
trade.KisResponse,
|
||||||
trade.ExecutionTimestamp,
|
trade.ExecutionTimestamp,
|
||||||
trade.SettlementTimestamp,
|
trade.SettlementTimestamp,
|
||||||
trade.PublishedAt,
|
trade.PublishedAt,
|
||||||
@@ -155,11 +165,9 @@ public class TradeSql : ITradeSql
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async Task UpdateTradeStatusAsync(
|
public async Task UpdateTradeStatusAsync(
|
||||||
Guid tradeId,
|
Trade trade,
|
||||||
TradeStatus newStatus,
|
|
||||||
JsonElement? kisResponse,
|
JsonElement? kisResponse,
|
||||||
string? errorMessage,
|
string? errorMessage,
|
||||||
Guid correlationId,
|
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
using var connection = await _dataSource.OpenConnectionAsync(ct);
|
using var connection = await _dataSource.OpenConnectionAsync(ct);
|
||||||
@@ -173,6 +181,14 @@ public class TradeSql : ITradeSql
|
|||||||
|
|
||||||
UPDATE model_operations.trades
|
UPDATE model_operations.trades
|
||||||
SET status = @newStatus,
|
SET status = @newStatus,
|
||||||
|
kis_order_id = COALESCE(@kisOrderId, kis_order_id),
|
||||||
|
executed_quantity = COALESCE(@executedQuantity, executed_quantity),
|
||||||
|
unit_price = COALESCE(@unitPrice, unit_price),
|
||||||
|
total_amount = COALESCE(@totalAmount, total_amount),
|
||||||
|
commission = COALESCE(@commission, commission),
|
||||||
|
net_proceeds = COALESCE(@netProceeds, net_proceeds),
|
||||||
|
execution_timestamp = COALESCE(@executionTimestamp, execution_timestamp),
|
||||||
|
settlement_timestamp = COALESCE(@settlementTimestamp, settlement_timestamp),
|
||||||
kis_response = COALESCE(@kisResponse::jsonb, kis_response),
|
kis_response = COALESCE(@kisResponse::jsonb, kis_response),
|
||||||
error_message = COALESCE(@errorMessage, error_message),
|
error_message = COALESCE(@errorMessage, error_message),
|
||||||
revision = revision + 1
|
revision = revision + 1
|
||||||
@@ -182,14 +198,22 @@ public class TradeSql : ITradeSql
|
|||||||
await connection.ExecuteAsync(sql, new
|
await connection.ExecuteAsync(sql, new
|
||||||
{
|
{
|
||||||
id = Guid.NewGuid(),
|
id = Guid.NewGuid(),
|
||||||
tradeId,
|
tradeId = trade.Id,
|
||||||
newStatus = newStatus.ToString(),
|
newStatus = trade.Status.ToString(),
|
||||||
|
kisOrderId = trade.KisOrderId,
|
||||||
|
executedQuantity = trade.ExecutedQuantity,
|
||||||
|
unitPrice = trade.UnitPrice,
|
||||||
|
totalAmount = trade.TotalAmount,
|
||||||
|
commission = trade.Commission,
|
||||||
|
netProceeds = trade.NetProceeds,
|
||||||
|
executionTimestamp = trade.ExecutionTimestamp,
|
||||||
|
settlementTimestamp = trade.SettlementTimestamp,
|
||||||
kisResponse = kisResponse?.ToString(),
|
kisResponse = kisResponse?.ToString(),
|
||||||
errorMessage,
|
errorMessage,
|
||||||
correlationId
|
correlationId = trade.CorrelationId
|
||||||
});
|
});
|
||||||
|
|
||||||
_logger.LogInformation("Updated trade {TradeId} status to {Status}", tradeId, newStatus);
|
_logger.LogInformation("Updated trade {TradeId} status to {Status}", trade.Id, trade.Status);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<int> CountTradesByStatusAsync(TradeStatus status, CancellationToken ct = default)
|
public async Task<int> CountTradesByStatusAsync(TradeStatus status, CancellationToken ct = default)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ namespace KArtSell.Integration.Tests.ApprovalWorkflow;
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Dapper;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using KArtSell.BuildingBlocks.Time;
|
using KArtSell.BuildingBlocks.Time;
|
||||||
using KArtSell.Modules.ModelOperations.ApprovalWorkflow;
|
using KArtSell.Modules.ModelOperations.ApprovalWorkflow;
|
||||||
@@ -165,6 +166,7 @@ public class ApprovalWorkflowTests : IAsyncLifetime
|
|||||||
var id = Guid.NewGuid();
|
var id = Guid.NewGuid();
|
||||||
var modelId = Guid.NewGuid();
|
var modelId = Guid.NewGuid();
|
||||||
var correlationId = Guid.NewGuid();
|
var correlationId = Guid.NewGuid();
|
||||||
|
await SeedModelAsync(modelId);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await _sql.InsertProposalAsync(
|
await _sql.InsertProposalAsync(
|
||||||
@@ -185,6 +187,15 @@ public class ApprovalWorkflowTests : IAsyncLifetime
|
|||||||
Assert.Equal(modelId, retrieved.ModelId);
|
Assert.Equal(modelId, retrieved.ModelId);
|
||||||
Assert.Equal(correlationId, retrieved.CorrelationId);
|
Assert.Equal(correlationId, retrieved.CorrelationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task SeedModelAsync(Guid modelId)
|
||||||
|
{
|
||||||
|
await using var conn = new Npgsql.NpgsqlConnection(_connectionString);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
"INSERT INTO model_operations.models (id, ticker, correlation_id) VALUES (@Id, @Ticker, @CorrelationId)",
|
||||||
|
new { Id = modelId, Ticker = "TEST", CorrelationId = Guid.NewGuid() });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class InMemoryOutbox : IOutbox
|
public class InMemoryOutbox : IOutbox
|
||||||
|
|||||||
@@ -106,6 +106,11 @@ public class AuditTrailTests : IAsyncLifetime
|
|||||||
var customerId = Guid.NewGuid();
|
var customerId = Guid.NewGuid();
|
||||||
var retentionId = Guid.NewGuid();
|
var retentionId = Guid.NewGuid();
|
||||||
|
|
||||||
|
await _sql.InsertAuditEventAsync(
|
||||||
|
_db, eventId, AuditEventTypes.ModelActivated, AuditEntityTypes.Model,
|
||||||
|
Guid.NewGuid(), "customer@company.com", null, DateTime.UtcNow, "SUCCESS", null,
|
||||||
|
null, null, null, null, Guid.NewGuid(), CancellationToken.None);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await _sql.InsertGdprRetentionAsync(
|
await _sql.InsertGdprRetentionAsync(
|
||||||
_db, retentionId, eventId, customerId,
|
_db, retentionId, eventId, customerId,
|
||||||
@@ -175,7 +180,8 @@ public class AuditTrailTests : IAsyncLifetime
|
|||||||
// Assert
|
// Assert
|
||||||
var @event = await _sql.GetAuditEventByIdAsync(_db, eventId, CancellationToken.None);
|
var @event = await _sql.GetAuditEventByIdAsync(_db, eventId, CancellationToken.None);
|
||||||
Assert.NotNull(@event);
|
Assert.NotNull(@event);
|
||||||
Assert.Contains("<redacted>", @event.Details?.ToString() ?? "");
|
Assert.Equal("<redacted>", @event.Details?["actor_email"].ToString());
|
||||||
|
Assert.Equal("<purged>", @event.Details?["customer_id"].ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
private const string TestConnectionString =
|
private const string TestConnectionString =
|
||||||
|
|||||||
@@ -118,8 +118,8 @@ public class SellPriorityRankerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void CalculateScore_HardImpairment_ReturnsLowestScore()
|
public void CalculateScore_HardImpairment_ReturnsLowestScore()
|
||||||
{
|
{
|
||||||
var score = _ranker.CalculateScore(SellPriority.HardImpairment, fundAgeDays: 200, liquidityPercent: 0.5m);
|
var score = _ranker.CalculateScore(SellPriority.HardImpairment, fundAgeDays: 400, liquidityPercent: 0.5m);
|
||||||
Assert.Equal(950m, score); // 1000 - 50 (age boost)
|
Assert.Equal(950m, score); // 1000 - 50 (age boost, fundAgeDays > 365 per VS-10-SLICE_SPEC.md)
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using Dapper;
|
||||||
using KArtSell.Modules.ModelOperations.TradeExecution;
|
using KArtSell.Modules.ModelOperations.TradeExecution;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
@@ -31,11 +32,31 @@ public class TradeExecutionTests : IAsyncLifetime
|
|||||||
await _dataSource.DisposeAsync();
|
await _dataSource.DisposeAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<Guid> SeedSellDecisionAsync()
|
||||||
|
{
|
||||||
|
await using var connection = await _dataSource.OpenConnectionAsync();
|
||||||
|
var modelId = Guid.NewGuid();
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"INSERT INTO model_operations.models (id, ticker, correlation_id) VALUES (@Id, @Ticker, @CorrelationId)",
|
||||||
|
new { Id = modelId, Ticker = "TEST", CorrelationId = Guid.NewGuid() });
|
||||||
|
|
||||||
|
var sellDecisionId = Guid.NewGuid();
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
INSERT INTO model_operations.sell_decisions
|
||||||
|
(id, model_id, status, created_by, published_at, correlation_id)
|
||||||
|
VALUES (@Id, @ModelId, 'PENDING', 'test@company.com', NOW(), @CorrelationId)
|
||||||
|
""",
|
||||||
|
new { Id = sellDecisionId, ModelId = modelId, CorrelationId = Guid.NewGuid() });
|
||||||
|
|
||||||
|
return sellDecisionId;
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CreateTrade_WithValidData_ShouldInsertSuccessfully()
|
public async Task CreateTrade_WithValidData_ShouldInsertSuccessfully()
|
||||||
{
|
{
|
||||||
var sql = new TradeSql(_dataSource, _logger);
|
var sql = new TradeSql(_dataSource, _logger);
|
||||||
var sellDecisionId = Guid.NewGuid();
|
var sellDecisionId = await SeedSellDecisionAsync();
|
||||||
var correlationId = Guid.NewGuid();
|
var correlationId = Guid.NewGuid();
|
||||||
|
|
||||||
var trade = Trade.Create(sellDecisionId, 1000, correlationId, DateTime.UtcNow);
|
var trade = Trade.Create(sellDecisionId, 1000, correlationId, DateTime.UtcNow);
|
||||||
@@ -55,14 +76,14 @@ public class TradeExecutionTests : IAsyncLifetime
|
|||||||
{
|
{
|
||||||
var sql = new TradeSql(_dataSource, _logger);
|
var sql = new TradeSql(_dataSource, _logger);
|
||||||
var correlationId = Guid.NewGuid();
|
var correlationId = Guid.NewGuid();
|
||||||
var trade = Trade.Create(Guid.NewGuid(), 1000, correlationId, DateTime.UtcNow);
|
var trade = Trade.Create(await SeedSellDecisionAsync(), 1000, correlationId, DateTime.UtcNow);
|
||||||
|
|
||||||
await sql.InsertTradeAsync(trade);
|
await sql.InsertTradeAsync(trade);
|
||||||
|
|
||||||
var response = JsonDocument.Parse("{}").RootElement;
|
var response = JsonDocument.Parse("{}").RootElement;
|
||||||
trade.MarkSubmitted("KIS-ORDER-123", response);
|
trade.MarkSubmitted("KIS-ORDER-123", response);
|
||||||
|
|
||||||
await sql.UpdateTradeStatusAsync(trade.Id, TradeStatus.Submitted, response, null, correlationId);
|
await sql.UpdateTradeStatusAsync(trade, response, null);
|
||||||
|
|
||||||
var retrieved = await sql.GetTradeByIdAsync(trade.Id, correlationId);
|
var retrieved = await sql.GetTradeByIdAsync(trade.Id, correlationId);
|
||||||
|
|
||||||
@@ -76,14 +97,14 @@ public class TradeExecutionTests : IAsyncLifetime
|
|||||||
{
|
{
|
||||||
var sql = new TradeSql(_dataSource, _logger);
|
var sql = new TradeSql(_dataSource, _logger);
|
||||||
var correlationId = Guid.NewGuid();
|
var correlationId = Guid.NewGuid();
|
||||||
var trade = Trade.Create(Guid.NewGuid(), 1000, correlationId, DateTime.UtcNow);
|
var trade = Trade.Create(await SeedSellDecisionAsync(), 1000, correlationId, DateTime.UtcNow);
|
||||||
|
|
||||||
await sql.InsertTradeAsync(trade);
|
await sql.InsertTradeAsync(trade);
|
||||||
|
|
||||||
var response = JsonDocument.Parse("{}").RootElement;
|
var response = JsonDocument.Parse("{}").RootElement;
|
||||||
trade.MarkFilled(1000, 49.95m, response, DateTime.UtcNow);
|
trade.MarkFilled(1000, 49.95m, response, DateTime.UtcNow);
|
||||||
|
|
||||||
await sql.UpdateTradeStatusAsync(trade.Id, TradeStatus.FullyFilled, response, null, correlationId);
|
await sql.UpdateTradeStatusAsync(trade, response, null);
|
||||||
|
|
||||||
var retrieved = await sql.GetTradeByIdAsync(trade.Id, correlationId);
|
var retrieved = await sql.GetTradeByIdAsync(trade.Id, correlationId);
|
||||||
|
|
||||||
@@ -100,8 +121,8 @@ public class TradeExecutionTests : IAsyncLifetime
|
|||||||
var sql = new TradeSql(_dataSource, _logger);
|
var sql = new TradeSql(_dataSource, _logger);
|
||||||
var correlationId = Guid.NewGuid();
|
var correlationId = Guid.NewGuid();
|
||||||
|
|
||||||
var trade1 = Trade.Create(Guid.NewGuid(), 1000, correlationId, DateTime.UtcNow);
|
var trade1 = Trade.Create(await SeedSellDecisionAsync(), 1000, correlationId, DateTime.UtcNow);
|
||||||
var trade2 = Trade.Create(Guid.NewGuid(), 2000, correlationId, DateTime.UtcNow);
|
var trade2 = Trade.Create(await SeedSellDecisionAsync(), 2000, correlationId, DateTime.UtcNow);
|
||||||
|
|
||||||
await sql.InsertTradeAsync(trade1);
|
await sql.InsertTradeAsync(trade1);
|
||||||
await sql.InsertTradeAsync(trade2);
|
await sql.InsertTradeAsync(trade2);
|
||||||
@@ -118,14 +139,14 @@ public class TradeExecutionTests : IAsyncLifetime
|
|||||||
{
|
{
|
||||||
var sql = new TradeSql(_dataSource, _logger);
|
var sql = new TradeSql(_dataSource, _logger);
|
||||||
var correlationId = Guid.NewGuid();
|
var correlationId = Guid.NewGuid();
|
||||||
var trade = Trade.Create(Guid.NewGuid(), 1000, correlationId, DateTime.UtcNow);
|
var trade = Trade.Create(await SeedSellDecisionAsync(), 1000, correlationId, DateTime.UtcNow);
|
||||||
|
|
||||||
await sql.InsertTradeAsync(trade);
|
await sql.InsertTradeAsync(trade);
|
||||||
|
|
||||||
trade.TotalAmount = 49950m;
|
trade.TotalAmount = 49950m;
|
||||||
trade.MarkConfirmed(DateTime.UtcNow, 50m);
|
trade.MarkConfirmed(DateTime.UtcNow, 50m);
|
||||||
|
|
||||||
await sql.UpdateTradeStatusAsync(trade.Id, TradeStatus.Confirmed, null, null, correlationId);
|
await sql.UpdateTradeStatusAsync(trade, null, null);
|
||||||
|
|
||||||
var retrieved = await sql.GetTradeByIdAsync(trade.Id, correlationId);
|
var retrieved = await sql.GetTradeByIdAsync(trade.Id, correlationId);
|
||||||
|
|
||||||
@@ -140,11 +161,16 @@ public class TradeExecutionTests : IAsyncLifetime
|
|||||||
{
|
{
|
||||||
var sql = new TradeSql(_dataSource, _logger);
|
var sql = new TradeSql(_dataSource, _logger);
|
||||||
var correlationId = Guid.NewGuid();
|
var correlationId = Guid.NewGuid();
|
||||||
var trade = Trade.Create(Guid.NewGuid(), 1000, correlationId, DateTime.UtcNow);
|
var trade = Trade.Create(await SeedSellDecisionAsync(), 1000, correlationId, DateTime.UtcNow);
|
||||||
|
|
||||||
await sql.InsertTradeAsync(trade);
|
await sql.InsertTradeAsync(trade);
|
||||||
await sql.UpdateTradeStatusAsync(trade.Id, TradeStatus.Submitted, null, null, correlationId);
|
|
||||||
await sql.UpdateTradeStatusAsync(trade.Id, TradeStatus.Accepted, null, null, correlationId);
|
var response = JsonDocument.Parse("{}").RootElement;
|
||||||
|
trade.MarkSubmitted("KIS-1", response);
|
||||||
|
await sql.UpdateTradeStatusAsync(trade, response, null);
|
||||||
|
|
||||||
|
trade.MarkAccepted(response);
|
||||||
|
await sql.UpdateTradeStatusAsync(trade, response, null);
|
||||||
|
|
||||||
var retrieved = await sql.GetTradeByIdAsync(trade.Id, correlationId);
|
var retrieved = await sql.GetTradeByIdAsync(trade.Id, correlationId);
|
||||||
|
|
||||||
@@ -158,8 +184,8 @@ public class TradeExecutionTests : IAsyncLifetime
|
|||||||
var sql = new TradeSql(_dataSource, _logger);
|
var sql = new TradeSql(_dataSource, _logger);
|
||||||
var correlationId = Guid.NewGuid();
|
var correlationId = Guid.NewGuid();
|
||||||
|
|
||||||
var trade1 = Trade.Create(Guid.NewGuid(), 1000, correlationId, DateTime.UtcNow);
|
var trade1 = Trade.Create(await SeedSellDecisionAsync(), 1000, correlationId, DateTime.UtcNow);
|
||||||
var trade2 = Trade.Create(Guid.NewGuid(), 2000, correlationId, DateTime.UtcNow);
|
var trade2 = Trade.Create(await SeedSellDecisionAsync(), 2000, correlationId, DateTime.UtcNow);
|
||||||
|
|
||||||
await sql.InsertTradeAsync(trade1);
|
await sql.InsertTradeAsync(trade1);
|
||||||
await sql.InsertTradeAsync(trade2);
|
await sql.InsertTradeAsync(trade2);
|
||||||
|
|||||||
Reference in New Issue
Block a user