diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-21 10:43:09 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-21 10:43:09 +0800 |
| commit | 3d9398234a54774544eb48a1caed6a1b2d5132eb (patch) | |
| tree | bf6773bfce7872da1d7aa40f5d1b8af8cdaacf76 | |
| parent | a5ac465ab59197a55a222d30ebd2cf26ffe80f86 (diff) | |
| download | fund-lab-3d9398234a54774544eb48a1caed6a1b2d5132eb.tar.gz | |
Add confirmation status and holdings UI (3d-2c)
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.
| -rw-r--r-- | qa/driver/browser-test.js | 162 | ||||
| -rw-r--r-- | src/FundLab.Api/App.fs | 30 | ||||
| -rw-r--r-- | src/FundLab.Web/App.fs | 349 | ||||
| -rw-r--r-- | src/FundLab.Web/src/api.js | 19 | ||||
| -rw-r--r-- | src/FundLab.Web/src/styles.css | 10 | ||||
| -rw-r--r-- | tests/FundLab.Web.Tests/BoundaryTests.fs | 232 |
6 files changed, 756 insertions, 46 deletions
diff --git a/qa/driver/browser-test.js b/qa/driver/browser-test.js index 4e557d3..46a8f85 100644 --- a/qa/driver/browser-test.js +++ b/qa/driver/browser-test.js @@ -9,12 +9,17 @@ const consoleErrors = []; const fundPosts = []; const orderPosts = []; const orderPostStatuses = []; +const confirmPosts = []; +const confirmPostStatuses = []; +let positionGets = 0; +let lastPositionsResponse = null; let fundPostResponses = 0; let fundGets = 0; let orderGets = 0; let createdFund = null; let createdFundResponse = null; let lastOrdersResponse = null; +let lastConfirmResponse = null; function check(name, ok, detail) { results.push({ name, ok, detail: detail || "" }); @@ -101,6 +106,18 @@ async function summaryLabelExists(page, label, name) { } catch {} } if (r.url().includes("/orders") && r.request().method() === "POST") orderPostStatuses.push(r.status()); + if (r.url().includes("/confirm") && r.request().method() === "POST") { + confirmPostStatuses.push(r.status()); + try { + lastConfirmResponse = await r.json(); + } catch {} + } + if (r.url().includes("/positions") && r.request().method() === "GET" && r.status() < 400) { + positionGets++; + try { + lastPositionsResponse = await r.json(); + } catch {} + } if (r.status() >= 400 && !/\/api\/instruments\//.test(r.url()) && !/\/orders/.test(r.url())) { consoleErrors.push("resource " + r.status() + ": " + r.url()); } @@ -109,11 +126,15 @@ async function summaryLabelExists(page, label, name) { if (r.url().includes("/api/funds") && r.method() === "POST" && !r.url().includes("/orders")) { fundPosts.push({ url: r.url(), key: r.headers()["idempotency-key"] || "", body: r.postData() || "" }); } - if (r.url().includes("/orders") && r.method() === "POST") { + if (r.url().includes("/orders") && !r.url().includes("/confirm") && r.method() === "POST") { orderPosts.push({ url: r.url(), key: r.headers()["idempotency-key"] || "", body: r.postData() || "" }); } + if (r.url().includes("/confirm") && r.method() === "POST") { + confirmPosts.push({ url: r.url(), key: r.headers()["idempotency-key"] || "" }); + } }); page.on("console", (m) => { + if (/^DBG /.test(m.text())) console.log("[dbg] " + m.text()); if (m.type() !== "error") return; const text = m.text(); if (/favicon/i.test(text)) return; @@ -133,6 +154,7 @@ async function summaryLabelExists(page, label, name) { await staleTokenScenario(page); await idempotentRetryScenario(page); await orderScenario(page); + await confirmScenario(page); } finally { check("G1 无浏览器控制台/页面错误", consoleErrors.length === 0, consoleErrors.slice(0, 3).join(" | ")); await browser.close(); @@ -518,7 +540,139 @@ async function orderScenario(page) { await page.waitForTimeout(1800); const rowsAfterStale = await page.locator(".order-row").count(); check("G24 延迟订单响应落地后仍被清除(stale 忽略)", rowsAfterStale === 0, `rows=${rowsAfterStale}`); - await page.unroute("**/api/funds/*/orders"); - await page.fill(".token-input", "qa-token"); - await page.screenshot({ path: SHOTS + "/10-order-stale-token.png" }); + await page.unroute("**/api/funds/*/orders"); + await page.fill(".token-input", "qa-token"); + await page.screenshot({ path: SHOTS + "/10-order-stale-token.png" }); +} + +async function confirmScenario(page) { + // J 系列: 确认动作 + 确认状态展示 + 持仓联动(G23/G24 换 token 后 createdFund 已清空,先重建基金) + await page.waitForSelector(".positions-panel", { timeout: 10000 }); + const panelEyebrow = ((await page.textContent(".positions-panel .eyebrow")) || "").trim(); + check("J1 持仓面板 eyebrow 为 05 / HOLDINGS", panelEyebrow === "05 / HOLDINGS", panelEyebrow); + const noFundHint = ((await page.textContent(".positions-panel")) || "").trim(); + check("J2 创建基金前持仓面板提示", noFundHint.includes("创建基金后"), noFundHint.slice(0, 60)); + + await page.fill(".fund-name-input", "确认持仓测试"); + await page.fill(".fund-cash-input", "5000.00"); + await page.click(".fund-create-action"); + await page.waitForSelector(".fund-summary", { timeout: 15000 }); + + await page.fill(".order-code-input", "000001"); + await page.fill(".order-amount-input", "100.00"); + await page.fill(".order-fee-input", "1.50"); + await page.click(".order-submit-action"); + await page.waitForSelector(".order-row", { timeout: 15000 }); + const submittedText = ((await page.textContent(".pending-orders")) || ""); + check( + "J3 下单后显示已提交·待确认与交易日", + submittedText.includes("已提交 · 待确认") && submittedText.includes("交易日 2026-09-21"), + submittedText.slice(0, 160) + ); + check("J4 下单后无持仓行", (await page.locator(".position-row").count()) === 0, ""); + + // 采集净值入库(getNav 只读库,refreshNav 才写库),确保确认时可用 + await page.fill(".search-input", "000001"); + await page.click(".search-row .primary-action"); + await page.waitForSelector(".result-list", { timeout: 15000 }); + await page.click(".result-list .result-row"); + await page.click(".nav-panel .primary-action"); + await page.waitForSelector(".nav-panel .metric-strip", { timeout: 20000 }); + await page.waitForTimeout(500); + + await page.click(".order-confirm-action"); + for (let i = 0; i < 200 && (confirmPostStatuses.length === 0 || lastConfirmResponse === null); i++) { + await page.waitForTimeout(100); + } + check("J5 确认 POST 恰好 1 次且幂等键 32 位十六进制", confirmPosts.length === 1 && /^[0-9a-f]{32}$/.test(confirmPosts[0].key), JSON.stringify(confirmPosts)); + const confirmStatus = lastConfirmResponse ? lastConfirmResponse.status : ""; + const confirmed = confirmStatus === "confirmed"; + check("J6 确认响应状态为 confirmed 或 pending_nav", confirmed || confirmStatus === "pending_nav", confirmStatus); + if (confirmed) { + check( + "J7 已确认分支展示 NAV/份额/日期精确字符串", + lastConfirmResponse.confirmedNav === "1.19020000" && + /^[0-9]+\.[0-9]{8}$/.test(lastConfirmResponse.confirmedUnits || "") && + lastConfirmResponse.confirmedNavDate === "2026-09-21", + JSON.stringify(lastConfirmResponse).slice(0, 200) + ); + } else { + check( + "J7 待净值分支展示 pendingReason", + typeof lastConfirmResponse.pendingReason === "string" && lastConfirmResponse.pendingReason.length > 0, + JSON.stringify(lastConfirmResponse).slice(0, 200) + ); + } + + // 订单行展示确认状态与结果 + await page.waitForFunction( + () => { + const rows = Array.from(document.querySelectorAll(".order-row")); + return rows.some((r) => r.textContent.includes("已确认") || r.textContent.includes("等待净值")); + }, + null, + { timeout: 15000 } + ); + const orderRowText = ((await page.textContent(".pending-orders")) || ""); + if (confirmed) { + check( + "J8 订单行显示已确认与确认净值 1.19020000", + orderRowText.includes("已确认") && orderRowText.includes("确认净值 1.19020000"), + orderRowText.slice(0, 240) + ); + } else { + check( + "J8 订单行显示等待净值与原因", + orderRowText.includes("等待净值") && orderRowText.includes("净值尚未公布") === false, + orderRowText.slice(0, 240) + ); + } + + // 持仓联动:确认后持仓接口数据展示 + if (confirmed) { + await page.waitForSelector(".position-row", { timeout: 15000 }); + } else { + await page.waitForFunction( + () => { + const panel = document.querySelector(".positions-panel"); + return panel !== null && (panel.textContent.includes("暂无持仓") || panel.textContent.includes("持仓尚未读取") === false); + }, + null, + { timeout: 15000 } + ); + } + const positionsText = ((await page.textContent(".positions-panel")) || ""); + const cashTexts = await page.$$eval(".positions-panel .fund-detail-value", (els) => els.map((el) => el.textContent.trim())); + if (confirmed) { + const units = lastConfirmResponse.confirmedUnits; + check( + "J9 确认后持仓行显示份额/成本/估值净值", + positionsText.includes("000001") && + positionsText.includes(`份额 ${units}`) && + positionsText.includes("成本") && + positionsText.includes("1.19020000"), + positionsText.slice(0, 260) + ); + check("J10 确认后保留现金归零", cashTexts.includes("0.00"), JSON.stringify(cashTexts)); + } else { + check( + "J9 待净值分支无持仓行且现金未动", + (await page.locator(".position-row").count()) === 0 && cashTexts.includes("101.50"), + JSON.stringify({ cashTexts, rows: await page.locator(".position-row").count() }) + ); + } + // 现金核算: 下单冻结 reservedTotal;确认后 invested 转持仓成本、residual 退回、费用消耗 + const cashCheck = + confirmed + ? Math.abs( + parseFloat(cashTexts[0]) - + (5000 - 101.50 + parseFloat(lastConfirmResponse.confirmedResidualCash || "0")) + ) < 0.005 && cashTexts[1] === "0.00" + : Math.abs(parseFloat(cashTexts[0]) + parseFloat(cashTexts[1]) - 5000) < 0.005; + check( + "J11 确认后现金核算一致", + cashCheck, + JSON.stringify({ cashTexts, residual: lastConfirmResponse ? lastConfirmResponse.confirmedResidualCash : null }) + ); + await page.screenshot({ path: SHOTS + "/12-confirm-positions.png" }); } 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 = [<Import("searchInstruments", "./src/api.js")>] let searchInstruments (token: string) (query: string) : JS.Promise<RawSearchResponse> = jsNative @@ -177,16 +245,25 @@ module Api = [<Import("getOrders", "./src/api.js")>] let getOrders (token: string) (fundId: string) : JS.Promise<RawOrder array> = jsNative + [<Import("confirmOrder", "./src/api.js")>] + let confirmOrder (token: string) (fundId: string) (orderId: string) (idempotencyKey: string) : JS.Promise<RawOrder> = jsNative + + [<Import("getPositions", "./src/api.js")>] + let getPositions (token: string) (fundId: string) : JS.Promise<RawPositions> = jsNative + let decodeOptionalText (raw: obj) : string option = if isNull raw then None else - let envelope = unbox<RawOptionalText> raw + match raw with + | :? string as value -> Some value + | _ -> + let envelope = unbox<RawOptionalText> 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; diff --git a/tests/FundLab.Web.Tests/BoundaryTests.fs b/tests/FundLab.Web.Tests/BoundaryTests.fs index 1d021a1..d48d08b 100644 --- a/tests/FundLab.Web.Tests/BoundaryTests.fs +++ b/tests/FundLab.Web.Tests/BoundaryTests.fs @@ -327,3 +327,235 @@ module FundBoundaryTests = let stale, _ = update (FundReadCompleted(2, { fund with availableCash = "1.00" })) updated Assert.Equal("12000.00", stale.createdFund.Value.availableCash) + +module ConfirmationBoundaryTests = + + let sampleFundId = "3fa85f64-5717-4562-b3fc-2c963f66afa6" + + let sampleOrderId = "7c9e6679-7425-40de-944b-e07fc1f90ae7" + + let someText (value: string) : obj = box { case = "Some"; fields = [| value |] } + + let sampleRawOrder () : RawOrder = + { + id = sampleOrderId + fundCode = "000001" + amount = "100.00" + feeAmount = "1.50" + reservedTotal = "101.50" + status = "submitted" + submittedAt = "2026-09-21T10:00:00+00:00" + tradeDate = "2026-09-21" + pendingReason = null + confirmedAt = null + confirmedNav = null + confirmedNavDate = null + confirmedUnits = null + confirmedInvestedCash = null + confirmedResidualCash = null + } + + let sampleConfirmedRaw () : RawOrder = + { sampleRawOrder () with + status = "confirmed" + pendingReason = null + confirmedAt = someText "2026-09-21T11:00:00+00:00" + confirmedNav = someText "2.50000000" + confirmedNavDate = someText "2026-09-21" + confirmedUnits = someText "40.00000000" + confirmedInvestedCash = someText "100.00" + confirmedResidualCash = someText "0.00" } + + let samplePendingRaw () : RawOrder = + { sampleRawOrder () with + status = "pending_nav" + pendingReason = someText "nav for trade date 2026-09-21 is not available yet" } + + let samplePositions () : RawPositions = + { + availableCash = "9900.00" + reservedCash = "101.50" + positions = + [| + { instrumentCode = "000001" + units = "40.00000000" + costCash = "100.00" + lastConfirmedAt = "2026-09-21T11:00:00+00:00" + valuationNav = someText "2.61000000" + valuationNavDate = someText "2026-09-21" + valuationCollectedAt = someText "2026-09-21T09:30:00+00:00" } + { instrumentCode = "110011" + units = "10.00000000" + costCash = "25.00" + lastConfirmedAt = "2026-09-21T11:00:00+00:00" + valuationNav = null + valuationNavDate = null + valuationCollectedAt = null } + |] + } + + let sampleFund () : FundSummary = + { + id = sampleFundId + name = "稳健一号" + currency = "CNY" + initialCash = "10000.00" + initialUnitNav = "1.00000000" + isSynthetic = false + availableCash = "9900.00" + reservedCash = "101.50" + status = "active" + } + + let withOrder model order = { model with token = "qa-token"; createdFund = Some(sampleFund ()); orders = [ order ] } + + [<Fact>] + let ``optional text decodes plain strings and the legacy case fields envelope`` () = + Assert.Equal(Some "1.19020000", Api.decodeOptionalText (box "1.19020000")) + Assert.Equal(None, Api.decodeOptionalText null) + Assert.Equal(Some "混合型", Api.decodeOptionalText (someText "混合型")) + Assert.Equal(None, Api.decodeOptionalText (box { case = "None"; fields = [||] })) + + [<Fact>] + let ``order decode keeps decimal strings and decodes option fields`` () = + let confirmed = Api.decodeOrder (sampleConfirmedRaw ()) + let pending = Api.decodeOrder (samplePendingRaw ()) + + Assert.Equal("confirmed", confirmed.status) + Assert.Equal("2.50000000", confirmed.confirmedNav.Value) + Assert.Equal("40.00000000", confirmed.confirmedUnits.Value) + Assert.Equal("100.00", confirmed.confirmedInvestedCash.Value) + Assert.Equal("0.00", confirmed.confirmedResidualCash.Value) + Assert.Equal(None, confirmed.pendingReason) + Assert.Equal("pending_nav", pending.status) + Assert.Equal(Some "nav for trade date 2026-09-21 is not available yet", pending.pendingReason) + Assert.Equal(None, pending.confirmedNav) + + [<Fact>] + let ``positions decode keeps exact strings and flags missing valuation`` () = + let decoded = Api.decodePositions (samplePositions ()) + + Assert.Equal("9900.00", decoded.availableCash) + Assert.Equal("101.50", decoded.reservedCash) + Assert.Equal(2, decoded.positions.Length) + + let first = decoded.positions[0] + + Assert.Equal("40.00000000", first.units) + Assert.Equal("100.00", first.costCash) + Assert.Equal(Some "2.61000000", first.valuationNav) + Assert.Equal(Some "2026-09-21", first.valuationNavDate) + + let second = decoded.positions[1] + + Assert.Equal("10.00000000", second.units) + Assert.Equal(None, second.valuationNav) + Assert.Equal(None, second.valuationNavDate) + + [<Fact>] + let ``confirm request requires fund token and unique in-flight guard`` () = + let noFund, _ = update (OrderConfirmRequested sampleOrderId) { init () with token = "qa-token" } + + Assert.Equal(0, noFund.confirmSeq) + Assert.False(noFund.confirmInFlight) + + let noToken, _ = + update (OrderConfirmRequested sampleOrderId) { init () with createdFund = Some(sampleFund ()) } + + Assert.Equal(Some "请输入 API token", noToken.error) + Assert.Equal(0, noToken.confirmSeq) + + let started, _ = + update (OrderConfirmRequested sampleOrderId) (withOrder { init () with confirmInFlight = false } (Api.decodeOrder (sampleRawOrder ()))) + + Assert.Equal(1, started.confirmSeq) + Assert.True(started.confirmInFlight) + Assert.Equal(sampleOrderId, started.lastConfirmAttempt.Value.orderId) + Assert.Equal(32, started.lastConfirmAttempt.Value.idempotencyKey.Length) + + [<Fact>] + let ``confirm completion replaces the order row and keeps exact decimals`` () = + let model = + withOrder + { init () with confirmSeq = 2; confirmInFlight = true; lastConfirmAttempt = Some { idempotencyKey = "k"; orderId = sampleOrderId } } + (Api.decodeOrder (sampleRawOrder ())) + + let updated, _ = update (OrderConfirmCompleted(2, sampleOrderId, sampleConfirmedRaw ())) model + + Assert.False(updated.confirmInFlight) + Assert.Equal(None, updated.lastConfirmAttempt) + Assert.Equal(1, updated.orders.Length) + + let confirmed = updated.orders[0] + + Assert.Equal("confirmed", confirmed.status) + Assert.Equal("2.50000000", confirmed.confirmedNav.Value) + Assert.Equal("40.00000000", confirmed.confirmedUnits.Value) + + [<Fact>] + let ``stale confirm completion is ignored`` () = + let model = + withOrder + { init () with confirmSeq = 5; confirmInFlight = true; lastConfirmAttempt = Some { idempotencyKey = "k"; orderId = sampleOrderId } } + (Api.decodeOrder (sampleRawOrder ())) + + let stale, _ = update (OrderConfirmCompleted(4, sampleOrderId, sampleConfirmedRaw ())) model + + Assert.True(stale.confirmInFlight) + Assert.Equal("submitted", stale.orders[0].status) + + [<Fact>] + let ``confirm failure clears flight and surfaces error`` () = + let model = + withOrder + { init () with confirmSeq = 1; confirmInFlight = true; lastConfirmAttempt = Some { idempotencyKey = "k"; orderId = sampleOrderId } } + (Api.decodeOrder (sampleRawOrder ())) + + let failed, _ = update (OrderConfirmFailed(1, sampleOrderId, "confirm failed")) model + + Assert.False(failed.confirmInFlight) + Assert.Equal(None, failed.lastConfirmAttempt) + Assert.Equal(Some "confirm failed", failed.error) + + [<Fact>] + let ``positions completion stores payload and stale reads are ignored`` () = + let model = { init () with token = "qa-token"; createdFund = Some(sampleFund ()); positionsSeq = 1; positionsInFlight = true } + + let updated, _ = update (PositionsReadCompleted(1, samplePositions ())) model + + Assert.False(updated.positionsInFlight) + Assert.Equal(Some "9900.00", updated.positions |> Option.map (fun p -> p.availableCash)) + Assert.Equal(2, updated.positions.Value.positions.Length) + + let stale, _ = update (PositionsReadCompleted(0, samplePositions ())) updated + + Assert.Equal(2, stale.positions.Value.positions.Length) + + [<Fact>] + let ``token change clears confirm and positions state and invalidates stale responses`` () = + let model = + { withOrder (init ()) (Api.decodeOrder (sampleRawOrder ())) with + token = "old-token" + confirmSeq = 3 + confirmInFlight = true + lastConfirmAttempt = Some { idempotencyKey = "k"; orderId = sampleOrderId } + positions = Some(Api.decodePositions (samplePositions ())) + positionsSeq = 3 + positionsInFlight = true } + + let cleared, _ = update (TokenChanged "new-token") model + + Assert.Empty(cleared.orders) + Assert.Equal(None, cleared.createdFund) + Assert.Equal(None, cleared.positions) + Assert.False(cleared.confirmInFlight) + Assert.False(cleared.positionsInFlight) + Assert.Equal(None, cleared.lastConfirmAttempt) + + let staleConfirm, _ = update (OrderConfirmCompleted(3, sampleOrderId, sampleConfirmedRaw ())) cleared + + Assert.Empty(staleConfirm.orders) + + let stalePositions, _ = update (PositionsReadCompleted(3, samplePositions ())) cleared + + Assert.Equal(None, stalePositions.positions) |
