From 4566d4b34ba6ef67099b29dc7bf6da38e65f639a Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Wed, 10 Jun 2026 09:00:17 +0500 Subject: [PATCH 01/34] Add IMPROVEMENT-040 design spec: AutoMarket raw material decoupling Decouples raw material coverage from the trade list. Materials are identified from entitydefaults (cf_raw_material bitmask) instead of recursive BOM explosion of market_orders_configuration. Co-Authored-By: Claude Sonnet 4.6 --- ...tomarket-raw-material-decoupling-design.md | 475 ++++++++++++++++++ 1 file changed, 475 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-10-automarket-raw-material-decoupling-design.md diff --git a/docs/superpowers/specs/2026-06-10-automarket-raw-material-decoupling-design.md b/docs/superpowers/specs/2026-06-10-automarket-raw-material-decoupling-design.md new file mode 100644 index 00000000..833102f0 --- /dev/null +++ b/docs/superpowers/specs/2026-06-10-automarket-raw-material-decoupling-design.md @@ -0,0 +1,475 @@ +# IMPROVEMENT-040: AutoMarket Raw Material Decoupling Design + +**Date:** 2026-06-10 +**Area:** AutoMarket / Economy / Admin Tool +**Status:** Approved for implementation +**Cross-references:** IMPROVEMENT-030 (pricing formula), IMPROVEMENT-031 (AdminTool panel), IMPROVEMENT-035 (player order signal — revisit conditions) + +--- + +## 1. Problem + +The AutoMarket identifies raw materials exclusively by recursively exploding the BOM of items in `market_orders_configuration` (the trade list). This creates tight coupling: + +- Materials for items outside the trade list receive no market support. +- Newly added craftable items require a manual trade list update before their raw material supply chain becomes active. +- The trade list's role is overloaded — it currently drives both finished product orders and raw material coverage. + +--- + +## 2. Goal + +Decouple raw material coverage from the trade list: + +- **Raw materials** — enumerated from `entitydefaults` using the `cf_raw_material` category flag bitmask. Coverage is automatic for all qualifying materials. +- **Trade list** — scoped to finished product buy/sell/buyback orders only. + +--- + +## 3. Dependency Inversion + +``` +Current: + market_orders_configuration + → v_required_raw_materials (recursive BOM explosion) + → recalculate_raw_material_prices (material list + demand signal) + → usp_RefreshAutoMarketOrders #raw_materials (Steps 4 + 5) + → v_all_production_costs raw_resources CTE + +Proposed: + entitydefaults (categoryflags & 0x114 = 0x114, enabled=1, hidden=0) + → #covered_rawmats temp table (materialized once per refresh) + → usp_RefreshAutoMarketOrders Step 4 (buy orders, weekly-cap sized) + → usp_RefreshAutoMarketOrders Step 5 (sell orders, flag-gated) + → recalculate_raw_material_prices materials CTE + → v_all_production_costs raw_resources CTE + + v_trade_list_raw_material_demand (renamed from v_required_raw_materials, unchanged internally) + → recalculate_raw_material_prices demand_cte only + (no longer used by usp_RefreshAutoMarketOrders or v_all_production_costs) + + market_orders_configuration + → usp_RefreshAutoMarketOrders Step 3 (product sell orders) + → usp_RefreshAutoMarketOrders Step 6 (product buyback orders) + → v_trade_list_raw_material_demand (still anchored to trade list) +``` + +--- + +## 4. Raw Material Coverage Filter + +Category flag bitmask: `cf_raw_material = 0x0000000000000114` (decimal 276). +The bitmask is hierarchical — subcategories (`cf_organic = 0x10114`, `cf_ore = 0x20114`) have the `cf_raw_material` bits set and are included automatically. + +SQL filter: +```sql +WHERE (categoryflags & 276) = 276 + AND enabled = 1 + AND hidden = 0 +``` + +**Before deploying:** validate the resulting entity list against live `entitydefaults` data to confirm no legacy/unobtainable items slip through. + +--- + +## 5. Pricing Formula — Demand Signal + +The existing formula from IMPROVEMENT-030 is preserved: + +``` +price = plasma_anchor × supply_demand_ratio × pvp_risk_multiplier +``` + +The `demand_cte` in `recalculate_raw_material_prices` continues to source from `v_trade_list_raw_material_demand` (the renamed view). For materials newly covered by IMPROVEMENT-040 that are not in any trade-listed BOM, `daily_demand` returns NULL → `ISNULL(d.daily_demand, 0) = 0` → `ds_max` scarcity. This is the correct default: a material nobody is currently supplying should be priced at maximum scarcity. As gathering activity begins, supply data accumulates in `resources_gathered` and the ratio normalises naturally. + +Recipe-graph demand analysis (considered and deferred): adding full-recipe-graph demand would inflate the demand numerator across all materials, requiring re-calibration of `ds_min`/`ds_max` and the anchor fraction. On a low-population server the signal would be near-noise. Revisit only if supply/demand divergence is observed in the economy report after this improvement ships. + +--- + +## 6. Guardrails + +Two independent guardrails coexist: + +| Guardrail | Scope | Zero semantics | +|---|---|---| +| `weekly_rawmat_cap_default` / `weekly_cap_override` | Max units AutoMarket will purchase per material per rolling 7-day window | 0 = unlimited quantity | +| `daily_rawmat_budget_nic` | Max NIC spent on raw material buy order fulfillments per UTC calendar day | 0 = unlimited budget | + +The daily NIC budget remains the hard injection guardrail. The weekly quantity cap prevents any single material from being exploited for unbounded sell volume regardless of price. + +Both guardrails must be independently satisfiable: the buy order quantity for a material is `min(remaining_weekly_qty, floor(remaining_daily_budget / price))`. + +--- + +## 7. Schema Changes + +### New table — `automarket_rawmat_overrides` + +Per-material exceptions to the global defaults. Only materials needing non-default behaviour get a row. + +```sql +CREATE TABLE automarket_rawmat_overrides ( + definitionname VARCHAR(100) NOT NULL, + weekly_cap_override INT NULL, -- NULL = use global default; 0 = unlimited + create_buy_orders BIT NOT NULL DEFAULT 1, + create_sell_orders BIT NOT NULL DEFAULT 1, + CONSTRAINT PK_rawmat_overrides PRIMARY KEY CLUSTERED (definitionname) +); +``` + +### New table — `automarket_rawmat_weekly_tracking` + +Written by the C# market sell handler (MERGE) when an AutoMarket raw material buy order is fulfilled. Cleaned up at 90-day rolling window alongside `resources_gathered`. + +```sql +CREATE TABLE automarket_rawmat_weekly_tracking ( + week_start DATE NOT NULL, + definitionname VARCHAR(100) NOT NULL, + qty_purchased BIGINT NOT NULL DEFAULT 0, + CONSTRAINT PK_rawmat_weekly PRIMARY KEY CLUSTERED (week_start, definitionname) +); +``` + +PK order `(week_start, definitionname)` matches the dominant query: filter by current `week_start`, lookup by `definitionname`. + +### `automarket_config` — new row + +| param_name | param_value | Description | +|---|---|---| +| `weekly_rawmat_cap_default` | `500000000` | Default weekly buy quantity cap per raw material. 0 = unlimited. | + +Labels for existing params updated: `daily_rawmat_budget_nic` gains `(0 = unlimited)` annotation in `AutoMarketLabels.cs`. + +### View rename + +`v_required_raw_materials` → `v_trade_list_raw_material_demand` +Internal definition unchanged. Executed via `sp_rename`. After rename, only `recalculate_raw_material_prices` references it. + +--- + +## 8. `v_all_production_costs` — `raw_resources` CTE + +Replace: +```sql +FROM (SELECT DISTINCT raw_material FROM v_required_raw_materials) base +``` +With: +```sql +FROM ( + SELECT definitionname AS raw_material + FROM entitydefaults + WHERE (categoryflags & 276) = 276 + AND enabled = 1 + AND hidden = 0 +) base +``` + +**Performance:** net improvement — a simple `entitydefaults` scan replaces the recursive CTE traversal. `entitydefaults` is a few thousand rows; the bitwise filter is O(n) with no index change required. The view is materialized into `#prod_costs` at the start of `usp_RefreshAutoMarketOrders`, so the gain compounds across all steps that reference `#prod_costs`. + +--- + +## 9. `recalculate_raw_material_prices` Changes + +Replace the `materials` CTE: + +```sql +-- Before: +materials AS ( + SELECT DISTINCT raw_material AS resource_name FROM v_required_raw_materials +), + +-- After: +materials AS ( + SELECT definitionname AS resource_name + FROM entitydefaults + WHERE (categoryflags & 276) = 276 + AND enabled = 1 + AND hidden = 0 +), +``` + +The `demand_cte` continues referencing `v_trade_list_raw_material_demand` (renamed view). No other changes to the procedure. + +**Performance note:** `resource_market_prices` grows proportionally with newly covered materials. Verify an index on `(calculated_on, resource_name)` exists; add if absent. The MERGE runs daily — not a hot path. + +--- + +## 10. `usp_RefreshAutoMarketOrders` Changes + +### New temp table — `#covered_rawmats` + +Replaces `#raw_materials`. Materialized once after `#prod_costs`, before Step 4. The current `#raw_materials` temp table and its indexes are removed. + +```sql +DECLARE @weekly_cap_default BIGINT = ( + SELECT param_value FROM automarket_config WHERE param_name = 'weekly_rawmat_cap_default' +); +DECLARE @week_start DATE = DATEADD(DAY, + -DATEPART(WEEKDAY, CAST(GETUTCDATE() AS DATE)) + 2, + CAST(GETUTCDATE() AS DATE)); + +SELECT + ed.definition, + ed.definitionname, + CASE + WHEN o.weekly_cap_override IS NOT NULL THEN CAST(o.weekly_cap_override AS BIGINT) + ELSE @weekly_cap_default + END AS effective_weekly_cap, + ISNULL(o.create_buy_orders, 1) AS create_buy_orders, + ISNULL(o.create_sell_orders, 1) AS create_sell_orders +INTO #covered_rawmats +FROM entitydefaults ed +LEFT JOIN automarket_rawmat_overrides o ON o.definitionname = ed.definitionname +WHERE (ed.categoryflags & 276) = 276 + AND ed.enabled = 1 + AND ed.hidden = 0; + +CREATE INDEX IX_crm_def ON #covered_rawmats (definition); +CREATE INDEX IX_crm_name ON #covered_rawmats (definitionname); +``` + +### Step 0 — `automarket_unbought_resources` + +The `automarket_unbought_resources` snapshot (current Step 0) is no longer used by Steps 4/5 (buy/sell orders are now cap-driven, not need-driven). Remove both the `DELETE FROM automarket_unbought_resources` and the `INSERT INTO automarket_unbought_resources` blocks. The `automarket_unsold_leftovers` snapshot (also in Step 0) is likewise unused after the rework and should be removed too. Both tables are retained (not dropped) until confirmed unused by any other query or tool — see Section 14. + +### Step 4 — Raw material buy orders (reworked) + +```sql +-- Weekly purchased qty per material for the current week +SELECT definitionname, ISNULL(SUM(qty_purchased), 0) AS qty_this_week +INTO #weekly_purchased +FROM automarket_rawmat_weekly_tracking +WHERE week_start >= @week_start +GROUP BY definitionname; + +CREATE INDEX IX_wp_name ON #weekly_purchased (definitionname); + +INSERT INTO marketitems ( + marketeid, itemdefinition, submittereid, duration, isSell, price, quantity, isvendoritem, isAutoorder +) +SELECT + @marketeid, + cr.definition, + @vendoreid, + 0, 0, + pc.production_cost_nic, + CASE + WHEN @remaining_rawmat_budget <= 0 THEN 0 + WHEN cr.effective_weekly_cap = 0 + THEN CAST(@remaining_rawmat_budget / pc.production_cost_nic AS BIGINT) + ELSE + LEAST( + GREATEST(0, cr.effective_weekly_cap - ISNULL(wp.qty_this_week, 0)), + CAST(@remaining_rawmat_budget / pc.production_cost_nic AS BIGINT) + ) + END AS order_qty, + 1, 1 +FROM #covered_rawmats cr +INNER JOIN #prod_costs pc ON pc.product = cr.definitionname +LEFT JOIN #weekly_purchased wp ON wp.definitionname = cr.definitionname +WHERE cr.create_buy_orders = 1 + AND pc.production_cost_nic > 0 + AND @remaining_rawmat_budget > 0 + AND ( + cr.effective_weekly_cap = 0 + OR ISNULL(wp.qty_this_week, 0) < cr.effective_weekly_cap + ); +``` + +### Step 5 — Raw material sell orders (reworked) + +```sql +INSERT INTO marketitems ( + marketeid, itemdefinition, submittereid, duration, isSell, price, quantity, isvendoritem, isAutoorder +) +SELECT + @marketeid, + cr.definition, + @vendoreid, + 0, 1, + pc.production_cost_nic * @raw_mat_sell_multiplier, + CASE + WHEN cr.effective_weekly_cap = 0 THEN 10000000 + ELSE cr.effective_weekly_cap + END, + 1, 1 +FROM #covered_rawmats cr +INNER JOIN #prod_costs pc ON pc.product = cr.definitionname +WHERE cr.create_sell_orders = 1 + AND pc.production_cost_nic > 0; +``` + +When `effective_weekly_cap = 0` (unlimited), sell order quantity falls back to 10,000,000 — consistent with the current fixed sell quantity. + +### Cleanup addition + +```sql +DELETE FROM automarket_rawmat_weekly_tracking +WHERE week_start < DATEADD(DAY, -90, @today); +``` + +Added alongside the existing 90-day cleanup block for `plasma_gathered`, `plasma_sold`, `resources_gathered`, `rawmat_purchased`. + +--- + +## 11. C# — Market Sell Handler + +When a player sells to an AutoMarket raw material buy order, after the existing `rawmat_purchased` write, add a MERGE into `automarket_rawmat_weekly_tracking`: + +```csharp +var weekStart = GetCurrentWeekStart(); // Monday of current UTC week + +Db.Query() + .CommandText(@" + MERGE automarket_rawmat_weekly_tracking AS t + USING (VALUES (@week_start, @defname, @qty)) + AS s (week_start, definitionname, qty_purchased) + ON t.week_start = s.week_start + AND t.definitionname = s.definitionname + WHEN MATCHED THEN UPDATE SET qty_purchased += s.qty_purchased + WHEN NOT MATCHED THEN INSERT VALUES (s.week_start, s.definitionname, s.qty_purchased);") + .AddParameter("@week_start", weekStart) + .AddParameter("@defname", itemDefinitionName) + .AddParameter("@qty", quantity) + .ExecuteNonQuery(); +``` + +`GetCurrentWeekStart()` returns Monday of the current UTC week — same logic as `@week_start` in `usp_RefreshAutoMarketOrders`. + +This is the only C# change required outside the AdminTool. No hot-path impact — the MERGE runs on raw material sell transactions only, on a table that stays under ~1,000 rows. + +--- + +## 12. AdminTool Changes + +### Config tab — `AutoMarketConfigViewModel` + +No structural change. The new `weekly_rawmat_cap_default` row appears automatically in the editable grid. Add to `AutoMarketLabels.cs`: + +```csharp +["weekly_rawmat_cap_default"] = "Weekly Raw Mat Cap (default, 0 = unlimited)", +// Update existing: +["daily_rawmat_budget_nic"] = "Daily Raw Mat Budget NIC (0 = unlimited)", +``` + +### New "Raw Materials" tab + +Inserted between Trade List and Statistics in `AutoMarketViewModel`'s tab list. + +**New files:** + +| File | Purpose | +|---|---| +| `AutoMarket/AutoMarketRawMaterialRow.cs` | Row model | +| `ViewModels/AutoMarketRawMaterialsViewModel.cs` | Tab VM, loads grid, wires ChangeQueue | +| `Views/AutoMarketRawMaterialsView.xaml` + `.cs` | XAML DataGrid | + +**Grid columns:** + +| Column | Editable | +|---|---| +| Name (translated, fallback to definitionname) | No | +| Current Price | No | +| Effective Cap | No | +| Weekly Cap Override (empty = use default, 0 = unlimited) | Yes | +| Bought This Week | No | +| Buy Orders (checkbox) | Yes | +| Sell Orders (checkbox) | Yes | + +All editable columns queue changes via ChangeQueue as a MERGE into `automarket_rawmat_overrides`. If all three editable values for a material are being reset to defaults (cap override NULL, both flags = 1), the queued change generates a `DELETE` to avoid orphaned rows. + +**Filter toggle:** "Show overrides only" — hides materials with no row in `automarket_rawmat_overrides`. + +**Repository query** (`AutoMarketRepository.GetRawMaterialRows()`): + +```sql +DECLARE @week_start DATE = DATEADD(DAY, + -DATEPART(WEEKDAY, CAST(GETUTCDATE() AS DATE)) + 2, + CAST(GETUTCDATE() AS DATE)); + +SELECT + ed.definitionname, + COALESCE(t.value, ed.definitionname) AS display_name, + ISNULL(rmp.unit_price, 0) AS current_price, + COALESCE(o.weekly_cap_override, cfg.param_value) AS effective_cap, + o.weekly_cap_override, + ISNULL(o.create_buy_orders, 1) AS create_buy_orders, + ISNULL(o.create_sell_orders, 1) AS create_sell_orders, + ISNULL(wt.qty_purchased, 0) AS bought_this_week +FROM entitydefaults ed +LEFT JOIN automarket_rawmat_overrides o + ON o.definitionname = ed.definitionname +LEFT JOIN ( + SELECT resource_name, unit_price + FROM resource_market_prices + WHERE calculated_on = (SELECT MAX(calculated_on) FROM resource_market_prices) +) rmp ON rmp.resource_name = ed.definitionname +LEFT JOIN ( + SELECT definitionname, SUM(qty_purchased) AS qty_purchased + FROM automarket_rawmat_weekly_tracking + WHERE week_start >= @week_start + GROUP BY definitionname +) wt ON wt.definitionname = ed.definitionname +CROSS JOIN ( + SELECT param_value + FROM automarket_config + WHERE param_name = 'weekly_rawmat_cap_default' +) cfg +LEFT JOIN entitytranslations t + ON t.definition = ed.definition AND t.languageID = 1 +WHERE (ed.categoryflags & 276) = 276 + AND ed.enabled = 1 + AND ed.hidden = 0 +ORDER BY display_name; +``` + +### Statistics tab — Pricing Trace + +Add **Bought This Week** and **Effective Cap** columns to `AutoMarketPricingTraceRow`. The Raw Materials tab shares the same repository method or the Statistics tab executes a lightweight variant. No structural VM change. + +--- + +## 13. Affected Systems Summary + +| System | Change | +|---|---| +| `v_required_raw_materials` | Renamed to `v_trade_list_raw_material_demand` (internal unchanged) | +| `v_all_production_costs` | `raw_resources` CTE switches to `entitydefaults` filter | +| `recalculate_raw_material_prices` | `materials` CTE switches to `entitydefaults` filter | +| `usp_RefreshAutoMarketOrders` | `#raw_materials` removed; `#covered_rawmats` added; Steps 4+5 reworked; cleanup extended | +| `automarket_config` | New `weekly_rawmat_cap_default` row | +| `automarket_rawmat_overrides` | New table | +| `automarket_rawmat_weekly_tracking` | New table | +| C# market sell handler | MERGE into `automarket_rawmat_weekly_tracking` on AutoMarket buy order fulfillment | +| AdminTool Config tab | New label entry | +| AdminTool Raw Materials tab | New tab (VM + View + Row model + repository method) | +| AdminTool Statistics Pricing Trace | Two new columns | + +--- + +## 14. Out of Scope + +- Recipe-graph demand signal (deferred — see Section 5) +- IMPROVEMENT-035 (player order signal) — revisit deferral conditions after this ships +- `automarket_unbought_resources` table drop — retain until confirmed unused by other systems + +--- + +## 15. Manual Validation Steps + +1. Run migration SQL; verify both new tables exist and `automarket_config` has `weekly_rawmat_cap_default`. +2. Confirm `v_trade_list_raw_material_demand` is accessible and returns the same rows as the old `v_required_raw_materials`. +3. Execute `recalculate_raw_material_prices` manually; verify `resource_market_prices` gains rows for materials not previously in the trade list BOM. +4. Execute `usp_RefreshAutoMarketOrders` manually; verify raw material buy and sell orders appear for the expanded material set; verify materials with `create_buy_orders = 0` have no buy orders. +5. In AdminTool Raw Materials tab: confirm all qualifying materials appear; set an override cap and verify it is reflected in the Effective Cap column after refresh; set a material's buy/sell flags to 0 and re-run the refresh; verify no orders appear for that material. +6. Sell a raw material to an AutoMarket buy order in-game; verify `automarket_rawmat_weekly_tracking` is updated with the correct material and quantity. +7. Set `weekly_rawmat_cap_default = 0` in Config tab; re-run refresh; verify buy orders are placed with unlimited (budget-only) quantity. +8. Set `daily_rawmat_budget_nic = 0` in Config tab; re-run refresh; verify no raw material buy orders are throttled by budget. + +--- + +## 16. Potential Regressions + +- **`v_all_production_costs` product cost changes** — switching `raw_resources` to `entitydefaults` filter may produce slightly different production costs for items whose raw material components were not previously in the trade list BOM. Monitor crafting cost data after deploy. +- **AutoMarket NIC injection** — covering more raw materials means more buy orders placed, potentially increasing NIC injection. The `daily_rawmat_budget_nic` cap limits exposure, but monitor the economy report (IMPROVEMENT-039) for the first week after deploy. +- **`automarket_unbought_resources` removal from Step 0** — any external query or admin tool reference to this table should be audited before removing the INSERT. From d3dd26298fa235a15ad0b06b625c12bf93b69764 Mon Sep 17 00:00:00 2001 From: "Dolzhukov, Viktor" Date: Wed, 10 Jun 2026 09:19:40 +0500 Subject: [PATCH 02/34] IMPROVEMENT-040: implementation plan Co-Authored-By: Claude Sonnet 4.6 --- ...2026-06-10-automarket-rawmat-decoupling.md | 1614 +++++++++++++++++ 1 file changed, 1614 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-10-automarket-rawmat-decoupling.md diff --git a/docs/superpowers/plans/2026-06-10-automarket-rawmat-decoupling.md b/docs/superpowers/plans/2026-06-10-automarket-rawmat-decoupling.md new file mode 100644 index 00000000..c421ca82 --- /dev/null +++ b/docs/superpowers/plans/2026-06-10-automarket-rawmat-decoupling.md @@ -0,0 +1,1614 @@ +# IMPROVEMENT-040: AutoMarket Raw Material Decoupling — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Decouple raw material coverage from the trade list by enumerating qualifying materials from `entitydefaults` (via the `cf_raw_material` category flag), replace the BOM-explosion-based buy/sell order sizing with a configurable weekly quantity cap per material, and expose both guardrails in the AdminTool. + +**Architecture:** A new `#covered_rawmats` temp table (derived from `entitydefaults` + `automarket_rawmat_overrides`) replaces `#raw_materials` as the raw material source in `usp_RefreshAutoMarketOrders`. The renamed view `v_trade_list_raw_material_demand` (formerly `v_required_raw_materials`) is retained only for the demand signal in `recalculate_raw_material_prices`. A new `automarket_rawmat_weekly_tracking` table records fulfilled raw material purchases per week; the market sell handler writes to it alongside the existing `rawmat_purchased` write. + +**Tech Stack:** SQL Server (T-SQL), .NET 8, C# 12, WPF/XAML, CommunityToolkit.Mvvm, Microsoft.Data.SqlClient + +**Spec:** `docs/superpowers/specs/2026-06-10-automarket-raw-material-decoupling-design.md` + +--- + +## File Map + +**Create:** +- `docs/db_structure/migrations/IMPROVEMENT-040-rawmat-decoupling.sql` +- `docs/db_structure/views/v_trade_list_raw_material_demand.sql` +- `docs/db_structure/stored_procedures/dbo.sp_RecordRawMatWeeklyPurchased.StoredProcedure.sql` +- `src/Perpetuum.AdminTool/AutoMarket/AutoMarketCoveredMaterialRow.cs` +- `src/Perpetuum.AdminTool/ViewModels/AutoMarketRawMaterialsViewModel.cs` +- `src/Perpetuum.AdminTool/Views/AutoMarketRawMaterialsView.xaml` +- `src/Perpetuum.AdminTool/Views/AutoMarketRawMaterialsView.xaml.cs` + +**Modify:** +- `docs/db_structure/views/v_required_raw_materials.sql` *(delete — replaced by v_trade_list_raw_material_demand.sql)* +- `docs/db_structure/views/v_all_production_costs.sql` +- `docs/db_structure/stored_procedures/dbo.recalculate_raw_material_prices.StoredProcedure.sql` +- `docs/db_structure/stored_procedures/dbo.usp_RefreshAutoMarketOrders.StoredProcedure.sql` +- `docs/db_structure/database_schema_documentation.md` +- `src/Perpetuum/Services/MarketEngine/Market.cs` +- `src/Perpetuum.AdminTool/AutoMarket/AutoMarketLabels.cs` +- `src/Perpetuum.AdminTool/AutoMarket/AutoMarketPricingTraceRow.cs` +- `src/Perpetuum.AdminTool/AutoMarket/AutoMarketRepository.cs` +- `src/Perpetuum.AdminTool/ViewModels/AutoMarketStatisticsViewModel.cs` +- `src/Perpetuum.AdminTool/ViewModels/AutoMarketViewModel.cs` +- `src/Perpetuum.AdminTool/Views/AutoMarketView.xaml` + +--- + +## Task 1: DB Migration SQL + `sp_RecordRawMatWeeklyPurchased` stored procedure + +**Files:** +- Create: `docs/db_structure/migrations/IMPROVEMENT-040-rawmat-decoupling.sql` +- Create: `docs/db_structure/stored_procedures/dbo.sp_RecordRawMatWeeklyPurchased.StoredProcedure.sql` +- Modify: `docs/db_structure/database_schema_documentation.md` + +- [ ] **Step 1: Create the migration file** + +```sql +-- docs/db_structure/migrations/IMPROVEMENT-040-rawmat-decoupling.sql +-- IMPROVEMENT-040: AutoMarket Raw Material Decoupling +-- Run against perpetuumsa while server is ONLINE (no data migration needed). +-- Apply in order — objects are dependencies of later steps. + +USE [perpetuumsa]; +GO + +-------------------------------------------------------------------- +-- 1. New table: per-material AutoMarket overrides +-------------------------------------------------------------------- +IF OBJECT_ID('dbo.automarket_rawmat_overrides', 'U') IS NULL +BEGIN + CREATE TABLE dbo.automarket_rawmat_overrides ( + definitionname VARCHAR(100) NOT NULL, + weekly_cap_override INT NULL, -- NULL = use global default; 0 = unlimited + create_buy_orders BIT NOT NULL DEFAULT 1, + create_sell_orders BIT NOT NULL DEFAULT 1, + CONSTRAINT PK_rawmat_overrides PRIMARY KEY CLUSTERED (definitionname) + ); + PRINT 'Created automarket_rawmat_overrides'; +END +GO + +-------------------------------------------------------------------- +-- 2. New table: weekly quantity tracking per raw material +-------------------------------------------------------------------- +IF OBJECT_ID('dbo.automarket_rawmat_weekly_tracking', 'U') IS NULL +BEGIN + CREATE TABLE dbo.automarket_rawmat_weekly_tracking ( + week_start DATE NOT NULL, + definitionname VARCHAR(100) NOT NULL, + qty_purchased BIGINT NOT NULL DEFAULT 0, + CONSTRAINT PK_rawmat_weekly PRIMARY KEY CLUSTERED (week_start, definitionname) + ); + PRINT 'Created automarket_rawmat_weekly_tracking'; +END +GO + +-------------------------------------------------------------------- +-- 3. New automarket_config row: weekly_rawmat_cap_default +-------------------------------------------------------------------- +IF NOT EXISTS (SELECT 1 FROM automarket_config WHERE param_name = 'weekly_rawmat_cap_default') +BEGIN + INSERT INTO automarket_config (param_name, param_value) + VALUES ('weekly_rawmat_cap_default', 500000000); + PRINT 'Inserted weekly_rawmat_cap_default into automarket_config'; +END +GO + +-------------------------------------------------------------------- +-- 4. Index on resource_market_prices(calculated_on, resource_name) +-- Required for efficient MERGE in recalculate_raw_material_prices +-- after material list expands. +-------------------------------------------------------------------- +IF NOT EXISTS ( + SELECT 1 FROM sys.indexes + WHERE object_id = OBJECT_ID('dbo.resource_market_prices') + AND name = 'IX_rmp_on_name' +) +BEGIN + CREATE NONCLUSTERED INDEX IX_rmp_on_name + ON dbo.resource_market_prices (calculated_on, resource_name); + PRINT 'Created IX_rmp_on_name on resource_market_prices'; +END +GO + +-------------------------------------------------------------------- +-- 5. Rename view: v_required_raw_materials → v_trade_list_raw_material_demand +-------------------------------------------------------------------- +IF OBJECT_ID('dbo.v_required_raw_materials', 'V') IS NOT NULL + AND OBJECT_ID('dbo.v_trade_list_raw_material_demand', 'V') IS NULL +BEGIN + EXEC sp_rename 'dbo.v_required_raw_materials', 'v_trade_list_raw_material_demand'; + PRINT 'Renamed v_required_raw_materials to v_trade_list_raw_material_demand'; +END +GO + +-------------------------------------------------------------------- +-- 6. New stored procedure: sp_RecordRawMatWeeklyPurchased +-------------------------------------------------------------------- +-- (See step 2 for the CREATE OR ALTER body — apply after running this file) +``` + +- [ ] **Step 2: Create `sp_RecordRawMatWeeklyPurchased` doc file** + +```sql +-- docs/db_structure/stored_procedures/dbo.sp_RecordRawMatWeeklyPurchased.StoredProcedure.sql +USE [perpetuumsa] +GO + +---- Upsert raw material AutoMarket purchase record for weekly quantity cap tracking. +---- Called by Market.FulfillSellOrderInstantly for every AutoMarket raw material buy +---- order fulfillment — alongside sp_RecordRawMatPurchased. + +CREATE OR ALTER PROCEDURE [dbo].[sp_RecordRawMatWeeklyPurchased] + @week_start DATE, + @definitionname VARCHAR(100), + @quantity BIGINT +AS +BEGIN + SET NOCOUNT ON; + MERGE dbo.automarket_rawmat_weekly_tracking AS target + USING (SELECT @week_start, @definitionname, @quantity) + AS source(week_start, definitionname, qty_purchased) + ON target.week_start = source.week_start + AND target.definitionname = source.definitionname + WHEN MATCHED THEN + UPDATE SET qty_purchased = target.qty_purchased + source.qty_purchased + WHEN NOT MATCHED THEN + INSERT (week_start, definitionname, qty_purchased) + VALUES (source.week_start, source.definitionname, source.qty_purchased); +END; +GO +``` + +Add this SP to the migration file above step 6 comment block — paste the `CREATE OR ALTER PROCEDURE` body there, or execute it separately after the migration. + +- [ ] **Step 3: Add this SP to the end of the migration file** + +Append to `docs/db_structure/migrations/IMPROVEMENT-040-rawmat-decoupling.sql`: + +```sql +CREATE OR ALTER PROCEDURE [dbo].[sp_RecordRawMatWeeklyPurchased] + @week_start DATE, + @definitionname VARCHAR(100), + @quantity BIGINT +AS +BEGIN + SET NOCOUNT ON; + MERGE dbo.automarket_rawmat_weekly_tracking AS target + USING (SELECT @week_start, @definitionname, @quantity) + AS source(week_start, definitionname, qty_purchased) + ON target.week_start = source.week_start + AND target.definitionname = source.definitionname + WHEN MATCHED THEN + UPDATE SET qty_purchased = target.qty_purchased + source.qty_purchased + WHEN NOT MATCHED THEN + INSERT (week_start, definitionname, qty_purchased) + VALUES (source.week_start, source.definitionname, source.qty_purchased); +END; +GO +``` + +- [ ] **Step 4: Update `database_schema_documentation.md`** + +In `docs/db_structure/database_schema_documentation.md`: + +1. In the table of contents, add entries (alphabetical order near `automarket_config`): +```markdown +- [automarket_rawmat_overrides](#automarket-rawmat-overrides) +- [automarket_rawmat_weekly_tracking](#automarket-rawmat-weekly-tracking) +``` + +2. After the `automarket_config` section, add: +```markdown +## automarket_rawmat_overrides + +**Schema:** `dbo` + +Per-material overrides for AutoMarket raw material coverage. Materials with no row use global defaults from `automarket_config`. + +### Columns + +| Column | Definition | +|---|---| +| `definitionname` | `varchar(100) [not null, pk]` | +| `weekly_cap_override` | `int [null]` — NULL = use global default; 0 = unlimited | +| `create_buy_orders` | `bit [not null, default: 1]` | +| `create_sell_orders` | `bit [not null, default: 1]` | + +--- + +## automarket_rawmat_weekly_tracking + +**Schema:** `dbo` + +Tracks units of each raw material purchased via AutoMarket buy orders per week. Written by `sp_RecordRawMatWeeklyPurchased`. Rolled up by `usp_RefreshAutoMarketOrders` to enforce the per-material weekly cap. Cleaned up at 90-day rolling window. + +### Columns + +| Column | Definition | +|---|---| +| `week_start` | `date [not null, pk]` — Monday of the ISO week | +| `definitionname` | `varchar(100) [not null, pk]` | +| `qty_purchased` | `bigint [not null, default: 0]` | +``` + +3. In the `automarket_config` seeded rows table, add the new row: +```markdown +| `weekly_rawmat_cap_default` | `500000000` | Default weekly buy quantity cap per raw material. 0 = unlimited. | +``` + +4. Update the `daily_rawmat_budget_nic` description to add `(0 = unlimited)`: +```markdown +| `daily_rawmat_budget_nic` | `5000000` | Max NIC spent on raw material buy orders per UTC calendar day (0 = unlimited). | +``` + +5. Find the `v_required_raw_materials` reference in any index/TOC and rename it to `v_trade_list_raw_material_demand`. + +- [ ] **Step 5: Commit** + +``` +git add docs/db_structure/migrations/IMPROVEMENT-040-rawmat-decoupling.sql +git add docs/db_structure/stored_procedures/dbo.sp_RecordRawMatWeeklyPurchased.StoredProcedure.sql +git add docs/db_structure/database_schema_documentation.md +git commit -m "IMPROVEMENT-040: migration SQL, sp_RecordRawMatWeeklyPurchased, schema docs" +``` + +--- + +## Task 2: View rename doc + `v_all_production_costs` update + +**Files:** +- Create: `docs/db_structure/views/v_trade_list_raw_material_demand.sql` +- Delete: `docs/db_structure/views/v_required_raw_materials.sql` +- Modify: `docs/db_structure/views/v_all_production_costs.sql` + +- [ ] **Step 1: Create renamed view doc** + +Create `docs/db_structure/views/v_trade_list_raw_material_demand.sql` with contents identical to the current `docs/db_structure/views/v_required_raw_materials.sql`, but update the object name in the header comment and `CREATE OR ALTER VIEW` statement: + +```sql +/****** Object: View [dbo].[v_trade_list_raw_material_demand] Script Date: 10.06.2026 ******/ +SET ANSI_NULLS ON +GO +SET QUOTED_IDENTIFIER ON +GO + +-- Returns the raw materials (and quantities) required to fulfil the AutoMarket trade list. +-- Used exclusively as a demand signal in recalculate_raw_material_prices. +-- Material ENUMERATION is now driven by entitydefaults (cf_raw_material flag) — not this view. +-- prod_data inlines production_data to avoid view-nesting-level accumulation inside the recursive member. +CREATE OR ALTER VIEW [dbo].[v_trade_list_raw_material_demand] AS + WITH prod_data AS ( + SELECT + ed.definitionname AS product, + ced.definitionname AS components, + c.componentamount AS amount + FROM dbo.components c + INNER JOIN dbo.entitydefaults ed ON c.definition = ed.definition + INNER JOIN dbo.entitydefaults ced ON c.componentdefinition = ced.definition + WHERE ed.purchasable = 1 AND ed.enabled = 1 AND ed.hidden = 0 + ), + RecursiveBreakdown AS ( + -- Base case: direct components + SELECT + moc.definitionname AS product, + pd.components AS component, + SUM(CAST(ROUND(pd.amount * 2.1, 0) AS BIGINT)) AS total_amount + FROM dbo.market_orders_configuration moc + JOIN prod_data pd ON moc.definitionname = pd.product + GROUP BY moc.definitionname, pd.components + + UNION ALL + + -- Recursive case: break down intermediate components + SELECT + rb.product, + pd.components AS component, + rb.total_amount * CAST(ROUND(pd.amount * 2.1, 0) AS BIGINT) AS total_amount + FROM RecursiveBreakdown rb + JOIN prod_data pd ON rb.component = pd.product + ) + + -- Final aggregation: only raw materials (not further craftable) + SELECT + rb.product as product, + rb.component AS raw_material, + SUM(rb.total_amount) AS total_quantity + FROM RecursiveBreakdown rb + LEFT JOIN prod_data pd ON rb.component = pd.product + WHERE pd.product IS NULL + GROUP BY rb.product, rb.component; +GO +``` + +- [ ] **Step 2: Delete the old view doc** + +``` +git rm docs/db_structure/views/v_required_raw_materials.sql +``` + +- [ ] **Step 3: Update `v_all_production_costs` — `raw_resources` CTE** + +In `docs/db_structure/views/v_all_production_costs.sql`, replace the `raw_resources` CTE: + +Old: +```sql +raw_resources AS ( + SELECT + base.raw_material AS product, + ISNULL(mp.unit_price, msp.price) AS production_cost_nic + FROM (SELECT DISTINCT raw_material FROM v_required_raw_materials) base + LEFT JOIN latest_market_prices mp + ON base.raw_material COLLATE DATABASE_DEFAULT = mp.resource_name COLLATE DATABASE_DEFAULT + CROSS JOIN max_scarcity_price msp +), +``` + +New: +```sql +raw_resources AS ( + SELECT + base.definitionname AS product, + ISNULL(mp.unit_price, msp.price) AS production_cost_nic + FROM ( + SELECT definitionname + FROM dbo.entitydefaults + WHERE (categoryflags & 276) = 276 -- cf_raw_material bitmask + AND enabled = 1 + AND hidden = 0 + ) base + LEFT JOIN latest_market_prices mp + ON base.definitionname COLLATE DATABASE_DEFAULT = mp.resource_name COLLATE DATABASE_DEFAULT + CROSS JOIN max_scarcity_price msp +), +``` + +Also update the script date in the header comment to `10.06.2026`. + +- [ ] **Step 4: Apply view changes to the database** + +Run in SQL Server Management Studio (or equivalent) against perpetuumsa: + +```sql +-- Apply renamed view +EXEC sp_rename 'dbo.v_required_raw_materials', 'v_trade_list_raw_material_demand'; +-- (skip if already applied in Task 1 migration) + +-- Apply updated v_all_production_costs (copy full CREATE OR ALTER VIEW from the updated doc file) +``` + +Expected: no errors. Run `SELECT TOP 5 * FROM v_trade_list_raw_material_demand` and `SELECT TOP 5 * FROM v_all_production_costs` to verify both return rows. + +- [ ] **Step 5: Commit** + +``` +git add docs/db_structure/views/v_trade_list_raw_material_demand.sql +git add docs/db_structure/views/v_all_production_costs.sql +git commit -m "IMPROVEMENT-040: rename v_required_raw_materials doc, update v_all_production_costs raw_resources CTE" +``` + +--- + +## Task 3: Update `recalculate_raw_material_prices` + +**Files:** +- Modify: `docs/db_structure/stored_procedures/dbo.recalculate_raw_material_prices.StoredProcedure.sql` + +- [ ] **Step 1: Replace the `materials` CTE and update `demand_cte` view reference** + +In `docs/db_structure/stored_procedures/dbo.recalculate_raw_material_prices.StoredProcedure.sql`, replace the `materials` and `demand_cte` CTEs inside the `WITH` block: + +Old: +```sql + demand_cte AS ( + SELECT raw_material, SUM(total_quantity) / 7.0 AS daily_demand + FROM v_required_raw_materials + GROUP BY raw_material + ) , + materials AS ( + SELECT DISTINCT raw_material AS resource_name + FROM v_required_raw_materials + ) , +``` + +New: +```sql + demand_cte AS ( + SELECT raw_material, SUM(total_quantity) / 7.0 AS daily_demand + FROM v_trade_list_raw_material_demand + GROUP BY raw_material + ) , + -- Material enumeration is now driven by entitydefaults (cf_raw_material = 0x114). + -- New materials with no demand data default to ds_max scarcity price (ISNULL branch below). + materials AS ( + SELECT definitionname AS resource_name + FROM dbo.entitydefaults + WHERE (categoryflags & 276) = 276 + AND enabled = 1 + AND hidden = 0 + ) , +``` + +Also update the script date in the header comment to `10.06.2026`. + +- [ ] **Step 2: Apply to the database** + +Run in SSMS: + +```sql +-- Copy full CREATE OR ALTER PROCEDURE body from updated doc file and execute. +``` + +Expected: procedure compiles without error. Run: +```sql +EXEC recalculate_raw_material_prices; +SELECT COUNT(*) FROM resource_market_prices +WHERE calculated_on = (SELECT MAX(calculated_on) FROM resource_market_prices); +``` + +Expected: row count increases compared to before (now includes all cf_raw_material items, not just trade-list BOM materials). + +- [ ] **Step 3: Commit** + +``` +git add docs/db_structure/stored_procedures/dbo.recalculate_raw_material_prices.StoredProcedure.sql +git commit -m "IMPROVEMENT-040: recalculate_raw_material_prices uses entitydefaults for material enumeration" +``` + +--- + +## Task 4: Rework `usp_RefreshAutoMarketOrders` + +**Files:** +- Modify: `docs/db_structure/stored_procedures/dbo.usp_RefreshAutoMarketOrders.StoredProcedure.sql` + +- [ ] **Step 1: Remove Step 0 snapshot blocks** + +In `usp_RefreshAutoMarketOrders`, delete these blocks entirely (Steps 4 and 5 are now cap-driven, not need-driven): + +```sql + -- Step 0: Snapshot unsold and unbought items + DELETE FROM [automarket_unsold_leftovers]; + DELETE FROM [automarket_unbought_resources]; + + INSERT INTO [automarket_unsold_leftovers] (itemdefinition, quantity) + SELECT itemdefinition, SUM(CAST(quantity AS BIGINT)) + FROM marketitems + WHERE isAutoOrder = 1 AND isSell = 1 + GROUP BY itemdefinition; + + -- Unbought mats: ... + INSERT INTO automarket_unbought_resources (itemdefinition, quantity) + ... + GROUP BY mi.itemdefinition; +``` + +Keep the Step 1 block (`DELETE FROM marketitems WHERE isAutoOrder = 1;`) and everything that follows. + +- [ ] **Step 2: Remove `#raw_materials` temp table, add `#covered_rawmats` and `@week_start`** + +After the `#prod_costs` block (which ends with `CREATE INDEX IX_pc_product ON #prod_costs (product);`), replace: + +```sql + SELECT product, raw_material, total_quantity + INTO #raw_materials + FROM v_required_raw_materials; + + CREATE INDEX IX_rm_product ON #raw_materials (product); + CREATE INDEX IX_rm_raw ON #raw_materials (raw_material); +``` + +With: + +```sql + -- Week start: Monday of current UTC week (matches recalculate_raw_material_prices formula) + DECLARE @week_start DATE = DATEADD(DAY, -DATEPART(WEEKDAY, CAST(GETUTCDATE() AS DATE)) + 2, CAST(GETUTCDATE() AS DATE)); + + DECLARE @weekly_cap_default BIGINT = ( + SELECT CAST(param_value AS BIGINT) FROM automarket_config WHERE param_name = 'weekly_rawmat_cap_default' + ); + + -- All qualifying raw materials with effective cap and buy/sell flags. + -- Materialized once; Steps 4 and 5 both read from this table. + SELECT + ed.definition, + ed.definitionname, + CASE + WHEN o.weekly_cap_override IS NOT NULL THEN CAST(o.weekly_cap_override AS BIGINT) + ELSE @weekly_cap_default + END AS effective_weekly_cap, -- 0 = unlimited + ISNULL(o.create_buy_orders, 1) AS create_buy_orders, + ISNULL(o.create_sell_orders, 1) AS create_sell_orders + INTO #covered_rawmats + FROM entitydefaults ed + LEFT JOIN automarket_rawmat_overrides o ON o.definitionname = ed.definitionname + WHERE (ed.categoryflags & 276) = 276 + AND ed.enabled = 1 + AND ed.hidden = 0; + + CREATE INDEX IX_crm_def ON #covered_rawmats (definition); + CREATE INDEX IX_crm_name ON #covered_rawmats (definitionname); + + -- Weekly purchases so far for the current week, per material. + SELECT definitionname, ISNULL(SUM(qty_purchased), 0) AS qty_this_week + INTO #weekly_purchased + FROM automarket_rawmat_weekly_tracking + WHERE week_start >= @week_start + GROUP BY definitionname; + + CREATE INDEX IX_wp_name ON #weekly_purchased (definitionname); +``` + +- [ ] **Step 3: Replace Step 4 (raw material buy orders)** + +Replace the entire Step 4 block: + +```sql + -- Old Step 4: ...NeedProducts...RequiredRaw...Unbought...Combined... + -- (all of it, from the ';WITH NeedProducts' comment through the final WHERE clause) +``` + +With: + +```sql + -- Step 4: Raw material buy orders — weekly-cap sized, daily-budget guarded. + INSERT INTO marketitems ( + marketeid, itemdefinition, submittereid, duration, isSell, price, quantity, isvendoritem, isAutoorder + ) + SELECT + @marketeid, + cr.definition, + @vendoreid, + 0, 0, + pc.production_cost_nic, + CASE + WHEN @remaining_rawmat_budget <= 0 OR pc.production_cost_nic <= 0 THEN 0 + WHEN cr.effective_weekly_cap = 0 + -- Unlimited cap: bounded only by daily NIC budget + THEN CAST(@remaining_rawmat_budget / pc.production_cost_nic AS BIGINT) + WHEN cr.effective_weekly_cap <= ISNULL(wp.qty_this_week, 0) THEN 0 + WHEN (cr.effective_weekly_cap - ISNULL(wp.qty_this_week, 0)) + <= CAST(@remaining_rawmat_budget / pc.production_cost_nic AS BIGINT) + THEN cr.effective_weekly_cap - ISNULL(wp.qty_this_week, 0) + ELSE CAST(@remaining_rawmat_budget / pc.production_cost_nic AS BIGINT) + END AS order_qty, + 1, 1 + FROM #covered_rawmats cr + INNER JOIN #prod_costs pc ON pc.product = cr.definitionname + LEFT JOIN #weekly_purchased wp ON wp.definitionname = cr.definitionname + WHERE cr.create_buy_orders = 1 + AND pc.production_cost_nic > 0 + AND @remaining_rawmat_budget > 0 + AND ( + cr.effective_weekly_cap = 0 + OR ISNULL(wp.qty_this_week, 0) < cr.effective_weekly_cap + ); +``` + +- [ ] **Step 4: Replace Step 5 (raw material sell orders)** + +Replace the entire Step 5 block: + +```sql + -- Old Step 5: + -- INSERT INTO marketitems (...) + -- SELECT @marketeid, ed.definition, @vendoreid, 0, 1, apc.production_cost_nic * @raw_mat_sell_multiplier, + -- 10000000, 1, 1 + -- FROM #raw_materials rrm + -- INNER JOIN entitydefaults ed ON rrm.raw_material = ed.definitionname + -- INNER JOIN #prod_costs apc ON rrm.raw_material = apc.product + -- GROUP BY ed.definition, apc.production_cost_nic; +``` + +With: + +```sql + -- Step 5: Raw material sell orders — quantity = effective_weekly_cap (0 → fallback 10 000 000). + INSERT INTO marketitems ( + marketeid, itemdefinition, submittereid, duration, isSell, price, quantity, isvendoritem, isAutoorder + ) + SELECT + @marketeid, + cr.definition, + @vendoreid, + 0, 1, + pc.production_cost_nic * @raw_mat_sell_multiplier, + CASE WHEN cr.effective_weekly_cap = 0 THEN 10000000 ELSE cr.effective_weekly_cap END, + 1, 1 + FROM #covered_rawmats cr + INNER JOIN #prod_costs pc ON pc.product = cr.definitionname + WHERE cr.create_sell_orders = 1 + AND pc.production_cost_nic > 0; +``` + +- [ ] **Step 5: Add weekly tracking cleanup to the cleanup block** + +In `recalculate_raw_material_prices`, the cleanup block already exists. In `usp_RefreshAutoMarketOrders` there is no cleanup block — add it at the end of the procedure, inside the `END TRY` block, before `END TRY`: + +```sql + -- 90-day rolling cleanup for weekly tracking table + DECLARE @today_cleanup DATE = CAST(GETUTCDATE() AS DATE); + DELETE FROM automarket_rawmat_weekly_tracking + WHERE week_start < DATEADD(DAY, -90, @today_cleanup); +``` + +- [ ] **Step 6: Apply to the database and verify** + +Run the full updated `CREATE OR ALTER PROCEDURE [dbo].[usp_RefreshAutoMarketOrders]` from the doc file in SSMS. + +Then run: +```sql +EXEC usp_RefreshAutoMarketOrders; + +-- Verify raw material orders now cover materials outside the trade list BOM: +SELECT COUNT(*) AS raw_buy_orders +FROM marketitems mi +JOIN entitydefaults ed ON ed.definition = mi.itemdefinition +WHERE mi.isAutoOrder = 1 AND mi.isSell = 0 + AND (ed.categoryflags & 276) = 276; + +-- Should be larger than before (all cf_raw_material items, not just trade-listed BOM materials). +``` + +- [ ] **Step 7: Commit** + +``` +git add docs/db_structure/stored_procedures/dbo.usp_RefreshAutoMarketOrders.StoredProcedure.sql +git commit -m "IMPROVEMENT-040: usp_RefreshAutoMarketOrders — #covered_rawmats replaces #raw_materials, Steps 4+5 reworked" +``` + +--- + +## Task 5: C# weekly tracking write in `Market.cs` + +**Files:** +- Modify: `src/Perpetuum/Services/MarketEngine/Market.cs` + +The three existing `sp_RecordRawMatPurchased` call sites are in `FulfillSellOrderInstantly`. Each is guarded by: +```csharp +if (buyOrder.isVendorItem && itemToSell.ED.CategoryFlags.IsCategory(CategoryFlags.cf_raw_material)) +``` + +Add a private helper method and call it alongside each `sp_RecordRawMatPurchased` block. + +- [ ] **Step 1: Add `GetWeekStart` and `RecordWeeklyRawMatPurchase` helpers** + +Find the end of the `Market` class (before the closing `}`). Add: + +```csharp +private static DateTime GetWeekStart(DateTime utcNow) +{ + // Matches SQL formula: DATEADD(DAY, -DATEPART(WEEKDAY, @today) + 2, @today) + // with SQL DATEFIRST=7 (Sunday=1, Monday=2, ..., Saturday=7) + var sqlWeekday = (int)utcNow.DayOfWeek + 1; // Sunday→1, Monday→2, ..., Saturday→7 + return utcNow.Date.AddDays(-sqlWeekday + 2); +} + +private void RecordWeeklyRawMatPurchase(string definitionName, int quantity) +{ + using var scope = Db.CreateTransaction(); + Db.Query() + .CommandText("exec sp_RecordRawMatWeeklyPurchased @week_start, @definitionname, @quantity") + .SetParameter("@week_start", GetWeekStart(DateTime.UtcNow)) + .SetParameter("@definitionname", definitionName) + .SetParameter("@quantity", quantity) + .ExecuteNonQuery(); + scope.Complete(); +} +``` + +- [ ] **Step 2: Add call at first `sp_RecordRawMatPurchased` site (case: `buyOrder.quantity < itemToSell.Quantity`)** + +Find the block at approximately line 792–806: +```csharp + // Log raw material AutoMarket purchase for daily budget tracking + if (buyOrder.isVendorItem && itemToSell.ED.CategoryFlags.IsCategory(CategoryFlags.cf_raw_material)) + { + using (TransactionScope scope = Db.CreateTransaction()) + { + _ = Db.Query() + .CommandText("exec sp_RecordRawMatPurchased @purchased_on, @item_def, @quantity, @income") + .SetParameter("@purchased_on", DateTime.UtcNow) + .SetParameter("@item_def", itemToSell.Definition) + .SetParameter("@quantity", buyOrder.quantity) + .SetParameter("@income", buyOrder.price * buyOrder.quantity) + .ExecuteNonQuery(); + scope.Complete(); + } + } +``` + +Add immediately after the closing `}` of that `if` block: +```csharp + if (buyOrder.isVendorItem && itemToSell.ED.CategoryFlags.IsCategory(CategoryFlags.cf_raw_material)) + RecordWeeklyRawMatPurchase(itemToSell.ED.Name, buyOrder.quantity); +``` + +- [ ] **Step 3: Add call at second `sp_RecordRawMatPurchased` site (case: `buyOrder.quantity == itemToSell.Quantity`)** + +Find the block at approximately line 832–847 (the second occurrence of the same guard pattern). Add immediately after its closing `}`: +```csharp + if (buyOrder.isVendorItem && itemToSell.ED.CategoryFlags.IsCategory(CategoryFlags.cf_raw_material)) + RecordWeeklyRawMatPurchase(itemToSell.ED.Name, quantity); +``` + +Note: `quantity` here is the local variable set to `buyOrder.quantity` for this branch. + +- [ ] **Step 4: Add call at third `sp_RecordRawMatPurchased` site (infinite vendor buy order path)** + +Find the block at approximately line 884–898 (the third occurrence). Add immediately after its closing `}`: +```csharp + if (buyOrder.isVendorItem && itemToSell.ED.CategoryFlags.IsCategory(CategoryFlags.cf_raw_material)) + RecordWeeklyRawMatPurchase(itemToSell.ED.Name, itemToSell.Quantity); +``` + +- [ ] **Step 5: Build** + +``` +dotnet build PerpetuumServer2.sln -c Release -p:Platform=x64 +``` + +Expected: 0 errors, 0 warnings related to changed files. + +- [ ] **Step 6: Commit** + +``` +git add src/Perpetuum/Services/MarketEngine/Market.cs +git commit -m "IMPROVEMENT-040: record weekly raw mat purchase in Market.FulfillSellOrderInstantly" +``` + +--- + +## Task 6: AdminTool row models + labels + +**Files:** +- Modify: `src/Perpetuum.AdminTool/AutoMarket/AutoMarketLabels.cs` +- Modify: `src/Perpetuum.AdminTool/AutoMarket/AutoMarketPricingTraceRow.cs` +- Create: `src/Perpetuum.AdminTool/AutoMarket/AutoMarketCoveredMaterialRow.cs` + +- [ ] **Step 1: Update `AutoMarketLabels.cs`** + +Replace the file content: + +```csharp +namespace Perpetuum.AdminTool.AutoMarket +{ + internal static class AutoMarketLabels + { + internal record LabelMeta(string Label, string Description); + + internal static readonly IReadOnlyDictionary Map = + new Dictionary + { + ["plasma_anchor_fraction"] = new("Plasma Anchor Fraction", "Fraction of alpha plasma price used as raw material pricing anchor"), + ["plasma_buy_qty_fraction"] = new("Plasma Buy Quantity", "Fraction of gathered plasma placed as buy orders"), + ["daily_plasma_budget_nic"] = new("Daily Plasma Budget (NIC)", "Max NIC spent on plasma buy orders per calendar day"), + ["daily_rawmat_budget_nic"] = new("Daily Rawmat Budget (NIC, 0=∞)", "Max NIC spent on raw material buy orders per calendar day. 0 = unlimited."), + ["weekly_rawmat_cap_default"] = new("Weekly Rawmat Cap (default, 0=∞)","Default max units AutoMarket buys per raw material per week. 0 = unlimited."), + ["resource_ds_ratio_min"] = new("S/D Ratio Min", "Lower clamp for supply/demand ratio in pricing formula"), + ["resource_ds_ratio_max"] = new("S/D Ratio Max", "Upper clamp for supply/demand ratio in pricing formula"), + ["product_sell_margin"] = new("Product Sell Margin", "Production item sell orders priced at production_cost × this value"), + ["raw_mat_sell_multiplier"] = new("Rawmat Sell Multiplier", "Raw material sell orders priced at production_cost × this value"), + ["product_buyback_margin"] = new("Product Buyback Margin", "Buyback buy orders priced at production_cost × this value"), + }; + } +} +``` + +- [ ] **Step 2: Add `BoughtThisWeek` and `EffectiveCap` to `AutoMarketPricingTraceRow`** + +Replace the file content: + +```csharp +namespace Perpetuum.AdminTool.AutoMarket +{ + public class AutoMarketPricingTraceRow + { + public string ResourceName { get; init; } = ""; + public string DisplayName { get; set; } = ""; + public double PlasmaAnchor { get; init; } + public double SdRatio { get; init; } + public double RiskMultiplier { get; init; } + public double ComputedPrice { get; init; } + public double? StoredPrice { get; init; } + public long BoughtThisWeek { get; init; } + public long EffectiveCap { get; init; } + } +} +``` + +- [ ] **Step 3: Create `AutoMarketCoveredMaterialRow.cs`** + +```csharp +namespace Perpetuum.AdminTool.AutoMarket +{ + public class AutoMarketCoveredMaterialRow + { + public string DefinitionName { get; init; } = ""; + public string DisplayName { get; set; } = ""; + public double CurrentPrice { get; init; } + public long EffectiveCap { get; init; } // BIGINT: COALESCE(override, global default) + public int? WeeklyCapOverride { get; set; } // INT NULL: matches DB column type + public long BoughtThisWeek { get; init; } + public bool CreateBuyOrders { get; set; } + public bool CreateSellOrders { get; set; } + + // Originals for change detection — need set because QueueSave updates them after dispatch + public int? OriginalCapOverride { get; set; } + public bool OriginalBuyOrders { get; set; } + public bool OriginalSellOrders { get; set; } + + public bool HasOverride => + WeeklyCapOverride.HasValue || !CreateBuyOrders || !CreateSellOrders; + + public bool IsAtDefaults => + !WeeklyCapOverride.HasValue && CreateBuyOrders && CreateSellOrders; + } +} +``` + +- [ ] **Step 4: Build** + +``` +dotnet build PerpetuumServer2.sln -c Release -p:Platform=x64 +``` + +Expected: 0 errors. + +- [ ] **Step 5: Commit** + +``` +git add src/Perpetuum.AdminTool/AutoMarket/AutoMarketLabels.cs +git add src/Perpetuum.AdminTool/AutoMarket/AutoMarketPricingTraceRow.cs +git add src/Perpetuum.AdminTool/AutoMarket/AutoMarketCoveredMaterialRow.cs +git commit -m "IMPROVEMENT-040: labels update, PricingTraceRow new columns, AutoMarketCoveredMaterialRow" +``` + +--- + +## Task 7: `AutoMarketRepository` updates + +**Files:** +- Modify: `src/Perpetuum.AdminTool/AutoMarket/AutoMarketRepository.cs` + +Three methods need changes; one new method is added. + +- [ ] **Step 1: Update `LoadDerivedMaterialsAsync` — rename view reference** + +In `LoadDerivedMaterialsAsync`, replace `v_required_raw_materials` with `v_trade_list_raw_material_demand`: + +```csharp + cmd.CommandText = + "SELECT raw_material, SUM(total_quantity) " + + "FROM v_trade_list_raw_material_demand " + + "GROUP BY raw_material " + + "ORDER BY raw_material"; +``` + +- [ ] **Step 2: Update `LoadPricingTraceAsync` — materials list + demand view + new columns** + +Replace the two references to `v_required_raw_materials` and add two new queries for weekly tracking and effective cap. The full updated `LoadPricingTraceAsync` method: + +```csharp + public async Task> LoadPricingTraceAsync() + { + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + + // 1. Alpha plasma anchor price + double alphaPlasmaPrice = 0; + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = + "SELECT TOP 1 dynamic_price FROM fn_CalculateDynamicPlasmaPrices(1) " + + "WHERE plasma_type = 'def_common_reactor_plasma'"; + await using var r = await cmd.ExecuteReaderAsync(); + if (await r.ReadAsync()) alphaPlasmaPrice = r.IsDBNull(0) ? 0 : (double)r.GetDecimal(0); + } + + // 2. Config params + double anchorFraction = 0.15, dsMin = 0.25, dsMax = 4.0; + long weeklyCapDefault = 500_000_000; + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = + "SELECT param_name, param_value FROM automarket_config " + + "WHERE param_name IN ('plasma_anchor_fraction','resource_ds_ratio_min','resource_ds_ratio_max','weekly_rawmat_cap_default')"; + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + { + switch (r.GetString(0)) + { + case "plasma_anchor_fraction": anchorFraction = r.GetDouble(1); break; + case "resource_ds_ratio_min": dsMin = r.GetDouble(1); break; + case "resource_ds_ratio_max": dsMax = r.GetDouble(1); break; + case "weekly_rawmat_cap_default": weeklyCapDefault = (long)r.GetDouble(1); break; + } + } + } + + var plasmaAnchor = alphaPlasmaPrice * anchorFraction; + + // 3. Supply data (last 7 days) + var supply = new Dictionary(StringComparer.OrdinalIgnoreCase); + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = + "SELECT resource_name, " + + " SUM(CASE WHEN is_pvp = 1 THEN quantity ELSE 0 END), " + + " SUM(quantity), " + + " SUM(quantity) / 7.0 " + + "FROM resources_gathered " + + "WHERE gathered_on >= DATEADD(DAY,-7,CAST(GETUTCDATE() AS DATE)) " + + "GROUP BY resource_name"; + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + supply[r.GetString(0)] = ((double)r.GetDecimal(3), r.GetInt64(1), r.GetInt64(2)); + } + + // 4. Demand data (from trade-list BOM — demand signal only) + var demand = new Dictionary(StringComparer.OrdinalIgnoreCase); + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = + "SELECT raw_material, SUM(total_quantity) / 7.0 " + + "FROM v_trade_list_raw_material_demand GROUP BY raw_material"; + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) demand[r.GetString(0)] = (double)r.GetDecimal(1); + } + + // 5. Materials list — all cf_raw_material items (categoryflags & 276 = 276) + var materials = new List(); + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = + "SELECT definitionname FROM entitydefaults " + + "WHERE (categoryflags & 276) = 276 AND enabled = 1 AND hidden = 0 " + + "ORDER BY definitionname"; + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) materials.Add(r.GetString(0)); + } + + // 6. Stored prices (latest week) + var storedPrices = new Dictionary(StringComparer.OrdinalIgnoreCase); + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = + "SELECT resource_name, unit_price FROM resource_market_prices " + + "WHERE calculated_on = (SELECT MAX(calculated_on) FROM resource_market_prices)"; + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) storedPrices[r.GetString(0)] = (double)r.GetDecimal(1); + } + + // 7. Weekly purchases this week per material + var weeklyPurchased = new Dictionary(StringComparer.OrdinalIgnoreCase); + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = + "DECLARE @ws DATE = DATEADD(DAY, -DATEPART(WEEKDAY, CAST(GETUTCDATE() AS DATE)) + 2, CAST(GETUTCDATE() AS DATE)); " + + "SELECT definitionname, ISNULL(SUM(qty_purchased),0) " + + "FROM automarket_rawmat_weekly_tracking WHERE week_start >= @ws " + + "GROUP BY definitionname"; + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) weeklyPurchased[r.GetString(0)] = r.GetInt64(1); + } + + // 8. Per-material cap overrides + var capOverrides = new Dictionary(StringComparer.OrdinalIgnoreCase); + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = "SELECT definitionname, weekly_cap_override FROM automarket_rawmat_overrides WHERE weekly_cap_override IS NOT NULL"; + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) capOverrides[r.GetString(0)] = r.GetInt32(1); + } + + // Compute rows + var result = new List(); + foreach (var name in materials) + { + var hasSupply = supply.TryGetValue(name, out var sup); + double supplyDailyAvg = hasSupply ? sup.DailyAvg : 0; + demand.TryGetValue(name, out var dailyDemand); + + double sdRatio = supplyDailyAvg <= 0 + ? dsMax + : Math.Clamp(dailyDemand / supplyDailyAvg, dsMin, dsMax); + + double pvpFraction = (hasSupply && sup.TotalQty > 0) + ? (double)sup.PvpQty / sup.TotalQty + : 1.0; + + var riskMultiplier = 1.0 + pvpFraction; + var computedPrice = Math.Round(plasmaAnchor * sdRatio * riskMultiplier, 2); + var effectiveCap = capOverrides.TryGetValue(name, out var ov) ? ov : weeklyCapDefault; + + result.Add(new AutoMarketPricingTraceRow + { + ResourceName = name, + PlasmaAnchor = Math.Round(plasmaAnchor, 4), + SdRatio = Math.Round(sdRatio, 4), + RiskMultiplier = Math.Round(riskMultiplier, 4), + ComputedPrice = computedPrice, + StoredPrice = storedPrices.TryGetValue(name, out var sp) ? sp : null, + BoughtThisWeek = weeklyPurchased.TryGetValue(name, out var bw) ? bw : 0, + EffectiveCap = effectiveCap, + }); + } + return result; + } +``` + +- [ ] **Step 3: Add `LoadCoveredMaterialsAsync`** + +Add after `LoadPricingTraceAsync`: + +```csharp + public async Task> LoadCoveredMaterialsAsync() + { + var result = new List(); + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + await using var cmd = cn.CreateCommand(); + cmd.CommandTimeout = 30; + cmd.CommandText = + "DECLARE @ws DATE = DATEADD(DAY, -DATEPART(WEEKDAY, CAST(GETUTCDATE() AS DATE)) + 2, CAST(GETUTCDATE() AS DATE)); " + + "SELECT " + + " ed.definitionname, " + + " ISNULL(rmp.unit_price, 0) AS current_price, " + + " COALESCE(o.weekly_cap_override, CAST(cfg.param_value AS BIGINT)) AS effective_cap, " + + " o.weekly_cap_override, " + + " ISNULL(o.create_buy_orders, 1) AS create_buy_orders, " + + " ISNULL(o.create_sell_orders, 1) AS create_sell_orders, " + + " ISNULL(wt.qty_purchased, 0) AS bought_this_week " + + "FROM entitydefaults ed " + + "LEFT JOIN automarket_rawmat_overrides o ON o.definitionname = ed.definitionname " + + "LEFT JOIN ( " + + " SELECT resource_name, unit_price FROM resource_market_prices " + + " WHERE calculated_on = (SELECT MAX(calculated_on) FROM resource_market_prices) " + + ") rmp ON rmp.resource_name = ed.definitionname " + + "LEFT JOIN ( " + + " SELECT definitionname, SUM(qty_purchased) AS qty_purchased " + + " FROM automarket_rawmat_weekly_tracking WHERE week_start >= @ws " + + " GROUP BY definitionname " + + ") wt ON wt.definitionname = ed.definitionname " + + "CROSS JOIN (SELECT param_value FROM automarket_config WHERE param_name = 'weekly_rawmat_cap_default') cfg " + + "WHERE (ed.categoryflags & 276) = 276 AND ed.enabled = 1 AND ed.hidden = 0 " + + "ORDER BY ed.definitionname"; + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + var capOverride = reader.IsDBNull(3) ? (int?)null : reader.GetInt32(3); + var buy = reader.GetBoolean(4); + var sell = reader.GetBoolean(5); + result.Add(new AutoMarketCoveredMaterialRow + { + DefinitionName = reader.GetString(0), + CurrentPrice = reader.IsDBNull(1) ? 0.0 : (double)reader.GetDecimal(1), + EffectiveCap = reader.GetInt64(2), + WeeklyCapOverride = capOverride, + CreateBuyOrders = buy, + CreateSellOrders = sell, + BoughtThisWeek = reader.GetInt64(6), + OriginalCapOverride = capOverride, + OriginalBuyOrders = buy, + OriginalSellOrders = sell, + }); + } + return result; + } +``` + +- [ ] **Step 4: Build** + +``` +dotnet build PerpetuumServer2.sln -c Release -p:Platform=x64 +``` + +Expected: 0 errors. + +- [ ] **Step 5: Commit** + +``` +git add src/Perpetuum.AdminTool/AutoMarket/AutoMarketRepository.cs +git commit -m "IMPROVEMENT-040: repository — rename view ref, expand PricingTrace, add LoadCoveredMaterialsAsync" +``` + +--- + +## Task 8: Statistics VM + XAML — add Pricing Trace columns + +**Files:** +- Modify: `src/Perpetuum.AdminTool/Views/AutoMarketStatisticsView.xaml` + +The Statistics VM (`AutoMarketStatisticsViewModel`) needs no code changes — `LoadPricingTraceAsync` now returns rows with `BoughtThisWeek` and `EffectiveCap` populated. Only the XAML needs two new columns. + +- [ ] **Step 1: Add columns to the Pricing Trace DataGrid** + +In `AutoMarketStatisticsView.xaml`, find the Pricing Trace `` block and add after the `Stored Price` column: + +```xml + + +``` + +- [ ] **Step 2: Build** + +``` +dotnet build PerpetuumServer2.sln -c Release -p:Platform=x64 +``` + +Expected: 0 errors, no BAML errors. + +- [ ] **Step 3: Commit** + +``` +git add src/Perpetuum.AdminTool/Views/AutoMarketStatisticsView.xaml +git commit -m "IMPROVEMENT-040: Statistics Pricing Trace — add Bought/Week and Weekly Cap columns" +``` + +--- + +## Task 9: Raw Materials VM + View + +**Files:** +- Create: `src/Perpetuum.AdminTool/ViewModels/AutoMarketRawMaterialsViewModel.cs` +- Create: `src/Perpetuum.AdminTool/Views/AutoMarketRawMaterialsView.xaml` +- Create: `src/Perpetuum.AdminTool/Views/AutoMarketRawMaterialsView.xaml.cs` + +- [ ] **Step 1: Create `AutoMarketRawMaterialsViewModel.cs`** + +```csharp +using System; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Perpetuum.AdminTool.AutoMarket; +using Perpetuum.AdminTool.Editing; +using Perpetuum.AdminTool.Translations; + +namespace Perpetuum.AdminTool.ViewModels +{ + public partial class AutoMarketRawMaterialsViewModel : ObservableObject + { + private readonly AutoMarketRepository _repo; + private readonly ChangeQueue _queue; + private readonly TranslationsViewModel? _translations; + private const int EnglishLangId = 0; + + [ObservableProperty] private bool _isLoading; + [ObservableProperty] private string _statusMessage = ""; + [ObservableProperty] private bool _statusIsError; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(FilteredRows))] + private bool _showOverridesOnly; + + public ObservableCollection Rows { get; } = new(); + + public System.Collections.Generic.IEnumerable FilteredRows => + _showOverridesOnly ? Rows.Where(r => r.HasOverride) : (System.Collections.Generic.IEnumerable)Rows; + + public AutoMarketRawMaterialsViewModel( + AutoMarketRepository repo, + ChangeQueue queue, + TranslationsViewModel? translations = null) + { + _repo = repo; + _queue = queue; + _translations = translations; + } + + [RelayCommand(CanExecute = nameof(CanRefresh))] + public async Task RefreshAsync() + { + IsLoading = true; + StatusMessage = "Loading raw materials..."; + StatusIsError = false; + try + { + var rows = await _repo.LoadCoveredMaterialsAsync(); + var store = _translations?.Store; + + Rows.Clear(); + foreach (var r in rows) + { + if (store != null) + { + var tr = store.Rows.FirstOrDefault(x => x.Key == r.DefinitionName); + var t = tr?[EnglishLangId]; + if (!string.IsNullOrEmpty(t)) r.DisplayName = t; + } + if (string.IsNullOrEmpty(r.DisplayName)) r.DisplayName = r.DefinitionName; + Rows.Add(r); + } + + OnPropertyChanged(nameof(FilteredRows)); + StatusMessage = $"Loaded {Rows.Count} materials at {DateTime.UtcNow:HH:mm:ss} UTC."; + } + catch (Exception ex) + { + StatusIsError = true; + StatusMessage = $"Load failed: {ex.Message}"; + } + finally { IsLoading = false; } + } + + private bool CanRefresh() => !IsLoading; + partial void OnIsLoadingChanged(bool value) => RefreshCommand.NotifyCanExecuteChanged(); + + [RelayCommand] + private void QueueSave(AutoMarketCoveredMaterialRow row) + { + var description = $"automarket_rawmat_overrides: {row.DefinitionName}"; + var existing = _queue.Items.FirstOrDefault(c => c.Description == description); + if (existing != null) _queue.Items.Remove(existing); + + string sql; + if (row.IsAtDefaults) + { + sql = $"DELETE FROM automarket_rawmat_overrides WHERE definitionname = {SqlLiteral.Of(row.DefinitionName)}"; + } + else + { + sql = + $"MERGE automarket_rawmat_overrides AS t " + + $"USING (VALUES ({SqlLiteral.Of(row.DefinitionName)}, {SqlLiteral.OfNullableInt(row.WeeklyCapOverride)}, " + + $"{(row.CreateBuyOrders ? 1 : 0)}, {(row.CreateSellOrders ? 1 : 0)})) " + + $"AS s (definitionname, weekly_cap_override, create_buy_orders, create_sell_orders) " + + $"ON t.definitionname = s.definitionname " + + $"WHEN MATCHED THEN UPDATE SET " + + $" weekly_cap_override = s.weekly_cap_override, " + + $" create_buy_orders = s.create_buy_orders, " + + $" create_sell_orders = s.create_sell_orders " + + $"WHEN NOT MATCHED THEN INSERT (definitionname, weekly_cap_override, create_buy_orders, create_sell_orders) " + + $"VALUES (s.definitionname, s.weekly_cap_override, s.create_buy_orders, s.create_sell_orders);"; + } + + _queue.Add(new RawSqlChange(description, sql)); + row.OriginalCapOverride = row.WeeklyCapOverride; // not init; these are mutable for display + StatusMessage = $"{row.DisplayName} queued."; + OnPropertyChanged(nameof(FilteredRows)); + } + } +} +``` + +- [ ] **Step 2: Create `AutoMarketRawMaterialsView.xaml.cs`** + +```csharp +using System.Windows.Controls; + +namespace Perpetuum.AdminTool.Views +{ + public partial class AutoMarketRawMaterialsView : UserControl + { + public AutoMarketRawMaterialsView() + { + InitializeComponent(); + } + } +} +``` + +- [ ] **Step 3: Create `AutoMarketRawMaterialsView.xaml`** + +```xml + + + + + + +