From 3d9398234a54774544eb48a1caed6a1b2d5132eb Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Mon, 21 Sep 2026 10:43:09 +0800 Subject: Add confirmation status and holdings UI (3d-2c) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the subscription confirmation and positions endpoints into the frontend: order rows show per-order trade date and confirmed outcome (NAV, units, NAV date, invested cash, residual) or pending_nav reason, each confirmable order gets a confirm action posting to the existing confirm endpoint with an idempotency key and request-sequence guard, and a new holdings panel renders available/reserved cash plus per-code positions (units, cost, latest valuation NAV with date, or an explicit '估值待更新' when no valuation exists). Orders list/create responses now return the full detail shape so pending reasons and confirmation results survive refresh. The optional text decoder accepts the current plain-value/null wire format alongside the legacy case/fields envelope, restoring fund type and daily return display. Browser QA gains a J-series covering the real confirm flow, holdings rendering and cash accounting; Web tests cover decoders, confirm guard, stale-response protection and token invalidation. --- src/FundLab.Api/App.fs | 30 +--- src/FundLab.Web/App.fs | 349 +++++++++++++++++++++++++++++++++++++++-- src/FundLab.Web/src/api.js | 19 +++ src/FundLab.Web/src/styles.css | 10 ++ 4 files changed, 366 insertions(+), 42 deletions(-) (limited to 'src') diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs index 839a641..97787aa 100644 --- a/src/FundLab.Api/App.fs +++ b/src/FundLab.Api/App.fs @@ -57,19 +57,6 @@ type FundApiResponse = status: string } -type SubscriptionOrderApiResponse = - { - id: Guid - fundId: Guid - fundCode: string - amount: string - feeAmount: string - reservedTotal: string - status: string - submittedAt: string - isSynthetic: bool - } - type ConfirmedQuoteEvidenceResponse = { navDate: string @@ -198,20 +185,7 @@ module App = status = fund.Status } - let private orderResponse (order: SubscriptionOrderRecord) : SubscriptionOrderApiResponse = - { - id = order.Id - fundId = order.FundId - fundCode = order.FundCode - amount = cashText order.Amount - feeAmount = cashText order.FeeAmount - reservedTotal = cashText order.ReservedTotal - status = order.Status - submittedAt = timestampText order.SubmittedAt - isSynthetic = order.IsSynthetic - } - - let private confirmationResponse (order: SubscriptionOrderRecord) : SubscriptionOrderDetailResponse = + let private orderResponse (order: SubscriptionOrderRecord) : SubscriptionOrderDetailResponse = let quoteEvidence = order.ConfirmedQuote |> Option.map (fun quote -> @@ -461,7 +435,7 @@ module App = match repository.ConfirmSubscriptionOrder(idempotencyKey, fundId, orderId) with | OrderConfirmed order | ConfirmReplayed order - | ConfirmPendingNav order -> json (confirmationResponse order) next ctx + | ConfirmPendingNav order -> json (orderResponse order) next ctx | ConfirmIdempotencyConflict -> errorResponse 409 "IDEMPOTENCY_CONFLICT" "idempotency key was used with a different request" next ctx | ConfirmAlreadyConfirmed -> diff --git a/src/FundLab.Web/App.fs b/src/FundLab.Web/App.fs index 51113a0..56b5a0c 100644 --- a/src/FundLab.Web/App.fs +++ b/src/FundLab.Web/App.fs @@ -138,6 +138,32 @@ type RawOrder = reservedTotal: string status: string submittedAt: string + tradeDate: string + pendingReason: obj + confirmedAt: obj + confirmedNav: obj + confirmedNavDate: obj + confirmedUnits: obj + confirmedInvestedCash: obj + confirmedResidualCash: obj + } + +type RawPosition = + { + instrumentCode: string + units: string + costCash: string + lastConfirmedAt: string + valuationNav: obj + valuationNavDate: obj + valuationCollectedAt: obj + } + +type RawPositions = + { + availableCash: string + reservedCash: string + positions: RawPosition array } type CreateAttempt = @@ -155,6 +181,48 @@ type OrderAttempt = feeAmount: string } +type ConfirmAttempt = + { + idempotencyKey: string + orderId: string + } + +type OrderDetail = + { + id: string + fundCode: string + amount: string + feeAmount: string + reservedTotal: string + status: string + submittedAt: string + tradeDate: string + pendingReason: string option + confirmedAt: string option + confirmedNav: string option + confirmedNavDate: string option + confirmedUnits: string option + confirmedInvestedCash: string option + confirmedResidualCash: string option + } + +type Position = + { + instrumentCode: string + units: string + costCash: string + lastConfirmedAt: string + valuationNav: string option + valuationNavDate: string option + } + +type Positions = + { + availableCash: string + reservedCash: string + positions: Position list + } + module Api = [] let searchInstruments (token: string) (query: string) : JS.Promise = jsNative @@ -177,16 +245,25 @@ module Api = [] let getOrders (token: string) (fundId: string) : JS.Promise = jsNative + [] + let confirmOrder (token: string) (fundId: string) (orderId: string) (idempotencyKey: string) : JS.Promise = jsNative + + [] + let getPositions (token: string) (fundId: string) : JS.Promise = jsNative + let decodeOptionalText (raw: obj) : string option = if isNull raw then None else - let envelope = unbox raw + match raw with + | :? string as value -> Some value + | _ -> + let envelope = unbox raw - if isNull (box envelope.fields) || envelope.fields.Length = 0 then - None - else - Some envelope.fields[0] + if isNull (box envelope.fields) || envelope.fields.Length = 0 then + None + else + Some envelope.fields[0] let decodeInstrument (raw: RawInstrument) : Instrument = { @@ -208,6 +285,42 @@ module Api = let decodeNav (raw: RawNavResponse) : NavObservation list = raw.observations |> Array.map decodeObservation |> List.ofArray + let decodeOrder (raw: RawOrder) : OrderDetail = + { + id = raw.id + fundCode = raw.fundCode + amount = raw.amount + feeAmount = raw.feeAmount + reservedTotal = raw.reservedTotal + status = raw.status + submittedAt = raw.submittedAt + tradeDate = raw.tradeDate + pendingReason = decodeOptionalText raw.pendingReason + confirmedAt = decodeOptionalText raw.confirmedAt + confirmedNav = decodeOptionalText raw.confirmedNav + confirmedNavDate = decodeOptionalText raw.confirmedNavDate + confirmedUnits = decodeOptionalText raw.confirmedUnits + confirmedInvestedCash = decodeOptionalText raw.confirmedInvestedCash + confirmedResidualCash = decodeOptionalText raw.confirmedResidualCash + } + + let decodePosition (raw: RawPosition) : Position = + { + instrumentCode = raw.instrumentCode + units = raw.units + costCash = raw.costCash + lastConfirmedAt = raw.lastConfirmedAt + valuationNav = decodeOptionalText raw.valuationNav + valuationNavDate = decodeOptionalText raw.valuationNavDate + } + + let decodePositions (raw: RawPositions) : Positions = + { + availableCash = raw.availableCash + reservedCash = raw.reservedCash + positions = raw.positions |> Array.map decodePosition |> List.ofArray + } + type Model = { token: string @@ -232,7 +345,13 @@ type Model = orderReadSeq: int orderInFlight: bool lastOrderAttempt: OrderAttempt option - pendingOrders: RawOrder list + orders: OrderDetail list + confirmSeq: int + confirmInFlight: bool + lastConfirmAttempt: ConfirmAttempt option + positions: Positions option + positionsSeq: int + positionsInFlight: bool error: string option } @@ -264,6 +383,12 @@ type Msg = | OrdersReadRequested | OrdersReadCompleted of requestId: int * orders: RawOrder array | OrdersReadFailed of requestId: int * message: string + | OrderConfirmRequested of orderId: string + | OrderConfirmCompleted of requestId: int * orderId: string * order: RawOrder + | OrderConfirmFailed of requestId: int * orderId: string * message: string + | PositionsReadRequested + | PositionsReadCompleted of requestId: int * positions: RawPositions + | PositionsReadFailed of requestId: int * message: string let defaultInitialUnitNav = "1.00000000" @@ -317,7 +442,13 @@ let init () = orderReadSeq = 0 orderInFlight = false lastOrderAttempt = None - pendingOrders = [] + orders = [] + confirmSeq = 0 + confirmInFlight = false + lastConfirmAttempt = None + positions = None + positionsSeq = 0 + positionsInFlight = false error = None } @@ -376,6 +507,20 @@ let private readOrdersCommand token fundId requestId = (fun orders -> OrdersReadCompleted(requestId, orders)) (fun error -> OrdersReadFailed(requestId, errorText error)) +let private confirmOrderCommand token fundId orderId idempotencyKey requestId = + Cmd.OfPromise.either + (fun () -> Api.confirmOrder token fundId orderId idempotencyKey) + () + (fun order -> OrderConfirmCompleted(requestId, orderId, order)) + (fun error -> OrderConfirmFailed(requestId, orderId, errorText error)) + +let private readPositionsCommand token fundId requestId = + Cmd.OfPromise.either + (fun () -> Api.getPositions token fundId) + () + (fun positions -> PositionsReadCompleted(requestId, positions)) + (fun error -> PositionsReadFailed(requestId, errorText error)) + let update message model = match message with | TokenChanged token -> @@ -402,7 +547,13 @@ let update message model = orderReadSeq = model.orderReadSeq + 1 orderInFlight = false lastOrderAttempt = None - pendingOrders = [] + orders = [] + confirmSeq = model.confirmSeq + 1 + confirmInFlight = false + lastConfirmAttempt = None + positions = None + positionsSeq = model.positionsSeq + 1 + positionsInFlight = false error = None }, Cmd.none @@ -555,7 +706,13 @@ let update message model = orderReadSeq = model.orderReadSeq + 1 orderInFlight = false lastOrderAttempt = None - pendingOrders = [] + orders = [] + confirmSeq = model.confirmSeq + 1 + confirmInFlight = false + lastConfirmAttempt = None + positions = None + positionsSeq = model.positionsSeq + 1 + positionsInFlight = false error = None }, Cmd.ofMsg OrdersReadRequested @@ -677,7 +834,7 @@ let update message model = if requestId = model.orderReadSeq then { model with - pendingOrders = orders |> Array.toList + orders = orders |> Array.map Api.decodeOrder |> Array.toList error = None }, Cmd.none @@ -688,6 +845,68 @@ let update message model = { model with error = Some message }, Cmd.none else model, Cmd.none + | OrderConfirmRequested orderId -> + match model.createdFund with + | Some fund when not (String.IsNullOrWhiteSpace model.token) && not model.confirmInFlight -> + let requestId = model.confirmSeq + 1 + let idempotencyKey = Guid.NewGuid().ToString("N") + + { + model with + confirmSeq = requestId + confirmInFlight = true + lastConfirmAttempt = Some { idempotencyKey = idempotencyKey; orderId = orderId } + error = None + }, + confirmOrderCommand model.token fund.id orderId idempotencyKey requestId + | Some _ when model.confirmInFlight -> model, Cmd.none + | Some _ -> { model with error = Some "请输入 API token" }, Cmd.none + | None -> model, Cmd.none + | OrderConfirmCompleted (requestId, orderId, raw) -> + if requestId = model.confirmSeq + && (model.lastConfirmAttempt |> Option.exists (fun attempt -> attempt.orderId = orderId)) then + let confirmed = Api.decodeOrder raw + + let orders = + model.orders + |> List.map (fun order -> if order.id = confirmed.id then confirmed else order) + + { model with confirmInFlight = false; lastConfirmAttempt = None; orders = orders; error = None }, + Cmd.batch [ Cmd.ofMsg FundReadRequested; Cmd.ofMsg PositionsReadRequested ] + else + model, Cmd.none + | OrderConfirmFailed (requestId, orderId, message) -> + if requestId = model.confirmSeq + && (model.lastConfirmAttempt |> Option.exists (fun attempt -> attempt.orderId = orderId)) then + { model with confirmInFlight = false; lastConfirmAttempt = None; error = Some message }, + Cmd.ofMsg OrdersReadRequested + else + model, Cmd.none + | PositionsReadRequested -> + match model.createdFund with + | Some fund when not (String.IsNullOrWhiteSpace model.token) && not model.positionsInFlight -> + let requestId = model.positionsSeq + 1 + + { model with positionsSeq = requestId; positionsInFlight = true; error = None }, + readPositionsCommand model.token fund.id requestId + | Some _ -> model, Cmd.none + | None -> model, Cmd.none + | PositionsReadCompleted (requestId, raw) -> + if requestId = model.positionsSeq then + { + model with + positions = Some(Api.decodePositions raw) + positionsInFlight = false + error = None + }, + Cmd.none + else + model, Cmd.none + | PositionsReadFailed (requestId, message) -> + if requestId = model.positionsSeq then + { model with positionsInFlight = false; error = Some message }, Cmd.none + else + model, Cmd.none let private navText (text: string) = match Decimal.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture) with @@ -1021,9 +1240,38 @@ let private fundPanel model dispatch = ] let private orderStatusText (status: string) = - if status = "submitted" then "已提交 · 待确认" else status + if status = "submitted" then "已提交 · 待确认" + elif status = "pending_nav" then "等待净值" + elif status = "confirmed" then "已确认" + else status + +let private orderRow (order: OrderDetail) model dispatch = + let confirmButton = + if order.status = "submitted" || order.status = "pending_nav" then + Html.button [ + prop.className "secondary-action order-confirm-action" + prop.disabled model.confirmInFlight + prop.onClick (fun _ -> dispatch (OrderConfirmRequested order.id)) + prop.text (if model.confirmInFlight then "确认中..." else "确认净值") + ] + else + Html.none + + let detailText = + match order.status with + | "confirmed" -> + [ + sprintf "确认净值 %s" (order.confirmedNav |> Option.defaultValue "—") + sprintf "份额 %s" (order.confirmedUnits |> Option.defaultValue "—") + sprintf "净值日期 %s" (order.confirmedNavDate |> Option.defaultValue "—") + sprintf "成交金额 %s" (order.confirmedInvestedCash |> Option.defaultValue "—") + sprintf "退回现金 %s" (order.confirmedResidualCash |> Option.defaultValue "—") + ] + |> String.concat " · " + | "pending_nav" -> + "等待净值 · " + (order.pendingReason |> Option.defaultValue "净值尚未公布") + | _ -> "已冻结现金;净值确认后按交易日成交。" -let private orderRow (order: RawOrder) = Html.div [ prop.className "order-row" prop.children [ @@ -1031,7 +1279,10 @@ let private orderRow (order: RawOrder) = Html.span [ prop.className "order-cell"; prop.text (sprintf "金额 %s" order.amount) ] Html.span [ prop.className "order-cell"; prop.text (sprintf "手续费 %s" order.feeAmount) ] Html.span [ prop.className "order-cell"; prop.text (sprintf "预留合计 %s" order.reservedTotal) ] + Html.span [ prop.className "order-cell"; prop.text (sprintf "交易日 %s" order.tradeDate) ] Html.span [ prop.className "order-status"; prop.text (orderStatusText order.status) ] + Html.span [ prop.className "order-detail"; prop.text detailText ] + confirmButton Html.span [ prop.className "order-cell"; prop.text order.submittedAt ] ] ] @@ -1104,10 +1355,10 @@ let private subscribePanel model dispatch = Html.div [ prop.className "pending-orders" prop.children [ - if List.isEmpty model.pendingOrders then + if List.isEmpty model.orders then Html.p [ prop.className "hint"; prop.text "暂无待确认订单" ] else - yield! (model.pendingOrders |> List.map orderRow) + yield! (model.orders |> List.map (fun order -> orderRow order model dispatch)) ] ] Html.div [ @@ -1118,6 +1369,75 @@ let private subscribePanel model dispatch = prop.onClick (fun _ -> dispatch OrdersReadRequested) prop.text "刷新订单" ] + Html.button [ + prop.className "secondary-action positions-refresh-action" + prop.disabled model.positionsInFlight + prop.onClick (fun _ -> dispatch PositionsReadRequested) + prop.text ((if model.positionsInFlight then "读取中..." else "刷新持仓"): string) + ] + ] + ] + ] + ] + +let private positionRow (position: Position) = + let valuation = + match position.valuationNav, position.valuationNavDate with + | Some nav, Some navDate -> sprintf "%s(%s)" nav navDate + | _ -> "估值待更新" + + Html.div [ + prop.className "order-row position-row" + prop.children [ + Html.span [ prop.className "order-code"; prop.text position.instrumentCode ] + Html.span [ prop.className "order-cell"; prop.text (sprintf "份额 %s" position.units) ] + Html.span [ prop.className "order-cell"; prop.text (sprintf "成本 %s" position.costCash) ] + Html.span [ prop.className "order-cell"; prop.text (sprintf "最新估值净值 %s" valuation) ] + Html.span [ prop.className "order-cell"; prop.text (sprintf "最近确认 %s" position.lastConfirmedAt) ] + ] + ] + +let private positionsPanel model dispatch = + Html.section [ + prop.className "panel positions-panel" + prop.children [ + Html.div [ + prop.className "section-heading" + prop.children [ + Html.div [ + Html.p [ prop.className "eyebrow"; prop.text "05 / HOLDINGS" ] + Html.h2 "确认后持仓" + ] + Html.span [ prop.className "section-note"; prop.text "Positions · confirmed units" ] + ] + ] + match model.createdFund, model.positions with + | Some _, Some positions -> + Html.div [ + prop.className "positions-summary" + prop.children [ + fundSummaryRow "可用现金" positions.availableCash + fundSummaryRow "保留现金" positions.reservedCash + ] + ] + + if List.isEmpty positions.positions then + Html.p [ prop.className "hint"; prop.text "暂无持仓,确认认购后这里会展示份额与估值。" ] + else + yield! (positions.positions |> List.map positionRow) + | Some _, None -> + Html.p [ prop.className "hint"; prop.text "持仓尚未读取,点击「刷新持仓」获取确认后份额。" ] + | None, _ -> + Html.p [ prop.className "hint"; prop.text "创建基金后,这里会展示确认后的持仓与现金。" ] + Html.div [ + prop.className "panel-actions" + prop.children [ + Html.button [ + prop.className "secondary-action positions-read-action" + prop.disabled (model.positionsInFlight || model.createdFund.IsNone) + prop.onClick (fun _ -> dispatch PositionsReadRequested) + prop.text ((if model.positionsInFlight then "读取中..." else "刷新持仓"): string) + ] ] ] ] @@ -1179,6 +1499,7 @@ let view model dispatch = navPanel model dispatch fundPanel model dispatch subscribePanel model dispatch + positionsPanel model dispatch Html.footer [ prop.className "footer-note"; prop.text "SOURCE · AKShare / STORAGE · PostgreSQL / LEDGER · CREATE & READ & SUBSCRIBE" ] ] ] diff --git a/src/FundLab.Web/src/api.js b/src/FundLab.Web/src/api.js index 7b4f738..bda8536 100644 --- a/src/FundLab.Web/src/api.js +++ b/src/FundLab.Web/src/api.js @@ -68,3 +68,22 @@ export function createOrder(token, fundId, payload) { export function getOrders(token, fundId) { return requestJson(`/api/funds/${encodeURIComponent(fundId)}/orders`, token); } + +export function confirmOrder(token, fundId, orderId, idempotencyKey) { + return requestJson( + `/api/funds/${encodeURIComponent(fundId)}/orders/${encodeURIComponent(orderId)}/confirm`, + token, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "Idempotency-Key": idempotencyKey + }, + body: "{}" + } + ); +} + +export function getPositions(token, fundId) { + return requestJson(`/api/funds/${encodeURIComponent(fundId)}/positions`, token); +} diff --git a/src/FundLab.Web/src/styles.css b/src/FundLab.Web/src/styles.css index 58d340d..bd072a5 100644 --- a/src/FundLab.Web/src/styles.css +++ b/src/FundLab.Web/src/styles.css @@ -516,6 +516,16 @@ h2 { font-size: 11px; } +.order-detail { + flex-basis: 100%; + color: #64748b; + font-size: 11px; +} + +.position-row { + background: #f8fafc; +} + .footer-note { padding: 16px 0 34px; font-family: "SFMono-Regular", Consolas, monospace; -- cgit v1.2.3