From 28669742e60c1c45a27c7778987f0a44c44df517 Mon Sep 17 00:00:00 2001 From: reputationly <197039020@qq.com> Date: Mon, 15 Jun 2026 12:42:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(reconcile):=20=E5=B7=AE=E5=BC=82=E5=AE=9A?= =?UTF-8?q?=E4=BD=8D=E9=87=8D=E6=9E=84=EF=BC=88=E5=AF=B9=E9=BD=90=E5=8E=BB?= =?UTF-8?q?=E6=BC=82=E7=A7=BB=20+=20=E4=BB=85=E5=B1=95=E7=A4=BA=E7=9C=9F?= =?UTF-8?q?=E5=B7=AE=E5=BC=82=EF=BC=89+=20=E8=AE=A1=E4=BB=B6=E6=A0=87?= =?UTF-8?q?=E5=BF=97=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端 - 新增 service/reconcile_align.go:逐模型自动探测整点位移(shift∈{-1,0,+1}) 对齐供方系统性小时漂移;仅 hour 粒度做"等额才整对清零"的相邻抵消(day 粒度 跳过,避免误隐藏真实日级差异);过滤出真差异桶并分类 diff_kind(price_only/ usage/missing_local/missing_supplier/mixed);buildDiffBreakdown 产出 Top-N 归因。 - Compare 接入对齐结果:替换 Rows/单边计数/RowsCount/diff_breakdown,并给 by_model 打 diff_kind;summary / by_model 数值保持权威,不受对齐影响;drift 分析仍跑全量 trace。 - dto:DiffRow 加 diff_kind/align_shift_hours/supplier_bucket;Summary 加 diff_breakdown; ByModelStat 加 diff_kind;新增 DiffBreakdownItem。 - common:新增对齐/显著性/TopN 等常量。 - task_billing.go:LogTaskConsumption 按 PerCallBilling(TaskPricePatches∪UsePrice) gate count_billing,taskBillingOther 按 bc.PerCallBilling gate——token 计费任务不再 被误标计件(修既有 bug)。 前端(web/classic) - 页面顺序改为 总览 → 按模型汇总 → 明细差异。 - 明细表删除"明细合并"选择器与客户端聚合,直接展示后端真差异行;新增"差异类型"列、 计件用量展示、时段对齐提示(供方 HH:00(对齐 ±Nh))。 - 按模型汇总按 |Δ¥| 降序 + diff_kind 标签;总览新增"差异构成"。 - 新增共享 diffKind.js(标签/颜色)。 测试 - reconcile_align_test.go:系统性 ±1/0、部分漂移保留、等额抵消、day 跳过抵消、 价差/用量/单边分类、breakdown TopN、聚合不可变性。 - task_billing_count_test.go:按次/ token 计费/ nil 上下文的 count_billing gate。 --- common/reconcile_constants.go | 25 ++ dto/reconcile_upload.go | 33 +- service/reconcile_align.go | 364 ++++++++++++++++++ service/reconcile_align_test.go | 263 +++++++++++++ service/reconcile_compare.go | 26 +- service/task_billing.go | 13 +- service/task_billing_count_test.go | 42 ++ .../table/reconcile/ByModelTable.jsx | 26 +- .../components/table/reconcile/DiffTable.jsx | 341 +++++----------- .../table/reconcile/SummaryCard.jsx | 19 + .../components/table/reconcile/diffKind.js | 16 + web/classic/src/pages/Reconcile/index.jsx | 8 +- 12 files changed, 912 insertions(+), 264 deletions(-) create mode 100644 service/reconcile_align.go create mode 100644 service/reconcile_align_test.go create mode 100644 service/task_billing_count_test.go create mode 100644 web/classic/src/components/table/reconcile/diffKind.js diff --git a/common/reconcile_constants.go b/common/reconcile_constants.go index 2496286ab04..0a14024a9f4 100644 --- a/common/reconcile_constants.go +++ b/common/reconcile_constants.go @@ -22,3 +22,28 @@ var ReconcileDriftWarnPct = 0.02 // granularity or shorten the time span — anything bigger overwhelms the JSON // response and the frontend table. var ReconcileMaxBuckets = 20000 + +// --- v3.1 difference-localisation (see docs §十三) --- + +// ReconcileMaxAlignShiftHours: per-model the comparator probes integer-hour +// shifts in [-N, +N] to absorb the supplier's systematic hour-bucket offset +// before deciding what is a *real* difference. The parallel supplier drifts +// by ±1h, so 1 is enough — a wider window risks swallowing genuine adjacent +// differences as drift. +var ReconcileMaxAlignShiftHours = 1 + +// ReconcileSignificantAmountCNY: after alignment + ±1h residual netting, a +// (model, hour) bucket is shown in the detail table only if its residual Δ¥ +// reaches this. Below it the bucket is pure drift / rounding and is hidden. +var ReconcileSignificantAmountCNY = 0.01 + +// ReconcileSignificantTokens / ReconcileSignificantPct gate whether a token +// dimension counts as a genuine *usage* mismatch (vs price-only): the delta +// must be at least this many tokens AND at least this share of the larger +// side. Both bounds avoid flagging rounding-scale token jitter. +var ReconcileSignificantTokens int64 = 1 +var ReconcileSignificantPct = 0.005 + +// ReconcileDiffBreakdownTopN caps how many models the summary "差异构成" +// attribution lists explicitly; the rest collapse into a single "其他" item. +var ReconcileDiffBreakdownTopN = 5 diff --git a/dto/reconcile_upload.go b/dto/reconcile_upload.go index 3003cef74a0..391f8f35309 100644 --- a/dto/reconcile_upload.go +++ b/dto/reconcile_upload.go @@ -36,6 +36,17 @@ type ReconcileSummary struct { LocalTotal Totals `json:"local_total"` Delta Totals `json:"delta"` DeltaAmountPct float64 `json:"delta_amount_pct"` + // DiffBreakdown attributes the interval's total Δ¥ to its top contributing + // models (v3.1 §13.3). Sorted by |delta| desc; the tail collapses into a + // single {model:"其他"} item. + DiffBreakdown []DiffBreakdownItem `json:"diff_breakdown,omitempty"` +} + +// DiffBreakdownItem is one row of the summary "差异构成" attribution. +type DiffBreakdownItem struct { + Model string `json:"model"` + DeltaAmountCNY float64 `json:"delta_amount_cny"` + DiffKind string `json:"diff_kind,omitempty"` } // DriftAnalysis classifies the cumulative Δ¥ pattern across the hour series. @@ -58,7 +69,11 @@ type DiffSide struct { RequestCount int `json:"request_count,omitempty"` } -// DiffRow is one (model, hour_bucket) cell. +// DiffRow is one (model, hour_bucket) cell. In v3.1 the comparator only emits +// rows that carry a *genuine* residual difference after drift alignment — +// pure drift buckets are dropped, so HourBucket is the local anchor and the +// supplier data (if any) may have come from SupplierBucket = HourBucket + +// AlignShiftHours·3600. type ReconcileDiffRow struct { HourBucket int64 `json:"hour_bucket"` Model string `json:"model"` @@ -67,7 +82,17 @@ type ReconcileDiffRow struct { Delta Totals `json:"delta"` CumulativeDeltaAmountCNY float64 `json:"cumulative_delta_amount_cny"` Status string `json:"status"` // matched / supplier_only / local_only - Regions []string `json:"regions,omitempty"` + // DiffKind localises *why* this row differs: price_only / usage / + // missing_local / missing_supplier (v3.1 §13.2). + DiffKind string `json:"diff_kind,omitempty"` + // AlignShiftHours is the integer-hour shift applied to this model's + // supplier series during alignment (supplier_bucket - hour_bucket, in + // hours). 0 when the supplier bucket already matched the local anchor. + AlignShiftHours int `json:"align_shift_hours,omitempty"` + // SupplierBucket is the supplier's original BucketEnd before alignment, + // for the "供方 19:00(对齐 −1h)" hint. 0 when there's no supplier side. + SupplierBucket int64 `json:"supplier_bucket,omitempty"` + Regions []string `json:"regions,omitempty"` } // ByModelKind is one token-kind row inside ByModel. @@ -86,6 +111,10 @@ type ByModelStat struct { SupplierAmountCNY float64 `json:"supplier_amount_cny"` LocalAmountCNY float64 `json:"local_amount_cny"` DeltaAmountCNY float64 `json:"delta_amount_cny"` + // DiffKind summarises this model's significant detail rows (v3.1 §13.3): + // price_only / usage / missing_local / missing_supplier / mixed, or empty + // when the model has no significant difference. + DiffKind string `json:"diff_kind,omitempty"` } // ParseError describes one bad row inside the uploaded xlsx. diff --git a/service/reconcile_align.go b/service/reconcile_align.go new file mode 100644 index 00000000000..7e1ee04c549 --- /dev/null +++ b/service/reconcile_align.go @@ -0,0 +1,364 @@ +package service + +import ( + "math" + "sort" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" +) + +// v3.1 difference localisation (docs/reconciliation-upload-design.md §十三). +// +// The supplier bills per hour and drifts each model's traffic by a systematic +// integer-hour offset (measured: supplier BucketEnd = our HourBucket + 1h on +// the dominant majority of buckets, with a little ±1h jitter on the rest). The +// old UI pushed a manual "明细合并" window selector onto the operator to soak +// that up. v3.1 does it automatically and only surfaces the buckets that carry +// a *genuine* residual Δ¥ — so the detail table answers "which hour, which +// usage dimension caused the total gap" without any knob. +// +// Pipeline per model: +// 1. probe shift ∈ [-N, +N] hours, pick the one minimising Σ|Δ¥| (ties → 0) +// 2. ±1h greedy opposite-sign netting on the aligned Δ¥ series (jitter) +// 3. keep buckets whose residual |Δ¥| ≥ threshold; classify each (diff_kind) +// +// This never mutates supAgg/locAgg, so summary / by_model stay authoritative. + +const ( + diffKindMissingLocal = "missing_local" // supplier billed, we didn't record + diffKindMissingSupplier = "missing_supplier" // we recorded, supplier didn't bill + diffKindPriceOnly = "price_only" // same tokens/count, amount differs → ratio/price + diffKindUsage = "usage" // token/count counts themselves differ + diffKindMixed = "mixed" // model spans multiple kinds +) + +// alignResult is what alignAndExtractDiffs hands back to Compare. +type alignResult struct { + rows []dto.ReconcileDiffRow + supplierOnly int + localOnly int + modelDiffKind map[string]string // model → merged diff_kind (for by_model / breakdown) +} + +// alignAndExtractDiffs runs the per-model align → net → filter → classify +// pipeline over the already-aggregated supplier and local sides. +func alignAndExtractDiffs( + supAgg, locAgg map[reconcileBucketKey]*dto.DiffSide, + supRegions map[reconcileBucketKey]map[string]struct{}, + granularity string, +) alignResult { + step := int64(3600) + maxShift := int64(common.ReconcileMaxAlignShiftHours) + if granularity == GranularityDay { + // Day buckets already subsume the ±1h offset; no sub-day alignment. + step = 86400 + maxShift = 0 + } + + // Group buckets per model so each model aligns independently. + type series struct { + sup map[int64]*dto.DiffSide + loc map[int64]*dto.DiffSide + } + models := map[string]*series{} + get := func(m string) *series { + s := models[m] + if s == nil { + s = &series{sup: map[int64]*dto.DiffSide{}, loc: map[int64]*dto.DiffSide{}} + models[m] = s + } + return s + } + for k, s := range supAgg { + get(k.model).sup[k.hourBucket] = s + } + for k, s := range locAgg { + get(k.model).loc[k.hourBucket] = s + } + + modelNames := make([]string, 0, len(models)) + for m := range models { + modelNames = append(modelNames, m) + } + sort.Strings(modelNames) + + res := alignResult{modelDiffKind: map[string]string{}} + + for _, m := range modelNames { + ms := models[m] + shift := bestAlignShift(ms.sup, ms.loc, step, maxShift) + + // Union of local-anchor buckets (supplier bucket sb maps to sb-shift). + anchors := map[int64]struct{}{} + for b := range ms.loc { + anchors[b] = struct{}{} + } + for sb := range ms.sup { + anchors[sb-shift*step] = struct{}{} + } + bs := make([]int64, 0, len(anchors)) + for b := range anchors { + bs = append(bs, b) + } + sort.Slice(bs, func(i, j int) bool { return bs[i] < bs[j] }) + + residual := make([]float64, len(bs)) + for i, b := range bs { + residual[i] = sideAmount(ms.sup[b+shift*step]) - sideAmount(ms.loc[b]) + } + // Residual ±1h netting only makes sense at hourly granularity, where the + // supplier's sub-hour drift splits a request across adjacent buckets. At + // day granularity that drift is already contained within the day, so + // netting adjacent *days* would wrongly hide a genuine overcharge-one-day + // / undercharge-next-day pattern. + if granularity == GranularityHour { + netAdjacentOppositeSigns(bs, residual, step) + } + + kinds := map[string]struct{}{} + for i, b := range bs { + if math.Abs(residual[i]) < common.ReconcileSignificantAmountCNY { + continue // pure drift / rounding — hide + } + sup := ms.sup[b+shift*step] + loc := ms.loc[b] + status, kind := classifyDiff(sup, loc) + kinds[kind] = struct{}{} + + var supplierBucket int64 + var regions []string + if sup != nil { + supplierBucket = b + shift*step + if reg, ok := supRegions[reconcileBucketKey{m, supplierBucket}]; ok && len(reg) > 0 { + regions = make([]string, 0, len(reg)) + for r := range reg { + regions = append(regions, r) + } + sort.Strings(regions) + } + } + switch status { + case "supplier_only": + res.supplierOnly++ + case "local_only": + res.localOnly++ + } + res.rows = append(res.rows, dto.ReconcileDiffRow{ + HourBucket: b, + Model: m, + Supplier: sup, + Local: loc, + Delta: computeDelta(sup, loc), + Status: status, + DiffKind: kind, + AlignShiftHours: int(shift), + SupplierBucket: supplierBucket, + Regions: regions, + }) + } + res.modelDiffKind[m] = mergeDiffKinds(kinds) + } + + // Sort by (hour, model) and lay down the cumulative Δ¥ over the kept rows. + sort.Slice(res.rows, func(i, j int) bool { + if res.rows[i].HourBucket != res.rows[j].HourBucket { + return res.rows[i].HourBucket < res.rows[j].HourBucket + } + return res.rows[i].Model < res.rows[j].Model + }) + var cum float64 + for i := range res.rows { + cum += res.rows[i].Delta.AmountCNY + res.rows[i].CumulativeDeltaAmountCNY = roundTo6(cum) + } + return res +} + +// bestAlignShift returns the integer-hour shift (in buckets, |shift| ≤ maxShift) +// that minimises Σ over local-anchor buckets of |supplier(b+shift) − local(b)|. +// Ties resolve to the smallest |shift| (most conservative — least re-attribution). +func bestAlignShift(sup, loc map[int64]*dto.DiffSide, step, maxShift int64) int64 { + if maxShift == 0 || (len(sup) == 0 || len(loc) == 0) { + return 0 + } + bestShift := int64(0) + bestCost := math.Inf(1) + for s := -maxShift; s <= maxShift; s++ { + anchors := map[int64]struct{}{} + for b := range loc { + anchors[b] = struct{}{} + } + for sb := range sup { + anchors[sb-s*step] = struct{}{} + } + var cost float64 + for b := range anchors { + cost += math.Abs(sideAmount(sup[b+s*step]) - sideAmount(loc[b])) + } + // Strictly-less keeps the first (smallest |s|, since we iterate -N..N + // and prefer 0 explicitly on equality). + if cost < bestCost-1e-9 || (math.Abs(cost-bestCost) <= 1e-9 && abs64(s) < abs64(bestShift)) { + bestCost = cost + bestShift = s + } + } + return bestShift +} + +// netAdjacentOppositeSigns soaks up residual ±1-bucket jitter. A whole bucket's +// billing shifted by ±1h lands as two opposite-sign deltas of *equal* magnitude +// (the same request, just one hour off), so we only treat a pair as drift — +// and cancel BOTH fully — when their magnitudes match within the significance +// threshold. Partial cancellation is deliberately avoided: if the two sides +// differ materially (e.g. +¥10 next to −¥7), they are not pure drift, so both +// rows are left intact. That keeps the detail summing to the authoritative +// summary / by_model gap (here +¥3 across the two rows) instead of emitting an +// inflated single +¥10 row and silently dropping the −¥7 side. +// +// This is virtual — it only decides which buckets are "real" differences; the +// displayed numbers are always the un-netted aligned values. +func netAdjacentOppositeSigns(buckets []int64, residual []float64, step int64) { + for i := 0; i+1 < len(buckets); i++ { + if buckets[i+1]-buckets[i] != step { + continue + } + a, b := residual[i], residual[i+1] + if a*b >= 0 { + continue + } + if math.Abs(math.Abs(a)-math.Abs(b)) >= common.ReconcileSignificantAmountCNY { + continue // materially unequal → not pure drift, keep both rows + } + residual[i] = 0 + residual[i+1] = 0 + } +} + +// classifyDiff labels an aligned (supplier, local) pair. Significance of the +// *amount* is already established by the caller; here we only decide the kind. +func classifyDiff(sup, loc *dto.DiffSide) (status, kind string) { + supEmpty := isEmptySide(sup) + locEmpty := isEmptySide(loc) + switch { + case !supEmpty && locEmpty: + return "supplier_only", diffKindMissingLocal + case supEmpty && !locEmpty: + return "local_only", diffKindMissingSupplier + default: + if tokenUsageDiffers(sup, loc) { + return "matched", diffKindUsage + } + return "matched", diffKindPriceOnly + } +} + +// tokenUsageDiffers reports whether any token/count dimension differs by a +// genuine margin (≥ Tokens absolute AND ≥ Pct of the larger side) — i.e. a +// real usage mismatch rather than a price/ratio-only difference. +func tokenUsageDiffers(sup, loc *dto.DiffSide) bool { + if sup == nil { + sup = &dto.DiffSide{} + } + if loc == nil { + loc = &dto.DiffSide{} + } + dims := [][2]int64{ + {sup.TokensInput, loc.TokensInput}, + {sup.TokensOutput, loc.TokensOutput}, + {sup.TokensCacheRead, loc.TokensCacheRead}, + {sup.TokensCacheWrite, loc.TokensCacheWrite}, + {sup.TokensCount, loc.TokensCount}, + } + for _, d := range dims { + delta := d[0] - d[1] + if delta < 0 { + delta = -delta + } + if delta < common.ReconcileSignificantTokens { + continue + } + larger := d[0] + if d[1] > larger { + larger = d[1] + } + if larger <= 0 { + return true + } + if float64(delta)/float64(larger) >= common.ReconcileSignificantPct { + return true + } + } + return false +} + +// isEmptySide treats a nil or all-zero (tokens+count zero, amount sub-threshold) +// side as "absent" for missing-row classification. +func isEmptySide(s *dto.DiffSide) bool { + if s == nil { + return true + } + return s.TokensInput == 0 && s.TokensOutput == 0 && + s.TokensCacheRead == 0 && s.TokensCacheWrite == 0 && s.TokensCount == 0 && + math.Abs(s.AmountCNY) < common.ReconcileSignificantAmountCNY +} + +// mergeDiffKinds collapses a model's per-row kinds into one label. +func mergeDiffKinds(kinds map[string]struct{}) string { + switch len(kinds) { + case 0: + return "" + case 1: + for k := range kinds { + return k + } + } + return diffKindMixed +} + +// buildDiffBreakdown attributes the interval total Δ¥ to the top contributing +// models, using the authoritative by_model amounts and the per-model diff_kind. +func buildDiffBreakdown(byModel []dto.ByModelStat, modelKinds map[string]string) []dto.DiffBreakdownItem { + items := make([]dto.DiffBreakdownItem, 0, len(byModel)) + for _, m := range byModel { + if math.Abs(m.DeltaAmountCNY) < common.ReconcileSignificantAmountCNY { + continue + } + items = append(items, dto.DiffBreakdownItem{ + Model: m.Model, + DeltaAmountCNY: m.DeltaAmountCNY, + DiffKind: modelKinds[m.Model], + }) + } + sort.Slice(items, func(i, j int) bool { + return math.Abs(items[i].DeltaAmountCNY) > math.Abs(items[j].DeltaAmountCNY) + }) + topN := common.ReconcileDiffBreakdownTopN + if topN <= 0 || len(items) <= topN { + return items + } + var otherSum float64 + for _, it := range items[topN:] { + otherSum += it.DeltaAmountCNY + } + out := append([]dto.DiffBreakdownItem{}, items[:topN]...) + if math.Abs(otherSum) >= common.ReconcileSignificantAmountCNY { + out = append(out, dto.DiffBreakdownItem{Model: "其他", DeltaAmountCNY: roundTo6(otherSum)}) + } + return out +} + +// sideAmount is a nil-safe amount reader. +func sideAmount(s *dto.DiffSide) float64 { + if s == nil { + return 0 + } + return s.AmountCNY +} + +func abs64(v int64) int64 { + if v < 0 { + return -v + } + return v +} diff --git a/service/reconcile_align_test.go b/service/reconcile_align_test.go new file mode 100644 index 00000000000..b8451a32081 --- /dev/null +++ b/service/reconcile_align_test.go @@ -0,0 +1,263 @@ +package service + +import ( + "math" + "reflect" + "testing" + + "github.com/QuantumNous/new-api/dto" +) + +// hour-bucket base (arbitrary; alignment only uses 3600-spacing arithmetic). +const tb = int64(1780567200) // 2026-06-04 18:00 CST + +const hr = int64(3600) + +func side(amount float64, in, out, cr, cw, count int64) *dto.DiffSide { + return &dto.DiffSide{ + TokensInput: in, TokensOutput: out, TokensCacheRead: cr, + TokensCacheWrite: cw, TokensCount: count, AmountCNY: amount, + } +} + +func sup(m string, b int64, s *dto.DiffSide) (reconcileBucketKey, *dto.DiffSide) { + return reconcileBucketKey{m, b}, s +} + +// run is a tiny helper that builds the two maps and aligns. +func run(supEntries, locEntries map[reconcileBucketKey]*dto.DiffSide) alignResult { + return alignAndExtractDiffs(supEntries, locEntries, nil, GranularityHour) +} + +func TestReconcileAlign_SystematicPlus1h(t *testing.T) { + // Supplier traffic lands one hour LATER than ours (the measured dominant + // case). Same numbers, shifted +1 bucket → must fully align, zero rows. + supEntries := map[reconcileBucketKey]*dto.DiffSide{} + locEntries := map[reconcileBucketKey]*dto.DiffSide{} + for i := int64(0); i < 4; i++ { + amt := 1.0 + float64(i) + locEntries[reconcileBucketKey{"GLM-5.1", tb + i*hr}] = side(amt, 1000, 200, 0, 0, 0) + supEntries[reconcileBucketKey{"GLM-5.1", tb + (i+1)*hr}] = side(amt, 1000, 200, 0, 0, 0) + } + res := run(supEntries, locEntries) + if len(res.rows) != 0 { + t.Fatalf("expected 0 significant rows after +1h alignment, got %d: %+v", len(res.rows), res.rows) + } + if k := res.modelDiffKind["GLM-5.1"]; k != "" { + t.Fatalf("expected empty diff kind, got %q", k) + } +} + +func TestReconcileAlign_SystematicMinus1h(t *testing.T) { + // Supplier one hour EARLIER than ours → shift -1 should align. + supEntries := map[reconcileBucketKey]*dto.DiffSide{} + locEntries := map[reconcileBucketKey]*dto.DiffSide{} + for i := int64(0); i < 4; i++ { + amt := 2.0 + float64(i) + locEntries[reconcileBucketKey{"Kimi", tb + (i+1)*hr}] = side(amt, 500, 100, 0, 0, 0) + supEntries[reconcileBucketKey{"Kimi", tb + i*hr}] = side(amt, 500, 100, 0, 0, 0) + } + if got := bestAlignShift(mapFor("Kimi", supEntries), mapFor("Kimi", locEntries), hr, 1); got != -1 { + t.Fatalf("expected shift -1, got %d", got) + } + if res := run(supEntries, locEntries); len(res.rows) != 0 { + t.Fatalf("expected 0 rows, got %d", len(res.rows)) + } +} + +func TestReconcileAlign_ExactMatch(t *testing.T) { + supEntries := map[reconcileBucketKey]*dto.DiffSide{} + locEntries := map[reconcileBucketKey]*dto.DiffSide{} + for i := int64(0); i < 3; i++ { + supEntries[reconcileBucketKey{"Qwen", tb + i*hr}] = side(1, 100, 10, 0, 0, 0) + locEntries[reconcileBucketKey{"Qwen", tb + i*hr}] = side(1, 100, 10, 0, 0, 0) + } + if res := run(supEntries, locEntries); len(res.rows) != 0 { + t.Fatalf("expected 0 rows on exact match, got %d", len(res.rows)) + } +} + +func TestReconcileAlign_PriceOnly(t *testing.T) { + // Same count (12 vs 12), amount 5×: classic ratio misconfig → price_only. + supEntries := map[reconcileBucketKey]*dto.DiffSide{ + {"MiniMax-T2V-01", tb}: side(36, 0, 0, 0, 0, 12), + } + locEntries := map[reconcileBucketKey]*dto.DiffSide{ + {"MiniMax-T2V-01", tb}: side(180, 0, 0, 0, 0, 12), + } + res := run(supEntries, locEntries) + if len(res.rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(res.rows)) + } + r := res.rows[0] + if r.DiffKind != diffKindPriceOnly { + t.Fatalf("expected price_only, got %q", r.DiffKind) + } + if r.Status != "matched" { + t.Fatalf("expected matched status, got %q", r.Status) + } + if math.Abs(r.Delta.AmountCNY-(-144)) > 1e-6 { + t.Fatalf("expected Δ -144, got %v", r.Delta.AmountCNY) + } + if res.modelDiffKind["MiniMax-T2V-01"] != diffKindPriceOnly { + t.Fatalf("model kind mismatch: %q", res.modelDiffKind["MiniMax-T2V-01"]) + } +} + +func TestReconcileAlign_Usage(t *testing.T) { + // Our input/cache far exceed supplier → genuine usage mismatch. + supEntries := map[reconcileBucketKey]*dto.DiffSide{ + {"DeepSeek-V4-Pro", tb}: side(3.59, 272597, 13449, 0, 0, 0), + } + locEntries := map[reconcileBucketKey]*dto.DiffSide{ + {"DeepSeek-V4-Pro", tb}: side(24.43, 950747, 139102, 9682688, 0, 0), + } + res := run(supEntries, locEntries) + if len(res.rows) != 1 || res.rows[0].DiffKind != diffKindUsage { + t.Fatalf("expected 1 usage row, got %+v", res.rows) + } +} + +func TestReconcileAlign_MissingLocal(t *testing.T) { + // Supplier billed a video, we have nothing, no neighbour → missing_local. + supEntries := map[reconcileBucketKey]*dto.DiffSide{ + {"MiniMax-Hailuo-02", tb}: side(2.29, 0, 0, 0, 0, 1), + } + res := run(supEntries, map[reconcileBucketKey]*dto.DiffSide{}) + if len(res.rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(res.rows)) + } + if res.rows[0].DiffKind != diffKindMissingLocal || res.rows[0].Status != "supplier_only" { + t.Fatalf("expected supplier_only/missing_local, got %s/%s", res.rows[0].Status, res.rows[0].DiffKind) + } + if res.supplierOnly != 1 { + t.Fatalf("expected supplierOnly=1, got %d", res.supplierOnly) + } +} + +func TestReconcileAlign_DriftPairNetted(t *testing.T) { + // Supplier extra at b, our extra at b+1, equal amounts, no other data → + // ±1h netting must cancel both → zero rows. + supEntries := map[reconcileBucketKey]*dto.DiffSide{ + {"GLM-5.1", tb}: side(5, 1000, 100, 0, 0, 0), + } + locEntries := map[reconcileBucketKey]*dto.DiffSide{ + {"GLM-5.1", tb + hr}: side(5, 1000, 100, 0, 0, 0), + } + // Note: this is the systematic +1h case (shift=1) so it aligns anyway; + // to exercise the *netting* path specifically, force shift=0 by adding a + // matched anchor that makes shift 0 the minimum. + supEntries[reconcileBucketKey{"GLM-5.1", tb - 5*hr}] = side(10, 0, 0, 0, 0, 0) + locEntries[reconcileBucketKey{"GLM-5.1", tb - 5*hr}] = side(10, 0, 0, 0, 0, 0) + res := run(supEntries, locEntries) + if len(res.rows) != 0 { + t.Fatalf("expected drift pair netted to 0 rows, got %d: %+v", len(res.rows), res.rows) + } +} + +func TestReconcileAlign_PartialDriftKeepsBoth(t *testing.T) { + // Materially-unequal adjacent opposite-sign buckets (+¥10 vs −¥7) are NOT + // pure drift: both rows must survive so the detail still sums to the real + // gap (+¥3), instead of emitting an inflated +¥10 row and hiding the ¥7. + supEntries := map[reconcileBucketKey]*dto.DiffSide{ + {"GLM-5.1", tb}: side(10, 2000, 0, 0, 0, 0), + {"GLM-5.1", tb - 5*hr}: side(10, 0, 0, 0, 0, 0), // matched anchor → forces shift 0 + } + locEntries := map[reconcileBucketKey]*dto.DiffSide{ + {"GLM-5.1", tb + hr}: side(7, 1400, 0, 0, 0, 0), + {"GLM-5.1", tb - 5*hr}: side(10, 0, 0, 0, 0, 0), + } + res := run(supEntries, locEntries) + if len(res.rows) != 2 { + t.Fatalf("expected 2 rows (both sides kept), got %d: %+v", len(res.rows), res.rows) + } + // Final cumulative must equal the authoritative model gap: 10 - 7 = +3. + final := res.rows[len(res.rows)-1].CumulativeDeltaAmountCNY + if math.Abs(final-3) > 1e-6 { + t.Fatalf("expected detail cumulative to sum to +3, got %v", final) + } +} + +func TestReconcileAlign_DayGranularitySkipsNetting(t *testing.T) { + // At day granularity, adjacent-day equal-and-opposite differences are NOT + // drift and must NOT be netted away — a real overcharge on day D and + // undercharge on day D+1 has to stay visible as two rows. + const day = int64(86400) + supEntries := map[reconcileBucketKey]*dto.DiffSide{ + {"GLM-5.1", tb}: side(10, 0, 0, 0, 0, 0), // supplier-only on day D + } + locEntries := map[reconcileBucketKey]*dto.DiffSide{ + {"GLM-5.1", tb + day}: side(10, 0, 0, 0, 0, 0), // local-only on day D+1 + } + res := alignAndExtractDiffs(supEntries, locEntries, nil, GranularityDay) + if len(res.rows) != 2 { + t.Fatalf("expected 2 day rows (no netting), got %d: %+v", len(res.rows), res.rows) + } +} + +func TestReconcileAlign_DoesNotMutateInputs(t *testing.T) { + supEntries := map[reconcileBucketKey]*dto.DiffSide{ + {"M", tb}: side(36, 0, 0, 0, 0, 12), + } + locEntries := map[reconcileBucketKey]*dto.DiffSide{ + {"M", tb}: side(180, 0, 0, 0, 0, 12), + } + supCopy := deepCopySides(supEntries) + locCopy := deepCopySides(locEntries) + _ = run(supEntries, locEntries) + if !reflect.DeepEqual(supEntries, supCopy) { + t.Fatalf("supplier aggregate mutated by alignment") + } + if !reflect.DeepEqual(locEntries, locCopy) { + t.Fatalf("local aggregate mutated by alignment") + } +} + +func TestBuildDiffBreakdown_TopNAndOther(t *testing.T) { + byModel := []dto.ByModelStat{ + {Model: "A", DeltaAmountCNY: 144}, + {Model: "B", DeltaAmountCNY: 69}, + {Model: "C", DeltaAmountCNY: -2.29}, + {Model: "D", DeltaAmountCNY: 1.5}, + {Model: "E", DeltaAmountCNY: 1.2}, + {Model: "F", DeltaAmountCNY: 1.1}, + {Model: "G", DeltaAmountCNY: 0.001}, // below threshold, dropped + } + kinds := map[string]string{"A": diffKindPriceOnly, "B": diffKindUsage, "C": diffKindMissingLocal} + out := buildDiffBreakdown(byModel, kinds) + // top 5 + 其他 + if len(out) != 6 { + t.Fatalf("expected 5 top + 1 其他 = 6, got %d: %+v", len(out), out) + } + if out[0].Model != "A" || out[0].DiffKind != diffKindPriceOnly { + t.Fatalf("expected A/price_only first, got %+v", out[0]) + } + last := out[len(out)-1] + if last.Model != "其他" { + t.Fatalf("expected last item 其他, got %q", last.Model) + } + if math.Abs(last.DeltaAmountCNY-1.1) > 1e-6 { // only F spills past top-5 + t.Fatalf("expected 其他 = 1.1, got %v", last.DeltaAmountCNY) + } +} + +// --- test helpers --- + +func mapFor(model string, m map[reconcileBucketKey]*dto.DiffSide) map[int64]*dto.DiffSide { + out := map[int64]*dto.DiffSide{} + for k, v := range m { + if k.model == model { + out[k.hourBucket] = v + } + } + return out +} + +func deepCopySides(m map[reconcileBucketKey]*dto.DiffSide) map[reconcileBucketKey]*dto.DiffSide { + out := map[reconcileBucketKey]*dto.DiffSide{} + for k, v := range m { + c := *v + out[k] = &c + } + return out +} diff --git a/service/reconcile_compare.go b/service/reconcile_compare.go index 5fca27225c7..43042b8dffa 100644 --- a/service/reconcile_compare.go +++ b/service/reconcile_compare.go @@ -131,10 +131,12 @@ func Compare(channelIDs []int, supplierRows []SupplierBillRow, parseErrs []Parse return keyList[i].model < keyList[j].model }) + // This full per-bucket trace feeds the drift analysis only; the detail + // rows the UI shows come from alignAndExtractDiffs (v3.1). Single-side + // counts for the summary are likewise taken from the aligned result. rows := make([]dto.ReconcileDiffRow, 0, len(keyList)) var cumulativeDelta float64 var maxAbsCumDelta float64 - var supplierOnlyCount, localOnlyCount int for _, k := range keyList { sup := supAgg[k] @@ -143,10 +145,8 @@ func Compare(channelIDs []int, supplierRows []SupplierBillRow, parseErrs []Parse status := "matched" if sup != nil && loc == nil { status = "supplier_only" - supplierOnlyCount++ } else if sup == nil && loc != nil { status = "local_only" - localOnlyCount++ } // delta = supplier - local @@ -207,9 +207,18 @@ func Compare(channelIDs []int, supplierRows []SupplierBillRow, parseErrs []Parse // --- by-model aggregation --- byModel := aggregateByModel(supAgg, locAgg) - // --- drift analysis --- + // --- drift analysis (over the FULL per-bucket trace, unchanged) --- drift := analyseDrift(rows, supTotal.AmountCNY, maxAbsCumDelta, cumulativeDelta) + // --- v3.1: align drift away & keep only genuine difference rows --- + // This does not touch supAgg/locAgg/byModel/summary totals — those stay + // authoritative. It only replaces what the detail table shows. + align := alignAndExtractDiffs(supAgg, locAgg, supRegions, granularity) + for i := range byModel { + byModel[i].DiffKind = align.modelDiffKind[byModel[i].Model] + } + diffBreakdown := buildDiffBreakdown(byModel, align.modelDiffKind) + // --- build final result --- convertedErrs := make([]dto.ReconcileParseError, len(parseErrs)) for i, e := range parseErrs { @@ -222,17 +231,18 @@ func Compare(channelIDs []int, supplierRows []SupplierBillRow, parseErrs []Parse To: to, ChannelIDs: channelIDs, ModelsCount: len(distinctModels), - RowsCount: len(rows), - SupplierOnlyRows: supplierOnlyCount, - LocalOnlyRows: localOnlyCount, + RowsCount: len(align.rows), + SupplierOnlyRows: align.supplierOnly, + LocalOnlyRows: align.localOnly, ParseErrorsCount: len(parseErrs), SupplierTotal: roundTotals(supTotal), LocalTotal: roundTotals(locTotal), Delta: totalDelta, DeltaAmountPct: deltaPct, + DiffBreakdown: diffBreakdown, }, DriftAnalysis: drift, - Rows: rows, + Rows: align.rows, ByModel: byModel, ParseErrors: convertedErrs, } diff --git a/service/task_billing.go b/service/task_billing.go index 509ed2ca672..8a3cf1a708c 100644 --- a/service/task_billing.go +++ b/service/task_billing.go @@ -37,6 +37,12 @@ func LogTaskConsumption(c *gin.Context, info *relaycommon.RelayInfo) { } other := make(map[string]interface{}) other["is_task"] = true + // 仅「按次/按个」任务(TaskPricePatches 命中或固定价格)供方才按「个」计费,需打 + // count_billing 供对账归类。token 计费任务(PerCallBilling=false,走轮询 token 重算) + // 必须保留 token 用量,不能标计件。此判定与 controller/relay.go 的 PerCallBilling 定义一致。 + if common.StringsContains(constant.TaskPricePatches, info.OriginModelName) || info.PriceData.UsePrice { + other["count_billing"] = true + } other["request_path"] = c.Request.URL.Path other["model_price"] = info.PriceData.ModelPrice if info.PriceData.ModelRatio > 0 { @@ -136,7 +142,12 @@ func taskBillingOther(task *model.Task) map[string]interface{} { other["is_model_mapped"] = true other["upstream_model_name"] = props.UpstreamModelName } - other["count_billing"] = true + // 只有按次/按个任务才标计件。token 计费任务(PerCallBilling=false,差额结算走 + // RecalculateTaskQuotaByTokens)保留 token 用量,避免对账把它误当 1 个计件。 + // BillingContext 缺失时无从判定,保守沿用旧行为(视为按次)。 + if bc := task.PrivateData.BillingContext; bc == nil || bc.PerCallBilling { + other["count_billing"] = true + } return other } diff --git a/service/task_billing_count_test.go b/service/task_billing_count_test.go new file mode 100644 index 00000000000..1f6e92ab43b --- /dev/null +++ b/service/task_billing_count_test.go @@ -0,0 +1,42 @@ +package service + +import ( + "testing" + + "github.com/QuantumNous/new-api/model" +) + +// taskBillingOther must only stamp count_billing for per-call/per-count tasks. +// Token-billed tasks (PerCallBilling=false, settled via +// RecalculateTaskQuotaByTokens) keep their token usage so reconciliation does +// not mis-classify them as 1-个 count billing. LogTaskConsumption gates the +// submit log on the same condition (TaskPricePatches ∪ PriceData.UsePrice, +// i.e. controller/relay.go's PerCallBilling definition). +func TestTaskBillingOther_CountBillingGate(t *testing.T) { + cases := []struct { + name string + bc *model.TaskBillingContext + wantSet bool + }{ + {"per-call → count", &model.TaskBillingContext{PerCallBilling: true}, true}, + {"token-billed → no count", &model.TaskBillingContext{PerCallBilling: false}, false}, + {"nil context → conservative count", nil, true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + task := &model.Task{ + Properties: model.Properties{OriginModelName: "test-model"}, + PrivateData: model.TaskPrivateData{BillingContext: c.bc}, + } + other := taskBillingOther(task) + v, ok := other["count_billing"] + if c.wantSet { + if !ok || v != true { + t.Fatalf("expected count_billing=true, got %v (present=%v)", v, ok) + } + } else if ok { + t.Fatalf("expected count_billing absent for token-billed task, got %v", v) + } + }) + } +} diff --git a/web/classic/src/components/table/reconcile/ByModelTable.jsx b/web/classic/src/components/table/reconcile/ByModelTable.jsx index ca2ff871b4b..0dd86af768b 100644 --- a/web/classic/src/components/table/reconcile/ByModelTable.jsx +++ b/web/classic/src/components/table/reconcile/ByModelTable.jsx @@ -1,6 +1,7 @@ import React, { useMemo } from 'react'; import { Table, Tag, Typography } from '@douyinfe/semi-ui'; import { useTranslation } from 'react-i18next'; +import { diffKindTag } from './diffKind'; const { Text } = Typography; @@ -30,13 +31,20 @@ export default function ByModelTable({ byModel }) { const { t } = useTranslation(); const data = useMemo(() => { + // Biggest amount contributors first, so the models driving the total gap + // sit at the top instead of being buried in alphabetical order. + const sorted = [...(byModel || [])].sort( + (a, b) => + Math.abs(b.delta_amount_cny || 0) - Math.abs(a.delta_amount_cny || 0), + ); const out = []; - (byModel || []).forEach((m) => { + sorted.forEach((m) => { m.kinds.forEach((k) => { out.push({ _key: `${m.model}__${k.kind}`, model: m.model, kind: k.kind, + diffKind: m.diff_kind, sup: k.supplier_tokens, loc: k.local_tokens, delta: k.delta_tokens, @@ -48,6 +56,7 @@ export default function ByModelTable({ byModel }) { _key: `${m.model}__amount`, model: m.model, kind: 'amount', + diffKind: m.diff_kind, sup: m.supplier_amount_cny, loc: m.local_amount_cny, delta: m.delta_amount_cny, @@ -62,8 +71,19 @@ export default function ByModelTable({ byModel }) { { title: t('模型'), dataIndex: 'model', - width: 160, - render: (v, r) => (r.kind === 'input' || r.kind === 'amount' ? v : ''), + width: 220, + render: (v, r) => { + // Only label the model on its amount row (last row of the group), + // so the diff_kind tag appears once per model. + if (!r.isAmount) return r.kind === 'input' ? v : ''; + const kt = diffKindTag(r.diffKind, t); + return ( +