Compare commits
22
Commits
ec16dd2c9b
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
baf7f1170e | ||
|
|
9b1564451d | ||
|
|
5409479e00 | ||
|
|
25af9e3ba8 | ||
|
|
3ccaddef0c | ||
|
|
79bf56afd8 | ||
|
|
96f5824d12 | ||
|
|
db054aa6dc | ||
|
|
8e2027d81b | ||
|
|
57c7aa5f9f | ||
|
|
34c9b77501 | ||
|
|
203ea0c69d | ||
|
|
5cbd06711c | ||
|
|
f3b911f37c | ||
|
|
1f04c5343a | ||
|
|
3fbcb05d4d | ||
|
|
4f21d536f8 | ||
|
|
c1409bbdfb | ||
|
|
7882ed7669 | ||
|
|
af37789dcd | ||
|
|
ebe725579b | ||
|
|
05179aa445 |
+4
-5
@@ -244,13 +244,12 @@ ModelManifest.xml
|
|||||||
# FAKE - F# Make
|
# FAKE - F# Make
|
||||||
.fake/
|
.fake/
|
||||||
|
|
||||||
# AlroCatalog scraper output
|
|
||||||
scripts/AlroCatalog/__pycache__/
|
|
||||||
scripts/AlroCatalog/pdfs/
|
|
||||||
scripts/AlroCatalog/screenshots/
|
|
||||||
|
|
||||||
# Superpowers subagent-driven-development scratch workspace
|
# Superpowers subagent-driven-development scratch workspace
|
||||||
.superpowers/
|
.superpowers/
|
||||||
|
|
||||||
# Playwright MCP browser session logs/traces
|
# Playwright MCP browser session logs/traces
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
|
|
||||||
|
# Python bytecode cache (tests/)
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
@@ -12,9 +12,9 @@ The solution contains four projects:
|
|||||||
|
|
||||||
| Project | Framework | Purpose |
|
| Project | Framework | Purpose |
|
||||||
|---------|-----------|---------|
|
|---------|-----------|---------|
|
||||||
| **CutList** | .NET 8.0 Windows Forms | Original desktop UI (MVP pattern) |
|
| **CutList** | .NET 10.0 Windows Forms | Original desktop UI (MVP pattern) |
|
||||||
| **CutList.Core** | .NET 8.0 Class Library | Domain models and packing algorithms (platform-agnostic) |
|
| **CutList.Core** | .NET 10.0 Class Library | Domain models and packing algorithms (platform-agnostic) |
|
||||||
| **CutList.Web** | .NET 8.0 Blazor Server | Web-based UI + REST API, EF Core + SQL Server |
|
| **CutList.Web** | .NET 10.0 Blazor Server | Web-based UI + REST API, EF Core + SQL Server |
|
||||||
| **CutList.Mcp** | .NET 10.0 Console (stdio) | MCP server exposing CutList.Web's REST API as tools for Claude |
|
| **CutList.Mcp** | .NET 10.0 Console (stdio) | MCP server exposing CutList.Web's REST API as tools for Claude |
|
||||||
|
|
||||||
**Key Dependencies**: Math-Expression-Evaluator (input parsing), Newtonsoft.Json (serialization), Entity Framework Core (data access), Bootstrap 5 + Bootstrap Icons (UI), ModelContextProtocol SDK (CutList.Mcp)
|
**Key Dependencies**: Math-Expression-Evaluator (input parsing), Newtonsoft.Json (serialization), Entity Framework Core (data access), Bootstrap 5 + Bootstrap Icons (UI), ModelContextProtocol SDK (CutList.Mcp)
|
||||||
@@ -97,6 +97,14 @@ CutList.Mcp is an stdio MCP server, not a hosted service — it's published to `
|
|||||||
|
|
||||||
**Error handling**: `UseExceptionHandler("/Error", ...)` in non-Development environments routes to `Components/Pages/Error.razor`.
|
**Error handling**: `UseExceptionHandler("/Error", ...)` in non-Development environments routes to `Components/Pages/Error.razor`.
|
||||||
|
|
||||||
|
**Table actions**: Every row-level action cell uses the shared `table-actions` class from `wwwroot/css/app.css`. It is an `inline-flex` non-wrapping control group, so Edit/Delete/Copy buttons remain side by side and do not increase table-row height.
|
||||||
|
|
||||||
|
**Job title**: The job-editor page uses the `job-title` class to keep long job names at a compact, readable heading size without changing the larger dashboard hero typography.
|
||||||
|
|
||||||
|
**Overview**: The root page uses `OverviewService` to show recently created jobs, headline planning counts, and the five stock configurations most frequently specified across job stock. The stock ranking is a planning-demand signal (distinct jobs configured for a material/length), not a count of on-hand inventory.
|
||||||
|
|
||||||
|
**Material list semantics**: The Results tab labels lengths not covered by the job's configured stock as a **Material List**, not a purchase list. It identifies required material; purchasing remains a separate decision outside the cut-list result.
|
||||||
|
|
||||||
### CutList.Mcp — MCP Server
|
### CutList.Mcp — MCP Server
|
||||||
|
|
||||||
Stdio-transport MCP server (`ModelContextProtocol` SDK) exposing CutList.Web's REST API as tools for Claude Code. Registers tools via `WithToolsFromAssembly`; logging is disabled entirely so it doesn't interfere with the stdio transport.
|
Stdio-transport MCP server (`ModelContextProtocol` SDK) exposing CutList.Web's REST API as tools for Claude Code. Registers tools via `WithToolsFromAssembly`; logging is disabled entirely so it doesn't interfere with the stdio transport.
|
||||||
@@ -171,7 +179,7 @@ Abstract base with TPC (Table Per Concrete type) mapping — each shape gets its
|
|||||||
|
|
||||||
### CatalogService
|
### CatalogService
|
||||||
- `ExportAsync()` — dumps cutting tools and materials (with dimensions + stock items) into a shape-grouped `CatalogData` DTO for bulk export/import tooling
|
- `ExportAsync()` — dumps cutting tools and materials (with dimensions + stock items) into a shape-grouped `CatalogData` DTO for bulk export/import tooling
|
||||||
- Backs the `CatalogController` REST endpoint and the `scripts/ExportData` / `scripts/AlroCatalog` data-loading workflows
|
- Backs the `CatalogController` REST endpoint and the `scripts/ExportData` data-loading workflow
|
||||||
|
|
||||||
## CutList.Web Pages
|
## CutList.Web Pages
|
||||||
|
|
||||||
@@ -208,7 +216,8 @@ Abstract base with TPC (Table Per Concrete type) mapping — each shape gets its
|
|||||||
- **Stock priority** — Lower number = used first; `-1` quantity = unlimited
|
- **Stock priority** — Lower number = used first; `-1` quantity = unlimited
|
||||||
- **Job stock** — Jobs must have stock explicitly configured (catalog-sourced `StockItem` rows or custom-length rows); there is no fallback to auto-discovered inventory
|
- **Job stock** — Jobs must have stock explicitly configured (catalog-sourced `StockItem` rows or custom-length rows); there is no fallback to auto-discovered inventory
|
||||||
- **Optimization persistence** — Results saved as JSON in `Job.OptimizationResultJson`; DTO layer (`SavedOptimizationResult` etc.) handles serialization since Core types use encapsulated collections; results auto-cleared when parts, stock, or cutting tool change
|
- **Optimization persistence** — Results saved as JSON in `Job.OptimizationResultJson`; DTO layer (`SavedOptimizationResult` etc.) handles serialization since Core types use encapsulated collections; results auto-cleared when parts, stock, or cutting tool change
|
||||||
- **Job lock flow** — Optimize job -> Lock Job (manual action, available whether or not purchases are needed) -> job becomes read-only until Unlock
|
- **Job lock flow** — Optimize job -> review/print results -> Lock Job (manual action beside Print Report on the Results tab, available whether or not purchases are needed) -> job becomes read-only until Unlock
|
||||||
|
- **Printed cut badges** — Print styles intentionally remove color fills; cut badges therefore force black text and a black part-number/length divider so both remain legible on paper. The print-only tool line shows the selected cut method and kerf immediately below the summary.
|
||||||
- **Timestamps** — `CreatedAt` defaults to `GETUTCDATE()`; `UpdatedAt` set on modifications
|
- **Timestamps** — `CreatedAt` defaults to `GETUTCDATE()`; `UpdatedAt` set on modifications
|
||||||
- **Collections** — Encapsulated in Core; use `AsReadOnly()`, access via `Add*` methods
|
- **Collections** — Encapsulated in Core; use `AsReadOnly()`, access via `Add*` methods
|
||||||
- **Priority system** — Lower priority bins used first in packing algorithm
|
- **Priority system** — Lower priority bins used first in packing algorithm
|
||||||
@@ -218,7 +227,6 @@ Abstract base with TPC (Table Per Concrete type) mapping — each shape gets its
|
|||||||
|
|
||||||
- `Deploy-CutListWeb.ps1` — publishes and installs CutList.Web as a Windows Service (see Build Commands above)
|
- `Deploy-CutListWeb.ps1` — publishes and installs CutList.Web as a Windows Service (see Build Commands above)
|
||||||
- `ExportData/` — standalone console project that exercises `CutList.Web`'s data layer to import/export catalog seed data (e.g. `Data/SeedData/oneals-catalog.json`)
|
- `ExportData/` — standalone console project that exercises `CutList.Web`'s data layer to import/export catalog seed data (e.g. `Data/SeedData/oneals-catalog.json`)
|
||||||
- `AlroCatalog/` — Python scraper (`scrape_alro.py`) for pulling material/size/grade data from the Alro Steel SmartGrid site into a JSON catalog for import; see `SCRAPE_PLAN.md` for scraper status and cascading-dropdown navigation notes. Not part of the .NET build.
|
|
||||||
|
|
||||||
## Key Files
|
## Key Files
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\CutList.Core\CutList.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using CutList.Core.Formatting;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace CutList.Core.Tests;
|
||||||
|
|
||||||
|
public class ResultsLengthDisplayTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(150, "150\"")]
|
||||||
|
[InlineData(150.375, "150-3/8\"")]
|
||||||
|
public void FormatInches_uses_total_inches_without_converting_to_feet(double inches, string expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, ArchUnits.FormatInches(inches));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Results_markup_includes_unit_toggle_and_divider_between_part_and_length()
|
||||||
|
{
|
||||||
|
var sourcePath = Path.GetFullPath(
|
||||||
|
Path.Combine(AppContext.BaseDirectory, "../../../../CutList.Web/Components/Pages/Jobs/Edit.razor"));
|
||||||
|
var markup = File.ReadAllText(sourcePath);
|
||||||
|
|
||||||
|
Assert.Contains("FormatResultLength", markup);
|
||||||
|
Assert.Contains("cut-part-badge-divider", markup);
|
||||||
|
Assert.Contains("Show feet + inches", markup);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Printed_cut_list_rows_are_kept_together()
|
||||||
|
{
|
||||||
|
var sourcePath = Path.GetFullPath(
|
||||||
|
Path.Combine(AppContext.BaseDirectory, "../../../../CutList.Web/wwwroot/css/report.css"));
|
||||||
|
var css = File.ReadAllText(sourcePath);
|
||||||
|
|
||||||
|
Assert.Contains(".cutlist-material-card tbody tr", css);
|
||||||
|
Assert.Contains("break-inside: avoid", css);
|
||||||
|
Assert.Contains("page-break-inside: avoid", css);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -80,5 +80,13 @@ namespace CutList.Core.Formatting
|
|||||||
return $"{inches}\"";
|
return $"{inches}\"";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Formats a measurement as a mixed fraction of total inches without converting to feet.
|
||||||
|
/// </summary>
|
||||||
|
public static string FormatInches(double totalInches)
|
||||||
|
{
|
||||||
|
return $"{FormatHelper.ConvertToMixedFraction(totalInches)}\"";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<base href="/" />
|
<base href="/" />
|
||||||
<link rel="stylesheet" href="css/bootstrap/bootstrap.min.css" />
|
<link rel="stylesheet" href="css/bootstrap/bootstrap.min.css" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" />
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
|
||||||
<link rel="stylesheet" href="css/app.css" />
|
<link rel="stylesheet" href="css/app.css" />
|
||||||
<link rel="stylesheet" href="css/report.css" />
|
<link rel="stylesheet" href="css/report.css" />
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<article class="content px-4">
|
<article class="content">
|
||||||
@Body
|
@Body
|
||||||
</article>
|
</article>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<div class="top-row ps-3 navbar navbar-dark">
|
<div class="top-row">
|
||||||
<div class="container-fluid">
|
<div class="brand-lockup">
|
||||||
<a class="navbar-brand" href="">CutList</a>
|
<a class="brand-mark" href="" aria-label="CutList home"><i class="bi bi-scissors"></i></a>
|
||||||
|
<a class="navbar-brand" href=""><span class="brand-cut">Cut</span><span class="brand-list">List</span><small>SHOP FLOOR EDITION</small></a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -8,29 +9,31 @@
|
|||||||
|
|
||||||
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
|
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
|
||||||
<nav class="flex-column">
|
<nav class="flex-column">
|
||||||
|
<p class="nav-section-label">Workspace</p>
|
||||||
<div class="nav-item px-3">
|
<div class="nav-item px-3">
|
||||||
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
|
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
|
||||||
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Home
|
<i class="bi bi-grid-1x2-fill" aria-hidden="true"></i> Overview
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-item px-3">
|
<div class="nav-item px-3">
|
||||||
<NavLink class="nav-link" href="jobs">
|
<NavLink class="nav-link" href="jobs">
|
||||||
<span class="bi bi-list-check-nav-menu" aria-hidden="true"></span> Jobs
|
<i class="bi bi-layers-fill" aria-hidden="true"></i> Jobs
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="nav-section-label">Library</p>
|
||||||
<div class="nav-item px-3">
|
<div class="nav-item px-3">
|
||||||
<NavLink class="nav-link" href="materials">
|
<NavLink class="nav-link" href="materials">
|
||||||
<span class="bi bi-box-nav-menu" aria-hidden="true"></span> Materials
|
<i class="bi bi-box-seam-fill" aria-hidden="true"></i> Materials
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-item px-3">
|
<div class="nav-item px-3">
|
||||||
<NavLink class="nav-link" href="stock">
|
<NavLink class="nav-link" href="stock">
|
||||||
<span class="bi bi-boxes-nav-menu" aria-hidden="true"></span> Stock Items
|
<i class="bi bi-rulers" aria-hidden="true"></i> Stock Items
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-item px-3">
|
<div class="nav-item px-3">
|
||||||
<NavLink class="nav-link" href="tools">
|
<NavLink class="nav-link" href="tools">
|
||||||
<span class="bi bi-tools-nav-menu" aria-hidden="true"></span> Cutting Tools
|
<i class="bi bi-tools" aria-hidden="true"></i> Cutting Tools
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -1,119 +1,20 @@
|
|||||||
.navbar-toggler {
|
.top-row { justify-content: flex-start; padding: 1.5rem 1.35rem 1.25rem; }
|
||||||
appearance: none;
|
.brand-lockup { display: flex; align-items: center; gap: .75rem; }
|
||||||
cursor: pointer;
|
.brand-mark { align-items: center; background: #2b75a8; border-radius: .45rem; color: #fff; display: flex; font-size: 1rem; height: 2.25rem; justify-content: center; text-decoration: none; transform: rotate(-8deg); width: 2.25rem; }
|
||||||
width: 3.5rem;
|
.brand-mark i { transform: rotate(8deg); }
|
||||||
height: 2.5rem;
|
.navbar-brand { color: #17212b; font-family: "IBM Plex Sans", Inter, ui-sans-serif, system-ui, sans-serif; font-size: 1.25rem; font-weight: 700; letter-spacing: -.035em; line-height: .95; text-decoration: none; }
|
||||||
color: white;
|
.navbar-brand .brand-cut { color: #2b75a8; }
|
||||||
position: absolute;
|
.navbar-brand .brand-list { color: #17212b; }
|
||||||
top: 0.5rem;
|
.navbar-brand small { color: #60778b; display: block; font-family: Arial, sans-serif; font-size: .48rem; font-weight: 700; letter-spacing: .14em; margin-top: .45rem; }
|
||||||
right: 1rem;
|
.nav-section-label { color: #91a8b8; font-size: .62rem; font-weight: 700; letter-spacing: .13em; margin: 1.35rem 1.5rem .5rem; text-transform: uppercase; }
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
.nav-item { padding: .1rem .75rem; }
|
||||||
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1);
|
.nav-item ::deep .nav-link { align-items: center; border-radius: .4rem; color: #c4d3dc; display: flex; font-size: .9rem; font-weight: 600; gap: .8rem; min-height: 2.8rem; padding: .65rem .75rem; transition: background .18s ease, color .18s ease; }
|
||||||
}
|
.nav-item ::deep .nav-link i { color: #8fa7b7; font-size: 1rem; width: 1.1rem; }
|
||||||
|
.nav-item ::deep .nav-link:hover { background: rgba(255,255,255,.06); color: #fff; }
|
||||||
.navbar-toggler:checked {
|
.nav-item ::deep a.active { background: #294655; color: #fff; }
|
||||||
background-color: rgba(255, 255, 255, 0.5);
|
.nav-item ::deep a.active i { color: #72b5df; }
|
||||||
}
|
.navbar-toggler { appearance: none; background: transparent; border: 1px solid #587382; border-radius: .35rem; color: white; cursor: pointer; height: 2.25rem; position: absolute; right: 1rem; top: 1rem; width: 2.75rem; }
|
||||||
|
.navbar-toggler::after { content: '☰'; }
|
||||||
.top-row {
|
.nav-scrollable { display: none; }
|
||||||
height: 3.5rem;
|
.navbar-toggler:checked ~ .nav-scrollable { display: block; }
|
||||||
background-color: rgba(0,0,0,0.4);
|
@media (min-width: 641px) { .navbar-toggler { display: none; } .nav-scrollable { display: block; } }
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-brand {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bi {
|
|
||||||
display: inline-block;
|
|
||||||
position: relative;
|
|
||||||
width: 1.25rem;
|
|
||||||
height: 1.25rem;
|
|
||||||
margin-right: 0.75rem;
|
|
||||||
top: -1px;
|
|
||||||
background-size: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bi-house-door-fill-nav-menu {
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
|
|
||||||
}
|
|
||||||
|
|
||||||
.bi-list-check-nav-menu {
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-check' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M5 11.5a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5zm0-4a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5zm0-4a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5zM3.854 2.146a.5.5 0 0 1 0 .708l-1.5 1.5a.5.5 0 0 1-.708 0l-.5-.5a.5.5 0 1 1 .708-.708L2 3.293l1.146-1.147a.5.5 0 0 1 .708 0zm0 4a.5.5 0 0 1 0 .708l-1.5 1.5a.5.5 0 0 1-.708 0l-.5-.5a.5.5 0 1 1 .708-.708L2 7.293l1.146-1.147a.5.5 0 0 1 .708 0zm0 4a.5.5 0 0 1 0 .708l-1.5 1.5a.5.5 0 0 1-.708 0l-.5-.5a.5.5 0 0 1 .708-.708l.146.147 1.146-1.147a.5.5 0 0 1 .708 0z'/%3E%3C/svg%3E");
|
|
||||||
}
|
|
||||||
|
|
||||||
.bi-box-nav-menu {
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-box' viewBox='0 0 16 16'%3E%3Cpath d='M8.186 1.113a.5.5 0 0 0-.372 0L1.846 3.5 8 5.961 14.154 3.5 8.186 1.113zM15 4.239l-6.5 2.6v7.922l6.5-2.6V4.24zM7.5 14.762V6.838L1 4.239v7.923l6.5 2.6zM7.443.184a1.5 1.5 0 0 1 1.114 0l7.129 2.852A.5.5 0 0 1 16 3.5v8.662a1 1 0 0 1-.629.928l-7.185 2.874a.5.5 0 0 1-.372 0L.63 13.09a1 1 0 0 1-.63-.928V3.5a.5.5 0 0 1 .314-.464L7.443.184z'/%3E%3C/svg%3E");
|
|
||||||
}
|
|
||||||
|
|
||||||
.bi-boxes-nav-menu {
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-boxes' viewBox='0 0 16 16'%3E%3Cpath d='M7.752.066a.5.5 0 0 1 .496 0l3.75 2.143a.5.5 0 0 1 .252.434v3.995l3.498 2A.5.5 0 0 1 16 9.07v4.286a.5.5 0 0 1-.252.434l-3.75 2.143a.5.5 0 0 1-.496 0l-3.502-2-3.502 2.001a.5.5 0 0 1-.496 0l-3.75-2.143A.5.5 0 0 1 0 13.357V9.071a.5.5 0 0 1 .252-.434L3.75 6.638V2.643a.5.5 0 0 1 .252-.434zM4.25 7.504 1.508 9.071l2.742 1.567 2.742-1.567zM7.5 9.933l-2.75 1.571v3.134l2.75-1.571zm1 3.134 2.75 1.571v-3.134L8.5 9.933zm.508-3.996 2.742 1.567 2.742-1.567-2.742-1.567zm2.242-2.433V3.504L8.5 5.076V8.21zM7.5 8.21V5.076L4.75 3.504v3.134zM5.258 2.643 8 4.21l2.742-1.567L8 1.076zM15 9.933l-2.75 1.571v3.134L15 13.067zM3.75 14.638v-3.134L1 9.933v3.134z'/%3E%3C/svg%3E");
|
|
||||||
}
|
|
||||||
|
|
||||||
.bi-cart-nav-menu {
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-cart3' viewBox='0 0 16 16'%3E%3Cpath d='M0 1.5A.5.5 0 0 1 .5 1H2a.5.5 0 0 1 .485.379L2.89 3H14.5a.5.5 0 0 1 .49.598l-1 5a.5.5 0 0 1-.465.401l-9.397.472L4.415 11H13a.5.5 0 0 1 0 1H4a.5.5 0 0 1-.491-.408L2.01 3.607 1.61 2H.5a.5.5 0 0 1-.5-.5zM3.102 4l.84 4.479 9.144-.459L13.89 4H3.102zM5 12a2 2 0 1 0 0 4 2 2 0 0 0 0-4zm7 0a2 2 0 1 0 0 4 2 2 0 0 0 0-4zm-7 1a1 1 0 1 1 0 2 1 1 0 0 1 0-2zm7 0a1 1 0 1 1 0 2 1 1 0 0 1 0-2z'/%3E%3C/svg%3E");
|
|
||||||
}
|
|
||||||
|
|
||||||
.bi-building-nav-menu {
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-building' viewBox='0 0 16 16'%3E%3Cpath d='M4 2.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1Zm3 0a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1Zm3.5-.5a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1ZM4 5.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1ZM7.5 5a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1Zm2.5.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1ZM4.5 8a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1Zm2.5.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1Zm3.5-.5a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1Z'/%3E%3Cpath d='M2 1a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V1Zm11 0H3v14h3v-2.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5V15h3V1Z'/%3E%3C/svg%3E");
|
|
||||||
}
|
|
||||||
|
|
||||||
.bi-tools-nav-menu {
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-tools' viewBox='0 0 16 16'%3E%3Cpath d='M1 0 0 1l2.2 3.081a1 1 0 0 0 .815.419h.07a1 1 0 0 1 .708.293l2.675 2.675-2.617 2.654A3.003 3.003 0 0 0 0 13a3 3 0 1 0 5.878-.851l2.654-2.617.968.968-.305.914a1 1 0 0 0 .242 1.023l3.27 3.27a.997.997 0 0 0 1.414 0l1.586-1.586a.997.997 0 0 0 0-1.414l-3.27-3.27a1 1 0 0 0-1.023-.242l-.914.305-.968-.968 2.617-2.654A3.003 3.003 0 0 0 13 0a3 3 0 1 0-.851 5.878L9.495 8.53l-2.675-2.675a1 1 0 0 1-.293-.707v-.071a1 1 0 0 0-.419-.814L3.081 2.2 1 0ZM3 13a2 2 0 1 1-4 0 2 2 0 0 1 4 0Zm7.5-8.5a2 2 0 1 1 4 0 2 2 0 0 1-4 0Z'/%3E%3C/svg%3E");
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
padding-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item:first-of-type {
|
|
||||||
padding-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item:last-of-type {
|
|
||||||
padding-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item ::deep .nav-link {
|
|
||||||
color: #d7d7d7;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
height: 3rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
line-height: 3rem;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item ::deep a.active {
|
|
||||||
background-color: rgba(255,255,255,0.37);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item ::deep .nav-link:hover {
|
|
||||||
background-color: rgba(255,255,255,0.1);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-scrollable {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-toggler:checked ~ .nav-scrollable {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 641px) {
|
|
||||||
.navbar-toggler {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-scrollable {
|
|
||||||
display: block;
|
|
||||||
position: relative;
|
|
||||||
overflow-y: auto;
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,49 +1,143 @@
|
|||||||
@page "/"
|
@page "/"
|
||||||
|
@inject OverviewService OverviewService
|
||||||
|
@using CutList.Core.Formatting
|
||||||
|
|
||||||
<PageTitle>CutList - Home</PageTitle>
|
<PageTitle>CutList - Overview</PageTitle>
|
||||||
|
|
||||||
<h1>CutList</h1>
|
@if (loading)
|
||||||
|
{
|
||||||
<p class="lead">1D Bin Packing Optimization for Material Cutting</p>
|
<p><em>Loading overview…</em></p>
|
||||||
|
}
|
||||||
<div class="row mt-4">
|
else
|
||||||
<div class="col-md-6 col-lg-4 mb-4">
|
{
|
||||||
<div class="card h-100">
|
<section class="overview-header">
|
||||||
<div class="card-body">
|
<div class="overview-actions">
|
||||||
<h5 class="card-title">Jobs</h5>
|
<a href="jobs/new" class="btn btn-primary"><i class="bi bi-plus-lg" aria-hidden="true"></i> New job</a>
|
||||||
<p class="card-text">Create and manage cut list jobs. Add parts and stock bins, then optimize to minimize waste.</p>
|
<a href="jobs" class="btn btn-outline-primary">All jobs <i class="bi bi-arrow-right" aria-hidden="true"></i></a>
|
||||||
<a href="jobs" class="btn btn-primary">Go to Jobs</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
<div class="col-md-6 col-lg-4 mb-4">
|
|
||||||
<div class="card h-100">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title">Materials</h5>
|
|
||||||
<p class="card-text">Manage material types (tube, bar, angle, etc.) with their shapes and sizes.</p>
|
|
||||||
<a href="materials" class="btn btn-outline-primary">Manage Materials</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6 col-lg-4 mb-4">
|
|
||||||
<div class="card h-100">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title">Cutting Tools</h5>
|
|
||||||
<p class="card-text">Configure cutting tools with their kerf widths for accurate waste calculations.</p>
|
|
||||||
<a href="tools" class="btn btn-outline-primary">Manage Tools</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr class="my-4" />
|
@if (loadError is not null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-warning mt-4" role="alert">
|
||||||
|
<strong>Overview data is unavailable.</strong> @loadError
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<h4>How It Works</h4>
|
<section class="overview-stats" aria-label="CutList statistics">
|
||||||
<ol>
|
<a href="jobs" class="overview-stat">
|
||||||
<li><strong>Set up materials</strong> - Define the shapes and sizes of materials you work with</li>
|
<span class="overview-stat-label">Jobs</span>
|
||||||
<li><strong>Add stock</strong> - Record which stock lengths you have on hand</li>
|
<strong>@overview.TotalJobs</strong>
|
||||||
<li><strong>Create a job</strong> - Add the parts you need to cut with their lengths and quantities</li>
|
<small>@overview.OpenJobs open for planning</small>
|
||||||
<li><strong>Add stock bins</strong> - Specify which stock lengths to cut from</li>
|
</a>
|
||||||
<li><strong>Optimize</strong> - Run the optimizer to find the best cutting pattern</li>
|
<a href="jobs" class="overview-stat">
|
||||||
<li><strong>Print report</strong> - Generate a printable cut list to take to the shop</li>
|
<span class="overview-stat-label">Parts required</span>
|
||||||
</ol>
|
<strong>@overview.TotalParts</strong>
|
||||||
|
<small>Across all current jobs</small>
|
||||||
|
</a>
|
||||||
|
<a href="stock" class="overview-stat">
|
||||||
|
<span class="overview-stat-label">Stock options</span>
|
||||||
|
<strong>@overview.ActiveStockItems</strong>
|
||||||
|
<small>Active catalog lengths</small>
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="overview-grid">
|
||||||
|
<article class="overview-panel overview-recent-jobs">
|
||||||
|
<div class="overview-panel-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Work queue</p>
|
||||||
|
<h2>Recently created jobs</h2>
|
||||||
|
</div>
|
||||||
|
<a href="jobs">View all <i class="bi bi-arrow-up-right" aria-hidden="true"></i></a>
|
||||||
|
</div>
|
||||||
|
@if (overview.RecentJobs.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="overview-empty">
|
||||||
|
<i class="bi bi-layers" aria-hidden="true"></i>
|
||||||
|
<p>No jobs yet. Create a job to begin building your planning history.</p>
|
||||||
|
<a href="jobs/new" class="btn btn-primary">Create first job</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="overview-job-list">
|
||||||
|
@foreach (var job in overview.RecentJobs)
|
||||||
|
{
|
||||||
|
<a href="jobs/@job.Id" class="overview-job-row">
|
||||||
|
<span class="overview-job-id">@job.JobNumber</span>
|
||||||
|
<span class="overview-job-name">@(string.IsNullOrWhiteSpace(job.Name) ? "Untitled job" : job.Name)</span>
|
||||||
|
<span class="overview-job-meta">@(string.IsNullOrWhiteSpace(job.Customer) ? "No customer" : job.Customer) · @job.PartCount @(job.PartCount == 1 ? "part" : "parts")</span>
|
||||||
|
<time datetime="@job.CreatedAt.ToString("O")">@job.CreatedAt.ToLocalTime().ToString("MMM d")</time>
|
||||||
|
@if (job.IsLocked)
|
||||||
|
{
|
||||||
|
<i class="bi bi-lock-fill overview-lock" title="Materials ordered" aria-label="Materials ordered"></i>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<i class="bi bi-arrow-right overview-row-arrow" aria-hidden="true"></i>
|
||||||
|
}
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="overview-panel overview-stock-panel">
|
||||||
|
<div class="overview-panel-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Planning signal</p>
|
||||||
|
<h2>Frequently specified stock</h2>
|
||||||
|
</div>
|
||||||
|
<a href="stock">Manage stock <i class="bi bi-arrow-up-right" aria-hidden="true"></i></a>
|
||||||
|
</div>
|
||||||
|
<p class="overview-panel-note">Based on stock configured on jobs. This shows recurring planning demand, not on-hand inventory.</p>
|
||||||
|
@if (overview.FrequentStock.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="overview-empty overview-empty-compact">
|
||||||
|
<i class="bi bi-box-seam" aria-hidden="true"></i>
|
||||||
|
<p>Add stock to jobs to reveal the configurations used most often.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<ol class="overview-stock-list">
|
||||||
|
@for (var stockIndex = 0; stockIndex < overview.FrequentStock.Count; stockIndex++)
|
||||||
|
{
|
||||||
|
var stock = overview.FrequentStock[stockIndex];
|
||||||
|
<li>
|
||||||
|
<span class="overview-stock-rank">@((stockIndex + 1).ToString("D2"))</span>
|
||||||
|
<div>
|
||||||
|
<strong>@stock.MaterialName</strong>
|
||||||
|
<span>@ArchUnits.FormatFromInches((double)stock.LengthInches) stock length</span>
|
||||||
|
</div>
|
||||||
|
<span class="overview-stock-count">@stock.JobCount <small>@(stock.JobCount == 1 ? "job" : "jobs")</small></span>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ol>
|
||||||
|
}
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private OverviewSnapshot overview = new(0, 0, 0, 0, [], []);
|
||||||
|
private bool loading = true;
|
||||||
|
private string? loadError;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
overview = await OverviewService.GetSnapshotAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
loadError = $"The dashboard could not connect to its database ({ex.GetType().Name}).";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,13 +13,13 @@
|
|||||||
<PageTitle>@(IsNew ? "New Job" : job.DisplayName)</PageTitle>
|
<PageTitle>@(IsNew ? "New Job" : job.DisplayName)</PageTitle>
|
||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<h1>@(IsNew ? "New Job" : job.DisplayName)</h1>
|
<h1 class="job-title">@(IsNew ? "New Job" : job.DisplayName)</h1>
|
||||||
<a href="jobs" class="btn btn-outline-secondary">Back to Jobs</a>
|
<a href="jobs" class="btn btn-outline-secondary">Back to Jobs</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (!IsNew && job.IsLocked)
|
@if (!IsNew && job.IsLocked)
|
||||||
{
|
{
|
||||||
<div class="alert alert-warning d-flex justify-content-between align-items-center mb-3">
|
<div class="alert alert-warning d-flex justify-content-between align-items-center mb-3 print-screen-only">
|
||||||
<div>
|
<div>
|
||||||
<i class="bi bi-lock-fill me-2"></i>
|
<i class="bi bi-lock-fill me-2"></i>
|
||||||
<strong>This job is locked</strong> — locked on @job.LockedAt!.Value.ToLocalTime().ToString("g"). Unlock to make changes.
|
<strong>This job is locked</strong> — locked on @job.LockedAt!.Value.ToLocalTime().ToString("g"). Unlock to make changes.
|
||||||
@@ -503,6 +503,7 @@ else
|
|||||||
private MultiMaterialPackingSummary? summary;
|
private MultiMaterialPackingSummary? summary;
|
||||||
private bool optimizing;
|
private bool optimizing;
|
||||||
private bool lockingJob;
|
private bool lockingJob;
|
||||||
|
private bool showLengthsInInches = true;
|
||||||
|
|
||||||
private IEnumerable<MaterialShape> DistinctShapes => materials.Select(m => m.Shape).Distinct().OrderBy(s => s);
|
private IEnumerable<MaterialShape> DistinctShapes => materials.Select(m => m.Shape).Distinct().OrderBy(s => s);
|
||||||
private IEnumerable<Material> FilteredMaterials => !selectedShape.HasValue
|
private IEnumerable<Material> FilteredMaterials => !selectedShape.HasValue
|
||||||
@@ -511,6 +512,9 @@ else
|
|||||||
|
|
||||||
private bool IsNew => !Id.HasValue;
|
private bool IsNew => !Id.HasValue;
|
||||||
private bool CanOptimize => job.Parts.Count > 0 && job.CuttingToolId != null;
|
private bool CanOptimize => job.Parts.Count > 0 && job.CuttingToolId != null;
|
||||||
|
private string FormatResultLength(double inches) => showLengthsInInches
|
||||||
|
? ArchUnits.FormatInches(inches)
|
||||||
|
: ArchUnits.FormatFromInches(inches);
|
||||||
|
|
||||||
private async Task UnlockJob()
|
private async Task UnlockJob()
|
||||||
{
|
{
|
||||||
@@ -686,8 +690,10 @@ else
|
|||||||
<td>
|
<td>
|
||||||
@if (!job.IsLocked)
|
@if (!job.IsLocked)
|
||||||
{
|
{
|
||||||
<button class="btn btn-sm btn-outline-primary me-1" @onclick="() => EditPart(part)" title="Edit"><i class="bi bi-pencil"></i></button>
|
<div class="table-actions">
|
||||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeletePart(part)" title="Delete"><i class="bi bi-trash"></i></button>
|
<button class="btn btn-sm btn-outline-primary" @onclick="() => EditPart(part)" title="Edit"><i class="bi bi-pencil"></i></button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeletePart(part)" title="Delete"><i class="bi bi-trash"></i></button>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -928,8 +934,10 @@ else
|
|||||||
<td>
|
<td>
|
||||||
@if (!job.IsLocked)
|
@if (!job.IsLocked)
|
||||||
{
|
{
|
||||||
<button class="btn btn-sm btn-outline-primary me-1" @onclick="() => EditStock(stock)" title="Edit"><i class="bi bi-pencil"></i></button>
|
<div class="table-actions">
|
||||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteStock(stock)" title="Delete"><i class="bi bi-trash"></i></button>
|
<button class="btn btn-sm btn-outline-primary" @onclick="() => EditStock(stock)" title="Edit"><i class="bi bi-pencil"></i></button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteStock(stock)" title="Delete"><i class="bi bi-trash"></i></button>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -983,6 +991,20 @@ else
|
|||||||
<button class="btn btn-outline-secondary ms-2" @onclick="PrintReport">
|
<button class="btn btn-outline-secondary ms-2" @onclick="PrintReport">
|
||||||
<i class="bi bi-printer me-1"></i> Print Report
|
<i class="bi bi-printer me-1"></i> Print Report
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn btn-outline-secondary ms-2" @onclick="() => showLengthsInInches = !showLengthsInInches"
|
||||||
|
aria-pressed="@showLengthsInInches">
|
||||||
|
@(showLengthsInInches ? "Show feet + inches" : "Show all inches")
|
||||||
|
</button>
|
||||||
|
@if (!job.IsLocked)
|
||||||
|
{
|
||||||
|
<button class="btn btn-warning ms-2" @onclick="LockJob" disabled="@lockingJob">
|
||||||
|
@if (lockingJob)
|
||||||
|
{
|
||||||
|
<span class="spinner-border spinner-border-sm me-1"></span>
|
||||||
|
}
|
||||||
|
<i class="bi bi-lock me-1"></i>Lock Job
|
||||||
|
</button>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@if (job.OptimizedAt.HasValue)
|
@if (job.OptimizedAt.HasValue)
|
||||||
{
|
{
|
||||||
@@ -1023,7 +1045,7 @@ else
|
|||||||
<div class="col-md-3 col-6 mb-3">
|
<div class="col-md-3 col-6 mb-3">
|
||||||
<div class="card text-center">
|
<div class="card text-center">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title mb-0">@ArchUnits.FormatFromInches(summary.TotalWaste)</h2>
|
<h2 class="card-title mb-0">@FormatResultLength(summary.TotalWaste)</h2>
|
||||||
<p class="card-text text-muted">Total Waste</p>
|
<p class="card-text text-muted">Total Waste</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1038,29 +1060,27 @@ else
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Purchase List -->
|
@if (job.CuttingTool != null)
|
||||||
<div class="card mb-4 print-purchase-list">
|
{
|
||||||
|
<div class="print-cut-method">
|
||||||
|
<strong>Cut Method:</strong> @job.CuttingTool.Name
|
||||||
|
<span class="ms-2">Kerf: @FormatResultLength((double)job.CuttingTool.KerfInches)</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Material List: required stock lengths not covered by job stock -->
|
||||||
|
<div class="card mb-4 print-material-list">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
<h5 class="mb-0"><i class="bi bi-cart me-2"></i>Purchase List</h5>
|
<h5 class="mb-0"><i class="bi bi-box-seam me-2"></i>Material List</h5>
|
||||||
@if (job.IsLocked)
|
@if (job.IsLocked)
|
||||||
{
|
{
|
||||||
<span class="badge bg-success"><i class="bi bi-lock-fill me-1"></i>Job Locked</span>
|
<span class="badge bg-success print-screen-only"><i class="bi bi-lock-fill me-1"></i>Job Locked</span>
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<button class="btn btn-warning btn-sm" @onclick="LockJob" disabled="@lockingJob">
|
|
||||||
@if (lockingJob)
|
|
||||||
{
|
|
||||||
<span class="spinner-border spinner-border-sm me-1"></span>
|
|
||||||
}
|
|
||||||
<i class="bi bi-lock me-1"></i>Lock Job
|
|
||||||
</button>
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@if (summary.TotalToBePurchasedBins == 0)
|
@if (summary.TotalToBePurchasedBins == 0)
|
||||||
{
|
{
|
||||||
<p class="text-muted mb-0">Everything is available in stock. No purchases needed.</p>
|
<p class="text-muted mb-0">No additional material lengths are required beyond the stock configured for this job.</p>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -1080,7 +1100,7 @@ else
|
|||||||
{
|
{
|
||||||
<tr>
|
<tr>
|
||||||
<td>@materialResult.Material.DisplayName</td>
|
<td>@materialResult.Material.DisplayName</td>
|
||||||
<td>@ArchUnits.FormatFromInches(group.Key)</td>
|
<td>@FormatResultLength(group.Key)</td>
|
||||||
<td class="text-end">@group.Count()</td>
|
<td class="text-end">@group.Count()</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
@@ -1088,7 +1108,7 @@ else
|
|||||||
</tbody>
|
</tbody>
|
||||||
<tfoot>
|
<tfoot>
|
||||||
<tr class="fw-bold">
|
<tr class="fw-bold">
|
||||||
<td colspan="2">Total</td>
|
<td colspan="2">Total Required</td>
|
||||||
<td class="text-end">@summary.TotalToBePurchasedBins bars</td>
|
<td class="text-end">@summary.TotalToBePurchasedBins bars</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
@@ -1154,16 +1174,21 @@ else
|
|||||||
{
|
{
|
||||||
<tr>
|
<tr>
|
||||||
<td>@binNum</td>
|
<td>@binNum</td>
|
||||||
<td style="white-space: nowrap;">@ArchUnits.FormatFromInches(entry.Bin.Length)</td>
|
<td style="white-space: nowrap;">@FormatResultLength(entry.Bin.Length)</td>
|
||||||
<td>
|
<td>
|
||||||
@foreach (var item in entry.Bin.Items)
|
@foreach (var item in entry.Bin.Items)
|
||||||
{
|
{
|
||||||
<span class="badge me-1 mb-1 fs-6" style="background-color: #6c8ebf; font-weight: 500;">
|
<span class="badge me-1 mb-1 fs-6 cut-part-badge">
|
||||||
@(string.IsNullOrWhiteSpace(item.Name) ? ArchUnits.FormatFromInches(item.Length) : $"{item.Name} ({ArchUnits.FormatFromInches(item.Length)})")
|
@if (!string.IsNullOrWhiteSpace(item.Name))
|
||||||
|
{
|
||||||
|
<span class="cut-part-badge-name">@item.Name</span>
|
||||||
|
<span class="cut-part-badge-divider" aria-hidden="true"></span>
|
||||||
|
}
|
||||||
|
<span class="cut-part-badge-length">@FormatResultLength(item.Length)</span>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td style="white-space: nowrap;">@ArchUnits.FormatFromInches(entry.Bin.RemainingLength)</td>
|
<td style="white-space: nowrap;">@FormatResultLength(entry.Bin.RemainingLength)</td>
|
||||||
</tr>
|
</tr>
|
||||||
binNum++;
|
binNum++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,9 +62,11 @@ else
|
|||||||
<td>@(job.CuttingTool?.Name ?? "-")</td>
|
<td>@(job.CuttingTool?.Name ?? "-")</td>
|
||||||
<td>@((job.UpdatedAt ?? job.CreatedAt).ToLocalTime().ToString("g"))</td>
|
<td>@((job.UpdatedAt ?? job.CreatedAt).ToLocalTime().ToString("g"))</td>
|
||||||
<td>
|
<td>
|
||||||
<a href="jobs/@job.Id" class="btn btn-sm btn-outline-primary" title="Edit"><i class="bi bi-pencil"></i></a>
|
<div class="table-actions">
|
||||||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => DuplicateJob(job)" title="Copy"><i class="bi bi-copy"></i></button>
|
<a href="jobs/@job.Id" class="btn btn-sm btn-outline-primary" title="Edit"><i class="bi bi-pencil"></i></a>
|
||||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(job)" title="Delete"><i class="bi bi-trash"></i></button>
|
<button class="btn btn-sm btn-outline-secondary" @onclick="() => DuplicateJob(job)" title="Copy"><i class="bi bi-copy"></i></button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(job)" title="Delete"><i class="bi bi-trash"></i></button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ else
|
|||||||
<td>@material.Grade</td>
|
<td>@material.Grade</td>
|
||||||
<td>@material.Size</td>
|
<td>@material.Size</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="d-flex gap-1">
|
<div class="table-actions">
|
||||||
<a href="materials/@material.Id" class="btn btn-sm btn-outline-primary" title="Edit"><i class="bi bi-pencil"></i></a>
|
<a href="materials/@material.Id" class="btn btn-sm btn-outline-primary" title="Edit"><i class="bi bi-pencil"></i></a>
|
||||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(material)" title="Delete"><i class="bi bi-trash"></i></button>
|
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(material)" title="Delete"><i class="bi bi-trash"></i></button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ else
|
|||||||
<td>@item.Material.Size</td>
|
<td>@item.Material.Size</td>
|
||||||
<td>@ArchUnits.FormatFromInches((double)item.LengthInches)</td>
|
<td>@ArchUnits.FormatFromInches((double)item.LengthInches)</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="d-flex gap-1">
|
<div class="table-actions">
|
||||||
<a href="stock/@item.Id" class="btn btn-sm btn-outline-primary" title="Edit"><i class="bi bi-pencil"></i></a>
|
<a href="stock/@item.Id" class="btn btn-sm btn-outline-primary" title="Edit"><i class="bi bi-pencil"></i></a>
|
||||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(item)" title="Delete"><i class="bi bi-trash"></i></button>
|
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(item)" title="Delete"><i class="bi bi-trash"></i></button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -90,8 +90,10 @@ else
|
|||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<button class="btn btn-sm btn-outline-primary" @onclick="() => Edit(tool)" title="Edit"><i class="bi bi-pencil"></i></button>
|
<div class="table-actions">
|
||||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(tool)" title="Delete"><i class="bi bi-trash"></i></button>
|
<button class="btn btn-sm btn-outline-primary" @onclick="() => Edit(tool)" title="Edit"><i class="bi bi-pencil"></i></button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(tool)" title="Delete"><i class="bi bi-trash"></i></button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
{
|
|
||||||
"exportedAt": "2026-02-16T17:09:52.843008+00:00",
|
|
||||||
"suppliers": [
|
|
||||||
{
|
|
||||||
"name": "Alro Steel"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"cuttingTools": [
|
|
||||||
{
|
|
||||||
"name": "Bandsaw",
|
|
||||||
"kerfInches": 0.0625,
|
|
||||||
"isDefault": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Chop Saw",
|
|
||||||
"kerfInches": 0.125,
|
|
||||||
"isDefault": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Cold Cut Saw",
|
|
||||||
"kerfInches": 0.0625,
|
|
||||||
"isDefault": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Hacksaw",
|
|
||||||
"kerfInches": 0.0625,
|
|
||||||
"isDefault": false
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"materials": {
|
|
||||||
"angles": [],
|
|
||||||
"channels": [],
|
|
||||||
"flatBars": [],
|
|
||||||
"iBeams": [],
|
|
||||||
"pipes": [],
|
|
||||||
"rectangularTubes": [],
|
|
||||||
"roundBars": [],
|
|
||||||
"roundTubes": [],
|
|
||||||
"squareBars": [],
|
|
||||||
"squareTubes": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using CutList.Web.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace CutList.Web.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(ApplicationDbContext))]
|
||||||
|
[Migration("20260802212500_RemoveJobNumberPadding")]
|
||||||
|
public partial class RemoveJobNumberPadding : Migration
|
||||||
|
{
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
UPDATE Jobs
|
||||||
|
SET JobNumber = 'JOB-' + CAST(Id AS varchar(11))
|
||||||
|
WHERE JobNumber LIKE 'JOB-%';
|
||||||
|
");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
UPDATE Jobs
|
||||||
|
SET JobNumber = 'JOB-' + RIGHT('00000' + CAST(Id AS varchar(5)), 5)
|
||||||
|
WHERE JobNumber LIKE 'JOB-%';
|
||||||
|
");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ builder.Services.AddDbContextFactory<ApplicationDbContext>(options =>
|
|||||||
builder.Services.AddScoped<MaterialService>();
|
builder.Services.AddScoped<MaterialService>();
|
||||||
builder.Services.AddScoped<StockItemService>();
|
builder.Services.AddScoped<StockItemService>();
|
||||||
builder.Services.AddScoped<JobService>();
|
builder.Services.AddScoped<JobService>();
|
||||||
|
builder.Services.AddScoped<OverviewService>();
|
||||||
builder.Services.AddScoped<CutListPackingService>();
|
builder.Services.AddScoped<CutListPackingService>();
|
||||||
builder.Services.AddScoped<ReportService>();
|
builder.Services.AddScoped<ReportService>();
|
||||||
builder.Services.AddScoped<CatalogService>();
|
builder.Services.AddScoped<CatalogService>();
|
||||||
|
|||||||
@@ -42,35 +42,17 @@ public class JobService
|
|||||||
{
|
{
|
||||||
await using var context = _factory.CreateDbContext();
|
await using var context = _factory.CreateDbContext();
|
||||||
job ??= new Job();
|
job ??= new Job();
|
||||||
job.JobNumber = await GenerateJobNumberAsync(context);
|
job.JobNumber = CreateTemporaryJobNumber();
|
||||||
job.CreatedAt = DateTime.UtcNow;
|
job.CreatedAt = DateTime.UtcNow;
|
||||||
context.Jobs.Add(job);
|
context.Jobs.Add(job);
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
job.JobNumber = $"JOB-{job.Id}";
|
||||||
|
await context.SaveChangesAsync();
|
||||||
return job;
|
return job;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string> GenerateJobNumberAsync()
|
private static string CreateTemporaryJobNumber() => $"TMP-{Guid.NewGuid():N}"[..20];
|
||||||
{
|
|
||||||
await using var context = _factory.CreateDbContext();
|
|
||||||
return await GenerateJobNumberAsync(context);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<string> GenerateJobNumberAsync(ApplicationDbContext context)
|
|
||||||
{
|
|
||||||
var maxNumber = await context.Jobs
|
|
||||||
.Where(j => j.JobNumber.StartsWith("JOB-"))
|
|
||||||
.Select(j => j.JobNumber)
|
|
||||||
.MaxAsync() as string;
|
|
||||||
|
|
||||||
if (maxNumber == null)
|
|
||||||
return "JOB-00001";
|
|
||||||
|
|
||||||
var numPart = maxNumber.Substring(4);
|
|
||||||
if (int.TryParse(numPart, out var num))
|
|
||||||
return $"JOB-{num + 1:D5}";
|
|
||||||
|
|
||||||
return $"JOB-{DateTime.UtcNow:yyyyMMddHHmmss}";
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Job> QuickCreateAsync(string? customer = null)
|
public async Task<Job> QuickCreateAsync(string? customer = null)
|
||||||
{
|
{
|
||||||
@@ -142,7 +124,7 @@ public class JobService
|
|||||||
|
|
||||||
var duplicate = new Job
|
var duplicate = new Job
|
||||||
{
|
{
|
||||||
JobNumber = await GenerateJobNumberAsync(context),
|
JobNumber = CreateTemporaryJobNumber(),
|
||||||
Name = string.IsNullOrWhiteSpace(original.Name) ? null : $"{original.Name} (Copy)",
|
Name = string.IsNullOrWhiteSpace(original.Name) ? null : $"{original.Name} (Copy)",
|
||||||
Customer = original.Customer,
|
Customer = original.Customer,
|
||||||
CuttingToolId = original.CuttingToolId,
|
CuttingToolId = original.CuttingToolId,
|
||||||
@@ -153,6 +135,9 @@ public class JobService
|
|||||||
context.Jobs.Add(duplicate);
|
context.Jobs.Add(duplicate);
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
duplicate.JobNumber = $"JOB-{duplicate.Id}";
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
// Copy parts
|
// Copy parts
|
||||||
foreach (var part in original.Parts)
|
foreach (var part in original.Parts)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
using CutList.Web.Data;
|
||||||
|
using CutList.Web.Data.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace CutList.Web.Services;
|
||||||
|
|
||||||
|
public class OverviewService
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<ApplicationDbContext> _factory;
|
||||||
|
|
||||||
|
public OverviewService(IDbContextFactory<ApplicationDbContext> factory)
|
||||||
|
{
|
||||||
|
_factory = factory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<OverviewSnapshot> GetSnapshotAsync()
|
||||||
|
{
|
||||||
|
await using var context = _factory.CreateDbContext();
|
||||||
|
|
||||||
|
var recentJobs = await context.Jobs
|
||||||
|
.AsNoTracking()
|
||||||
|
.OrderByDescending(j => j.CreatedAt)
|
||||||
|
.Take(8)
|
||||||
|
.Select(j => new RecentJobOverview(
|
||||||
|
j.Id,
|
||||||
|
j.JobNumber,
|
||||||
|
j.Name,
|
||||||
|
j.Customer,
|
||||||
|
j.CreatedAt,
|
||||||
|
j.LockedAt != null,
|
||||||
|
j.Parts.Sum(p => (int?)p.Quantity) ?? 0))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var frequentStock = await context.JobStocks
|
||||||
|
.AsNoTracking()
|
||||||
|
.GroupBy(s => new { s.MaterialId, s.LengthInches, s.Material.Shape, s.Material.Size })
|
||||||
|
.Select(g => new
|
||||||
|
{
|
||||||
|
g.Key.MaterialId,
|
||||||
|
g.Key.LengthInches,
|
||||||
|
g.Key.Shape,
|
||||||
|
g.Key.Size,
|
||||||
|
JobCount = g.Select(s => s.JobId).Distinct().Count()
|
||||||
|
})
|
||||||
|
.OrderByDescending(s => s.JobCount)
|
||||||
|
.ThenBy(s => s.Shape)
|
||||||
|
.ThenBy(s => s.Size)
|
||||||
|
.ThenBy(s => s.LengthInches)
|
||||||
|
.Take(5)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var totalJobs = await context.Jobs.CountAsync();
|
||||||
|
var openJobs = await context.Jobs.CountAsync(j => j.LockedAt == null);
|
||||||
|
var totalParts = await context.JobParts.SumAsync(p => (int?)p.Quantity) ?? 0;
|
||||||
|
var activeStockItems = await context.StockItems.CountAsync(s => s.IsActive);
|
||||||
|
|
||||||
|
return new OverviewSnapshot(
|
||||||
|
totalJobs,
|
||||||
|
openJobs,
|
||||||
|
totalParts,
|
||||||
|
activeStockItems,
|
||||||
|
recentJobs,
|
||||||
|
frequentStock.Select(s => new FrequentStockOverview(
|
||||||
|
s.MaterialId,
|
||||||
|
$"{s.Shape.GetDisplayName()} - {s.Size}",
|
||||||
|
s.LengthInches,
|
||||||
|
s.JobCount)).ToList());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record OverviewSnapshot(
|
||||||
|
int TotalJobs,
|
||||||
|
int OpenJobs,
|
||||||
|
int TotalParts,
|
||||||
|
int ActiveStockItems,
|
||||||
|
IReadOnlyList<RecentJobOverview> RecentJobs,
|
||||||
|
IReadOnlyList<FrequentStockOverview> FrequentStock);
|
||||||
|
|
||||||
|
public record RecentJobOverview(
|
||||||
|
int Id,
|
||||||
|
string JobNumber,
|
||||||
|
string? Name,
|
||||||
|
string? Customer,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
bool IsLocked,
|
||||||
|
int PartCount);
|
||||||
|
|
||||||
|
public record FrequentStockOverview(
|
||||||
|
int MaterialId,
|
||||||
|
string MaterialName,
|
||||||
|
decimal LengthInches,
|
||||||
|
int JobCount);
|
||||||
@@ -88,8 +88,8 @@ main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.top-row {
|
.top-row {
|
||||||
background-color: #f3f1ee;
|
background-color: #edf2f5;
|
||||||
border-bottom: 1px solid #e0ddd8;
|
border-bottom: 1px solid #d4dee5;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
height: 3.5rem;
|
height: 3.5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -145,7 +145,7 @@ main {
|
|||||||
width: 0;
|
width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-row, article {
|
article {
|
||||||
padding-left: 2rem !important;
|
padding-left: 2rem !important;
|
||||||
padding-right: 1.5rem !important;
|
padding-right: 1.5rem !important;
|
||||||
}
|
}
|
||||||
@@ -225,3 +225,95 @@ h1 {
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* CutList visual system: industrial blue and steel, not seasonal orange/green. */
|
||||||
|
:root {
|
||||||
|
--ink: #17212b;
|
||||||
|
--muted: #617282;
|
||||||
|
--paper: #f3f6f8;
|
||||||
|
--paper-bright: #ffffff;
|
||||||
|
--line: #d4dee5;
|
||||||
|
--accent: #2b75a8;
|
||||||
|
--accent-dark: #1d5d8d;
|
||||||
|
--navy: #203747;
|
||||||
|
--rail: #18242e;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body { background: var(--paper); color: var(--ink); font-family: "IBM Plex Sans", Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
||||||
|
body { letter-spacing: -.01em; }
|
||||||
|
main { background: var(--paper); box-shadow: none; min-width: 0; }
|
||||||
|
.sidebar { background: var(--rail); border-right: 1px solid #314653; }
|
||||||
|
.content { margin: 0 auto; max-width: 1440px; padding: 2.75rem clamp(1.5rem, 4vw, 4.5rem) 4rem; }
|
||||||
|
.eyebrow { color: var(--muted); font-size: .68rem; font-weight: 800; letter-spacing: .14em; margin: 0; text-transform: uppercase; }
|
||||||
|
h1, h2, h3, h4, h5, h6 { color: var(--ink); font-family: "IBM Plex Sans", Inter, ui-sans-serif, system-ui, sans-serif; font-weight: 650; letter-spacing: -.025em; }
|
||||||
|
h1 { font-size: clamp(1.85rem, 3.2vw, 3.15rem); line-height: 1.04; }
|
||||||
|
h2 { font-size: clamp(1.35rem, 2vw, 1.85rem); line-height: 1.12; }
|
||||||
|
.job-title { font-size: clamp(1.5rem, 2.1vw, 2.1rem); line-height: 1.12; }
|
||||||
|
a, .btn-link { color: var(--accent-dark); }
|
||||||
|
.btn { border-radius: .35rem; font-size: .85rem; font-weight: 750; letter-spacing: .01em; padding: .65rem 1rem; }
|
||||||
|
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||||
|
.btn-primary:hover, .btn-primary:focus { background: var(--accent-dark); border-color: var(--accent-dark); color: #fff; }
|
||||||
|
.btn-outline-primary { border-color: #8299a8; color: var(--accent-dark); }
|
||||||
|
.btn-outline-primary:hover { background: var(--accent-dark); border-color: var(--accent-dark); }
|
||||||
|
.btn-success { background: #267c6c; border-color: #267c6c; }
|
||||||
|
.btn-success:hover { background: #1d6558; border-color: #1d6558; }
|
||||||
|
.form-control, .form-select { background: var(--paper-bright); border-color: #c5d2db; border-radius: .35rem; padding: .62rem .75rem; }
|
||||||
|
.form-control:focus, .form-select:focus { border-color: var(--accent); box-shadow: 0 0 0 .2rem rgba(43,117,168,.14); }
|
||||||
|
.card { background: var(--paper-bright); border: 1px solid var(--line); border-radius: .5rem; box-shadow: none; }
|
||||||
|
.card-header { background: #edf2f5; }
|
||||||
|
.table { --bs-table-striped-bg: rgba(23,33,43,.025); border-color: var(--line); font-size: .9rem; }
|
||||||
|
.table > :not(caption) > * > * { padding: .85rem .75rem; }
|
||||||
|
.table thead th { border-bottom-width: 1px; color: var(--muted); font-size: .68rem; font-weight: 800; letter-spacing: .09em; text-transform: uppercase; }
|
||||||
|
.table a { font-weight: 700; text-decoration-color: rgba(43,117,168,.28); text-underline-offset: .15em; }
|
||||||
|
.table-actions { align-items: center; display: inline-flex; flex-wrap: nowrap; gap: .35rem; white-space: nowrap; }
|
||||||
|
.table-actions .btn { align-items: center; display: inline-flex; justify-content: center; min-height: 2rem; min-width: 2rem; padding: .35rem .45rem; }
|
||||||
|
.alert { border-radius: .4rem; border-width: 1px; }
|
||||||
|
|
||||||
|
/* Results cut badges keep the part number and its length visually distinct. */
|
||||||
|
.cut-part-badge { background-color: #6c8ebf; display: inline-flex; font-weight: 500; overflow: hidden; padding: 0; }
|
||||||
|
.cut-part-badge-name { padding: .35em .55em; }
|
||||||
|
.cut-part-badge-divider { border-left: 1px solid rgba(255, 255, 255, .75); margin: .2em 0; }
|
||||||
|
.cut-part-badge-length { background-color: #4c6680; padding: .35em .55em; }
|
||||||
|
|
||||||
|
/* Overview */
|
||||||
|
.overview-header { align-items: end; border-bottom: 1px solid var(--line); display: flex; gap: 2rem; justify-content: space-between; padding-bottom: 2rem; }
|
||||||
|
.overview-header h1 { margin: .4rem 0 .75rem; max-width: 14ch; }
|
||||||
|
.overview-header > div > p:last-child { color: var(--muted); line-height: 1.55; margin: 0; max-width: 39rem; }
|
||||||
|
.overview-actions { display: flex; flex: 0 0 auto; flex-wrap: wrap; gap: .65rem; }
|
||||||
|
.overview-stats { display: grid; gap: 1px; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 2rem 0; background: var(--line); border: 1px solid var(--line); }
|
||||||
|
.overview-stat { background: var(--paper-bright); color: var(--ink); display: grid; gap: .25rem; padding: 1.4rem 1.5rem; text-decoration: none; }
|
||||||
|
.overview-stat:hover { background: #edf4f8; color: var(--ink); }.overview-stat-label { color: var(--muted); font-size: .68rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; }.overview-stat strong { font-family: "IBM Plex Sans", Inter, ui-sans-serif, system-ui, sans-serif; font-size: 2rem; letter-spacing: -.04em; line-height: 1; }.overview-stat small { color: var(--muted); font-size: .78rem; }
|
||||||
|
.overview-grid { align-items: start; display: grid; gap: 1.5rem; grid-template-columns: minmax(0, 1.25fr) minmax(300px, .75fr); }
|
||||||
|
.overview-recent-jobs { min-width: 0; }
|
||||||
|
.overview-panel { background: var(--paper-bright); border: 1px solid var(--line); }.overview-panel-heading { align-items: start; display: flex; gap: 1rem; justify-content: space-between; padding: 1.5rem 1.5rem 1.2rem; }.overview-panel-heading h2 { font-size: 1.25rem; margin: .3rem 0 0; }.overview-panel-heading > a { font-size: .78rem; font-weight: 750; text-decoration: none; white-space: nowrap; }
|
||||||
|
.overview-job-list { border-top: 1px solid var(--line); }.overview-job-row { align-items: center; border-bottom: 1px solid var(--line); color: var(--ink); display: grid; gap: .3rem 1rem; grid-template-columns: 7.5rem minmax(9rem, 1fr) minmax(8rem, .85fr) 3.75rem 1rem; padding: 1rem 1.5rem; text-decoration: none; }.overview-job-row:last-child { border-bottom: 0; }.overview-job-row:hover { background: #f2f6f8; color: var(--ink); }.overview-job-id { color: var(--accent-dark); font-size: .75rem; font-weight: 800; letter-spacing: .04em; }.overview-job-name { font-weight: 750; }.overview-job-meta, .overview-job-row time { color: var(--muted); font-size: .78rem; }.overview-job-row time { text-align: right; }.overview-row-arrow, .overview-lock { color: var(--muted); font-size: .85rem; text-align: right; }.overview-lock { color: #9c6a20; }
|
||||||
|
.overview-panel-note { color: var(--muted); font-size: .8rem; line-height: 1.5; margin: -.25rem 1.5rem 1rem; }.overview-stock-list { border-top: 1px solid var(--line); list-style: none; margin: 0; padding: 0; }.overview-stock-list li { align-items: center; border-bottom: 1px solid var(--line); display: grid; gap: .85rem; grid-template-columns: 1.7rem minmax(0, 1fr) auto; padding: 1rem 1.5rem; }.overview-stock-list li:last-child { border-bottom: 0; }.overview-stock-rank { color: #8796a1; font-size: .66rem; font-weight: 800; letter-spacing: .08em; }.overview-stock-list strong, .overview-stock-list span { display: block; }.overview-stock-list strong { font-size: .88rem; }.overview-stock-list div > span { color: var(--muted); font-size: .76rem; margin-top: .15rem; }.overview-stock-count { color: var(--accent-dark); font-size: 1.2rem; font-weight: 800; text-align: right; }.overview-stock-count small { color: var(--muted); display: block; font-size: .66rem; font-weight: 700; text-transform: uppercase; }.overview-empty { align-items: center; color: var(--muted); display: flex; flex-direction: column; gap: 1rem; justify-content: center; min-height: 265px; padding: 2rem; text-align: center; }.overview-empty .bi { color: #9baab4; font-size: 1.8rem; }.overview-empty p { line-height: 1.5; margin: 0; max-width: 20rem; }.overview-empty-compact { min-height: 190px; }
|
||||||
|
|
||||||
|
/* Dashboard */
|
||||||
|
.dashboard-hero { background: var(--navy); color: #f5f9fc; display: grid; gap: 2rem; grid-template-columns: minmax(0, 1.2fr) minmax(190px, .8fr); min-height: 310px; overflow: hidden; padding: clamp(2rem, 5vw, 4.5rem); position: relative; }
|
||||||
|
.dashboard-hero h1 { color: #f8fbfd; margin: .55rem 0 1rem; max-width: 8.5ch; }
|
||||||
|
.dashboard-hero .eyebrow { color: #a8c5d7; }
|
||||||
|
.hero-copy { color: #d1e0e9; font-size: 1.05rem; line-height: 1.6; margin: 0; max-width: 33rem; }
|
||||||
|
.hero-actions { display: flex; flex-wrap: wrap; gap: .75rem; margin-top: 2rem; }
|
||||||
|
.btn-quiet { align-items: center; color: #f8fbfd; display: inline-flex; gap: .5rem; padding-left: .5rem; }
|
||||||
|
.btn-quiet:hover { color: #8fc5e4; }
|
||||||
|
.hero-graphic { align-self: stretch; min-height: 180px; position: relative; }
|
||||||
|
.hero-graphic b { bottom: -.35em; color: rgba(255,255,255,.09); font-family: Georgia, serif; font-size: clamp(8rem, 18vw, 15rem); line-height: .7; position: absolute; right: -.1em; }
|
||||||
|
.cut-line { background: var(--accent); display: block; height: 5px; position: absolute; transform: rotate(-28deg); transform-origin: left center; }
|
||||||
|
.line-one { left: 6%; top: 23%; width: 75%; }.line-two { background: #9fc5db; left: 20%; top: 51%; width: 64%; }.line-three { left: 1%; top: 78%; width: 53%; }
|
||||||
|
.dashboard-section { margin-top: 4rem; }
|
||||||
|
.section-heading { align-items: end; display: flex; justify-content: space-between; margin-bottom: 1.5rem; }
|
||||||
|
.section-heading h2 { margin: .35rem 0 0; }.section-note { color: var(--muted); font-size: .82rem; }
|
||||||
|
.action-grid { display: grid; gap: 1rem; grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
|
.action-card { background: var(--paper-bright); border: 1px solid var(--line); color: var(--ink); display: flex; flex-direction: column; min-height: 255px; padding: 1.5rem; position: relative; text-decoration: none; transition: transform .2s ease, box-shadow .2s ease; }
|
||||||
|
.action-card:hover { box-shadow: 5px 5px 0 var(--line); color: var(--ink); transform: translate(-2px, -2px); }
|
||||||
|
.action-card > .bi { color: var(--accent); font-size: 1.55rem; margin-top: 1.35rem; }.action-card h3 { font-size: 1.35rem; margin: 1rem 0 .4rem; }
|
||||||
|
.action-card p { color: var(--muted); font-size: .9rem; line-height: 1.55; margin: 0; }.card-number { color: #8494a1; font-size: .7rem; font-weight: 800; letter-spacing: .12em; }
|
||||||
|
.action-card-featured { background: #e8f0f5; border-color: #bfd0dc; }.card-link { color: var(--accent-dark); font-size: .8rem; font-weight: 800; margin-top: auto; padding-top: 1.25rem; }
|
||||||
|
.workflow-panel { background: #e8f0f5; display: grid; gap: 2rem; grid-template-columns: .75fr 1.25fr; margin-top: 4rem; padding: 2.25rem; }
|
||||||
|
.workflow-panel h2 { margin: .35rem 0 0; max-width: 14rem; }.workflow-list { counter-reset: item; display: grid; gap: .75rem; list-style: none; margin: 0; padding: 0; }
|
||||||
|
.workflow-list li { align-items: center; border-bottom: 1px solid #c3d2dc; display: flex; gap: 1rem; padding: .65rem 0; }.workflow-list li:last-child { border-bottom: 0; }
|
||||||
|
.workflow-list li > span { align-items: center; background: var(--accent); border-radius: 50%; display: inline-flex; font-size: .72rem; font-weight: 800; height: 1.6rem; justify-content: center; width: 1.6rem; }
|
||||||
|
.workflow-list strong, .workflow-list small { display: block; }.workflow-list strong { font-size: .9rem; }.workflow-list small { color: var(--muted); font-size: .78rem; margin-top: .1rem; }
|
||||||
|
@media (max-width: 800px) { .overview-header { align-items: start; flex-direction: column; }.overview-grid { grid-template-columns: 1fr; }.overview-job-row { grid-template-columns: 7rem minmax(0, 1fr) 4rem 1rem; }.overview-job-meta { grid-column: 2 / 4; }.overview-job-row time { grid-column: 3; grid-row: 1; } .dashboard-hero, .workflow-panel { grid-template-columns: 1fr; }.hero-graphic { display: none; }.action-grid { grid-template-columns: 1fr; }.section-heading { align-items: start; flex-direction: column; gap: .5rem; } }
|
||||||
|
@media (max-width: 640.98px) { .content { padding-top: 2rem; }.overview-stats { grid-template-columns: 1fr; }.overview-job-row { grid-template-columns: 1fr auto; padding: 1rem; }.overview-job-id { grid-column: 1; }.overview-job-name { grid-column: 1; }.overview-job-meta { grid-column: 1; }.overview-job-row time { grid-column: 2; grid-row: 1; }.overview-row-arrow, .overview-lock { grid-column: 2; grid-row: 2 / span 2; align-self: center; }.overview-panel-heading { padding: 1.25rem 1rem 1rem; }.overview-panel-note { margin-left: 1rem; margin-right: 1rem; }.overview-stock-list li { padding-left: 1rem; padding-right: 1rem; } }
|
||||||
|
|||||||
@@ -141,14 +141,27 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The selected tool belongs on the paper cut report, not in the interactive results layout. */
|
||||||
|
.print-cut-method {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Print styles - Compact layout to save paper */
|
/* Print styles - Compact layout to save paper */
|
||||||
@media print {
|
@media print {
|
||||||
body {
|
body {
|
||||||
-webkit-print-color-adjust: exact;
|
-webkit-print-color-adjust: economy;
|
||||||
print-color-adjust: exact;
|
print-color-adjust: economy;
|
||||||
font-size: 10pt;
|
font-size: 10pt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
background: transparent !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-screen-only,
|
||||||
.sidebar,
|
.sidebar,
|
||||||
.top-row,
|
.top-row,
|
||||||
.page > main > .top-row,
|
.page > main > .top-row,
|
||||||
@@ -304,8 +317,16 @@
|
|||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Keep purchase list with cut lists to save paper */
|
.print-cut-method {
|
||||||
.print-purchase-list {
|
display: block !important;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
font-size: 9pt;
|
||||||
|
margin: -0.25rem 0 0.5rem;
|
||||||
|
padding: 0.3rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keep material list with cut lists to save paper */
|
||||||
|
.print-material-list {
|
||||||
break-inside: avoid;
|
break-inside: avoid;
|
||||||
page-break-inside: avoid;
|
page-break-inside: avoid;
|
||||||
}
|
}
|
||||||
@@ -329,6 +350,15 @@
|
|||||||
border: 1px solid #999;
|
border: 1px solid #999;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Badges lose their blue fills in print, so force their white screen text and divider to black. */
|
||||||
|
.cut-part-badge {
|
||||||
|
color: #000 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cut-part-badge-divider {
|
||||||
|
border-left: 1px solid #000 !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Cut list tables: hide screen header, show repeating print header in thead */
|
/* Cut list tables: hide screen header, show repeating print header in thead */
|
||||||
.cutlist-material-screen-header {
|
.cutlist-material-screen-header {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
@@ -365,6 +395,12 @@
|
|||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Keep each stock-bar result, its cuts, and its waste on one printed page. */
|
||||||
|
.cutlist-material-card tbody tr {
|
||||||
|
break-inside: avoid;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
/* Reduce spacing */
|
/* Reduce spacing */
|
||||||
.mb-4 {
|
.mb-4 {
|
||||||
margin-bottom: 0.5rem !important;
|
margin-bottom: 0.5rem !important;
|
||||||
|
|||||||
+14
@@ -11,6 +11,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CutList.Mcp", "CutList.Mcp\
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CutList.Web", "CutList.Web\CutList.Web.csproj", "{E3B33DE6-803C-4557-BF40-D8A5DB154144}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CutList.Web", "CutList.Web\CutList.Web.csproj", "{E3B33DE6-803C-4557-BF40-D8A5DB154144}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CutList.Core.Tests", "CutList.Core.Tests\CutList.Core.Tests.csproj", "{95E386F9-C906-423D-9869-BB1D2EA755E5}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@@ -69,6 +71,18 @@ Global
|
|||||||
{E3B33DE6-803C-4557-BF40-D8A5DB154144}.Release|x64.Build.0 = Release|Any CPU
|
{E3B33DE6-803C-4557-BF40-D8A5DB154144}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{E3B33DE6-803C-4557-BF40-D8A5DB154144}.Release|x86.ActiveCfg = Release|Any CPU
|
{E3B33DE6-803C-4557-BF40-D8A5DB154144}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{E3B33DE6-803C-4557-BF40-D8A5DB154144}.Release|x86.Build.0 = Release|Any CPU
|
{E3B33DE6-803C-4557-BF40-D8A5DB154144}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Release|x86.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
# Alro Steel SmartGrid Scraper — Remaining Steps
|
|
||||||
|
|
||||||
## Status: Script is READY TO RUN
|
|
||||||
|
|
||||||
The scraper at `scripts/AlroCatalog/scrape_alro.py` is complete and tested. Discovery mode confirmed it works correctly against the live site.
|
|
||||||
|
|
||||||
## What's Done
|
|
||||||
1. Script written with correct ASP.NET control IDs (discovered via `--discover` mode)
|
|
||||||
2. Level 1 (main grid) navigation: working
|
|
||||||
3. Level 2 (popup grid) navigation: working
|
|
||||||
4. Level 3 (dims panel) scraping: working — uses cascading dropdowns `ddlDimA` → `ddlDimB` → `ddlDimC` → `ddlLength`
|
|
||||||
5. Grade filter: 11 common grades (A-36, 1018, 1045, 1144, 12L14, etc.)
|
|
||||||
6. Size string normalization: "1-1/2\"" matches O'Neal format
|
|
||||||
7. Progress save/resume: working
|
|
||||||
8. Discovery mode verified: A-36 Round bars → 27 sizes, 80 items (lengths include "20 FT", "Custom Cut List", "Drop/Remnant" — non-stock entries filtered out in catalog builder)
|
|
||||||
|
|
||||||
## Remaining Steps
|
|
||||||
|
|
||||||
### Step 1: Run the full scrape
|
|
||||||
```bash
|
|
||||||
cd C:\Users\aisaacs\Desktop\Projects\CutList
|
|
||||||
python scripts/AlroCatalog/scrape_alro.py
|
|
||||||
```
|
|
||||||
- This scrapes all 3 categories (Bars, Pipe/Tube, Structural) for 11 filtered grades
|
|
||||||
- Takes ~30-60 minutes (cascading dropdown selections with 1.5s delay each)
|
|
||||||
- Progress saved incrementally to `scripts/AlroCatalog/alro-scrape-progress.json`
|
|
||||||
- If interrupted, resume with `python scripts/AlroCatalog/scrape_alro.py --resume`
|
|
||||||
- To scrape ALL grades: `python scripts/AlroCatalog/scrape_alro.py --all-grades`
|
|
||||||
|
|
||||||
### Step 2: Review output
|
|
||||||
- Output: `CutList.Web/Data/SeedData/alro-catalog.json`
|
|
||||||
- Verify material counts, shapes, sizes
|
|
||||||
- Spot-check dimensions against myalro.com
|
|
||||||
- Compare shape coverage to O'Neal catalog
|
|
||||||
|
|
||||||
### Step 3: Post-scrape adjustments (if needed)
|
|
||||||
|
|
||||||
**Dimension mapping for Structural/Pipe shapes**: The `build_size_and_dims()` function handles all shapes but Structural (Angle, Channel, Beam) and Pipe/Tube shapes haven't been tested live yet. After scraping, check the screenshots in `scripts/AlroCatalog/screenshots/` to verify dimension mapping. The first item of each new shape gets a screenshot + HTML dump.
|
|
||||||
|
|
||||||
**Known dimension mapping assumptions:**
|
|
||||||
- Angle: DimA = leg size, DimB = thickness → `"leg1 x leg2 x thickness"` (assumes equal legs)
|
|
||||||
- Channel: DimA = height, DimB = flange → needs verification
|
|
||||||
- IBeam: DimA = depth, DimB = weight/ft → `"W{depth} x {wt}"`
|
|
||||||
- SquareTube: DimA = size, DimB = wall
|
|
||||||
- RectTube: DimA = width, DimB = height, DimC = wall
|
|
||||||
- RoundTube: DimA = OD, DimB = wall
|
|
||||||
- Pipe: DimA = NPS, DimB = schedule
|
|
||||||
|
|
||||||
**If dimension mapping is wrong for a shape**: Edit the `build_size_and_dims()` function in `scrape_alro.py` and re-run just the catalog builder:
|
|
||||||
```python
|
|
||||||
python -c "
|
|
||||||
import json
|
|
||||||
from scripts.AlroCatalog.scrape_alro import build_catalog
|
|
||||||
data = json.load(open('scripts/AlroCatalog/alro-scrape-progress.json'))
|
|
||||||
catalog = build_catalog(data['items'])
|
|
||||||
json.dump(catalog, open('CutList.Web/Data/SeedData/alro-catalog.json', 'w'), indent=2)
|
|
||||||
"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 4: Part numbers (optional future enhancement)
|
|
||||||
The current scraper captures sizes and lengths but NOT part numbers. To get part numbers, the script would need to:
|
|
||||||
1. Select DimA + DimB + Length
|
|
||||||
2. Click the "Next >" button (`btnSearch`)
|
|
||||||
3. Capture part number from the results panel
|
|
||||||
4. Click Back
|
|
||||||
|
|
||||||
This adds significant time per item. The catalog works without part numbers — the supplierOfferings have empty partNumber/supplierDescription fields.
|
|
||||||
|
|
||||||
## Key Files
|
|
||||||
| File | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `scripts/AlroCatalog/scrape_alro.py` | The scraper script |
|
|
||||||
| `scripts/AlroCatalog/alro-scrape-progress.json` | Incremental progress (resume support) |
|
|
||||||
| `scripts/AlroCatalog/screenshots/` | Discovery HTML/screenshots per shape |
|
|
||||||
| `CutList.Web/Data/SeedData/alro-catalog.json` | Final output (same schema as oneals-catalog.json) |
|
|
||||||
| `CutList.Web/Data/SeedData/oneals-catalog.json` | Reference format |
|
|
||||||
|
|
||||||
## Grade Filter (editable in script)
|
|
||||||
Located at line ~50 in `scrape_alro.py`. Current filter:
|
|
||||||
- A-36, 1018 CF, 1018 HR, 1044 HR, 1045 CF, 1045 HR, 1045 TG&P
|
|
||||||
- 1144 CF, 1144 HR, 12L14 CF, A311/Stressproof
|
|
||||||
|
|
||||||
To add/remove grades, edit the `GRADE_FILTER` set in the script.
|
|
||||||
@@ -1,976 +0,0 @@
|
|||||||
{
|
|
||||||
"completed": [
|
|
||||||
[
|
|
||||||
"Bars",
|
|
||||||
"A-36",
|
|
||||||
"ROUND"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"Bars",
|
|
||||||
"A-36",
|
|
||||||
"FLAT"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".188",
|
|
||||||
"dim_a_text": "3/16",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Custom Cut List",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".188",
|
|
||||||
"dim_a_text": "3/16",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "20 FT",
|
|
||||||
"length_inches": 240.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".250",
|
|
||||||
"dim_a_text": "1/4",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Custom Cut List",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".250",
|
|
||||||
"dim_a_text": "1/4",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Drop/Remnant",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".250",
|
|
||||||
"dim_a_text": "1/4",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "20 FT",
|
|
||||||
"length_inches": 240.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".313",
|
|
||||||
"dim_a_text": "5/16",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Custom Cut List",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".313",
|
|
||||||
"dim_a_text": "5/16",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Drop/Remnant",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".313",
|
|
||||||
"dim_a_text": "5/16",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "20 FT",
|
|
||||||
"length_inches": 240.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".375",
|
|
||||||
"dim_a_text": "3/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Custom Cut List",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".375",
|
|
||||||
"dim_a_text": "3/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Drop/Remnant",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".375",
|
|
||||||
"dim_a_text": "3/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "20 FT",
|
|
||||||
"length_inches": 240.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".438",
|
|
||||||
"dim_a_text": "7/16",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Custom Cut List",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".438",
|
|
||||||
"dim_a_text": "7/16",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Drop/Remnant",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".438",
|
|
||||||
"dim_a_text": "7/16",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "20 FT",
|
|
||||||
"length_inches": 240.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".500",
|
|
||||||
"dim_a_text": "1/2",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Custom Cut List",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".500",
|
|
||||||
"dim_a_text": "1/2",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Drop/Remnant",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".500",
|
|
||||||
"dim_a_text": "1/2",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "20 FT",
|
|
||||||
"length_inches": 240.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".563",
|
|
||||||
"dim_a_text": "9/16",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Custom Cut List",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".563",
|
|
||||||
"dim_a_text": "9/16",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Drop/Remnant",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".563",
|
|
||||||
"dim_a_text": "9/16",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "20 FT",
|
|
||||||
"length_inches": 240.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".625",
|
|
||||||
"dim_a_text": "5/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Custom Cut List",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".625",
|
|
||||||
"dim_a_text": "5/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Drop/Remnant",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".625",
|
|
||||||
"dim_a_text": "5/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "20 FT",
|
|
||||||
"length_inches": 240.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".750",
|
|
||||||
"dim_a_text": "3/4",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Custom Cut List",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".750",
|
|
||||||
"dim_a_text": "3/4",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Drop/Remnant",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".750",
|
|
||||||
"dim_a_text": "3/4",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "20 FT",
|
|
||||||
"length_inches": 240.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".875",
|
|
||||||
"dim_a_text": "7/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Custom Cut List",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".875",
|
|
||||||
"dim_a_text": "7/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Drop/Remnant",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".875",
|
|
||||||
"dim_a_text": "7/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "20 FT",
|
|
||||||
"length_inches": 240.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "1.000",
|
|
||||||
"dim_a_text": "1",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Custom Cut List",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "1.000",
|
|
||||||
"dim_a_text": "1",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Drop/Remnant",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "1.000",
|
|
||||||
"dim_a_text": "1",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "20 FT",
|
|
||||||
"length_inches": 240.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "1.125",
|
|
||||||
"dim_a_text": "1 1/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Custom Cut List",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "1.125",
|
|
||||||
"dim_a_text": "1 1/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Drop/Remnant",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "1.125",
|
|
||||||
"dim_a_text": "1 1/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "20 FT",
|
|
||||||
"length_inches": 240.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "1.250",
|
|
||||||
"dim_a_text": "1 1/4",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Custom Cut List",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "1.250",
|
|
||||||
"dim_a_text": "1 1/4",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Drop/Remnant",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "1.250",
|
|
||||||
"dim_a_text": "1 1/4",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "20 FT",
|
|
||||||
"length_inches": 240.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "1.375",
|
|
||||||
"dim_a_text": "1 3/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Custom Cut List",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "1.375",
|
|
||||||
"dim_a_text": "1 3/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Drop/Remnant",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "1.375",
|
|
||||||
"dim_a_text": "1 3/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "20 FT",
|
|
||||||
"length_inches": 240.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "1.500",
|
|
||||||
"dim_a_text": "1 1/2",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Custom Cut List",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "1.500",
|
|
||||||
"dim_a_text": "1 1/2",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Drop/Remnant",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "1.500",
|
|
||||||
"dim_a_text": "1 1/2",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "20 FT",
|
|
||||||
"length_inches": 240.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "1.625",
|
|
||||||
"dim_a_text": "1 5/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Custom Cut List",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "1.625",
|
|
||||||
"dim_a_text": "1 5/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Drop/Remnant",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "1.625",
|
|
||||||
"dim_a_text": "1 5/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "20 FT",
|
|
||||||
"length_inches": 240.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "1.750",
|
|
||||||
"dim_a_text": "1 3/4",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Custom Cut List",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "1.750",
|
|
||||||
"dim_a_text": "1 3/4",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Drop/Remnant",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "1.750",
|
|
||||||
"dim_a_text": "1 3/4",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "20 FT",
|
|
||||||
"length_inches": 240.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "1.875",
|
|
||||||
"dim_a_text": "1 7/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Custom Cut List",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "1.875",
|
|
||||||
"dim_a_text": "1 7/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Drop/Remnant",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "1.875",
|
|
||||||
"dim_a_text": "1 7/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "20 FT",
|
|
||||||
"length_inches": 240.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "2.000",
|
|
||||||
"dim_a_text": "2",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Custom Cut List",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "2.000",
|
|
||||||
"dim_a_text": "2",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Drop/Remnant",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "2.000",
|
|
||||||
"dim_a_text": "2",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "20 FT",
|
|
||||||
"length_inches": 240.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "2.125",
|
|
||||||
"dim_a_text": "2 1/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Custom Cut List",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "2.125",
|
|
||||||
"dim_a_text": "2 1/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Drop/Remnant",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "2.125",
|
|
||||||
"dim_a_text": "2 1/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "20 FT",
|
|
||||||
"length_inches": 240.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "2.250",
|
|
||||||
"dim_a_text": "2 1/4",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Custom Cut List",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "2.250",
|
|
||||||
"dim_a_text": "2 1/4",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Drop/Remnant",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "2.250",
|
|
||||||
"dim_a_text": "2 1/4",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "20 FT",
|
|
||||||
"length_inches": 240.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "2.375",
|
|
||||||
"dim_a_text": "2 3/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Custom Cut List",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "2.375",
|
|
||||||
"dim_a_text": "2 3/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Drop/Remnant",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "2.375",
|
|
||||||
"dim_a_text": "2 3/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "20 FT",
|
|
||||||
"length_inches": 240.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "2.500",
|
|
||||||
"dim_a_text": "2 1/2",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Custom Cut List",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "2.500",
|
|
||||||
"dim_a_text": "2 1/2",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Drop/Remnant",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "2.500",
|
|
||||||
"dim_a_text": "2 1/2",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "20 FT",
|
|
||||||
"length_inches": 240.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "2.625",
|
|
||||||
"dim_a_text": "2 5/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Custom Cut List",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "2.625",
|
|
||||||
"dim_a_text": "2 5/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Drop/Remnant",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "2.625",
|
|
||||||
"dim_a_text": "2 5/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "20 FT",
|
|
||||||
"length_inches": 240.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "2.750",
|
|
||||||
"dim_a_text": "2 3/4",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Custom Cut List",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "2.750",
|
|
||||||
"dim_a_text": "2 3/4",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Drop/Remnant",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "2.750",
|
|
||||||
"dim_a_text": "2 3/4",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "20 FT",
|
|
||||||
"length_inches": 240.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "2.875",
|
|
||||||
"dim_a_text": "2 7/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Custom Cut List",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "2.875",
|
|
||||||
"dim_a_text": "2 7/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Drop/Remnant",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "2.875",
|
|
||||||
"dim_a_text": "2 7/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "20 FT",
|
|
||||||
"length_inches": 240.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "3.000",
|
|
||||||
"dim_a_text": "3",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Custom Cut List",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "3.000",
|
|
||||||
"dim_a_text": "3",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Drop/Remnant",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "3.000",
|
|
||||||
"dim_a_text": "3",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "20 FT",
|
|
||||||
"length_inches": 240.0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,790 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Alro Steel SmartGrid Scraper
|
|
||||||
Scrapes myalro.com's SmartGrid for Carbon Steel materials and outputs
|
|
||||||
a catalog JSON matching the O'Neal catalog format.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python scrape_alro.py # Scrape filtered grades (resumes from saved progress)
|
|
||||||
python scrape_alro.py --all-grades # Scrape ALL grades (slow)
|
|
||||||
python scrape_alro.py --discover # Scrape first item only, dump HTML/screenshots
|
|
||||||
python scrape_alro.py --fresh # Start fresh, ignoring saved progress
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
import logging
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
from playwright.async_api import async_playwright, Page, TimeoutError as PwTimeout
|
|
||||||
from playwright_stealth import Stealth
|
|
||||||
|
|
||||||
# ── Logging ──────────────────────────────────────────────────────────
|
|
||||||
logging.basicConfig(
|
|
||||||
level=logging.INFO,
|
|
||||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
|
||||||
datefmt="%H:%M:%S",
|
|
||||||
)
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# ── Paths ────────────────────────────────────────────────────────────
|
|
||||||
SCRIPT_DIR = Path(__file__).parent.resolve()
|
|
||||||
OUTPUT_PATH = (SCRIPT_DIR / "../../CutList.Web/Data/SeedData/alro-catalog.json").resolve()
|
|
||||||
PROGRESS_PATH = SCRIPT_DIR / "alro-scrape-progress.json"
|
|
||||||
SCREENSHOTS_DIR = SCRIPT_DIR / "screenshots"
|
|
||||||
|
|
||||||
# ── Config ───────────────────────────────────────────────────────────
|
|
||||||
BASE_URL = "https://www.myalro.com/SmartGrid.aspx?PT=Steel&Clear=true"
|
|
||||||
DELAY = 5 # seconds between postback clicks
|
|
||||||
TIMEOUT = 15_000 # ms for element waits
|
|
||||||
CS_ROW = 4 # Carbon Steel row index in main grid
|
|
||||||
|
|
||||||
CATEGORIES = ["Bars", "Pipe / Tube", "Structural"]
|
|
||||||
|
|
||||||
# ┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
# │ GRADE FILTER — only these grades will be scraped. │
|
|
||||||
# │ Use --all-grades flag to override and scrape everything. │
|
|
||||||
# │ Grade names must match the gpname attribute exactly. │
|
|
||||||
# └─────────────────────────────────────────────────────────────────┘
|
|
||||||
GRADE_FILTER = {
|
|
||||||
# Common structural / general purpose
|
|
||||||
"A-36",
|
|
||||||
# Mild steel
|
|
||||||
"1018 CF",
|
|
||||||
"1018 HR",
|
|
||||||
# Medium carbon (shafts, gears, pins)
|
|
||||||
"1045 CF",
|
|
||||||
"1045 HR",
|
|
||||||
"1045 TG&P",
|
|
||||||
# Free-machining
|
|
||||||
"1144 CF",
|
|
||||||
"1144 HR",
|
|
||||||
"12L14 CF",
|
|
||||||
# Hot-rolled plate/bar
|
|
||||||
"1044 HR",
|
|
||||||
# Stressproof (high-strength shafting)
|
|
||||||
"A311/Stressproof",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Alro shape column header → our MaterialShape enum
|
|
||||||
SHAPE_MAP = {
|
|
||||||
"ROUND": "RoundBar",
|
|
||||||
"FLAT": "FlatBar",
|
|
||||||
"SQUARE": "SquareBar",
|
|
||||||
"ANGLE": "Angle",
|
|
||||||
"CHANNEL": "Channel",
|
|
||||||
"BEAM": "IBeam",
|
|
||||||
"SQ TUBE": "SquareTube",
|
|
||||||
"SQUARE TUBE": "SquareTube",
|
|
||||||
"REC TUBE": "RectangularTube",
|
|
||||||
"RECT TUBE": "RectangularTube",
|
|
||||||
"RECTANGULAR TUBE": "RectangularTube",
|
|
||||||
"ROUND TUBE": "RoundTube",
|
|
||||||
"RND TUBE": "RoundTube",
|
|
||||||
"PIPE": "Pipe",
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── ASP.NET control IDs ─────────────────────────────────────────
|
|
||||||
_CP = "ctl00_ContentPlaceHolder1"
|
|
||||||
_PU = f"{_CP}_pnlPopUP"
|
|
||||||
ID = dict(
|
|
||||||
main_grid = f"{_CP}_grdMain",
|
|
||||||
popup_grid = f"{_PU}_grdPopUp",
|
|
||||||
popup_window = f"{_PU}_Window",
|
|
||||||
dims_panel = f"{_PU}_upnlDims",
|
|
||||||
back_btn = f"{_PU}_btnBack",
|
|
||||||
# Dimension dropdowns (cascading: A → B → C → Length)
|
|
||||||
dim_a = f"{_PU}_ddlDimA",
|
|
||||||
dim_b = f"{_PU}_ddlDimB",
|
|
||||||
dim_c = f"{_PU}_ddlDimC",
|
|
||||||
dim_length = f"{_PU}_ddlLength",
|
|
||||||
btn_next = f"{_PU}_btnSearch",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Postback targets ($ separators)
|
|
||||||
PB = dict(
|
|
||||||
main_grid = "ctl00$ContentPlaceHolder1$grdMain",
|
|
||||||
popup_grid = "ctl00$ContentPlaceHolder1$pnlPopUP$grdPopUp",
|
|
||||||
back_btn = "ctl00$ContentPlaceHolder1$pnlPopUP$btnBack",
|
|
||||||
popup = "ctl00$ContentPlaceHolder1$pnlPopUP",
|
|
||||||
dim_a = "ctl00$ContentPlaceHolder1$pnlPopUP$ddlDimA",
|
|
||||||
dim_b = "ctl00$ContentPlaceHolder1$pnlPopUP$ddlDimB",
|
|
||||||
dim_c = "ctl00$ContentPlaceHolder1$pnlPopUP$ddlDimC",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════
|
|
||||||
# Utility helpers
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
def parse_fraction(s: str) -> float | None:
|
|
||||||
"""Parse fraction/decimal string → float. '1-1/4' → 1.25, '.250' → 0.25"""
|
|
||||||
if not s:
|
|
||||||
return None
|
|
||||||
s = s.strip().strip('"\'')
|
|
||||||
# Collapse double spaces from Alro dropdown text ("1 1/4" → "1 1/4")
|
|
||||||
s = re.sub(r"\s+", " ", s)
|
|
||||||
if not s:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return float(s)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
# Mixed fraction: "1-1/4" or "1 1/4"
|
|
||||||
m = re.match(r"^(\d+)[\s-](\d+)/(\d+)$", s)
|
|
||||||
if m:
|
|
||||||
return int(m[1]) + int(m[2]) / int(m[3])
|
|
||||||
m = re.match(r"^(\d+)/(\d+)$", s)
|
|
||||||
if m:
|
|
||||||
return int(m[1]) / int(m[2])
|
|
||||||
m = re.match(r"^(\d+)$", s)
|
|
||||||
if m:
|
|
||||||
return float(m[1])
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def decimal_to_fraction(value: float) -> str:
|
|
||||||
"""0.25 → '1/4', 1.25 → '1-1/4', 3.0 → '3'"""
|
|
||||||
if value <= 0:
|
|
||||||
return "0"
|
|
||||||
whole = int(value)
|
|
||||||
frac = value - whole
|
|
||||||
if abs(frac) < 0.001:
|
|
||||||
return str(whole)
|
|
||||||
from math import gcd
|
|
||||||
sixteenths = round(frac * 16)
|
|
||||||
if sixteenths == 16:
|
|
||||||
return str(whole + 1)
|
|
||||||
g = gcd(sixteenths, 16)
|
|
||||||
num, den = sixteenths // g, 16 // g
|
|
||||||
frac_s = f"{num}/{den}"
|
|
||||||
return f"{whole}-{frac_s}" if whole else frac_s
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_dim_text(s: str) -> str:
|
|
||||||
"""Normalize dimension text: '1 1/4' → '1-1/4', '3/16' → '3/16'"""
|
|
||||||
s = re.sub(r"\s+", " ", s.strip())
|
|
||||||
# "1 1/4" → "1-1/4" (mixed fraction with space → hyphen)
|
|
||||||
s = re.sub(r"^(\d+)\s+(\d+/\d+)$", r"\1-\2", s)
|
|
||||||
return s
|
|
||||||
|
|
||||||
|
|
||||||
def parse_length_to_inches(text: str) -> float | None:
|
|
||||||
"""Parse length string to inches. \"20'\" → 240, \"240\" → 240"""
|
|
||||||
s = text.strip().upper()
|
|
||||||
s = re.sub(r"\s*(RL|RANDOM.*|LENGTHS?|EA|EACH|STOCK)\s*", "", s).strip()
|
|
||||||
m = re.match(r"^(\d+(?:\.\d+)?)\s*['\u2032]", s)
|
|
||||||
if m:
|
|
||||||
return float(m[1]) * 12
|
|
||||||
m = re.match(r"^(\d+(?:\.\d+)?)\s*FT", s)
|
|
||||||
if m:
|
|
||||||
return float(m[1]) * 12
|
|
||||||
m = re.match(r'^(\d+(?:\.\d+)?)\s*"?\s*$', s)
|
|
||||||
if m:
|
|
||||||
v = float(m[1])
|
|
||||||
return v * 12 if v <= 30 else v
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════
|
|
||||||
# SmartGrid navigation
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
async def wait_for_update(page: Page, timeout: int = TIMEOUT):
|
|
||||||
"""Wait for ASP.NET partial postback to finish."""
|
|
||||||
try:
|
|
||||||
await page.wait_for_load_state("networkidle", timeout=timeout)
|
|
||||||
except PwTimeout:
|
|
||||||
log.warning(" networkidle timeout – continuing")
|
|
||||||
await asyncio.sleep(0.5)
|
|
||||||
|
|
||||||
|
|
||||||
async def do_postback(page: Page, target: str, arg: str):
|
|
||||||
"""Execute a __doPostBack call."""
|
|
||||||
await page.evaluate(f"__doPostBack('{target}', '{arg}')")
|
|
||||||
|
|
||||||
|
|
||||||
async def click_category(page: Page, category: str) -> bool:
|
|
||||||
"""Click a category blue-button for Carbon Steel in the main grid."""
|
|
||||||
log.info(f"Clicking main grid: {category} (row {CS_ROW})")
|
|
||||||
arg = f"{category}${CS_ROW}"
|
|
||||||
link = await page.query_selector(
|
|
||||||
f"#{ID['main_grid']} a[href*=\"'{arg}'\"] img[src*='blue_button']"
|
|
||||||
)
|
|
||||||
if not link:
|
|
||||||
log.error(f" Button not found for {arg}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
parent = await link.evaluate_handle("el => el.parentElement")
|
|
||||||
await parent.as_element().click()
|
|
||||||
|
|
||||||
try:
|
|
||||||
await page.wait_for_selector(f"#{ID['popup_grid']}", state="visible", timeout=TIMEOUT)
|
|
||||||
await wait_for_update(page)
|
|
||||||
return True
|
|
||||||
except PwTimeout:
|
|
||||||
log.error(f" Popup did not appear for {category}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def scrape_popup_grid(page: Page):
|
|
||||||
"""Parse the popup grid → [(grade_name, grade_id, shape, row_idx, has_btn)]."""
|
|
||||||
headers = await page.eval_on_selector_all(
|
|
||||||
f"#{ID['popup_grid']} tr.DataHeader th",
|
|
||||||
"els => els.map(el => el.textContent.trim())",
|
|
||||||
)
|
|
||||||
log.info(f" Popup columns: {headers}")
|
|
||||||
|
|
||||||
rows = await page.query_selector_all(
|
|
||||||
f"#{ID['popup_grid']} tr.griditemP, #{ID['popup_grid']} tr.gridaltItemP"
|
|
||||||
)
|
|
||||||
combos = []
|
|
||||||
for row_idx, row in enumerate(rows):
|
|
||||||
first_td = await row.query_selector("td[gpid]")
|
|
||||||
if not first_td:
|
|
||||||
continue
|
|
||||||
gid = (await first_td.get_attribute("gpid") or "").strip()
|
|
||||||
gname = (await first_td.get_attribute("gpname") or "").strip()
|
|
||||||
tds = await row.query_selector_all("td")
|
|
||||||
for col_idx, td in enumerate(tds):
|
|
||||||
if col_idx == 0:
|
|
||||||
continue
|
|
||||||
shape = headers[col_idx] if col_idx < len(headers) else ""
|
|
||||||
img = await td.query_selector("img[src*='blue_button']")
|
|
||||||
combos.append((gname, gid, shape, row_idx, img is not None))
|
|
||||||
|
|
||||||
active = sum(1 for c in combos if c[4])
|
|
||||||
log.info(f" {active} active grade/shape combos")
|
|
||||||
return combos
|
|
||||||
|
|
||||||
|
|
||||||
async def click_shape(page: Page, shape: str, row_idx: int) -> bool:
|
|
||||||
"""Click a shape button in the popup grid; wait for dims panel."""
|
|
||||||
arg = f"{shape}${row_idx}"
|
|
||||||
link = await page.query_selector(
|
|
||||||
f"#{ID['popup_grid']} a[href*=\"'{arg}'\"] img[src*='blue_button']"
|
|
||||||
)
|
|
||||||
if not link:
|
|
||||||
try:
|
|
||||||
await do_postback(page, PB["popup_grid"], arg)
|
|
||||||
except Exception:
|
|
||||||
log.warning(f" Could not click shape {arg}")
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
parent = await link.evaluate_handle("el => el.parentElement")
|
|
||||||
await parent.as_element().click()
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Wait for the DimA dropdown to appear (the real indicator of dims panel loaded)
|
|
||||||
await page.wait_for_selector(f"#{ID['dim_a']}", state="attached", timeout=TIMEOUT)
|
|
||||||
await wait_for_update(page)
|
|
||||||
return True
|
|
||||||
except PwTimeout:
|
|
||||||
# Check if panel has any content at all
|
|
||||||
html = await page.inner_html(f"#{ID['dims_panel']}")
|
|
||||||
if len(html.strip()) > 50:
|
|
||||||
await wait_for_update(page)
|
|
||||||
return True
|
|
||||||
log.warning(f" Dims panel timeout for {arg}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def click_back(page: Page):
|
|
||||||
"""Click Back to return to the popup grid view."""
|
|
||||||
try:
|
|
||||||
await do_postback(page, PB["back_btn"], "")
|
|
||||||
await wait_for_update(page)
|
|
||||||
await asyncio.sleep(DELAY)
|
|
||||||
except Exception as e:
|
|
||||||
log.warning(f" Back button error: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
async def close_popup(page: Page):
|
|
||||||
"""Close the popup window and return to the main grid."""
|
|
||||||
try:
|
|
||||||
await do_postback(page, PB["popup"], "Close")
|
|
||||||
await wait_for_update(page)
|
|
||||||
await asyncio.sleep(DELAY)
|
|
||||||
except Exception as e:
|
|
||||||
log.warning(f" Close popup error: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════
|
|
||||||
# Level 3 — Dimension Panel Scraping
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
async def get_select_options(page: Page, sel_id: str):
|
|
||||||
"""Return [(value, text), ...] for a <select>, excluding placeholders."""
|
|
||||||
el = await page.query_selector(f"#{sel_id}")
|
|
||||||
if not el:
|
|
||||||
return []
|
|
||||||
# Check if disabled
|
|
||||||
disabled = await el.get_attribute("disabled")
|
|
||||||
if disabled:
|
|
||||||
return []
|
|
||||||
try:
|
|
||||||
opts = await page.eval_on_selector(
|
|
||||||
f"#{sel_id}",
|
|
||||||
"""el => Array.from(el.options).map(o => ({
|
|
||||||
v: o.value, t: o.text.trim(), d: o.disabled
|
|
||||||
}))""",
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
return []
|
|
||||||
return [
|
|
||||||
(o["v"], o["t"])
|
|
||||||
for o in opts
|
|
||||||
if o["v"] and o["v"] != "-1" and o["t"] and not o["d"]
|
|
||||||
and o["t"].lower() not in ("- select -", "--select--", "select...", "select", "")
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
async def scrape_dims_panel(page: Page, grade: str, shape_alro: str,
|
|
||||||
shape_mapped: str, *, save_discovery: bool = False,
|
|
||||||
on_item=None, scraped_dim_a: set[str] | None = None):
|
|
||||||
"""Main Level 3 extraction. Returns list of raw item dicts.
|
|
||||||
|
|
||||||
If on_item callback is provided, it is called with each item dict
|
|
||||||
as soon as it is discovered (for incremental saving).
|
|
||||||
If scraped_dim_a is provided, DimA values in that set are skipped (resume).
|
|
||||||
"""
|
|
||||||
items: list[dict] = []
|
|
||||||
|
|
||||||
if save_discovery:
|
|
||||||
SCREENSHOTS_DIR.mkdir(exist_ok=True)
|
|
||||||
safe = f"{grade}_{shape_alro}".replace(" ", "_").replace("/", "-")
|
|
||||||
await page.screenshot(path=str(SCREENSHOTS_DIR / f"dims_{safe}.png"), full_page=True)
|
|
||||||
html = await page.inner_html(f"#{ID['dims_panel']}")
|
|
||||||
(SCREENSHOTS_DIR / f"dims_{safe}.html").write_text(html, encoding="utf-8")
|
|
||||||
log.info(f" Discovery saved → screenshots/dims_{safe}.*")
|
|
||||||
|
|
||||||
# ── Get DimA options (primary dimension: diameter, width, size, etc.) ──
|
|
||||||
dim_a_opts = await get_select_options(page, ID["dim_a"])
|
|
||||||
if not dim_a_opts:
|
|
||||||
log.warning(f" No DimA options found")
|
|
||||||
try:
|
|
||||||
html = await page.inner_html(f"#{ID['dims_panel']}")
|
|
||||||
if len(html) > 50:
|
|
||||||
SCREENSHOTS_DIR.mkdir(exist_ok=True)
|
|
||||||
safe = f"{grade}_{shape_alro}_nodimopts".replace(" ", "_").replace("/", "-")
|
|
||||||
(SCREENSHOTS_DIR / f"{safe}.html").write_text(html, encoding="utf-8")
|
|
||||||
except Exception as e:
|
|
||||||
log.warning(f" Could not dump dims panel: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
already_done = scraped_dim_a or set()
|
|
||||||
remaining = [(v, t) for v, t in dim_a_opts if v not in already_done]
|
|
||||||
if already_done:
|
|
||||||
log.info(f" DimA: {len(dim_a_opts)} sizes ({len(dim_a_opts) - len(remaining)} already scraped, {len(remaining)} remaining)")
|
|
||||||
else:
|
|
||||||
log.info(f" DimA: {len(dim_a_opts)} sizes")
|
|
||||||
|
|
||||||
# All DimA values already scraped — combo is complete
|
|
||||||
if not remaining:
|
|
||||||
return []
|
|
||||||
|
|
||||||
for a_val, a_text in remaining:
|
|
||||||
# Select DimA → triggers postback → DimB/Length populate
|
|
||||||
await page.select_option(f"#{ID['dim_a']}", a_val)
|
|
||||||
await asyncio.sleep(DELAY)
|
|
||||||
await wait_for_update(page)
|
|
||||||
|
|
||||||
# Check if DimB appeared (secondary dimension: thickness, wall, etc.)
|
|
||||||
dim_b_opts = await get_select_options(page, ID["dim_b"])
|
|
||||||
if dim_b_opts:
|
|
||||||
for b_val, b_text in dim_b_opts:
|
|
||||||
await page.select_option(f"#{ID['dim_b']}", b_val)
|
|
||||||
await asyncio.sleep(DELAY)
|
|
||||||
await wait_for_update(page)
|
|
||||||
|
|
||||||
# Check for DimC (tertiary — rare)
|
|
||||||
dim_c_opts = await get_select_options(page, ID["dim_c"])
|
|
||||||
if dim_c_opts:
|
|
||||||
for c_val, c_text in dim_c_opts:
|
|
||||||
await page.select_option(f"#{ID['dim_c']}", c_val)
|
|
||||||
await asyncio.sleep(DELAY)
|
|
||||||
await wait_for_update(page)
|
|
||||||
|
|
||||||
lengths = await get_select_options(page, ID["dim_length"])
|
|
||||||
for l_val, l_text in lengths:
|
|
||||||
item = _make_item(
|
|
||||||
grade, shape_mapped,
|
|
||||||
a_val, a_text, b_val, b_text, c_val, c_text,
|
|
||||||
l_text,
|
|
||||||
)
|
|
||||||
items.append(item)
|
|
||||||
if on_item:
|
|
||||||
on_item(item)
|
|
||||||
else:
|
|
||||||
# No DimC — read lengths
|
|
||||||
lengths = await get_select_options(page, ID["dim_length"])
|
|
||||||
for l_val, l_text in lengths:
|
|
||||||
item = _make_item(
|
|
||||||
grade, shape_mapped,
|
|
||||||
a_val, a_text, b_val, b_text, None, None,
|
|
||||||
l_text,
|
|
||||||
)
|
|
||||||
items.append(item)
|
|
||||||
if on_item:
|
|
||||||
on_item(item)
|
|
||||||
else:
|
|
||||||
# No DimB — just DimA + Length
|
|
||||||
lengths = await get_select_options(page, ID["dim_length"])
|
|
||||||
for l_val, l_text in lengths:
|
|
||||||
item = _make_item(
|
|
||||||
grade, shape_mapped,
|
|
||||||
a_val, a_text, None, None, None, None,
|
|
||||||
l_text,
|
|
||||||
)
|
|
||||||
items.append(item)
|
|
||||||
if on_item:
|
|
||||||
on_item(item)
|
|
||||||
|
|
||||||
return items
|
|
||||||
|
|
||||||
|
|
||||||
def _make_item(grade, shape, a_val, a_text, b_val, b_text, c_val, c_text, l_text):
|
|
||||||
"""Build a raw item dict from dimension selections."""
|
|
||||||
return {
|
|
||||||
"grade": grade,
|
|
||||||
"shape": shape,
|
|
||||||
"dim_a_val": a_val, # decimal string like ".500"
|
|
||||||
"dim_a_text": a_text, # fraction string like "1/2"
|
|
||||||
"dim_b_val": b_val,
|
|
||||||
"dim_b_text": b_text,
|
|
||||||
"dim_c_val": c_val,
|
|
||||||
"dim_c_text": c_text,
|
|
||||||
"length_text": l_text,
|
|
||||||
"length_inches": parse_length_to_inches(l_text),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════
|
|
||||||
# Output — build catalog JSON
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
def build_size_and_dims(shape: str, item: dict):
|
|
||||||
"""Return (size_string, dimensions_dict) for a catalog material entry.
|
|
||||||
|
|
||||||
Uses the decimal values from dropdown option values for precision,
|
|
||||||
and fraction text from dropdown option text for display.
|
|
||||||
"""
|
|
||||||
# Use the numeric value from the dropdown (e.g. ".500") for precision
|
|
||||||
a = float(item["dim_a_val"]) if item.get("dim_a_val") else None
|
|
||||||
b = float(item["dim_b_val"]) if item.get("dim_b_val") else None
|
|
||||||
c = float(item["dim_c_val"]) if item.get("dim_c_val") else None
|
|
||||||
|
|
||||||
a_txt = normalize_dim_text(item.get("dim_a_text") or "")
|
|
||||||
b_txt = normalize_dim_text(item.get("dim_b_text") or "")
|
|
||||||
c_txt = normalize_dim_text(item.get("dim_c_text") or "")
|
|
||||||
|
|
||||||
if shape == "RoundBar" and a is not None:
|
|
||||||
return f'{a_txt}"', {"diameter": round(a, 4)}
|
|
||||||
|
|
||||||
if shape == "FlatBar":
|
|
||||||
if a is not None and b is not None:
|
|
||||||
return (f'{a_txt}" x {b_txt}"',
|
|
||||||
{"width": round(a, 4), "thickness": round(b, 4)})
|
|
||||||
if a is not None:
|
|
||||||
return f'{a_txt}"', {"width": round(a, 4), "thickness": 0}
|
|
||||||
|
|
||||||
if shape == "SquareBar" and a is not None:
|
|
||||||
return f'{a_txt}"', {"sideLength": round(a, 4)}
|
|
||||||
|
|
||||||
if shape == "Angle":
|
|
||||||
if a is not None and b is not None:
|
|
||||||
return (f'{a_txt}" x {a_txt}" x {b_txt}"',
|
|
||||||
{"leg1": round(a, 4), "leg2": round(a, 4), "thickness": round(b, 4)})
|
|
||||||
if a is not None:
|
|
||||||
return f'{a_txt}"', {"leg1": round(a, 4), "leg2": round(a, 4), "thickness": 0}
|
|
||||||
|
|
||||||
if shape == "Channel":
|
|
||||||
# Channels may use DimA for combined designation or height
|
|
||||||
if a is not None and b is not None:
|
|
||||||
return (f'{a_txt}" x {b_txt}"',
|
|
||||||
{"height": round(a, 4), "flange": round(b, 4), "web": 0})
|
|
||||||
if a is not None:
|
|
||||||
return a_txt, {"height": round(a, 4), "flange": 0, "web": 0}
|
|
||||||
|
|
||||||
if shape == "IBeam":
|
|
||||||
# DimA might be the W-designation, DimB the weight/ft
|
|
||||||
if a is not None and b is not None:
|
|
||||||
return (f"W{int(a)} x {b}",
|
|
||||||
{"height": round(a, 4), "weightPerFoot": round(b, 4)})
|
|
||||||
if a is not None:
|
|
||||||
return f"W{int(a)}", {"height": round(a, 4), "weightPerFoot": 0}
|
|
||||||
|
|
||||||
if shape == "SquareTube":
|
|
||||||
if a is not None and b is not None:
|
|
||||||
return (f'{a_txt}" x {b_txt}" wall',
|
|
||||||
{"sideLength": round(a, 4), "wall": round(b, 4)})
|
|
||||||
if a is not None:
|
|
||||||
return f'{a_txt}"', {"sideLength": round(a, 4), "wall": 0}
|
|
||||||
|
|
||||||
if shape == "RectangularTube":
|
|
||||||
if a is not None and b is not None and c is not None:
|
|
||||||
return (f'{a_txt}" x {b_txt}" x {c_txt}" wall',
|
|
||||||
{"width": round(a, 4), "height": round(b, 4), "wall": round(c, 4)})
|
|
||||||
if a is not None and b is not None:
|
|
||||||
return (f'{a_txt}" x {b_txt}"',
|
|
||||||
{"width": round(a, 4), "height": round(b, 4), "wall": 0})
|
|
||||||
|
|
||||||
if shape == "RoundTube":
|
|
||||||
if a is not None and b is not None:
|
|
||||||
return (f'{a_txt}" OD x {b_txt}" wall',
|
|
||||||
{"outerDiameter": round(a, 4), "wall": round(b, 4)})
|
|
||||||
if a is not None:
|
|
||||||
return f'{a_txt}" OD', {"outerDiameter": round(a, 4), "wall": 0}
|
|
||||||
|
|
||||||
if shape == "Pipe":
|
|
||||||
sched = b_txt or c_txt or "40"
|
|
||||||
if a is not None:
|
|
||||||
return (f'{a_txt}" NPS Sch {sched}',
|
|
||||||
{"nominalSize": round(a, 4), "schedule": sched})
|
|
||||||
|
|
||||||
# Fallback
|
|
||||||
return a_txt or "", {}
|
|
||||||
|
|
||||||
|
|
||||||
SHAPE_GROUP_KEY = {
|
|
||||||
"Angle": "angles",
|
|
||||||
"Channel": "channels",
|
|
||||||
"FlatBar": "flatBars",
|
|
||||||
"IBeam": "iBeams",
|
|
||||||
"Pipe": "pipes",
|
|
||||||
"RectangularTube": "rectangularTubes",
|
|
||||||
"RoundBar": "roundBars",
|
|
||||||
"RoundTube": "roundTubes",
|
|
||||||
"SquareBar": "squareBars",
|
|
||||||
"SquareTube": "squareTubes",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def build_catalog(scraped: list[dict]) -> dict:
|
|
||||||
"""Assemble the final catalog JSON from scraped item dicts."""
|
|
||||||
materials: dict[tuple, dict] = {}
|
|
||||||
|
|
||||||
for item in scraped:
|
|
||||||
shape = item.get("shape", "")
|
|
||||||
grade = item.get("grade", "")
|
|
||||||
if not shape or not grade:
|
|
||||||
continue
|
|
||||||
|
|
||||||
size_str, dims = build_size_and_dims(shape, item)
|
|
||||||
key = (shape, grade, size_str)
|
|
||||||
|
|
||||||
if key not in materials:
|
|
||||||
mat = {
|
|
||||||
"type": "Steel",
|
|
||||||
"grade": grade,
|
|
||||||
"size": size_str,
|
|
||||||
"stockItems": [],
|
|
||||||
}
|
|
||||||
mat.update(dims)
|
|
||||||
materials[key] = mat
|
|
||||||
|
|
||||||
length = item.get("length_inches")
|
|
||||||
if length and length > 0:
|
|
||||||
existing = {si["lengthInches"] for si in materials[key]["stockItems"]}
|
|
||||||
if round(length, 4) not in existing:
|
|
||||||
materials[key]["stockItems"].append({
|
|
||||||
"lengthInches": round(length, 4),
|
|
||||||
"quantityOnHand": 0,
|
|
||||||
"supplierOfferings": [{
|
|
||||||
"supplierName": "Alro Steel",
|
|
||||||
"partNumber": "",
|
|
||||||
"supplierDescription": "",
|
|
||||||
}],
|
|
||||||
})
|
|
||||||
|
|
||||||
# Group by shape key
|
|
||||||
grouped: dict[str, list] = {v: [] for v in SHAPE_GROUP_KEY.values()}
|
|
||||||
for (shape, _, _), mat in sorted(materials.items(), key=lambda kv: (kv[0][0], kv[0][1], kv[0][2])):
|
|
||||||
group_key = SHAPE_GROUP_KEY.get(shape)
|
|
||||||
if group_key:
|
|
||||||
grouped[group_key].append(mat)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"exportedAt": datetime.now(timezone.utc).isoformat(),
|
|
||||||
"suppliers": [{"name": "Alro Steel"}],
|
|
||||||
"cuttingTools": [
|
|
||||||
{"name": "Bandsaw", "kerfInches": 0.0625, "isDefault": True},
|
|
||||||
{"name": "Chop Saw", "kerfInches": 0.125, "isDefault": False},
|
|
||||||
{"name": "Cold Cut Saw", "kerfInches": 0.0625, "isDefault": False},
|
|
||||||
{"name": "Hacksaw", "kerfInches": 0.0625, "isDefault": False},
|
|
||||||
],
|
|
||||||
"materials": grouped,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════
|
|
||||||
# Progress management
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
def load_progress() -> dict:
|
|
||||||
if PROGRESS_PATH.exists():
|
|
||||||
return json.loads(PROGRESS_PATH.read_text(encoding="utf-8"))
|
|
||||||
return {"completed": [], "items": []}
|
|
||||||
|
|
||||||
|
|
||||||
def save_progress(progress: dict):
|
|
||||||
PROGRESS_PATH.write_text(json.dumps(progress, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════
|
|
||||||
# Main
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
async def main():
|
|
||||||
discover = "--discover" in sys.argv
|
|
||||||
fresh = "--fresh" in sys.argv
|
|
||||||
all_grades = "--all-grades" in sys.argv
|
|
||||||
|
|
||||||
progress = {"completed": [], "items": []} if fresh else load_progress()
|
|
||||||
all_items: list[dict] = progress.get("items", [])
|
|
||||||
done_keys: set[tuple] = {tuple(k) for k in progress.get("completed", [])}
|
|
||||||
|
|
||||||
# Build index of saved DimA values per (grade, shape) for partial resume
|
|
||||||
saved_dim_a: dict[tuple[str, str], set[str]] = {}
|
|
||||||
if all_items and not fresh:
|
|
||||||
for item in all_items:
|
|
||||||
key = (item.get("grade", ""), item.get("shape", ""))
|
|
||||||
saved_dim_a.setdefault(key, set()).add(item.get("dim_a_val", ""))
|
|
||||||
|
|
||||||
log.info("Alro Steel SmartGrid Scraper")
|
|
||||||
if all_grades:
|
|
||||||
log.info(" Mode: ALL grades")
|
|
||||||
else:
|
|
||||||
log.info(f" Filtering to {len(GRADE_FILTER)} grades: {', '.join(sorted(GRADE_FILTER))}")
|
|
||||||
if fresh:
|
|
||||||
log.info(" Fresh start — ignoring saved progress")
|
|
||||||
elif done_keys:
|
|
||||||
log.info(f" Resuming: {len(done_keys)} combos done, {len(all_items)} items saved")
|
|
||||||
if discover:
|
|
||||||
log.info(" Discovery mode — will scrape first item then stop")
|
|
||||||
|
|
||||||
async with Stealth().use_async(async_playwright()) as pw:
|
|
||||||
browser = await pw.chromium.launch(headless=False)
|
|
||||||
ctx = await browser.new_context(
|
|
||||||
viewport={"width": 1280, "height": 900},
|
|
||||||
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
|
||||||
locale="en-US",
|
|
||||||
timezone_id="America/Indiana/Indianapolis",
|
|
||||||
)
|
|
||||||
page = await ctx.new_page()
|
|
||||||
|
|
||||||
log.info(f"Navigating to SmartGrid …")
|
|
||||||
await page.goto(BASE_URL, wait_until="networkidle", timeout=30_000)
|
|
||||||
await asyncio.sleep(2)
|
|
||||||
|
|
||||||
if not await page.query_selector(f"#{ID['main_grid']}"):
|
|
||||||
log.error("Main grid not found! Saving screenshot.")
|
|
||||||
SCREENSHOTS_DIR.mkdir(exist_ok=True)
|
|
||||||
await page.screenshot(path=str(SCREENSHOTS_DIR / "error_no_grid.png"))
|
|
||||||
await browser.close()
|
|
||||||
return
|
|
||||||
|
|
||||||
log.info("Main grid loaded")
|
|
||||||
total_scraped = 0
|
|
||||||
first_item = True
|
|
||||||
|
|
||||||
for category in CATEGORIES:
|
|
||||||
log.info(f"\n{'=' * 60}")
|
|
||||||
log.info(f" Category: {category}")
|
|
||||||
log.info(f"{'=' * 60}")
|
|
||||||
|
|
||||||
if not await click_category(page, category):
|
|
||||||
continue
|
|
||||||
await asyncio.sleep(DELAY)
|
|
||||||
|
|
||||||
combos = await scrape_popup_grid(page)
|
|
||||||
|
|
||||||
for grade_name, grade_id, shape_name, row_idx, has_btn in combos:
|
|
||||||
if not has_btn:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Grade filter
|
|
||||||
if not all_grades and grade_name not in GRADE_FILTER:
|
|
||||||
continue
|
|
||||||
|
|
||||||
shape_upper = shape_name.upper().strip()
|
|
||||||
shape_mapped = SHAPE_MAP.get(shape_upper)
|
|
||||||
if shape_mapped is None:
|
|
||||||
log.info(f" Skip unmapped shape: {shape_name}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
combo_key = (category, grade_name, shape_name)
|
|
||||||
if combo_key in done_keys:
|
|
||||||
log.info(f" Skip (done): {grade_name} / {shape_name}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
log.info(f"\n -- {grade_name} / {shape_name} -> {shape_mapped} --")
|
|
||||||
|
|
||||||
if not await click_shape(page, shape_name, row_idx):
|
|
||||||
await click_back(page)
|
|
||||||
await asyncio.sleep(DELAY)
|
|
||||||
continue
|
|
||||||
|
|
||||||
await asyncio.sleep(DELAY)
|
|
||||||
|
|
||||||
combo_count = 0
|
|
||||||
def on_item_discovered(item):
|
|
||||||
nonlocal total_scraped, combo_count
|
|
||||||
all_items.append(item)
|
|
||||||
total_scraped += 1
|
|
||||||
combo_count += 1
|
|
||||||
progress["items"] = all_items
|
|
||||||
save_progress(progress)
|
|
||||||
|
|
||||||
# Pass already-scraped DimA values so partial combos resume correctly
|
|
||||||
already = saved_dim_a.get((grade_name, shape_mapped), set())
|
|
||||||
|
|
||||||
items = await scrape_dims_panel(
|
|
||||||
page, grade_name, shape_name, shape_mapped,
|
|
||||||
save_discovery=first_item or discover,
|
|
||||||
on_item=on_item_discovered,
|
|
||||||
scraped_dim_a=already,
|
|
||||||
)
|
|
||||||
first_item = False
|
|
||||||
|
|
||||||
log.info(f" -> {combo_count} items (total {total_scraped})")
|
|
||||||
|
|
||||||
done_keys.add(combo_key)
|
|
||||||
progress["completed"] = [list(k) for k in done_keys]
|
|
||||||
save_progress(progress)
|
|
||||||
|
|
||||||
await click_back(page)
|
|
||||||
await asyncio.sleep(DELAY)
|
|
||||||
|
|
||||||
if discover:
|
|
||||||
log.info("\nDiscovery done. Check: scripts/AlroCatalog/screenshots/")
|
|
||||||
await browser.close()
|
|
||||||
return
|
|
||||||
|
|
||||||
await close_popup(page)
|
|
||||||
await asyncio.sleep(DELAY)
|
|
||||||
|
|
||||||
await browser.close()
|
|
||||||
|
|
||||||
# ── Build output ──
|
|
||||||
log.info(f"\n{'=' * 60}")
|
|
||||||
log.info(f"Building catalog from {len(all_items)} items …")
|
|
||||||
catalog = build_catalog(all_items)
|
|
||||||
|
|
||||||
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
OUTPUT_PATH.write_text(json.dumps(catalog, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
||||||
|
|
||||||
log.info(f"Written: {OUTPUT_PATH}")
|
|
||||||
total_mats = sum(len(v) for v in catalog["materials"].values())
|
|
||||||
total_stock = sum(len(m["stockItems"]) for v in catalog["materials"].values() for m in v)
|
|
||||||
log.info(f"Materials: {total_mats}")
|
|
||||||
log.info(f"Stock items: {total_stock}")
|
|
||||||
for shape_key, mats in sorted(catalog["materials"].items()):
|
|
||||||
if mats:
|
|
||||||
log.info(f" {shape_key}: {len(mats)}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(main())
|
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Focused regression checks for split part-number and length cut badges."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
JOB_EDITOR = REPO_ROOT / "CutList.Web/Components/Pages/Jobs/Edit.razor"
|
||||||
|
APP_CSS = REPO_ROOT / "CutList.Web/wwwroot/css/app.css"
|
||||||
|
|
||||||
|
|
||||||
|
class CutBadgeFormatTests(unittest.TestCase):
|
||||||
|
def test_named_cut_badge_renders_part_number_and_length_in_separate_sections(self) -> None:
|
||||||
|
markup = JOB_EDITOR.read_text()
|
||||||
|
results_start = markup.index("private RenderFragment RenderResultsTab()")
|
||||||
|
cuts_cell_start = markup.index("@foreach (var item in entry.Bin.Items)", results_start)
|
||||||
|
cut_rows_start = markup.rfind("<td>", results_start, cuts_cell_start)
|
||||||
|
cut_rows = markup[cut_rows_start:markup.index("</td>", cuts_cell_start)]
|
||||||
|
|
||||||
|
self.assertIn('class="badge me-1 mb-1 fs-6 cut-part-badge"', cut_rows)
|
||||||
|
self.assertIn('class="cut-part-badge-name"', cut_rows)
|
||||||
|
self.assertIn('class="cut-part-badge-length"', cut_rows)
|
||||||
|
self.assertIn('@item.Name', cut_rows)
|
||||||
|
self.assertIn('@FormatResultLength(item.Length)', cut_rows)
|
||||||
|
self.assertNotIn('$"{item.Name} (', cut_rows)
|
||||||
|
|
||||||
|
def test_length_section_has_a_darker_background_than_the_part_number_section(self) -> None:
|
||||||
|
css = APP_CSS.read_text()
|
||||||
|
|
||||||
|
self.assertIn('.cut-part-badge {', css)
|
||||||
|
self.assertIn('.cut-part-badge-name {', css)
|
||||||
|
self.assertIn('.cut-part-badge-length {', css)
|
||||||
|
self.assertIn('background-color: #4c6680;', css)
|
||||||
|
|
||||||
|
def test_printed_cut_badges_keep_part_numbers_and_separators_legible(self) -> None:
|
||||||
|
report_css = (REPO_ROOT / "CutList.Web/wwwroot/css/report.css").read_text()
|
||||||
|
print_styles = report_css[report_css.index("@media print {"):]
|
||||||
|
|
||||||
|
self.assertIn(".cut-part-badge {", print_styles)
|
||||||
|
self.assertIn("color: #000 !important;", print_styles)
|
||||||
|
self.assertIn(".cut-part-badge-divider {", print_styles)
|
||||||
|
self.assertIn("border-left: 1px solid #000 !important;", print_styles)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Regression checks for compact CutList identifiers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
JOB_SERVICE = REPO_ROOT / "CutList.Web/Services/JobService.cs"
|
||||||
|
UNPAD_MIGRATION = REPO_ROOT / "CutList.Web/Migrations/20260802212500_RemoveJobNumberPadding.cs"
|
||||||
|
|
||||||
|
|
||||||
|
class JobNumberFormatTests(unittest.TestCase):
|
||||||
|
def test_new_jobs_use_the_database_id_without_zero_padding(self) -> None:
|
||||||
|
service = JOB_SERVICE.read_text()
|
||||||
|
|
||||||
|
self.assertIn('job.JobNumber = $"JOB-{job.Id}";', service)
|
||||||
|
self.assertNotIn("D5", service)
|
||||||
|
self.assertNotIn("MaxAsync() as string", service)
|
||||||
|
|
||||||
|
def test_existing_job_numbers_are_converted_to_their_database_ids(self) -> None:
|
||||||
|
migration = UNPAD_MIGRATION.read_text()
|
||||||
|
|
||||||
|
self.assertIn("SET JobNumber = 'JOB-' + CAST(Id AS varchar(11))", migration)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Focused regression checks for job-level actions on the Results tab."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
JOB_EDITOR = REPO_ROOT / "CutList.Web/Components/Pages/Jobs/Edit.razor"
|
||||||
|
|
||||||
|
|
||||||
|
class JobResultsActionsTests(unittest.TestCase):
|
||||||
|
def test_lock_job_is_in_the_results_action_row_before_result_cards(self) -> None:
|
||||||
|
markup = JOB_EDITOR.read_text()
|
||||||
|
results_start = markup.index("private RenderFragment RenderResultsTab()")
|
||||||
|
result_cards_start = markup.index("@if (packResult != null && summary != null)", results_start)
|
||||||
|
action_row = markup[results_start:result_cards_start]
|
||||||
|
|
||||||
|
self.assertIn('@onclick="PrintReport"', action_row)
|
||||||
|
self.assertIn('@onclick="LockJob"', action_row)
|
||||||
|
self.assertIn("Lock Job", action_row)
|
||||||
|
|
||||||
|
def test_print_report_hides_job_lock_status(self) -> None:
|
||||||
|
markup = JOB_EDITOR.read_text()
|
||||||
|
report_css = (REPO_ROOT / "CutList.Web/wwwroot/css/report.css").read_text()
|
||||||
|
|
||||||
|
self.assertIn('class="alert alert-warning d-flex justify-content-between align-items-center mb-3 print-screen-only"', markup)
|
||||||
|
self.assertIn('<span class="badge bg-success print-screen-only">', markup)
|
||||||
|
self.assertIn(".print-screen-only", report_css)
|
||||||
|
self.assertIn("display: none !important;", report_css)
|
||||||
|
|
||||||
|
def test_print_report_forces_backgrounds_to_plain_white(self) -> None:
|
||||||
|
report_css = (REPO_ROOT / "CutList.Web/wwwroot/css/report.css").read_text()
|
||||||
|
print_styles = report_css[report_css.index("@media print {"):]
|
||||||
|
|
||||||
|
self.assertIn("*::before", print_styles)
|
||||||
|
self.assertIn("background: transparent !important;", print_styles)
|
||||||
|
self.assertIn("box-shadow: none !important;", print_styles)
|
||||||
|
|
||||||
|
def test_print_report_shows_the_selected_cutting_method_and_kerf(self) -> None:
|
||||||
|
markup = JOB_EDITOR.read_text()
|
||||||
|
results_start = markup.index("private RenderFragment RenderResultsTab()")
|
||||||
|
summary_start = markup.index('<div class="row mb-4 print-summary">', results_start)
|
||||||
|
material_list_start = markup.index('<!-- Material List:', summary_start)
|
||||||
|
report_summary = markup[summary_start:material_list_start]
|
||||||
|
|
||||||
|
self.assertIn('class="print-cut-method"', report_summary)
|
||||||
|
self.assertIn("<strong>Cut Method:</strong>", report_summary)
|
||||||
|
self.assertIn("@job.CuttingTool.Name", report_summary)
|
||||||
|
self.assertIn("Kerf:", report_summary)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""Regression checks for the CutList navigation brand on its light header."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
NAV_MENU = REPO_ROOT / "CutList.Web/Components/Layout/NavMenu.razor"
|
||||||
|
NAV_MENU_CSS = REPO_ROOT / "CutList.Web/Components/Layout/NavMenu.razor.css"
|
||||||
|
APP_CSS = REPO_ROOT / "CutList.Web/wwwroot/css/app.css"
|
||||||
|
APP_SHELL = REPO_ROOT / "CutList.Web/Components/App.razor"
|
||||||
|
|
||||||
|
|
||||||
|
def selector_color(css: str, selector: str) -> str:
|
||||||
|
match = re.search(rf"{re.escape(selector)}\s*\{{([^}}]*)\}}", css)
|
||||||
|
if match is None:
|
||||||
|
raise AssertionError(f"Missing CSS selector: {selector}")
|
||||||
|
|
||||||
|
color = re.search(r"\bcolor:\s*(#[0-9a-fA-F]{6})", match.group(1))
|
||||||
|
if color is None:
|
||||||
|
raise AssertionError(f"Missing hex color for selector: {selector}")
|
||||||
|
return color.group(1)
|
||||||
|
|
||||||
|
|
||||||
|
def contrast_ratio(foreground: str, background: str) -> float:
|
||||||
|
def luminance(color: str) -> float:
|
||||||
|
channels = [int(color[index:index + 2], 16) / 255 for index in (1, 3, 5)]
|
||||||
|
linear = [value / 12.92 if value <= 0.04045 else ((value + 0.055) / 1.055) ** 2.4 for value in channels]
|
||||||
|
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]
|
||||||
|
|
||||||
|
lighter, darker = sorted((luminance(foreground), luminance(background)), reverse=True)
|
||||||
|
return (lighter + 0.05) / (darker + 0.05)
|
||||||
|
|
||||||
|
|
||||||
|
class NavMenuBrandContrastTests(unittest.TestCase):
|
||||||
|
def test_list_word_has_accessible_contrast_against_light_top_row(self) -> None:
|
||||||
|
list_color = selector_color(NAV_MENU_CSS.read_text(), ".navbar-brand .brand-list")
|
||||||
|
top_row_color = selector_color(APP_CSS.read_text(), ".top-row")
|
||||||
|
|
||||||
|
self.assertGreaterEqual(contrast_ratio(list_color, top_row_color), 4.5)
|
||||||
|
|
||||||
|
def test_brand_keeps_cut_and_list_as_adjacent_words(self) -> None:
|
||||||
|
markup = NAV_MENU.read_text()
|
||||||
|
self.assertIn('<span class="brand-cut">Cut</span><span class="brand-list">List</span>', markup)
|
||||||
|
|
||||||
|
def test_visual_system_uses_the_industrial_blue_accent(self) -> None:
|
||||||
|
css = APP_CSS.read_text()
|
||||||
|
self.assertIn("--accent: #2b75a8;", css)
|
||||||
|
self.assertIn("--navy: #203747;", css)
|
||||||
|
self.assertNotIn("--orange:", css)
|
||||||
|
|
||||||
|
def test_desktop_content_padding_does_not_push_the_nav_brand_right(self) -> None:
|
||||||
|
css = APP_CSS.read_text()
|
||||||
|
self.assertNotIn(".top-row, article {", css)
|
||||||
|
|
||||||
|
def test_nav_brand_is_left_aligned_with_the_navigation_items(self) -> None:
|
||||||
|
css = NAV_MENU_CSS.read_text()
|
||||||
|
self.assertIn(".top-row { justify-content: flex-start;", css)
|
||||||
|
|
||||||
|
def test_typography_uses_a_sans_serif_face_and_compact_heading_scale(self) -> None:
|
||||||
|
shell = APP_SHELL.read_text()
|
||||||
|
css = APP_CSS.read_text()
|
||||||
|
self.assertIn("family=IBM+Plex+Sans", shell)
|
||||||
|
self.assertIn('font-family: "IBM Plex Sans",', css)
|
||||||
|
self.assertIn("h1 { font-size: clamp(1.85rem, 3.2vw, 3.15rem);", css)
|
||||||
|
self.assertIn("h2 { font-size: clamp(1.35rem, 2vw, 1.85rem);", css)
|
||||||
|
self.assertIn(".overview-panel-heading h2 { font-size: 1.25rem;", css)
|
||||||
|
self.assertIn('font-family: "IBM Plex Sans",', NAV_MENU_CSS.read_text())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""Focused regression checks for the data-backed CutList overview."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
HOME_PAGE = REPO_ROOT / "CutList.Web/Components/Pages/Home.razor"
|
||||||
|
OVERVIEW_SERVICE = REPO_ROOT / "CutList.Web/Services/OverviewService.cs"
|
||||||
|
PROGRAM = REPO_ROOT / "CutList.Web/Program.cs"
|
||||||
|
APP_CSS = REPO_ROOT / "CutList.Web/wwwroot/css/app.css"
|
||||||
|
|
||||||
|
|
||||||
|
class OverviewDashboardTests(unittest.TestCase):
|
||||||
|
def test_overview_uses_a_dedicated_data_service(self) -> None:
|
||||||
|
self.assertTrue(OVERVIEW_SERVICE.exists(), "Expected a data-backed overview service")
|
||||||
|
source = OVERVIEW_SERVICE.read_text()
|
||||||
|
self.assertIn("GetSnapshotAsync", source)
|
||||||
|
self.assertIn("context.Jobs", source)
|
||||||
|
self.assertIn("context.JobStocks", source)
|
||||||
|
self.assertIn("OrderByDescending(j => j.CreatedAt)", source)
|
||||||
|
self.assertIn("GroupBy(s => new { s.MaterialId, s.LengthInches, s.Material.Shape, s.Material.Size })", source)
|
||||||
|
|
||||||
|
def test_overview_renders_recent_jobs_and_frequently_specified_stock(self) -> None:
|
||||||
|
markup = HOME_PAGE.read_text()
|
||||||
|
self.assertIn("@inject OverviewService OverviewService", markup)
|
||||||
|
self.assertIn("Recently created jobs", markup)
|
||||||
|
self.assertIn("Frequently specified stock", markup)
|
||||||
|
self.assertIn("Based on stock configured on jobs", markup)
|
||||||
|
self.assertIn("overview.RecentJobs", markup)
|
||||||
|
self.assertIn("overview.FrequentStock", markup)
|
||||||
|
|
||||||
|
def test_overview_groups_stock_by_scalar_material_fields(self) -> None:
|
||||||
|
source = OVERVIEW_SERVICE.read_text()
|
||||||
|
self.assertIn(
|
||||||
|
"GroupBy(s => new { s.MaterialId, s.LengthInches, s.Material.Shape, s.Material.Size })",
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
self.assertNotIn("Material = g.Select(s => s.Material).First()", source)
|
||||||
|
|
||||||
|
def test_overview_service_is_registered_and_has_dashboard_layout_rules(self) -> None:
|
||||||
|
self.assertIn("AddScoped<OverviewService>()", PROGRAM.read_text())
|
||||||
|
css = APP_CSS.read_text()
|
||||||
|
self.assertIn(".overview-grid", css)
|
||||||
|
self.assertIn(".overview-stock-list", css)
|
||||||
|
self.assertIn(".overview-recent-jobs", css)
|
||||||
|
|
||||||
|
def test_overview_shows_an_explicit_unavailable_state_if_data_cannot_load(self) -> None:
|
||||||
|
markup = HOME_PAGE.read_text()
|
||||||
|
self.assertIn("loadError", markup)
|
||||||
|
self.assertIn("Overview data is unavailable", markup)
|
||||||
|
self.assertIn("catch (Exception ex)", markup)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user