V13-FE-006: consolidate approved UI and contract hardening
deploy / deploy (push) Successful in 1m52s
deploy / notify (push) Successful in 1s

This commit is contained in:
2026-08-13 02:41:00 +09:00
parent d79edae546
commit 3f293d8aa8
1278 changed files with 14384 additions and 1664 deletions
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import KbxButton from '@shared/ui/adapter/KbxButton.vue'
import { KsButton } from '@shared/ui/components'
import { useKbxRegistry } from '@shared/composables/useKbxRegistry'
import { useModelDetail, useActivateModel, useDeactivateModel, useTransitionPhase } from '../composables/useModels'
@@ -143,27 +143,27 @@ onUnmounted(() => {
</p>
</div>
<div class="header-actions">
<KbxButton
<KsButton
label="Edit"
variant="default"
severity="secondary"
@click="handleEdit"
/>
<KbxButton
<KsButton
v-if="!model.active"
:label="canActivate ? 'Activate' : 'Cannot Activate'"
:variant="canActivate ? 'primary' : 'default'"
:severity="canActivate ? 'primary' : 'secondary'"
:disabled="!canActivate"
@click="handleActivate"
/>
<KbxButton
<KsButton
v-else
label="Deactivate"
variant="danger"
severity="danger"
@click="handleDeactivate"
/>
<KbxButton
<KsButton
label="Back"
variant="default"
severity="secondary"
@click="handleBack"
/>
</div>
@@ -0,0 +1,12 @@
import { api } from '../../shared/api/client'
import { holdingsResponseSchema, mismatchesResponseSchema, type HoldingsResponse, type MismatchesResponse } from './schema'
export async function getReconciliationHoldings(): Promise<HoldingsResponse> {
const response = await api.get('/reconciliation/holdings')
return holdingsResponseSchema.parse(response.data)
}
export async function getReconciliationMismatches(params?: { dateFrom?: string; dateTo?: string }): Promise<MismatchesResponse> {
const response = await api.get('/reconciliation/mismatches', { params })
return mismatchesResponseSchema.parse(response.data)
}
@@ -0,0 +1,27 @@
import { useQuery } from '@tanstack/vue-query'
import { getReconciliationHoldings, getReconciliationMismatches } from './api'
export const reconciliationKeys = {
all: ['reconciliation'] as const,
holdings: () => [...reconciliationKeys.all, 'holdings'] as const,
mismatches: (dateFrom?: string, dateTo?: string) => [...reconciliationKeys.all, 'mismatches', { dateFrom, dateTo }] as const
}
export function useReconciliationHoldingsQuery() {
return useQuery({
queryKey: reconciliationKeys.holdings(),
queryFn: getReconciliationHoldings,
staleTime: 60_000,
retry: 1
})
}
export function useReconciliationMismatchesQuery(dateFrom?: string, dateTo?: string) {
return useQuery({
queryKey: reconciliationKeys.mismatches(dateFrom, dateTo),
queryFn: () => getReconciliationMismatches({ dateFrom, dateTo }),
staleTime: 60_000,
retry: 1,
enabled: Boolean(dateFrom && dateTo)
})
}
@@ -0,0 +1,41 @@
import { z } from 'zod'
const utcDateTime = z.string().datetime({ offset: true })
export const holdingSchema = z.object({
id: z.string().uuid(),
securityId: z.string().uuid(),
quantity: z.number().int(),
weightedAvgCost: z.number(),
totalCostBasis: z.number(),
marketValue: z.number().nullable(),
unrealizedGainLoss: z.number().nullable(),
updatedAt: utcDateTime,
correlationId: z.string().uuid()
})
export const holdingsResponseSchema = z.object({
items: z.array(holdingSchema),
total: z.number().int().nonnegative(),
pages: z.number().int().positive()
})
export const mismatchSchema = z.object({
id: z.string().uuid(),
tradeId: z.string().uuid(),
holdingId: z.string().uuid(),
mismatchReason: z.string().nullable(),
quantityBefore: z.number().int(),
quantityAfter: z.number().int(),
costBasisDelta: z.number(),
detectedAt: utcDateTime
})
export const mismatchesResponseSchema = z.object({
items: z.array(mismatchSchema),
total: z.number().int().nonnegative(),
pages: z.number().int().positive()
})
export type HoldingsResponse = z.infer<typeof holdingsResponseSchema>
export type MismatchesResponse = z.infer<typeof mismatchesResponseSchema>
@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest'
import { reconciliationKeys } from '../queries'
describe('reconciliation query keys', () => {
it('keeps holdings and mismatch resources separate', () => {
expect(reconciliationKeys.holdings()).not.toEqual(reconciliationKeys.mismatches('2026-08-01', '2026-08-12'))
})
it('includes the mismatch date window in the cache identity', () => {
expect(reconciliationKeys.mismatches('2026-08-01', '2026-08-12')).not.toEqual(reconciliationKeys.mismatches('2026-08-02', '2026-08-12'))
})
})
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { holdingsResponseSchema, mismatchesResponseSchema } from '../schema'
const id = '550e8400-e29b-41d4-a716-446655440001'
describe('reconciliation API contracts', () => {
it('accepts the holdings response emitted by the current endpoint', () => {
expect(holdingsResponseSchema.safeParse({
items: [{ id, securityId: id, quantity: 10, weightedAvgCost: 100, totalCostBasis: 1000, marketValue: 1100, unrealizedGainLoss: 100, updatedAt: '2026-08-12T00:00:00Z', correlationId: id }],
total: 1,
pages: 1
}).success).toBe(true)
})
it('rejects a mismatch response with an invalid timestamp', () => {
expect(mismatchesResponseSchema.safeParse({
items: [{ id, tradeId: id, holdingId: id, mismatchReason: null, quantityBefore: 10, quantityAfter: 9, costBasisDelta: -100, detectedAt: 'not-a-date' }],
total: 1,
pages: 1
}).success).toBe(false)
})
it('rejects a negative response total', () => {
expect(holdingsResponseSchema.safeParse({ items: [], total: -1, pages: 1 }).success).toBe(false)
})
})
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import KbxButton from '@shared/ui/adapter/KbxButton.vue'
import { KsButton } from '@shared/ui/components'
import { useKbxRegistry } from '@shared/composables/useKbxRegistry'
import { useShadowRunDetail } from '../composables/useShadowRuns'
@@ -108,25 +108,25 @@ onUnmounted(() => {
</p>
</div>
<div class="header-actions">
<KbxButton
<KsButton
:label="`Status: ${run.status}`"
variant="default"
severity="secondary"
disabled
/>
<KbxButton
<KsButton
label="Export"
variant="default"
severity="secondary"
@click="handleExport"
/>
<KbxButton
<KsButton
v-if="validationStatus === 'valid'"
label="Approve"
variant="primary"
severity="primary"
@click="handleApprove"
/>
<KbxButton
<KsButton
label="Back"
variant="default"
severity="secondary"
@click="handleBack"
/>
</div>