功能链路速查
把核心功能从 UI 入口到 DB 的完整数据流串起来,回答「这个功能怎么运作」。 数据表格式见 data.md、分层与异步模式见 architecture.md。 本页为手写文档、无自动同步:改动业务代码后请同步本页。 正文只写「模块.函数」不写行号,避免随代码漂移。行号请以
docs/api/为准。
制造评分
UI(工业页)→ workers/industry_workers.ScoreWorker
→ scoring_service.ScoringService.calc_manufacturing_score(薄委托,签名稳定)
→ scoring_facade.calc_manufacturing_score(编排)
· db.connect("ref","mkt","bp") 跨库读蓝图/材料/SDE
· _DbPriceProvider 取价取量(get_price/get_volume/get_system_cost_index)→ domain.scoring
→ domain/scoring.py 纯算法(PriceProvider 注入,无 DB)
→ domain/formulas.py(材料/时长/费用纯公式)
→ ScoringService._cache(TtlLRUCache,按 cache_key 命中)- 数据结构:blueprint.db(
blueprint_products/blueprint_activities/blueprint_materials)、reference.db(item名称、industry_system_costs、研究成本)、market.db(market_prices) - 入口:
services/scoring_service.py的ScoringService.calc_manufacturing_score - 关键差异:评分链路取价走
scoring_service模块级get_price(直查mkt.market_prices),不走PricingService - 「按产物取配方蓝图」只能走
services/blueprint_reader.py(get_blueprint_products/get_blueprint_type_for_product,services/scoring_facade.py与plan_aggregator等已收口): 它优先非测试蓝图,同一产物多张时再按blueprint_type_id升序取 —— 结果必须确定, 不能随插入顺序变;连接要带ref(只 attachbp时读不到item名称,函数会退化成 「只按 type_id 取」,测试蓝图排除就失效)。理由:CCP 在 SDE 里塞了两张测试蓝图 (45732「Test Reaction Blueprint / 测试反应堆蓝图」、26843「…TEST Blueprint / …测试蓝图」), 而碳化钨(16672) 同时挂 45732(20/轮)与 46207「碳化钨反应配方」(10000/轮) —— 旧写法... AND bp.activity='reaction' LIMIT 1(无 ORDER BY)命中的是测试蓝图, 自制成本被算成 33,818.87/件(市价 86,真实 ≈76.4)。只有测试蓝图的产物(26842 狂暴级部族型) 仍返回它,别整批排除 —— 那会让界面凭空少掉配方。
跨区域价差排行(市场贸易页)
贸易页「开始计算」→ workers/trade_workers.CrossRegionRankWorker(**零网络请求**)
· market_browser_service.fetch_cross_region_spread(A/B 两 region JOIN,可选市场分类子树)
· market_browser_service.fetch_hub_order_change(B 侧挂单量的近日变化,零请求)
→ models/trade_rank_model.TradeRankQmlModel(一个物品一行)- 「开始计算」只读
market.db里已存的价格,不拉 ESI —— 这是用户明确要求:算得快, 且「这份价新不新」由用户自己看着状态栏判断,而不是被按着头等一次全量刷新。 全库唯一的取价入口是主工具栏右上角的「更新价格」 (shell/Main.qml→ShellWindow.trigger_price_update→request_price_update), 那条通道带单写者排队与进度条,页面不得自行起PriceUpdateWorker—— 两个写者同时动market.db会撞锁。更新价格现在还会按 TTL 增量拉取可制造/反应产物的市场历史 (market.db.price_history,即「日订单量 / 日成交量」的来源;见services/importers/getprices.fetch_and_save_histories)—— 页面同样不得自己拉 ESI。 勾了哪些中心就拉哪些中心(用户口径:「同步哪些市场的数据,就自动把成交量一块拉过来」): 覆盖集 = 制造产物 ∪ 反应产物 ∪ 被当材料的 type ∪ PLEX(实测 5554 个), 请求量 = 5554 × 勾选的中心数,单中心约 4.6 分钟(@20 req/s 全局限流),靠 12h TTL 增量摊平; 升级后首轮必然跑满(材料此前从没拉过历史)。读端各自显式传region_id: 可制造物品窗口按 Jita,贸易页按它当前选的起点/终点(两列「起点/终点日成交量」)。 同一步还会写全服统一价快照(market.db.global_price_daily,只存没有区域成交历史的 品种 = PLEX):/markets/prices/那一次请求已经拉过,顺手落 1 行/天 —— 它是大盘 PLEX 锚 的唯一数据源(ESI 的区块历史对 PLEX 恒返回空,实测 Jita/Amarr 都是空列表)。 ⚠️ 这两列的聚合窗口是最近 7 个日历天(services/price_history.get_history_summary), 缺失的日子按 0 计入:ESI 历史只返回有成交的日子,按「最近 7 条记录」取会把冷门物品 算成「上次活跃那几天」的平均值(实测屹立白蚁 II已 80 天没成交却报 21.6/天)。 本地历史整段过期(fetched_at也早于窗口)时给—而不是 0。 工业页的「刷新」是例外:它走PlanPriceRefreshWorker只拉当前 计划相关的 type_id(带 5 分钟缓存判定),不是全量更新,故保留在页面内。 - 价格时效直接显示在页面状态栏:
{行数} · {A} {刚刚/35 分钟前/3 天前} / {B} {…}, 取数走market_browser_service.fetch_hub_fetch_time(各中心MAX(fetch_time), 5 个中心的刷新节奏并不一致,混着新旧价看排行会误判)。格式化在TradeBridge._age_text。 - ⚠️
PriceUpdateWorker的success标志不可信:run_price_update在拉取失败时 不抛异常(失败 region 被跳过、旧价保留)。所以「这份价到底是不是刚拉的」只能靠fetch_hub_fetch_time如实读出来 —— 这也是状态栏那一行的存在理由 - 页面加载不查库、不拉取,只有点「开始计算」才读一遍本地价
- 筛选项在内存里做(
TradeBridge._apply_filters):切筛选项不重算 SQL、不重读库。 两个开关都是「排除」语义,默认全开:- 「只看赚钱的」→
价差 > 0 - 「两侧 ≥ N」→
min(A侧、B侧对手盘挂单量) ≥ N。对手盘挂单量取自market_prices的buy_volume/sell_volume(按所选价位取对应那侧)。这一项是必要的:importers/getprices.save_prices会把 ESI/markets/prices/的全局均价作为兜底 混进market_prices,那种行两侧价格看着很高、挂单量却是 0,根本成交不了 —— 实测全量约 1.15 万行里这类「假价差」占了绝大多数,默认筛选后只剩两三百行 (具体数字随行情漂,别把它当断言)
- 「只看赚钱的」→
- 购物车(
ui_qml/views/trade_cart_window.py)存data/trade_cart.json,按方向分组; 外壳ShellWindow.trade_cart()持单例,页面与独立窗口共用同一份
贸易评分(无 UI 入口)
同制造评分的编排结构,入口为 ScoringService.calc_trade_score → scoring_facade.calc_trade_score:
- 额外读取:reference.db
item.volume(体积成本)、_ss.get_volume(成交量门槛) - 参数维度:buy_hub / sell_hub / buy_price_type / sell_price_type / char_config
- 产物:每跳利润估计等,经
domain/scoring.pytrade 分支 - 当前没有 UI 调用方:市场贸易页重构成排行表后,原先的「贸易评分」区块已删除。 链路本身(服务与纯算法)保留未动
统一定价
UI(财务/运费/精炼/BOM)→ services/pricing_service.py PricingService
→ services/repositories/market_repository.py MarketRepository
→ mkt.market_prices(价格/成交量/adjusted price)· ref.industry_system_costs(SCI)- 数据落库:
services/importers/getprices写market_prices/market_volume_snapshots;getindustry写industry_system_costs/industry_facilities - 并存约定:评分链路走
scoring_service.get_price,其余 UI 链路走PricingService——两套取价路径同时存在,改价需两边同步
BOM 递归展开
services/bom_expander.py: expand_bom / get_material_tree / get_flat_materials(公共入口)
→ _expand 递归(T2/T3 产业链)
· _find_blueprint_for_product:bp.blueprint_products JOIN bp.blueprint_activities
· _get_materials:bp.blueprint_materials
· calc_material_for_runs:domain/formulas.py
· 价格:_default_pricing().get_price(PricingService,mkt.market_prices)- UI 无直接调用方;内部查询被
plan_decompose、plan_rebuild复用
精炼价值
估算页 _on_refine → workers/refine_worker → services/refining_service.RefiningService.calc_value
· filter_refinable:ref.reprocessing_materials(可精炼判定)
· 产率 calc_refining_yield:core/eve_formulas.py
· 输入/产出价:PricingService.get_price(mkt.market_prices)物流运费(当前无 UI 入口)
services/logistics.py
· estimate_freight_cost / calc_transport_profit
· get_distance_jumps:TRADE_HUB_DISTANCES 硬编码距离表(含 Hek)
· 体积:reference.db item.volume
· 价格:PricingService.get_price
· 费率常量:core/eve_formulas(经纪人费/销售税)- 市场贸易页的「运输利润」Tab 已删除(2026-09),这些函数的调用方随之消失; 服务与纯算法保留,计划挪到合同市场(合同页的运输 Tab 目前只算合同报酬口径:
domain/contract_analysis.courier_metrics,没有运费成本与净利润) compute_jumps仍被services/contract_service.py使用
生产计划
- 表:user 库
production_plans/plan_blueprint_bindings/user_blueprints/price_snapshots - 仓库:
services/repositories/plan_repository.py(不存在services/plan_repository.py) - 保存:
plan_service.insert_plan;批量导入insert_plans_batch(blueprint 导入走批量) - 启动:
plan_table._start_plan→plan_start_check.plan_start_block(纯逻辑、零 DB;返回(类别码, 文案),UI 用码选短标签、用文案做 tooltip;plan_start_block_reason是同一次判定的文案投影)→plan_execution.check_materials→start_plan(原子 UPDATE status +inventory_manager.deduct_item) - 部分启动:
plan_execution.start_plan_partial(plan_id, lines, ...)—— 只启动 N 条产线。时序必须是「先拆行、后启动」:先UPDATE parallels=N+insert_split_remainder(复制结构列、清空执行列、source_mother_ids置'')+move_bindings(把前 N 张之外的绑定移动给余量行),提交后再plan_service.load_plan重新取数(漏了这步会按 P 条扣料),最后start_plan(auto_bind=False)(自动绑定走自己的连接立即提交,是回滚看不见的副作用)。失败则_rollback_split把两行并回一条。独立计划、子项全部完成的母项、子项行都可以部分启动(余量行照抄sub_level/group_number,仍是同组子项;子项拆开后不要用母项的「母项调整(递归拆解)」,那会按母项需求重放整组子项)。预览用preview_partial_start(按 N 条口径报缺口) - 完成:
plan_execution.complete_plan(成品入inventory_items+consume_bpc_runs消耗user_blueprints+ 清 bindings);撤销cancel_plan返还材料 - 母项结束时清理已完成的子项行:
complete_plan在同一事务内(deposited回写之后、commit之前)调plan_execution.remove_completed_children(group_number, conn=conn),返回体带removed计数供 UI 出文案。- 触发条件:
sub_level==0且group_number>0,且组内没有别的活跃 level-0 行(部分启动拆出的 「已启动 / 未启动」两半必须都结束,否则仍在跑的那半会失去子项制造价口径)。 - 子项自身完成不删自己:母项的「市场」口径三列(材料成本/利润/市场利润率)靠同组子项行 (
plan_metrics.mother_subitem_cost_map+adjust_mother_metrics)才能按子项制造价计;删早了会在 混合态下回退市场价。入库成本不受影响——子项下线时产出已按其自身成本入库,母项启动与下线都按库存成本核算。 - 清理判据是「已无归属」且全局扫:有
source_mother_ids的行要等引用它的母项全部结束才删 (跨组共享件靠这条兜住,只按组过滤会留永久孤儿);没有来源记录的行只在触发组内清。 - ⚠️ 绑定清理由传入的
conn完成,不得调release_blueprint—— 后者自开另一条缓存连接并独立提交, 在外层未提交写事务内会 WAL 写锁自锁(卡满busy_timeout后database is locked),且破坏「清理与置 completed 同生共死」的原子性。 - 删母项(右键「删除产线」,
plan_table._delete_rows)时也调同一函数,否则已完成的子项行会成为永远清不掉的 孤儿(plan_rebuild的 prune 分支显式豁免_DONE_STATUSES)。
- 触发条件:
- 展开:
plan_table._decompose_parent→plan_decompose.decompose_plan(递归读user_blueprints+ bom 材料)→plan_rebuild.rebuild_children→PlanRepository增删改 - 需求传播(
plan_rebuild.compute_child_forest):每轮先把需求累齐 → 再定稿 runs → 最后才拿 runs 展开下一级, 且每个节点每轮只展开一次;迭代到所有节点 runs 不再变(_MAX_ROUNDS=10)为止,跨层共享 2-3 轮收敛。 两条铁律,违反哪条都会静默算错(都不报错,只是数字不对):- 别在累需求过程中折算 runs:共享中间件会被每个母项各展开一遍,下级 demand 翻倍(实测 60 算成 80)。
- 别用短路写递归(
changed = changed or _propagate(...)):本节点 runs 有变化时就不往下走了, 而「正在变」恰是最该往下走的时候。多母项共享中间件时每轮都在变,level≥2 永远进不了nodes;prune又按「type 不在 nodes 里」判孤儿,会把已存在的孙项行删掉(先单母项拆解出孙项、再加第二个母项、再拆解一次 → 孙项消失)。 回归防线:tests/test_plan_rebuild.py::test_shared_intermediate_expands_to_its_own_children与::test_prune_keeps_grandchildren_when_a_second_mother_shows_up。
- 读取:
plan_service.load_plans;价格快照save_price_snapshots - 筛选只影响「显示哪些行」,绝不影响任何判定:状态筛选(
industry_bridge.FILTERS= 全部/待排/运行中/待下线/已完成)与类别筛选(全部/制造/科研/反应,按行上_enrich_rows给的category)都只是视图条件;材料/等子项判定的基准是全量计划集(含所有状态的同组子项)。 历史缺陷:先按状态 SQL 过滤、再拿筛过的行去标注 → 运行中的子项被筛掉, 母项的pending_children_count从 1 掉到 0、缺料从 0 种变 1 种,界面从「等待 1 条子项」 跳到「材料不足」(实测计划 365 母项 / 375 运行中子项;两个筛选下同一行的make_cost/market_margin/personal_margin也必须逐字相同)。_loaded_rows(30s 心跳重算标注的基线)与状态栏统计同样按全量 —— 「计划总数」本来就是全局口径。 - 计划表的派生视图要缓存(
industry_models.PlanTableModel._view_cache):可见行、行号映射、 「有子项的组」都是 O(行数),却被data()按「列 × 角色」逐格调用 —— 实测 50 行、折叠两个组时 全表刷一遍 485ms(不折叠 58ms),QML 滚动每帧都要取角色,于是「几十行就开始卡」。 缓存按签名(id(列表), 行数, 折叠快照)失效,beginResetModel另有兜底清空。 ⚠️ 改这几个方法时别退回「每格重扫全表」,回归防线:tests/test_qml_plan_model.py::test_cell_reads_do_not_rescan_the_whole_table(断言的是调用次数而不是耗时 —— 耗时断言在 CI 上会飘)。 - 旁路:
plan_aggregator是采购/需求聚合,不是计划展开 - 价格口径(工具栏双行价格设置):
mat_hub/mat_price_type/mat_mult与prod_hub/prod_price_type/prod_mult由top_toolbar.get_price_settings()提供,消费方必须整套一起透传(漏一项就是「改设置数字不动」的缺陷):- 「成本 / 利润 / 利润率」三列 = 个人口径(用户 2026-10-07 拍板,推翻 10-03 的市场口径): 拆解母项的同组自制件按自制单件价计、缺口按库存/市价(
plan_metrics.adjust_mother_metrics, 与「个人利润率%」和查看核算汇总同一套算法)。原因:市场口径把用户自己排产的自制件按买入市价计, 同一行会出现「利润 −2.88 亿」+「个人利润率 +21%」这种自相矛盾,核算对话框也跟大表对不上。 市场口径另存production_plans.market_margin(「市场利润率%」列)= 假如全按市价买卖的参考;ISK/h必须与当行利润同口径(profit ÷ 耗时),别取市场口径的metrics["iskph"]。 - 心跳重算的指纹必须含「市场数据版本」(
industry_view._price_data_fp()=market_prices最新fetch_time):「更新价格」只换库里的价格、不动价格设置,缺这一项指纹不变 → 心跳不重算 → 成本/利润停在旧价格,而每次load_plans都实时重算的「自制成本/件」已是新价格(实测: 紫外晶体 XL 从 ~97 万跌到 ~51 万,占该批材料 60%,整条产线从亏 2.88 亿翻成赚 2.88 亿, 大表却还挂着旧那个负数)。指纹 = 价格设置 + 市场数据版本 + 库存快照,三样缺一不可。 - 计划成本/利润:
BatchPlanCalcWorker(表格批量重算)、industry_view._on_plan_add、plan_table._view_cost_breakdown→ 成本明细弹窗、parent_decompose_dialog、blueprint_tab的「加入制造规划」预览 →ScoringService.calculate_plan_metrics(mat_mult=, prod_mult=)→scoring_facade.calc_manufacturing_score→domain.scoring - ⚠️
scoring_facade有 TTL 缓存,mat_price_mult/prod_price_mult必须进 cache_key,否则同一类陈旧缓存缺陷会在评分层复现 - 倍率只作用于玩家买卖价:材料价乘
mat_mult、成品价乘prod_mult;EIV 用的 adjusted_price 不乘(CCP 官方估价,与买卖价无关) - 状态栏「备料中采购」:
industry_view._refresh_procurement_summary→ProcurementSummaryWorker(region_id, price_type, price_mult, self_made)→ui_data_service.aggregate_procurement_summary→plan_aggregator.aggregate_procurement。该函数带指纹缓存,指纹 = (价格口径, 自制件集合, 计划字段集),任一漏进指纹就会回吐旧值 - ⚠️ 自制件集合必须按「全量计划」算(
plan_aggregator.self_made_type_ids,由调用方传成self_made=): 两个调用方都会把计划筛成materials_ready && pending(ready/running 的材料已扣库存,计入会虚高), 而子项产线往往正在生产中 —— 拿筛过的那份现算,子线就「不存在」了,它的产物被当成没有子线、 重复计成待采购。用户报的「电磁发生器已在生产,采购却仍报缺 2504」就是这么来的 (母项待生产、子线生产中)。实测同一份数据:按筛过的算 19 行 / 55,739,040 ISK,按全量算 18 行 / 0。 回归防线:tests/test_plan_aggregator.py::TestSelfMadeComponents与tests/test_procurement_tab.py::test_recalculate_excludes_running_sublines。 同一模块的collect_direct_materials用的是「全部活跃计划」查询,天然没这个问题。 - 口径分叉(有意):采购小助手
procurement_tab有自己独立的 Hub/价格类型控件、无倍率控件,不套用工具栏倍率 - 「买卖差价」列:
plan_aggregator._spread=(sell_price - buy_price) × 需采购量—— 金额,与「总价」同阶 (原来给的是单价差,读着像单价、和旁边数量 × 单价的总价对不上,用户报过)。不吃price_mult(倍率是跨区运费/溢价的模拟,价差是市场事实本身)。单边无挂单 →None,显示-、复制给空串 —— 给 0 会被读成「卖买同价」。get_market_prices本来就同时返回 sell/buy,这一列不额外查库。 - 采购表当前 6 列:物品名称 / 总需求 / 需采购 / 买卖差价 / 总价 / 自制成本/件。「库存」「单价」「体积」 已从显示下线(列表太宽装不下),数据仍在行里(
owned/price/volume)—— 汇总行、复制整单、 增量添加都还在用。「自制成本/件」是每件金额(口径见下文「自制成本」段;必须带「/件」: 同行「总价」是整批金额),由控制器_apply_make_costs在每次recalculate()后写进行里; 窗口最小宽因此 572 → 716px。 - ⚠️ 列五处按索引对齐:
procurement_bridge._HEADERS/_SORT_FIELDS/_COPY_FIELDS/_COLUMNS/procure_rows的 cells。加列必须五处一起插,插错位是「排序按这列、复制按那列」的 静默串列(回归防线:tests/test_procurement_tab.py::test_column_lists_stay_index_aligned, 含「cells 数 == 表头数」那条 —— 只改表头忘了_display_cells时长度断言会放过)。 QML 侧的allColumns从columns长度推,不要写死下标数组。 - ⚠️ 窗口宽度按工具栏那一行定,不是按表格:
FSummaryTable.colWidth给弹性列的下限是 80px, 且不会为了塞下而挤固定列 —— 固定列一多总宽就超出窗口、右侧列被裁(「首次打开显示不全」)。 按Σ(列宽+cellPadding 12) + 12 + 80估表格,再量工具栏那行的implicitWidth(实测 ~753px), 两者取大。护栏:test_procurement_tab.py::test_table_stays_narrow_enough_to_fit_the_window。
- 「成本 / 利润 / 利润率」三列 = 个人口径(用户 2026-10-07 拍板,推翻 10-03 的市场口径): 拆解母项的同组自制件按自制单件价计、缺口按库存/市价(
- 工具窗的置顶要在显示/前置时重申(
pin_utils.reassert_pin,采购小助手与产线启动小助手共用): 窗口刚构造时还没显示,置顶是在显示那一刻才第一次真正设上去;而QWindow.raise_()在 Windows 上是SetWindowPos(HWND_TOP)—— 不带HWND_TOPMOST的插入位置,可能让置顶在用户真正看到窗口之前丢掉, 表现是「勾着置顶却没置顶,再点一次才好」。所以window_visibility_changed(True)与置顶态下的raise_()都重申一次(幂等、一次 Win32 调用)。 - 置顶不持久化(只对本次会话有效):窗口一律以不置顶启动,只有点了置顶按钮才置顶。
self._pinned构造时恒为False,set_pinned(...)只改会话状态、不写 settings; 旧配置里的*_pin键不再读写(残留值是死数据,不删用户配置)。 - 启动成本快照(
production_plans.material_cost_snapshot,schema v12→v13):start_plan在扣减之前、事务之外采样机库加权单价,写入{"total": 总成本, "unit": {type_id: 单价}}:- 两个「之前」都是硬约束 ——
deduct_item把余量清到 0 会删行(扣完再取价得 0);db.connect()同线程复用连接、嵌套 with 退出会提前 commit(在事务内取价会毁掉「失败整体回滚」) complete_plan按快照total算成品入库单价;cancel_plan按快照unit返还成本 —— 都是启动那一刻的口径,不受在产期间价格重算影响- 旧计划无快照 → 回退
material_cost/ 机库当前加权成本(向后兼容);cancel_plan与reset_plan_for_reuse会清空快照
- 两个「之前」都是硬约束 ——
- 重算失败不得清零成本:
BatchPlanCalcWorker对「评分异常→空 dict」与no_price/no_blueprint/no_materials(calculate_plan_metrics对这三种 status 返回全零 dict)的行跳过不发,保留库中上次的正确值;父项若同组有更深的失败子项也一并跳过(否则mother_subitem_cost_map拿空 dict 会把母项成本算低)。跳过数量经failed_names由状态栏提示「N 条计划估值失败,成本沿用上次值」 - 完成入口统一:右键「下线」与状态列「待下线」都走
CompletePlansDialog选产出机库(批量只弹一次框);deposit_hangar_id为空时不再静默跳过入库 - 「删除产线」语义(菜单曾名「取消生产」「删除行」):解除蓝图绑定 + 删除本行及其连带的子项, 不返还已扣材料、不动库存行 —— 与游戏「取消产线只退蓝图」一致(领域模型见
AUDIT-20260801.md)。- 删母项 → 子项走
rebuild_children(prune=True)的需求式收缩(仍被别的活跃母项引用的共享件保留); 删子项 → 走plan_decompose.collect_removed_child_ids的血缘式同组子孙连坐 (与「母项拆解」预览里移除组件是同一条语义,两处共用一个函数)。 - 连坐在产(
in_progress/running)的子项不删(plan_rebuild._is_locked的既有口径:已投产产线不砍), 确认框里会带出「N 条在产子项不会删除」。 - 误点启动请用「撤销启动(返还材料)」;删除前有确认框按状态说明后果。
- 菜单里没有「设置蓝图等级」「查看蓝图原图的 NPC 卖家」「产线启动小助手」: 蓝图等级统一由库存蓝图带出(不再有计划级的手填等级),NPC 卖家仍在蓝图选择弹窗里, 小助手在工业页底部状态栏已有按钮。
- 删母项 → 子项走
- 行内动作要推迟出 QML 信号处理器:
LauncherBridge的rowStart/rowComplete/rowNotes/rowPartialStart/mainAction一律经_defer(QTimer.singleShot(0, self, …)) 再落到页面。这些动作会开模态框(FMessageDialog.exec()= 嵌套事件循环),而对话框开着时 5s 轮询重建行 → 持有该onClicked的 delegate 被销毁 → QtqFatal「Object … destroyed while one of its QML signal handlers is in progress … or the application is running a nested event loop. This behavior is NOT supported!」 → 进程静默闪退(0.25.1 真机实测,日志只留一行 CRITICAL 指向LauncherWindow.qml:481)。 同步动作(选中 / 折叠 / 复制蓝图)不推迟:它们不进入嵌套循环。 - 蓝图流程不足可强制启动:
_binding_shortfall有两道(张数 / 每张流程 ≥ runs),start_plan与complete_plan成对提供allow_bp_short—— 只放开启动会造成死锁 (强制启动的计划永远无法下线)。强制时不换绑,完成时consume_bpc_runs按实际可用量消耗; 账面偏差由用户在蓝图管理做全量剪贴板导入矫正。- 蓝图所在机库也是启动短板:
_binding_shortfall(..., mat_hangar_id=…)传了生效材料机库才校验 「绑定蓝图是否在这个机库」(_binding_hangar_mismatch,消息里带它现在在哪)。EVE 里作业的输入 (材料 + 蓝图)必须同一地点 —— 改了材料机库而蓝图没跟着挪,游戏那边装不下这个作业。 只有启动链传它(start_plan/preview_partial_start/ 小助手与计划表的binding_shortfall(plan_id, mat_hangar_id=…));完成链不传 —— 货都造出来了,蓝图当时在哪 不该拦住记账(complete_plan/complete_guard保持旧口径)。 回归背景(2026-10-06 用户报):改了机库、蓝图还在原机库,小助手既不提示「缺蓝图」也照常给「启动」。 - 改机库时连带问一句要不要挪蓝图:
PlanTable._offer_blueprint_move在「编辑生产计划」 写入mat_hangar_id之后调misplaced_bound_blueprints(plan_ids, mat_hangar_id)拿「不在目标 机库的绑定行」,弹「绑定的 N 张蓝图还在「X」,要一起挪到「Y」吗?」—— 点「是」才走inventory_manager.move_blueprints_to_hangar(库存是用户数据,不静默搬)。 - 五条下线入口都要覆盖:计划表格状态列(单行)、计划表格右键(批量)、采购页「一键完成」、 工业页底部状态栏「全部下线」、产线启动小助手行内「可下线」(单行)。 单行下线共用
ui_qml/views/industry/complete_plans_dialog.py::complete_one_plan(返回 None = 用户取消或已弹过失败告警,非 None 即成功——调用方据此决定要不要把行标成已完成); 预检与放行必须共用ui_qml/bridge/complete_guard.py::confirm_bp_shortfall。 状态栏那条曾漏传allow_bp_short,强制启动过的计划在该入口永远下不了线,且失败原因被complete_plans吞成一句「失败 N 项」(现已随failed_reasons带出)。确认框留 UI 层:complete_plans()是无 parent 的服务函数,validate 档会在无 QApplication 下直调它。 - 原图(
is_bpo=1)不受该限制:_bp_available_runs视为无限流程,既不触发「不足」确认, 下线时也永不消耗、永不删除(complete_plan与consume_bpc_runs各有独立防线)。 不变量是is_bpo=1⇒runs=0,由 v15→v16 迁移与剪贴板解析两侧同口径维护 (见domain/blueprint_sync.py)。
- 蓝图所在机库也是启动短板:
- 并行产线逐线计算:各线按各自绑定蓝图的 ME/TE 独立结算
- 等级来源:
plan_service._enrich_rows预填line_levels(user_blueprints.me_level/te_level, 绑定来源与bound_blueprint_ids同一回退:关联表优先 →assigned_blueprint_id); 未绑的线取已绑里最差那张(min(me)与min(te)分别取);一张没绑则留空 - 短路:
line_levels为空、或与计划级 (me,te) 完全一致 → 走原单次路径(零行为变化)。 ⚠️ 短路条件不能写成「各线彼此一致」—— 五张都绑 ME8 而计划级写 10 时仍须走逐线 - 汇总(
ScoringService.combine_per_line):材料/作业费/收入/利润 Σ; 时长取 max(并行同时跑,单次路径的hours_per_run × runs是其均匀特例); 日产出 Σ(24/hoursᵢ);利润率 = Σ利润/Σ总成本(与单线同式) - ⚠️
materials[].qty保持「单线单轮」语义(逐线时取最差线):plan_metrics两处消费方 都会再乘runs × parallels,放汇总值会被重复放大。 要求精确的消费方改读同条目的total_qty(整批取整量,见下)或materials_all_lines - 取整口径(2026-09-16 统一):材料需求按整批取一次整,不是逐轮取整后乘轮数 —— 后者对基础量 ≥2 的材料系统性多要货(基础量 22、ME10、2510 次作业:49,698 对 50,200)。 单一定义处是
domain/formulas.material_total_for_runs,成本 / 扣料 / 判定三处都走它。 每条材料另带total_qty(由calculate_total_metrics/combine_per_line算好; 逐线时各线各自整批再求和,因为每条并行线在 EVE 里是独立作业、各带自己的 ME)。material_requirements与plan_metrics优先读total_qty,缺失时才回退旧乘法 - ⚠️ 材料成本口径变了,利润/利润率/ISK-h 必须一起重算:
profit_per_run里含的是 单轮材料成本,所以用增量式(total_profit = profit_per_run × 倍数 + 材料省下的部分) 而不是自己拼total_cost = 材料 + 费用——fees_per_run里没有research_cost(它在domain/scoring.py才加进 total_cost),拼出来会让 T2/T3 计划的利润虚高 - 展示:表格 ME/TE 列一致时不变;不一致时显示最低那组 +
≠,tooltip 逐条列出
- 等级来源:
科研计划(拷贝 / 发明 / ME-TE 研究)
production_plans.activity 把「制造计划」泛化为「工业计划」;活动契约的唯一真源是 services/plan_job_kinds.py(输入蓝图规则 / 产出口径 / 提示文案)。所有消费方必须经它判定, 不得硬编码 activity='manufacturing'。
入口(手动加入,母项拆解不生成科研产线)
蓝图库右键 / 全物品页右键
→ services/research_plans.create_research_plan(单次 INSERT,含科研专属列)
· 拷贝 CopyPlanDialog 份数 + 每份流程(≤ 蓝图 copying 的 max_production_limit)
· 发明 InventionPlanDialog 产物(多产物下拉)+ 解码器 + 预期成功率 + 每线尝试次数 × 并行数
· 研究 ResearchPlanDialog ME/TE + 目标等级
评分(按行 activity 分派)
ScoringService.calculate_plan_metrics
· manufacturing → scoring_facade(现状路径,零改动)
· copying/invention/researching_* → ScoringService._calculate_research_metrics
→ services/plan_metrics.invention_plan_cost / copying_plan_cost / research_plan_cost
→ domain/research.py(成功率 / 解码器表 / BPC 产出 / 科学作业时长)
执行
启动:plan_start_check.plan_start_block(blueprint_ready=plan_execution.plan_blueprint_ready)
· 拷贝/研究要 BPO;发明要够流程的 BPC(不能用 BPO)
完成:plan_execution.complete_plan 按 output_kind 分派
· copying → 产出 BPC 入 user_blueprints(不消耗原图流程)
· invention → **必须回填 actual_output_runs**;否则返回 code='need_outcome'
拒绝静默完成 → 工业页弹 InventionOutcomeDialog
(默认值 = 期望流程;「发明失败」按钮置 0)
· researching_* → 只完成作业(等级仍由用户手动维护)
消耗:发明每次尝试扣输入 T1 BPC 的 1 个流程;拷贝/研究不扣语义契约(product_type_id 恒为「本计划的产物」):制造=物品、拷贝/研究=被操作的蓝图、 发明=产出的 T2/T3 蓝图。科研行的产物不在 blueprint_products.activity='manufacturing' 里, 所以 plan_aggregator 不按产物反查它的「制造蓝图」(那是蓝图需求清单的口径); 待采购材料与启动校验走同一路径:aggregate_procurement(采购小助手 + 状态栏备料中采购) 与 collect_direct_materials(「填料总表」)对科研行都调 plan_execution.material_requirements(数据核心/解码器按作业次数算好,不再乘 runs×parallels), 于是这两处与启动闸门 check_materials 不会各说一套 (2026-10-06 前两处都整行跳过科研行 → 计划表报缺数据核心/解码器、采购与填料总表却一份不要)。
「要绑哪张蓝图」的唯一取数:plan_aggregator.plan_input_blueprint_type_id —— 制造/反应 → 该产物的蓝图;拷贝/研究 → 被操作的那张 BPO;发明 → 由产物那张 T2 反查出的 T1 (发明作业跑在 T1 上)。四个消费方共用它:绑定弹窗 (industry_dialog_queries.get_blueprint_picker_data)、自动绑定 (plan_execution._available_blueprint_options)、以及复制蓝图名的两条入口 (产线小助手双击 / 计划表右键「复制蓝图名称」,都经 ui_data_service.resolve_plan_blueprint_name)—— 发明行复制过去必须是 T1 拷贝, 拿到产物那张 T2 在游戏里开不了工。各写一份就会出现「弹窗说缺 T1 拷贝、自动绑定却去绑 产物那张拷贝」(旧口径按 product_type_id 反查 activity='manufacturing',科研行必然空白, 即用户报的「点『蓝图差几张』弹窗全空」)。
科研行的图标:plan_service._enrich_rows 给每行算 icon_type_id —— 图标缓存里只有 物品、没有蓝图 png,所以科研行取「这张蓝图造出来的物品」(bp.blueprint_products 的 manufacturing 反向表);制造/反应就是产物本身。界面(计划表图标列、产线小助手行首)只读 这一列,缺图才回退类别首字。不这么取,科研行会退回类别首字「科」。
「自制成本」三处共用一个取数(判断这件料/这个产物自己造还是买): ScoringService.manufacturing_unit_costs(type_ids, mat_hub=…, price_type_mat=…, mat_mult=…,char_config=…, hangar_id=…, facility_tax_pct=…, system_id=…, cost_overrides=…) → {type_id: 每件成本}。 口径 = domain.scoring 的 make_cost_per_unit(料钱 + 作业费(含 SCI/设施税)+ 研究费 ÷ 单轮产出, 不含经纪费/改单费/销售税)。刻意与 cost_per_unit 分开:后者是「可制造物品」窗口的 历史全成本口径(含卖出费用,且那两笔按售价算),拿它当「自己造一件多少钱」会随市价浮动, 也不该和左边「单价」并排比 —— 别为了让两列一样去改 cost_per_unit。 制造走 calc_manufacturing_score、产物只挂 activity='reaction' 的走 calc_reaction_score; 用用户库存里最好的那张蓝图的 ME/TE(MAX(me_level/te_level));T2 含拷贝/发明研究费 (facade 未传 research_costs 时按件现算)。 材料里「用户自己排了产线」的同组自制件按它的自制单件价计入(cost_overrides,由 plan_service._attach_make_costs 第二遍按 sub_level 降序算好),其余料仍按市价 —— 也就是说这是一层递归,只认自己排产线的东西,不去猜「有蓝图就该自己造」。 不这么做,母项会拿自制中间件的市场价去比成品卖价:灼烧XL(383) 的紫外晶体 XL 市价 972,500/件、自制只要 ≈538,371/件,于是「自制成本/件」被高估到 1,423,889.99 (> 成品卖价 1,213,000),列上读成「买更划算」,与同一行「个人利润率」的结论相反。 既没有制造配方也没有反应配方的(矿物、数据核心、解码器、科研行产物/蓝图行) 不进结果 —— 界面一律显示 —,不许填 0 冒充成本。 消费方:查看核算材料表「自制成本」列(与左边「单价」并排)、采购小助手「自制成本/件」列、 生产计划大表「自制成本/件」列(必须带"/件":采购表同行「总价」、大表第 15 列「成本」都是 整批/总额口径,两列并排时不带单位会被读成同一个量级)。三处各用自己窗口的价格设置 (查看核算=计划的 hub/工具栏卖价买价/倍率,采购=窗口 Hub/卖价买价/倍率,大表=工具栏), 所以同一件料在三处可能显示不同的值 —— 这是有意的,别把它们"统一"成一份缓存。 制造那条链路带 TTL 缓存(重复调用 ~1ms),反应那条不缓存但调用量很小。
「自制成本」在真库上的样子(判断列对不对时的参照):制造/反应产物有数 (重型离子疾速炮 I 188,780.78、碳化钨 ≈76.4、紫外晶体 XL ≈538,371、灼烧XL 大表列 ≈959,435); 行星工业产物与矿物没有蓝图产物行(三钛合金、莫尔石、机械元件、传信器、微型电子元件、合成神经键…) → —。行星工业(PI)本程序不建模,所以那些料只能自己看市场价决定。
查看核算(成本明细)的口径(ui_qml/bridge/cost_breakdown_bridge.py):
- 汇总里的 总成本/利润/利润率/日利润/ISK/h 全是个人口径,彼此同源:利润由
adjust_mother_metrics调整后算出,ISK/h = 调整后利润 ÷ 耗时。历史缺陷是只换了 profit/margin、ISK/h 仍取市场口径的metrics["iskph"],于是同一个框里 「利润 +3.4 亿」和「ISK/h −514.7 万」自相矛盾。汇总另列一行市场口径利润率,两个口径 不许混着看。 - 拆解子项用
plan_metrics.SubitemCost(unit_cost, covered_qty):子项产线排的是净需求 (services/plan_rebuild.plan_net_runs先扣掉母项机库里已有的成品),所以「产出量」常小于母项需求 —— 实测紫外晶体 XL 需求 1368、只排 1310(差的 58 件在库存里)。行内小计 = min(需求, 覆盖量)×单件价 + 缺口×市价、单价 = 小计 ÷ 需求,行名标「(自制 1310/1368)」。 旧写法把整线总价直接除以母项需求(701,615,501.12 ÷ 1368 = 512,876.83,比子项真实单件 535,584.35 低 4.24%),还把那 58 件当 0 成本 —— 单件价与同行的「自制成本」列自相矛盾。covered_qty=None= 取不到产出量时的回退(等价于覆盖全部需求),别把它当正常值。
已知陷阱(改这块前先看):
plan_execution.plan_blueprint_ready取代旧has_image口径;has_image对科研行恒 False。- 研发活动的表名不一致:
blueprint_activities用researching_material_efficiency, 而blueprint_materials用research_material(映射见plan_job_kinds.material_activity)。 - 发明时长/产出上限在输入 T1 蓝图上,不在产物那张蓝图。
- 发明产出的 BPC 基础流程数 =
blueprint_products.quantity(invention 行)= SDE 的 授权流程数,取值 1/3/5/10/20。旧口径min(T1 拷贝上限, T2 制造上限)已废弃 (228/1349 条路径不符,216 条 T3 古文物路径错给 300)。
库存管理
- 表:user 库
hangars/inventory_items/user_blueprints - 机库列表的刷新契约:机库增/删/改(「机库设置」对话框)只落 DB、不发信号,所以 把机库列表缓存在内存里的地方必须自己重取 —— 仓库管理页(
InventoryBridge._hangars) 靠on_shown(外壳切页钩子)+ShellWindow.show_hangar_settings收尾时显式refreshAll();其余入口(估价页hangars()、各计划对话框、入库机库选择框)都是 用的时候现查get_hangars()。漏掉重取的表现是「新建的机库在仓库管理里看不到、 也不能当入库目标,要重启才出现」(2026-10-06 报)。入库机库选择框只有一份实现 (review_bridge.choose_hangar):「采购小助手」的增量添加与钱包粘贴、 「仓库管理」的钱包粘贴都走它,默认预选默认材料机库 - UI 同步调用(无独立 worker):
inventory_manager.add_item(加权平均成本)、set_item_quantity(经inventory_import.compute_import_diff全量覆盖/删除)、move_items(右键「移动到机库」) deduct_item被计划启动/展开/重建调用- 剪贴板导入按仓库类型校验(同一机库分两张表、两页签):材料侧
inventory_clipboard_service.parse_clipboard(机库管理「库存修正/增量粘贴」、采购页「增量添加到仓库」)过滤蓝图行;parse_purchase_clipboard(机库管理 / 采购页「从钱包交易记录粘贴」)吃「钱包 → 交易记录」的买入行、不过滤蓝图;蓝图侧ui_data_service.parse_blueprint_clipboard(蓝图管理「粘贴导入蓝图」)过滤材料行。判定见services/item_kind.py(itemgroup 名后缀 = 蓝图,失败开放),过滤数在预览框统计栏提示- 蓝图侧解析同时接受
manufacturing与reaction:反应公式是蓝图仓库的正式成员, 只认 manufacturing 会让它们在剪贴板里解析不出、进而被「全量同步」当成库中冗余删除。 结构完整却认不出蓝图的行计入「未识别」计数并在预览框提示(不再无声消失)。 - 匹配键是
(蓝图类型, is_bpo, ME, TE),不含流程数:流程数是行内属性,进了键就会让 「同一张图流程数变了」(BPC 被消耗、原图归一)退化成删旧行 + 插新行,连带丢掉行id、notes,并经delete_blueprint静默解除活跃计划的绑定。配对与增删规则见domain/blueprint_sync.py::plan_group_sync(先取流程数相同的份额 → 幂等)。 - 张数按
SUM(quantity)而非行数计:下线产出会把同规格 BPC 合并成quantity>1的一行, 按行数计会让全量同步误判「库里少」而多插行。 - 全量同步的删除是危险动作,三重保护:预览框纯删除行默认不勾选、确认前弹删除清单、 被活跃计划占用的行硬阻断(
get_occupied_blueprint_ids,跳过数经blocked回报)。 「最终」列只接受非负整数,非法值标红并拦下确认。
- 蓝图侧解析同时接受
物品查询页
两态:未选物品 = 仪表盘(产线详情 / 资产折线图 / 挂单列表);已选物品 = 详情面板(5 中心价格 / 订单 / 精炼 / 制造材料)。 没有结果表格 —— 候选弹窗就是匹配清单,点一条直接出详情(用户明确要求;ui_qml/models/query_model(s).py 已随之删除)。
候选:QueryPage 输入框 → QueryBridge.onTextChanged(200ms 防抖)
→ ui_qml.workers.query_workers.SuggestionWorker → query_suggest_items
→ QueryBridge._on_suggestions → suggestionsChanged → 弹窗显式 open()
选中:QueryBridge.pickSuggestion(候选项带 type_id)→ QueryDetailBridge.setItem
历史项是**裸查询词**、没有 type_id → 填回输入框 + 重新拉候选,由用户挑
两态判据:QML 的 workArea.idle = !(detail.typeId > 0),且**不带 busy**- 子桥懒建:
QueryBridge._get_detail/_get_dashboard首次被 QML 读到才 import 并构造(query_detail_bridge/query_dashboard_bridge);导入失败返回None,QML 按detail === null写占位,不连累整页(外壳只把该面板记为暂缺) - 详情面板四块(
QueryDetailBridge):① 价格market_repo.get_batch_market_snapshot(每 hub 一次);② 订单workers/order_workers.OrderFetchWorker+order_popup_bridge.order_rows(订单弹窗已删,这个纯函数只剩详情面板一个调用方),缓存order_workers.order_cache由QueryDetailBridge._on_orders_fetched写入;③ 精炼workers/refine_worker.RefineWorker(产率按所选人物的提炼技能算,人物与数量/站点都是面板上的输入);④ 制造材料bom_expander.get_flat_materials(买/卖各展开一次)。几何/文案在ui_qml/models/query_detail_model.py纯函数里 - 候选口径:
services/ui_data_service.query_suggest_items只做前缀匹配(Type ID 全等,或名字LIKE 'x%'),并排除无用类别_JUNK_CATEGORY_IDS(category_id=11那类 0 价 0 蓝图的遗留条目,也是候选里「两条一模一样的♦ 毒蜥级」的来源);不设条数上限。原先「查询串命中类别名 → 返回整个类别」的兜底、名字子串匹配、以及按名字跑整表搜索的query_search_items*都已删除 - 站名解析:
order_workers.OrderFetchWorker._resolve_names先查本地 SDEreference.db.station(复用services.npc_seller.resolve_stations_by_ids),只有本地没有的玩家建筑才打 ESI/universe/names/;解析失败不写缓存(写进去会让该 location 永久显示编号)
空闲态三条线(QueryDashboardBridge):
- 产线详情:
occupancyByChar(每人物一块,块内制造/科研/反应各一行;行尾给「待下线 N」= 该人物该线型status=='ready'的计划数,与「空 N」= 剩余产线)←_refresh_occupancy←services.char_capacity.char_line_usage(人物 × 线型聚合)+ui_qml.bridge.occupancy(块数据:标签/语义色/统一分母cap/待下线提示)。同一算法另出occupancyRows(按人物,与LauncherBridge.occupancyRows逐字同形状)。渲染是components/OccupancyPanel.qml,工业页「人物占用情况」对话框用的是同一块面板(dialogs/CharUsageDialog.qml+ui_qml.bridge.char_usage_bridge)—— 两者只差口径:仪表盘RUNNING_STATUSES(正在生产),对话框PLANNED_STATUSES(已规划:待生产/生产中/待下线都占线)。(仪表盘原有的「快捷操作(可启动 / 可下线)」列表已按用户要求删除 —— 启动/下线在生产计划表与产线启动小助手里都有) - 资产折线图:
assetPlot/assetSeries/assetSummaryRows←_refresh_snapshots→services.asset_snapshot_service.load_series→query_dashboard_bridge.asset_plot(几何复用ui_qml.bridge.price_chart_bridge的nice_range/axis_values/map_values/pick_indices);区间档位range_window+trim_from。5 条线取数来源见services/asset_snapshot_service模块 docstring(inventory / orders / line_value / wallet / total)。面板右上角「刷新」→reloadAssets(强制重读,绕开指纹) - 挂单列表:
buyOrderRows/sellOrderRows(买单、卖单各一张表,各带笔数)←_ensure_orders←user.db.open_orders
挂单导入(QueryDashboardBridge.readOrders):
services.order_export.find_latest_export(None) ← 目录固定游戏默认(%USERPROFILE%\Documents\EVE\logs\Marketlogs;文件名含 order/订单)
→ services.order_export.read_export_text(utf-8-sig → utf-16 → gbk 逐档)
→ services.order_export.parse_order_export(表头驱动 CSV,退化启发式;不抛异常)
→ QueryDashboardBridge._fill_location_names → services.npc_seller.resolve_stations_by_ids(补中文站名)
→ QueryDashboardBridge._fill_type_names → ref.item(补中文物品名;真实导出**无物品名列**)
→ _snapshot_orders(写库**之前**的挂单快照 = before)
→ QueryDashboardBridge._write_orders → INSERT OR REPLACE INTO open_orders(order_id 主键 → 幂等)
→ classify_order_changes(before, records)(**纯函数**:消失 → gone、剩余量变少 → partial;新增不算变动)
→ _review_changes(有变动才弹「订单变动」确认框)→ _apply_outcomes(钱包增减 / 台账 / 删行或回写剩余量)
→ QueryDashboardBridge._record_snapshot → services.asset_snapshot_service.record_snapshot订单变动确认(ui_qml/bridge/order_change_bridge.py + ui_qml/qml/dialogs/OrderChangeDialog.qml): 每条变动逐条选「买到了 / 卖完了」(默认,计入钱包)或「手动撤销」(不动钱包)。_apply_outcomes 四步: ① DELETE(整笔消失)或 UPDATE volume_remain(部分成交);② 落 order_events 台账(只记录,不参与计算); ③ asset_snapshot_service.adjust_wallet_balance 增减余额(卖 + 价格×减少量、买 −,不扣税费,用户确认口径); ④ 清 _pending_changes + 重记快照。用户点「取消」→ 什么都不做,变动留在 _pending_changes 里下次导入重新提示。
- 钱包余额手填:
QueryDashboardBridge.setWalletText→asset_snapshot_service.set_wallet_balance(settings.jsonwallet_balance)+record_snapshot;订单变动也走同一个键(adjust_wallet_balance)。导出目录固定为游戏默认目录 —— 面板上的自定义目录输入框与settings.json的order_export_dir键已按用户要求退场 - 表:user 库
asset_snapshots(含line_value)/open_orders/order_events,schema 迁移 v16→v17 建前两张表、v17→v18 加line_value列并建order_events(services.schema_migrations._USER_V17_TABLES_SQL/_ORDER_EVENTS_SQL/_migrate_user_v16_to_v17/_migrate_user_v17_to_v18);服务入口另有CREATE TABLE IF NOT EXISTS兜底(测试 / 新库) - 日期口径:快照日期一律由 SQLite 侧
date('now','localtime')决定(asset_snapshots.snap_date唯一,当天重复记录覆盖不累积),不混用 Python 的date.today() - 刷新:
QueryDashboardBridge.refresh幂等且便宜 —— 先算「计划字段 + 机库库存 + 角色技能 + 快照/挂单行数 + 本桥本地状态」指纹,没变就直接返回(不重算、不发changed)。由 QML 的空闲态可见性驱动(可见即刷一次 + 60s 定时器),桥不自建定时器;折线图右上角的「刷新」按钮走reloadAssets(强制)
已知陷阱(改这块前先看):
- QML 没有
int():JS 全局只有Number/parseInt/Math.*。写int(x)会让整条绑定抛ReferenceError,而 QML 对绑定错误静默(属性停在默认值)—— 实测容量条一个槽位都画不出来,只剩一条空轨道。 - 面板容器用
FPanel不用FSection:FSection把子项收进内层ColumnLayout,在里面写anchors.fill: parent会被布局静默忽略、子项塌成implicitHeight(实测整张表只剩表头)。要放「自己管布局的一整块」(表格 / 图表 / 占用条)用FPanel,只放若干行依次排列的控件才用FSection。 - 两态判据取桥上的 Property:
workArea.idle = !(detail.typeId > 0)(QueryDetailBridge.typeId,notify=changed)—— 绑定追得到,不像 Slot 调用那样要自己补发通知。判据里不能带busy:那是「按候选查明细」的中间态,掺进来会让界面在仪表盘与详情之间来回翻两次。 - 订单导出文件的真实格式(详见
services/order_export.py模块 docstring):UTF-8 带 BOM、文件名是中文的(个人订单-…/军团订单-…)、volRemaining是浮点串、位置名包在<localized hint="英文">中文</localized>里、没有物品名列。 - 在途取数线程必须在页面销毁时停:
QueryBridge.shutdown(+ QMLComponent.onDestruction)会停_detail_bridge/_dash_bridge,QueryDetailBridge.shutdown收_order_worker/_refine_worker(订单走 ESI,超时 30 秒)。不停的话 Qt 会在退出时析构一个还在跑的QThread→ 进程退出崩,且输出里连一行 traceback 都没有。⚠️QueryBridge.shutdown不能读self.detail/self.dashboard—— 那两个 getter 会把没用过的子桥无谓地建出来(连带 import 整条业务链)。
数据初始化(SDE/ESI)
初始向导 → init_service.InitService.start → _run_sequence(asyncio 并行步骤)
→ 动态加载 importers:
getitems → ref.item / market_tree
getprices → mkt.market_prices / market_volume_snapshots
getblueprints → bp.* 四表
getindustry → ref.industry_system_costs / industry_facilities
sde_loader → ref.category / station / solar_system / reprocessing_materials 等
geticon → PNG 图标缓存
就绪判定:init_check.check_all(各 check_* 数行数)- 初始化里的价格步骤是
getprices.fetch_baseline_only(只拉 1 次/markets/prices/做兜底), 不写市场历史;market.db.price_history由「更新价格」流程 (getprices.main→fetch_and_save_histories)按 12 小时 TTL 增量补,只拉 Jita (读端固定按 Jita 聚合),每产物每区域只保留近 180 天。
角色配置
- 无 DB,读写
data/char_config.json:char_config_validator.load_char_config/migrate_char_config - 解析:
char_config_resolver.resolve_char_config(优先级 skills > char_data > char_name → get_character) - 消费方:
plan_service.calculate_plan_metrics、scoring_service、industry workers char_capacity读技能计算产线数(读production_plans状态)
数据库层
database_manager.py:connect(primary, *attach)用 SQLiteATTACH联合 4 库(ref/mkt/usr/bp),每库 WAL- 迁移:
schema_migrations.ensure_schema(逐版本走_MIGRATIONS,先备份到database/backups/);ensure_all_schemas循环做全库 - 入口:
startup_worker(Main.py 启动)、init_service。规范见 schema-migration.md