summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--qa/driver/browser-test.js162
-rw-r--r--src/FundLab.Api/App.fs30
-rw-r--r--src/FundLab.Web/App.fs349
-rw-r--r--src/FundLab.Web/src/api.js19
-rw-r--r--src/FundLab.Web/src/styles.css10
-rw-r--r--tests/FundLab.Web.Tests/BoundaryTests.fs232
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)