From b83c32ca011c7d46ba6360e2591154982ce51cd7 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 13:29:51 -0700 Subject: [PATCH 01/39] test: a provider's 401 reaches the material intake as a refusal in words (red) Item 130, N3. The intake answers the canned sample list, and the page says no reader is set up, when the provider refused the saved key. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/internal/parsing/ai_refused_wire_test.go | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 core/internal/parsing/ai_refused_wire_test.go diff --git a/core/internal/parsing/ai_refused_wire_test.go b/core/internal/parsing/ai_refused_wire_test.go new file mode 100644 index 000000000..9b99565f8 --- /dev/null +++ b/core/internal/parsing/ai_refused_wire_test.go @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package parsing + +import ( + "bytes" + "encoding/json" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gablelbm/gable/internal/ai" +) + +// When the provider refuses the saved key (a 401) the intake says so in words +// the desk shows, in the wire envelope with the stable code ai_refused. It is +// never the "no reader" sample list and never a bare 500. +func TestWire_UploadProviderRefusalIsAnswerInWords(t *testing.T) { + an := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"type":"error","error":{"type":"authentication_error","message":"invalid x-api-key"}}`)) + })) + defer an.Close() + client := ai.NewClient("").WithProvider(ai.ProviderAnthropic). + WithAnthropicKey("test-" + "key-not-real").WithAnthropicBaseURL(an.URL) + h := NewHandler(NewService(&mockProductRepo{}, client)) + + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + fw, _ := mw.CreateFormFile("file", "list.png") + _, _ = fw.Write([]byte("\x89PNG\r\n\x1a\n not really a picture")) + _ = mw.Close() + req := httptest.NewRequest(http.MethodPost, "/api/v1/parsing/upload", &buf) + req.Header.Set("Content-Type", mw.FormDataContentType()) + rec := httptest.NewRecorder() + h.HandleUpload(rec, req) + + if rec.Code != http.StatusBadGateway { + t.Fatalf("status = %d, want 502; body %s", rec.Code, rec.Body) + } + var b struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &b); err != nil { + t.Fatal(err) + } + if b.Error.Code != "ai_refused" { + t.Errorf("code = %q, want ai_refused", b.Error.Code) + } + want := "Anthropic refused the saved key. An administrator can enter it again in Tech Admin, AI Settings." + if b.Error.Message != want { + t.Errorf("message = %q, want %q", b.Error.Message, want) + } + if strings.Contains(rec.Body.String(), "key-not-real") { + t.Error("the answer holds the key") + } +} From 57eb9e6fcbd690781fea455c8e6d26a43327c3ec Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 13:29:51 -0700 Subject: [PATCH 02/39] test: a provider's 401 reaches PIM generate as a refusal in words (red) Item 130, N4. The routes answer a bare 500 the page cannot explain. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/internal/pim/ai_refused_wire_test.go | 55 +++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 core/internal/pim/ai_refused_wire_test.go diff --git a/core/internal/pim/ai_refused_wire_test.go b/core/internal/pim/ai_refused_wire_test.go new file mode 100644 index 000000000..428d352f3 --- /dev/null +++ b/core/internal/pim/ai_refused_wire_test.go @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pim + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gablelbm/gable/internal/ai" + "github.com/google/uuid" +) + +// When the provider refuses the saved key (a 401) each PIM generate route that +// answers in the request says so in words with the stable code ai_refused: a +// 502 in the wire envelope, never a bare 500 the page cannot explain. (The +// image route generates after it answers; its refusal is the media row's.) +func TestWire_PIMGenerateRoutesAnswerAProviderRefusalInWords(t *testing.T) { + an := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"type":"error","error":{"type":"authentication_error","message":"invalid x-api-key"}}`)) + })) + defer an.Close() + client := ai.NewClient("").WithProvider(ai.ProviderAnthropic). + WithAnthropicKey("test-" + "key-not-real").WithAnthropicBaseURL(an.URL) + id := uuid.New().String() + for _, route := range []string{"descriptions", "seo", "collateral"} { + t.Run(route, func(t *testing.T) { + mux := aiOffMux(t, client) + body := `{}` + if route == "collateral" { + body = `{"type":"spec_sheet"}` + } + rec := postJSON(mux, "/api/v1/products/"+id+"/pim/generate/"+route, body) + if rec.Code != http.StatusBadGateway { + t.Fatalf("status = %d, want 502; body %s", rec.Code, rec.Body) + } + var b offBody + if err := json.Unmarshal(rec.Body.Bytes(), &b); err != nil { + t.Fatal(err) + } + if b.Error.Code != "ai_refused" { + t.Errorf("code = %q, want ai_refused", b.Error.Code) + } + if !strings.Contains(b.Error.Message, "Anthropic refused the saved key") || + !strings.Contains(b.Error.Message, "Tech Admin, AI Settings") { + t.Errorf("message = %q", b.Error.Message) + } + }) + } +} From 7d130ba355b6b9ca9917593cf147f9af9212c373 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 13:29:51 -0700 Subject: [PATCH 03/39] test: saving an AI key tests it and the document reads the last test (red) Item 130, N7. AI on means a key is saved, not that it works. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- .../internal/techadmin/ai_test_status_test.go | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 core/internal/techadmin/ai_test_status_test.go diff --git a/core/internal/techadmin/ai_test_status_test.go b/core/internal/techadmin/ai_test_status_test.go new file mode 100644 index 000000000..2a5beedd4 --- /dev/null +++ b/core/internal/techadmin/ai_test_status_test.go @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package techadmin_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/gablelbm/gable/internal/ai" + "github.com/gablelbm/gable/internal/techadmin" +) + +// anthropicAnswering is a stand-in provider that answers every call with the +// status and counts the calls. +func anthropicAnswering(t *testing.T, status int, hits *int32) *ai.Client { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + atomic.AddInt32(hits, 1) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if status == http.StatusOK { + _, _ = w.Write([]byte(`{"id":"m","type":"message","role":"assistant","model":"m","content":[{"type":"text","text":"ok"}]}`)) + return + } + _, _ = w.Write([]byte(`{"type":"error","error":{"type":"authentication_error","message":"invalid x-api-key"}}`)) + })) + t.Cleanup(srv.Close) + return ai.NewClient("").WithProvider(ai.ProviderAnthropic). + WithAnthropicKey(fakeAnthropicKey).WithAnthropicBaseURL(srv.URL) +} + +// RULE (N7): saving a key tests it with the provider and the document reads +// from that test: "passed" after an answer, "refused" with the plain words +// after a refusal. The result is stored, so a later read says the same. +func TestWire_SavingAKeyTestsItAndStoresTheResult(t *testing.T) { + for _, tc := range []struct { + name string + status int + want string + message string + }{ + {"passed", http.StatusOK, "passed", ""}, + {"refused", http.StatusUnauthorized, "refused", "Anthropic refused the saved key"}, + } { + t.Run(tc.name, func(t *testing.T) { + var hits int32 + client := anthropicAnswering(t, tc.status, &hits) + f := newFixtureWith(t, func(s *techadmin.Service) *techadmin.Service { + return s.WithVault(testVault(t)).WithAITester(client) + }) + r := f.do("PUT", "/api/v1/admin/settings/ai", + map[string]any{"provider": "anthropic", "anthropic_api_key": fakeAnthropicKey}, "If-Match", `"1"`) + if r.status != http.StatusOK { + t.Fatalf("save = %d: %s", r.status, r.raw) + } + if atomic.LoadInt32(&hits) != 1 { + t.Errorf("the provider was called %d times by the save, want 1", hits) + } + if r.body["test_status"] != tc.want { + t.Errorf("save answer test_status = %v, want %q: %s", r.body["test_status"], tc.want, r.raw) + } + if msg, _ := r.body["test_message"].(string); !strings.Contains(msg, tc.message) { + t.Errorf("test_message = %q, want it to hold %q", msg, tc.message) + } + get := f.do("GET", "/api/v1/admin/settings/ai", nil) + if get.body["test_status"] != tc.want { + t.Errorf("GET test_status = %v, want %q", get.body["test_status"], tc.want) + } + if strings.Contains(string(r.raw)+string(get.raw), fakeAnthropicKey) { + t.Error("an answer holds the key") + } + }) + } +} + +// RULE (N7): a key that was never tested says so; a Test connection press is +// stored like a save's test. +func TestWire_AnUntestedKeyReadsUntestedAndAPressStoresTheResult(t *testing.T) { + var hits int32 + client := anthropicAnswering(t, http.StatusUnauthorized, &hits) + f := newFixtureWith(t, func(s *techadmin.Service) *techadmin.Service { + return s.WithAITester(client).WithSettingsDefaults("or-"+"test-not-real", "", "") + }) + get := f.do("GET", "/api/v1/admin/settings/ai", nil) + if get.body["configured"] != true || get.body["test_status"] != "untested" { + t.Fatalf("configured, never tested: %s", get.raw) + } + press := f.do("POST", "/api/v1/admin/settings/ai/test", nil) + if press.status != http.StatusOK { + t.Fatalf("test = %d: %s", press.status, press.raw) + } + after := f.do("GET", "/api/v1/admin/settings/ai", nil) + if after.body["test_status"] == "untested" || after.body["test_status"] == nil { + t.Errorf("a pressed test left the document %v", after.body["test_status"]) + } +} From 55732dfcbdfde945864e398195b18af92888991f Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 13:29:51 -0700 Subject: [PATCH 04/39] test: the illustration prompt asks for no words, labels, brand names or logos (red) Item 130, N8 (FROM-PRINCIPAL 118 a). Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/internal/pim/illustration_prompt_test.go | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 core/internal/pim/illustration_prompt_test.go diff --git a/core/internal/pim/illustration_prompt_test.go b/core/internal/pim/illustration_prompt_test.go new file mode 100644 index 000000000..c5a5da2b9 --- /dev/null +++ b/core/internal/pim/illustration_prompt_test.go @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pim + +import ( + "strings" + "testing" +) + +// FROM-PRINCIPAL 118 a: a drawn product carries no words, labels, brand names +// or logos. The house style asks for none, and no longer allows an invented +// brand word on a box, a bag or a can. +func TestIllustrationPromptAsksForNoWordsOrBrands(t *testing.T) { + p := strings.ToLower(illustrationSystemPrompt) + if !strings.Contains(p, "no words, labels, brand names or logos") { + t.Error("the illustration prompt must ask for no words, labels, brand names or logos on the product") + } + if strings.Contains(p, "invented brand") { + t.Error("the illustration prompt still allows an invented brand word") + } + if strings.Contains(p, "tspan") || strings.Contains(p, "text and") { + t.Error("the illustration prompt still lists the text elements as allowed") + } +} From 8c7aecf9156f96ecbfce7f3c378246ee2ea38e8f Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 13:29:51 -0700 Subject: [PATCH 05/39] test: the sample list, its tests and the import hold no brand word (red) Item 130, N5 and N6. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/internal/app/seed/demodata_test.go | 1 + core/internal/parsing/brand_words_test.go | 38 +++++++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 core/internal/parsing/brand_words_test.go diff --git a/core/internal/app/seed/demodata_test.go b/core/internal/app/seed/demodata_test.go index 84f1f27ed..194afcff5 100644 --- a/core/internal/app/seed/demodata_test.go +++ b/core/internal/app/seed/demodata_test.go @@ -90,6 +90,7 @@ var bannedSeedWords = []string{ "simpson", "strong-tie", "hardie", "cemtrim", "channellock", "romex", "dryline", "firecode", "brownwood", "turtle", "gable.com", "nordstrom", "kellogg", "zellweger", + "quikrete", "tyvek", "proframe angle", "simp-", "ice & water shield", "speed square", } diff --git a/core/internal/parsing/brand_words_test.go b/core/internal/parsing/brand_words_test.go new file mode 100644 index 000000000..d7188c16b --- /dev/null +++ b/core/internal/parsing/brand_words_test.go @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package parsing + +import ( + "os" + "strings" + "testing" +) + +// brandWords are real brands the intake's sample list and its tests never +// carry: a sample line is named for what the product is. +var brandWords = []string{ + "quikrete", "tyvek", "simpson", "strong-tie", "proframe angle", "trueline", + "hardie", "romex", "dryline", "firecode", +} + +// FROM-PRINCIPAL 118 a (N5): the canned sample list and the service's own +// tests hold plain product words only. +func TestSampleListAndItsTestsHoldNoBrandWord(t *testing.T) { + sources := map[string]string{"sample list": generateFallbackMaterialList()} + for _, name := range []string{"service.go", "service_test.go", "service_degradation_test.go"} { + data, err := os.ReadFile(name) + if err != nil { + t.Fatal(err) + } + sources[name] = string(data) + } + for name, text := range sources { + lower := strings.ToLower(text) + for _, w := range brandWords { + if strings.Contains(lower, w) { + t.Errorf("%s holds the brand word %q", name, w) + } + } + } +} From 90baa9184f353235fbf3490db9105dbaea33ed74 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 13:30:59 -0700 Subject: [PATCH 06/39] test: a text list is read by rules when the provider refuses the key (red) Item 130, N3. A pasted list needs no AI reader; the refusal must not take it down. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/internal/parsing/ai_refused_text_test.go | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 core/internal/parsing/ai_refused_text_test.go diff --git a/core/internal/parsing/ai_refused_text_test.go b/core/internal/parsing/ai_refused_text_test.go new file mode 100644 index 000000000..b17908b48 --- /dev/null +++ b/core/internal/parsing/ai_refused_text_test.go @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package parsing + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gablelbm/gable/internal/ai" +) + +// A pasted or text list never needs the AI reader: when the provider refuses +// the saved key the rule-based reader still returns the caller's own lines, +// not a refusal and not the canned sample. +func TestExtractItemsWithAI_TextListIsReadByRulesWhenTheKeyIsRefused(t *testing.T) { + an := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"type":"error","error":{"type":"authentication_error","message":"invalid x-api-key"}}`)) + })) + defer an.Close() + client := ai.NewClient("").WithProvider(ai.ProviderAnthropic). + WithAnthropicKey("test-" + "key-not-real").WithAnthropicBaseURL(an.URL) + svc := NewService(&mockProductRepo{}, client) + + items, synthetic, err := svc.ExtractItemsWithAI(context.Background(), []byte("50 pcs - 2x4x8 stud"), "text/plain") + if err != nil || synthetic { + t.Fatalf("err = %v, synthetic = %v, want the caller's own line", err, synthetic) + } + if len(items) != 1 || items[0].quantity != 50 { + t.Fatalf("items = %+v, want the one pasted line", items) + } +} From 5502824e6ecc40990bd3dc969a975d597d5dd301 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 13:30:59 -0700 Subject: [PATCH 07/39] test: Tech Admin says AI on only after a passed test (red) Item 130, N7. A refused key reads AI off with the provider named; a saved key that was not tested says so. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- web/apps/desk/src/lib/aiWords.test.ts | 26 +++++++++++++++---- .../admin/tech_admin/AIProviderWords.test.ts | 17 +++++++++++- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/web/apps/desk/src/lib/aiWords.test.ts b/web/apps/desk/src/lib/aiWords.test.ts index a3cd0be55..a2d4fce2c 100644 --- a/web/apps/desk/src/lib/aiWords.test.ts +++ b/web/apps/desk/src/lib/aiWords.test.ts @@ -5,10 +5,26 @@ import { describe, it, expect } from 'vitest'; import { aiHeadline, aiStatusLine, imageWords, modelLabel, providerLabel } from './aiWords.ts'; describe('aiWords', () => { - it('names the chosen provider and model when a key is saved', () => { - expect(aiStatusLine({ provider: 'anthropic', model: 'claude-sonnet-5-5', configured: true })).toBe('AI on: Anthropic, Sonnet 5.5'); - expect(aiStatusLine({ provider: 'anthropic', model: 'claude-opus-5-5', configured: true })).toBe('AI on: Anthropic, Opus 5.5'); - expect(aiStatusLine({ provider: 'openrouter', model: 'claude-sonnet-5-5', configured: true })).toBe('AI on: OpenRouter'); + it('names the chosen provider and model once the saved key passed its test', () => { + const passed = { configured: true, test_status: 'passed' as const }; + expect(aiStatusLine({ provider: 'anthropic', model: 'claude-sonnet-5-5', ...passed })).toBe('AI on: Anthropic, Sonnet 5.5'); + expect(aiStatusLine({ provider: 'anthropic', model: 'claude-opus-5-5', ...passed })).toBe('AI on: Anthropic, Opus 5.5'); + expect(aiStatusLine({ provider: 'openrouter', model: 'claude-sonnet-5-5', ...passed })).toBe('AI on: OpenRouter'); + }); + + it('says AI is off when the provider refused the saved key', () => { + const refused = { configured: true, test_status: 'refused' as const }; + expect(aiStatusLine({ provider: 'anthropic', model: 'claude-sonnet-5-5', ...refused })).toBe('AI off: Anthropic refused the saved key'); + expect(aiStatusLine({ provider: 'openrouter', model: '', ...refused })).toBe('AI off: OpenRouter refused the saved key'); + expect(aiHeadline({ provider: 'anthropic', model: '', ...refused })).toBe('AI Features Off'); + }); + + it('says a saved key was not tested yet, and that a failed test is not a pass', () => { + const untested = { configured: true, test_status: 'untested' as const }; + expect(aiStatusLine({ provider: 'anthropic', model: 'claude-sonnet-5-5', ...untested })).toBe('Key saved, not tested yet. Press Test connection to turn AI on.'); + expect(aiHeadline({ provider: 'anthropic', model: '', ...untested })).toBe('AI Features Not Tested'); + const failed = { configured: true, test_status: 'failed' as const }; + expect(aiStatusLine({ provider: 'anthropic', model: 'claude-sonnet-5-5', ...failed })).toBe('AI off: the test of the saved Anthropic key did not pass'); }); it('asks for the chosen provider\'s key, never the other one\'s', () => { @@ -18,7 +34,7 @@ describe('aiWords', () => { }); it('reads the headline from the state', () => { - expect(aiHeadline({ provider: 'anthropic', model: '', configured: true })).toBe('AI Features Active'); + expect(aiHeadline({ provider: 'anthropic', model: '', configured: true, test_status: 'passed' })).toBe('AI Features Active'); expect(aiHeadline(null)).toBe('AI Features Inactive'); }); diff --git a/web/apps/desk/src/pages/admin/tech_admin/AIProviderWords.test.ts b/web/apps/desk/src/pages/admin/tech_admin/AIProviderWords.test.ts index 82dd7c561..fe4b9dbe2 100644 --- a/web/apps/desk/src/pages/admin/tech_admin/AIProviderWords.test.ts +++ b/web/apps/desk/src/pages/admin/tech_admin/AIProviderWords.test.ts @@ -48,13 +48,28 @@ afterEach(() => { describe('Tech Admin AI words follow the chosen provider', () => { it('names Anthropic and the model when its key is saved', async () => { - serve({ ...BASE, provider: 'anthropic', model: 'claude-sonnet-5-5', configured: true, anthropic_configured: true, anthropic_key_hint: 'sk-a...wxyz', source: 'admin' }) + serve({ ...BASE, provider: 'anthropic', model: 'claude-sonnet-5-5', configured: true, anthropic_configured: true, anthropic_key_hint: 'sk-a...wxyz', source: 'admin', test_status: 'passed' }) const body = text(await openAITab()) expect(body).toContain('AI on: Anthropic, Sonnet 5.5') expect(body).toContain('AI Features Active') expect(body).not.toContain('Enter your OpenRouter API key') }) + it('does not say AI is on for a key the provider refused', async () => { + serve({ ...BASE, provider: 'anthropic', model: 'claude-sonnet-5-5', configured: true, anthropic_configured: true, anthropic_key_hint: '****dmin', source: 'admin', test_status: 'refused', test_message: 'Anthropic refused the saved key. An administrator can enter it again in Tech Admin, AI Settings.' }) + const body = text(await openAITab()) + expect(body).toContain('AI off: Anthropic refused the saved key') + expect(body).not.toContain('AI on:') + expect(body).not.toContain('AI Features Active') + }) + + it('says a saved key was not tested yet', async () => { + serve({ ...BASE, provider: 'anthropic', model: 'claude-sonnet-5-5', configured: true, anthropic_configured: true, anthropic_key_hint: '****real', source: 'admin', test_status: 'untested' }) + const body = text(await openAITab()) + expect(body).toContain('Key saved, not tested yet') + expect(body).not.toContain('AI on:') + }) + it('asks for the Anthropic key, not the OpenRouter one, when none is saved', async () => { serve({ ...BASE, provider: 'anthropic', model: 'claude-sonnet-5-5' }) const body = text(await openAITab()) From 28f11e5c625e7bf04418847110284f72eaef39a2 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 13:30:59 -0700 Subject: [PATCH 08/39] test: the intake says the provider refused the key and shows no sample (red) Item 130, N3. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- .../src/components/quotes/MaterialIntake.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/web/apps/desk/src/components/quotes/MaterialIntake.test.ts b/web/apps/desk/src/components/quotes/MaterialIntake.test.ts index 7ebdf3654..5862ee90e 100644 --- a/web/apps/desk/src/components/quotes/MaterialIntake.test.ts +++ b/web/apps/desk/src/components/quotes/MaterialIntake.test.ts @@ -143,6 +143,21 @@ describe('MaterialIntake', () => { expect(el.querySelector('[data-testid="intake-accept-confident"]')).toBeNull() }) + it('says the provider refused the saved key, with what to do, and shows no sample list', async () => { + const words = 'Anthropic refused the saved key. An administrator can enter it again in Tech Admin, AI Settings.' + fetchMock.mockResolvedValueOnce(new Response( + JSON.stringify({ error: { code: 'ai_refused', message: words }, meta: { request_id: 'r' } }), + { status: 502, headers: { 'Content-Type': 'application/json' } }, + )) + const el = await mount('gable-material-intake') + await paste(el, 'x') + const refused = q(el, '[data-testid="intake-ai-refused"]') + expect(text(refused)).toContain(words) + expect(el.querySelector('[data-testid="intake-sample-banner"]')).toBeNull() + expect(text(el)).not.toContain('No AI reader is set up') + expect(el.querySelectorAll('[data-testid="intake-line"]').length).toBe(0) + }) + it('shows a sample read only: no per line Accept, no swap, no accept all, and says how to turn AI on', async () => { fetchMock.mockResolvedValueOnce(jsonResponse(result({ synthetic: true }))) const el = await mount('gable-material-intake') From 89bef4872c71de63c024dcdf18c623589c6d9265 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 13:30:59 -0700 Subject: [PATCH 09/39] test: PIM generate shows the provider's refusal on the page (red) Item 130, N4. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- .../inventory/tabs/ProductContentTab.test.ts | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 web/apps/desk/src/pages/inventory/tabs/ProductContentTab.test.ts diff --git a/web/apps/desk/src/pages/inventory/tabs/ProductContentTab.test.ts b/web/apps/desk/src/pages/inventory/tabs/ProductContentTab.test.ts new file mode 100644 index 000000000..de12b466c --- /dev/null +++ b/web/apps/desk/src/pages/inventory/tabs/ProductContentTab.test.ts @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Surface-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +/** + * When the AI provider refuses the saved key, PIM's Generate shows the + * refusal in words on the page, where it stays until the next try; it is not + * a toast that leaves nothing behind. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import './ProductContentTab' +import './ProductSEOTab' +import './ProductCollateralTab' +import { mount, flush, q, text, update } from '../../../test/dom' + +const WORDS = 'Anthropic refused the saved key. An administrator can enter it again in Tech Admin, AI Settings.' + +beforeEach(() => { + localStorage.setItem('token', 'test-token') + vi.stubGlobal('fetch', vi.fn(async () => new Response( + JSON.stringify({ error: { code: 'ai_refused', message: WORDS }, meta: { request_id: 'r' } }), + { status: 502, headers: { 'Content-Type': 'application/json' } }, + ))) +}) +afterEach(() => { + vi.unstubAllGlobals() + document.body.innerHTML = '' +}) + +function press(el: HTMLElement, label: string) { + const button = Array.from(el.querySelectorAll('button')).find((b) => text(b).includes(label)) + if (!button) throw new Error(`no button ${label}`) + button.click() +} + +describe('PIM generate shows a provider refusal on the page', () => { + it('on the description tab', async () => { + const el = await mount('gable-product-content-tab', { productId: 'p1' }) + press(el, 'Generate') + await flush() + await update(el as never, {}) + expect(text(q(el, '[data-testid="pim-ai-notice"]'))).toContain(WORDS) + }) + + it('on the SEO tab', async () => { + const el = await mount('gable-product-seo-tab', { productId: 'p1' }) + press(el, 'Generate') + await flush() + await update(el as never, {}) + expect(text(q(el, '[data-testid="pim-ai-notice"]'))).toContain(WORDS) + }) + + it('on the collateral tab', async () => { + const el = await mount('gable-product-collateral-tab', { productId: 'p1' }) + press(el, 'Generate') + await flush() + await update(el as never, {}) + expect(text(q(el, '[data-testid="pim-ai-notice"]'))).toContain(WORDS) + }) +}) From 635536ca868ac2c1cb793a90b55b383d32cf6fb9 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 13:45:31 -0700 Subject: [PATCH 10/39] AI: a provider's refusal of the saved key is answered in words A 401 or 403 from the provider is a refusal of the saved key, not no reader and not a bare 500. The intake and the PIM generate routes answer 502 with the code ai_refused and the sentence naming the provider and what to do; a text list is still read by the rule reader. The contract names the new answer. Item 130, N3 and N4. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/api/fragments/_shared.yaml | 11 +++++++ core/api/fragments/parsing.yaml | 1 + core/api/fragments/product.yaml | 6 ++++ core/internal/ai/anthropic.go | 3 ++ core/internal/ai/openrouter.go | 3 ++ core/internal/ai/refused.go | 56 ++++++++++++++++++++++++++++++++ core/internal/parsing/handler.go | 4 +++ core/internal/parsing/service.go | 16 +++++++-- core/internal/pim/handler.go | 9 +++-- 9 files changed, 104 insertions(+), 5 deletions(-) create mode 100644 core/internal/ai/refused.go diff --git a/core/api/fragments/_shared.yaml b/core/api/fragments/_shared.yaml index a4ff93eba..d8a5a3783 100644 --- a/core/api/fragments/_shared.yaml +++ b/core/api/fragments/_shared.yaml @@ -235,6 +235,17 @@ components: application/json: schema: $ref: "#/components/schemas/WireError" + AIRefused: + description: >- + The AI provider refused the saved key (code ai_refused; a 401 or 403 + from the provider). It is not AI off: a key is saved and was not + accepted. The message names the provider and says an administrator + can enter the key again in Tech Admin, AI Settings. Nothing answers a + canned result for it. ADR 0001 envelope. + content: + application/json: + schema: + $ref: "#/components/schemas/WireError" AIOffEither: description: >- AI is off (code ai_off): no key for the chosen provider, a stored key diff --git a/core/api/fragments/parsing.yaml b/core/api/fragments/parsing.yaml index 135ce6eda..ae743f990 100644 --- a/core/api/fragments/parsing.yaml +++ b/core/api/fragments/parsing.yaml @@ -59,6 +59,7 @@ paths: "415": { $ref: "#/components/responses/UnsupportedType" } "422": { $ref: "#/components/responses/UnprocessableEntity" } "500": { $ref: "#/components/responses/InternalError" } + "502": { $ref: "#/components/responses/AIRefused" } components: schemas: ParsingParseResponse: diff --git a/core/api/fragments/product.yaml b/core/api/fragments/product.yaml index e56f986ba..4ff1f5b04 100644 --- a/core/api/fragments/product.yaml +++ b/core/api/fragments/product.yaml @@ -434,6 +434,8 @@ paths: $ref: '#/components/responses/PayloadTooLarge' '422': $ref: '#/components/responses/UnprocessableEntity' + '502': + $ref: '#/components/responses/AIRefused' '500': $ref: '#/components/responses/InternalError' /api/v1/products/{id}/pim/generate/descriptions: @@ -477,6 +479,8 @@ paths: $ref: '#/components/responses/PayloadTooLarge' '422': $ref: '#/components/responses/UnprocessableEntity' + '502': + $ref: '#/components/responses/AIRefused' '500': $ref: '#/components/responses/InternalError' /api/v1/products/{id}/pim/generate/image: @@ -570,6 +574,8 @@ paths: $ref: '#/components/responses/PayloadTooLarge' '422': $ref: '#/components/responses/UnprocessableEntity' + '502': + $ref: '#/components/responses/AIRefused' '500': $ref: '#/components/responses/InternalError' /api/v1/products/{id}/margins: diff --git a/core/internal/ai/anthropic.go b/core/internal/ai/anthropic.go index cdd21f1ba..5c424642f 100644 --- a/core/internal/ai/anthropic.go +++ b/core/internal/ai/anthropic.go @@ -196,6 +196,9 @@ func (c *Client) anthropicMessages(ctx context.Context, req anthropicRequest) (* if parsed && ar.Error != nil && ar.Error.Message != "" { msg = ar.Error.Message } + if refused := refusedIfAuth("Anthropic", resp.StatusCode, scrub(msg)); refused != nil { + return nil, refused + } return nil, fmt.Errorf("Anthropic API error (%d): %s", resp.StatusCode, scrub(msg)) } if !parsed { diff --git a/core/internal/ai/openrouter.go b/core/internal/ai/openrouter.go index 62fda13a1..b088d142c 100644 --- a/core/internal/ai/openrouter.go +++ b/core/internal/ai/openrouter.go @@ -523,6 +523,9 @@ func (c *Client) chatCompletion(ctx context.Context, req chatRequest) (*chatResp if json.Unmarshal(respBody, &errResp) == nil && errResp.Error != nil { msg = errResp.Error.Message } + if refused := refusedIfAuth("OpenRouter", resp.StatusCode, providerWords(msg, apiKey)); refused != nil { + return nil, refused + } return nil, fmt.Errorf("OpenRouter API error (%d): %s", resp.StatusCode, providerWords(msg, apiKey)) } diff --git a/core/internal/ai/refused.go b/core/internal/ai/refused.go new file mode 100644 index 000000000..3c71e6b41 --- /dev/null +++ b/core/internal/ai/refused.go @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package ai + +import ( + "errors" + "fmt" + "net/http" +) + +// CodeAIRefused is the stable wire code every AI feature answers when the +// provider refused the saved key (a 401 or 403 from the provider). It is not +// AI off: a key is saved, and the provider did not accept it. Clients branch +// on the code, never on the message. +const CodeAIRefused = "ai_refused" + +// ErrKeyRefused is the sentinel every provider refusal matches (errors.Is). +var ErrKeyRefused = errors.New("the AI provider refused the saved key") + +// RefusedError is a provider's refusal of the saved key. Message is the plain +// sentence a person reads (RefusedMessage); Error() is the provider's own +// words for logs and the admin's detail, scrubbed of the key before it lands. +type RefusedError struct { + Provider string + Message string + Detail string +} + +func (e *RefusedError) Error() string { return e.Detail } +func (e *RefusedError) Is(target error) bool { return target == ErrKeyRefused } + +// refusalWords is what a person reads when provider (a display name) refused +// the saved key. +func refusalWords(provider string) string { + return fmt.Sprintf("%s refused the saved key. An administrator can enter it again in Tech Admin, AI Settings.", provider) +} + +// refusedIfAuth answers a RefusedError for a provider answer that means the +// key is not accepted (401 unauthorized, 403 forbidden), and nil otherwise. +func refusedIfAuth(provider string, status int, detail string) error { + if status != http.StatusUnauthorized && status != http.StatusForbidden { + return nil + } + return &RefusedError{Provider: provider, Message: refusalWords(provider), Detail: fmt.Sprintf("%s API error (%d): %s", provider, status, detail)} +} + +// RefusedMessage reports whether err is a provider refusal of the saved key +// and the sentence to show. A wrapped error is found through errors.As. +func RefusedMessage(err error) (string, bool) { + var re *RefusedError + if errors.As(err, &re) { + return re.Message, true + } + return "", false +} diff --git a/core/internal/parsing/handler.go b/core/internal/parsing/handler.go index 8e48b6b7f..de4ed550f 100644 --- a/core/internal/parsing/handler.go +++ b/core/internal/parsing/handler.go @@ -145,6 +145,10 @@ func (h *Handler) HandleUpload(w http.ResponseWriter, r *http.Request) { httpx.WriteError(w, r, &httpx.Error{Status: http.StatusUnsupportedMediaType, Code: "unsupported_type", Message: msg}) return } + if msg, ok := ai.RefusedMessage(extractErr); ok { + httpx.WriteError(w, r, &httpx.Error{Status: http.StatusBadGateway, Code: ai.CodeAIRefused, Message: msg, Operator: true}) + return + } slog.Error("Failed to extract items", "error", extractErr) httputil.RespondError(w, r, "Failed to extract items from file", http.StatusInternalServerError, extractErr) return diff --git a/core/internal/parsing/service.go b/core/internal/parsing/service.go index 8d13919ea..62305085e 100644 --- a/core/internal/parsing/service.go +++ b/core/internal/parsing/service.go @@ -70,6 +70,16 @@ func (s *Service) ExtractItemsWithAI(ctx context.Context, fileBytes []byte, cont // the provider cannot read. return nil, false, aiErr } + if errors.Is(aiErr, ai.ErrKeyRefused) { + // The provider refused the saved key: a text list is still read + // by the rule-based reader, and anything else is refused in words, + // never answered with the canned list. + slog.Error("AI provider refused the saved key", "error", aiErr, "content_type", contentType) + if isTextContent(contentType) { + return s.ExtractItems(string(fileBytes)), false, nil + } + return nil, false, aiErr + } slog.Error("AI extraction failed; returning the canned demo material list, NOT the uploaded file's contents", "error", aiErr, "content_type", contentType, "bytes_discarded", len(fileBytes)) return s.ExtractItems(generateFallbackMaterialList()), true, nil @@ -138,11 +148,11 @@ func generateFallbackMaterialList() string { 10 sheets - CDX Plywood 1/2 4x8 15 pcs - 2x10x16 Hem Fir 8 pcs - 2x12x20 -20 bags - Quikrete 80lb -4 rolls - Tyvek House Wrap +20 bags - Concrete mix 80lb +4 rolls - House wrap 100 lf - 2x4 Pressure Treated Custom powder-coat railing 12ft bronze -6 pcs - Simpson Strong-Tie A35 +6 pcs - Framing angle A35 Specialty glass panel 48x72 frosted` } diff --git a/core/internal/pim/handler.go b/core/internal/pim/handler.go index 39200bb0a..06b256414 100644 --- a/core/internal/pim/handler.go +++ b/core/internal/pim/handler.go @@ -326,9 +326,14 @@ func (h *Handler) HandleDeleteCollateral(w http.ResponseWriter, r *http.Request) } // respondAIOff answers an AI off error as the module's error envelope: a 409 -// with the stable code ai_off and the plain sentence a person reads. It -// reports whether it answered. +// with the stable code ai_off and the plain sentence a person reads; a +// provider's refusal of the saved key is a 502 with the code ai_refused and +// its own sentence. It reports whether it answered. func respondAIOff(w http.ResponseWriter, r *http.Request, err error) bool { + if msg, ok := ai.RefusedMessage(err); ok { + httpx.WriteError(w, r, &httpx.Error{Status: http.StatusBadGateway, Code: ai.CodeAIRefused, Message: msg, Operator: true}) + return true + } msg, ok := ai.OffMessage(err) if !ok { return false From 039db59ae200b76a34c7f50a611a8a98c8551f51 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 13:45:31 -0700 Subject: [PATCH 11/39] AI settings: saving a key tests it and the document reads the last test A save runs the provider test and stores its result; the document's test_status is none, untested, passed, refused or failed, with the plain sentence for the last two, and the test route stores its result the same way. The characterization harness points the provider at a local stand-in so a save's test never reaches the network; the two goldens gain test_status. Item 130, N7. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/api/fragments/admin.yaml | 17 +++- .../internal/characterization/harness_test.go | 23 +++-- .../testdata/goldens/machine_key.json | 1 + .../testdata/goldens/techadmin_settings.json | 5 + core/internal/techadmin/ai_fix1_test.go | 7 +- core/internal/techadmin/model.go | 25 ++++- core/internal/techadmin/service.go | 95 +++++++++++++++++-- core/internal/techadmin/tx_test.go | 4 +- core/internal/techadmin/wire_test.go | 2 +- 9 files changed, 155 insertions(+), 24 deletions(-) diff --git a/core/api/fragments/admin.yaml b/core/api/fragments/admin.yaml index 11ee1a3b0..60775ecc5 100644 --- a/core/api/fragments/admin.yaml +++ b/core/api/fragments/admin.yaml @@ -751,7 +751,7 @@ components: $ref: "#/components/schemas/ApiKey" AISettings: type: object - required: [configured, source, key_hint, base_url, revision, provider, model, anthropic_configured, anthropic_key_hint, vault_present] + required: [configured, source, key_hint, base_url, revision, provider, model, anthropic_configured, anthropic_key_hint, vault_present, test_status] description: >- Go type techadmin.AISettings. source is admin (a stored override), env (a key from the environment only) or none. key_hint and base_url @@ -779,15 +779,28 @@ components: type: [string, "null"] vault_present: type: boolean + test_status: + type: string + enum: [none, untested, passed, refused, failed] + description: >- + The last test of the saved key: none with no key saved, untested + for a key never tested, passed, refused (the provider refused the + key) or failed (the test could not finish). A save runs the test. + test_message: + type: string + description: The plain sentence for refused and failed; absent otherwise. AdminTestAIResult: type: object - required: [ok, message] + required: [ok, message, status] description: Go type techadmin.TestResult. A provider failure is ok false with its words, never the key. properties: ok: type: boolean message: type: string + status: + type: string + enum: [passed, refused, failed] RoutingSettings: type: object required: [configured, source, key_hint, revision] diff --git a/core/internal/characterization/harness_test.go b/core/internal/characterization/harness_test.go index 5e8199c07..b73b8b27c 100644 --- a/core/internal/characterization/harness_test.go +++ b/core/internal/characterization/harness_test.go @@ -18,6 +18,7 @@ import ( "net" "net/http" "net/http/cookiejar" + "net/http/httptest" "net/url" "os" "os/exec" @@ -351,15 +352,25 @@ func (h *harness) startServer(t *testing.T, dbURL, buildDir string, extraEnv map t.Fatalf("create server log: %v", err) } + // Saving an AI key tests it with the provider (item 130, N7), so the + // server's provider is a local stand-in that answers every call: the + // goldens never reach the network and never depend on its answer. + aiStub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"model":"stub","choices":[{"message":{"content":"ok"},"finish_reason":"stop"}]}`)) + })) + t.Cleanup(aiStub.Close) + cmd := exec.Command(filepath.Join(buildDir, "core"), "serve") cmd.Dir = srvDir env := map[string]string{ - "PORT": strconv.Itoa(port), - "DATABASE_URL": dbURL, - "AUTH_MODE": "dev", - "LOG_LEVEL": "ERROR", - "EDI_OUTPUT_DIR": filepath.Join(srvDir, "edi_out"), - "TZ": "Etc/UTC", + "OPENROUTER_BASE_URL": aiStub.URL, + "PORT": strconv.Itoa(port), + "DATABASE_URL": dbURL, + "AUTH_MODE": "dev", + "LOG_LEVEL": "ERROR", + "EDI_OUTPUT_DIR": filepath.Join(srvDir, "edi_out"), + "TZ": "Etc/UTC", // The harness client is the loopback peer and rotates // X-Forwarded-For (see do()), so loopback is its trusted proxy. "TRUSTED_PROXIES": "127.0.0.1/32,::1/128", diff --git a/core/internal/characterization/testdata/goldens/machine_key.json b/core/internal/characterization/testdata/goldens/machine_key.json index 53913761b..8fbb95508 100644 --- a/core/internal/characterization/testdata/goldens/machine_key.json +++ b/core/internal/characterization/testdata/goldens/machine_key.json @@ -365,6 +365,7 @@ "provider": "openrouter", "revision": 1, "source": "none", + "test_status": "none", "vault_present": false } } diff --git a/core/internal/characterization/testdata/goldens/techadmin_settings.json b/core/internal/characterization/testdata/goldens/techadmin_settings.json index 8f493223b..231e9925b 100644 --- a/core/internal/characterization/testdata/goldens/techadmin_settings.json +++ b/core/internal/characterization/testdata/goldens/techadmin_settings.json @@ -18,6 +18,7 @@ "provider": "openrouter", "revision": 1, "source": "none", + "test_status": "none", "vault_present": false } } @@ -172,6 +173,7 @@ "provider": "openrouter", "revision": 1, "source": "none", + "test_status": "none", "vault_present": false } } @@ -201,6 +203,7 @@ "provider": "openrouter", "revision": 2, "source": "admin", + "test_status": "passed", "vault_present": false } } @@ -224,6 +227,7 @@ "provider": "openrouter", "revision": 2, "source": "admin", + "test_status": "passed", "vault_present": false } } @@ -284,6 +288,7 @@ "provider": "openrouter", "revision": 3, "source": "none", + "test_status": "none", "vault_present": false } } diff --git a/core/internal/techadmin/ai_fix1_test.go b/core/internal/techadmin/ai_fix1_test.go index 93165b5c5..9af38279c 100644 --- a/core/internal/techadmin/ai_fix1_test.go +++ b/core/internal/techadmin/ai_fix1_test.go @@ -79,8 +79,9 @@ func TestWire_SaveTakesEffectAtOnce(t *testing.T) { if r.body["ok"] != true { t.Fatalf("test right after the save = %s", r.raw) } - if n := atomic.LoadInt32(&hits); n != 1 { - t.Errorf("the Anthropic server received %d calls, want 1", n) + // The save tested the key itself (N7) and the press tested it again. + if n := atomic.LoadInt32(&hits); n != 2 { + t.Errorf("the Anthropic server received %d calls, want 2", n) } // A delete takes effect at once too: the old key is not used again. @@ -93,7 +94,7 @@ func TestWire_SaveTakesEffectAtOnce(t *testing.T) { if r.body["ok"] != false { t.Errorf("test after delete = %s", r.raw) } - if n := atomic.LoadInt32(&hits); n != 1 { + if n := atomic.LoadInt32(&hits); n != 2 { t.Errorf("a deleted key still reached the server (%d calls)", n) } } diff --git a/core/internal/techadmin/model.go b/core/internal/techadmin/model.go index 9f7cb7e05..33be6dea0 100644 --- a/core/internal/techadmin/model.go +++ b/core/internal/techadmin/model.go @@ -52,7 +52,10 @@ const ( const ( settingAIKey = "openrouter_api_key" settingAIBaseURL = "openrouter_base_url" - settingORSKey = "openrouteservice_api_key" + // settingAITest holds the last test of the saved key: {"status", "message"}. + // Never a key; the message is the provider's refusal in words. + settingAITest = "ai_last_test" + settingORSKey = "openrouteservice_api_key" ) // AISettings is the AI settings document: the key's status (never the key @@ -73,6 +76,14 @@ type AISettings struct { AnthropicConfigured bool `json:"anthropic_configured"` AnthropicKeyHint *string `json:"anthropic_key_hint"` VaultPresent bool `json:"vault_present"` + + // The last test of the saved key (N7): "none" with no key saved, + // "untested" for a key never tested, then "passed", "refused" (the + // provider refused the key) or "failed" (the test could not finish). + // TestMessage is the plain sentence for refused and failed. A save + // runs the test, so a fresh key reads from its own test. + TestStatus string `json:"test_status"` + TestMessage string `json:"test_message,omitempty"` } // AISave is the AI settings write: every field is optional except that a @@ -89,8 +100,20 @@ type AISave struct { type TestResult struct { OK bool `json:"ok"` Message string `json:"message"` + // Status is "passed", "refused" or "failed", the value the settings + // document's test_status takes from this test. + Status string `json:"status"` } +// AI test statuses, the settings document's test_status. +const ( + AITestNone = "none" + AITestUntested = "untested" + AITestPassed = "passed" + AITestRefused = "refused" + AITestFailed = "failed" +) + // RoutingSettings is the routing (OpenRouteService) settings document, the // AI settings' shape without the base URL. type RoutingSettings struct { diff --git a/core/internal/techadmin/service.go b/core/internal/techadmin/service.go index af54405b1..91d5fa017 100644 --- a/core/internal/techadmin/service.go +++ b/core/internal/techadmin/service.go @@ -11,6 +11,7 @@ import ( "encoding/json" "errors" "fmt" + "log/slog" "net/http" "strconv" "strings" @@ -461,9 +462,77 @@ func (s *Service) GetAISettings(ctx context.Context) (*AISettings, error) { if hasBase { out.BaseURL = &baseURL } + if err := s.readAITest(ctx, out); err != nil { + return nil, err + } return out, nil } +// aiTestRow is the stored last test of the saved key. +type aiTestRow struct { + Status string `json:"status"` + Message string `json:"message,omitempty"` +} + +// readAITest fills the document's test status from the stored last test: +// "none" when no key is saved for the chosen provider, "untested" when one is +// saved and was never tested, else the stored result. +func (s *Service) readAITest(ctx context.Context, out *AISettings) error { + out.TestStatus, out.TestMessage = AITestNone, "" + if !out.Configured { + return nil + } + out.TestStatus = AITestUntested + raw, ok, err := s.repo.ReadSetting(ctx, settingAITest) + if err != nil { + return err + } + if !ok { + return nil + } + var row aiTestRow + if json.Unmarshal([]byte(raw), &row) != nil { + return nil + } + switch row.Status { + case AITestPassed, AITestRefused, AITestFailed: + out.TestStatus, out.TestMessage = row.Status, row.Message + } + return nil +} + +// runAITest calls the provider once and stores the result for the settings +// document. A provider failure is an answer, not an error; only a storage +// failure is returned. The message is the provider's refusal in words, never +// the key. +func (s *Service) runAITest(ctx context.Context) (TestResult, error) { + res := TestResult{OK: true, Status: AITestPassed, Message: "The provider answered."} + switch { + case s.aiTester == nil: + res = TestResult{Status: AITestFailed, Message: "AI is off: the AI client is not available on this server"} + default: + if err := s.aiTester.TestConnection(ctx); err != nil { + if words, refused := ai.RefusedMessage(err); refused { + res = TestResult{Status: AITestRefused, Message: words} + } else { + res = TestResult{Status: AITestFailed, Message: err.Error()} + } + } + } + if s.aiTester == nil || s.repo == nil { + return res, nil + } + stored := aiTestRow{Status: res.Status, Message: res.Message} + if res.Status == AITestPassed { + stored.Message = "" + } + row, err := json.Marshal(stored) + if err != nil { + return res, err + } + return res, s.repo.WriteSetting(ctx, settingAITest, string(row)) +} + // SaveAISettings writes the OpenRouter key and, when the body names it, the // base URL (the original save, kept for its callers). func (s *Service) SaveAISettings(ctx context.Context, apiKey string, baseURL *string, pre Precondition) (*AISettings, error) { @@ -574,19 +643,27 @@ func (s *Service) SaveAISettingsFull(ctx context.Context, in AISave, pre Precond return nil, err } s.invalidateAI() + // Saving a key tests it with the provider (N7), so the document the + // admin reads says whether the key works, not only that one is saved. The + // test is the provider's answer, never a reason to refuse the save. + if out.Configured && s.aiTester != nil { + if _, terr := s.runAITest(ctx); terr != nil { + return nil, terr + } + return s.GetAISettings(ctx) + } return out, nil } -// TestAIConnection runs the test connection action. A provider failure is an -// answer (ok false and its words), not an error. +// TestAIConnection runs the test connection action and stores its result as +// the last test of the saved key. A provider failure is an answer (ok false +// and its words), not an error. func (s *Service) TestAIConnection(ctx context.Context) TestResult { - if s.aiTester == nil { - return TestResult{OK: false, Message: "AI is off: the AI client is not available on this server"} - } - if err := s.aiTester.TestConnection(ctx); err != nil { - return TestResult{OK: false, Message: err.Error()} + res, err := s.runAITest(ctx) + if err != nil { + slog.Error("failed to store the AI test result", "error", err) } - return TestResult{OK: true, Message: "The provider answered."} + return res } // SealLegacySettings seals any AI key row still stored in plain text, the @@ -635,7 +712,7 @@ func (s *Service) DeleteAISettings(ctx context.Context, pre Precondition) error if err := s.repo.DeleteSetting(ctx, settingAIBaseURL); err != nil { return err } - for _, k := range []string{ai.SettingAnthropicKey, ai.SettingProvider, ai.SettingAnthropicModel} { + for _, k := range []string{ai.SettingAnthropicKey, ai.SettingProvider, ai.SettingAnthropicModel, settingAITest} { if err := s.repo.DeleteSetting(ctx, k); err != nil { return err } diff --git a/core/internal/techadmin/tx_test.go b/core/internal/techadmin/tx_test.go index 306535e2c..698a5d018 100644 --- a/core/internal/techadmin/tx_test.go +++ b/core/internal/techadmin/tx_test.go @@ -60,11 +60,11 @@ func countRows(t *testing.T, db *database.DB, sql string, args ...any) int { func cleanSettings(t *testing.T, db *database.DB) { t.Helper() ctx := context.Background() - _, _ = db.Pool.Exec(ctx, `DELETE FROM system_settings WHERE key IN ('openrouter_api_key','openrouter_base_url','openrouteservice_api_key','anthropic_api_key','ai.provider','ai.anthropic.model')`) + _, _ = db.Pool.Exec(ctx, `DELETE FROM system_settings WHERE key IN ('openrouter_api_key','openrouter_base_url','openrouteservice_api_key','anthropic_api_key','ai.provider','ai.anthropic.model','ai_last_test')`) _, _ = db.Pool.Exec(ctx, `DELETE FROM admin_revisions WHERE resource LIKE 'admin.settings.%'`) _, _ = db.Pool.Exec(ctx, `DELETE FROM events_outbox WHERE entity_type = 'admin_settings'`) t.Cleanup(func() { - _, _ = db.Pool.Exec(ctx, `DELETE FROM system_settings WHERE key IN ('openrouter_api_key','openrouter_base_url','openrouteservice_api_key','anthropic_api_key','ai.provider','ai.anthropic.model')`) + _, _ = db.Pool.Exec(ctx, `DELETE FROM system_settings WHERE key IN ('openrouter_api_key','openrouter_base_url','openrouteservice_api_key','anthropic_api_key','ai.provider','ai.anthropic.model','ai_last_test')`) _, _ = db.Pool.Exec(ctx, `DELETE FROM admin_revisions WHERE resource LIKE 'admin.settings.%'`) _, _ = db.Pool.Exec(ctx, `DELETE FROM events_outbox WHERE entity_type = 'admin_settings'`) }) diff --git a/core/internal/techadmin/wire_test.go b/core/internal/techadmin/wire_test.go index 4fc2f645a..a5a24cd89 100644 --- a/core/internal/techadmin/wire_test.go +++ b/core/internal/techadmin/wire_test.go @@ -91,7 +91,7 @@ func (v keyValidator) ValidateKey(ctx context.Context, rawKey string) (middlewar // each test starts from the same settings and leaves nothing behind. func (f *fixture) cleanup() { ctx := context.Background() - _, _ = f.db.Pool.Exec(ctx, `DELETE FROM system_settings WHERE key IN ('openrouter_api_key','openrouter_base_url','openrouteservice_api_key','anthropic_api_key','ai.provider','ai.anthropic.model')`) + _, _ = f.db.Pool.Exec(ctx, `DELETE FROM system_settings WHERE key IN ('openrouter_api_key','openrouter_base_url','openrouteservice_api_key','anthropic_api_key','ai.provider','ai.anthropic.model','ai_last_test')`) _, _ = f.db.Pool.Exec(ctx, `DELETE FROM admin_revisions WHERE resource LIKE 'admin.settings.%'`) _, _ = f.db.Pool.Exec(ctx, `DELETE FROM events_outbox WHERE entity_type = 'admin_settings'`) } From e8190d0ea0bf7676b7336ae5cb65a96e45c00afd Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 13:45:31 -0700 Subject: [PATCH 12/39] AI: no words or brands in a drawing, plain names in the sample list and the import The illustration prompt asks for no words, labels, brand names or logos and no longer lists the text elements. The sample list and its test fixtures, and the demo import's framing angle, carry plain product words. Item 130, N5, N6 and N8. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/internal/app/seed/demodata/catalog.jsonl | 2 +- core/internal/parsing/service_test.go | 4 ++-- core/internal/pim/service.go | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/core/internal/app/seed/demodata/catalog.jsonl b/core/internal/app/seed/demodata/catalog.jsonl index b02a203c5..d2638f3ba 100644 --- a/core/internal/app/seed/demodata/catalog.jsonl +++ b/core/internal/app/seed/demodata/catalog.jsonl @@ -704,7 +704,7 @@ {"sku":"CN_HT1","description":"ProFrame Hurricane Tie H1","department":"Yard","category_slug":"hardware.connectors","vendor_key":"PROFRAME","uom_primary":"EA","alternate_uoms":[],"species":"Galvanized Steel","grade":"Hurricane Tie","treatment":"G90","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":0.3,"cost":1.25,"price":2.02,"contractor_price":1.72,"vip_price":1.52,"target_margin":38.0,"tax_class":"TAXABLE","image_family":"framing_connector","barcode":"2000100004931","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":82,"reorder_point":24,"reorder_qty":82},"WK":{"on_hand":24,"reorder_point":24,"reorder_qty":82},"LK":{"on_hand":8,"reorder_point":24,"reorder_qty":82}}} {"sku":"CN_HT2","description":"ProFrame Hurricane Tie H2.5","department":"Yard","category_slug":"hardware.connectors","vendor_key":"PROFRAME","uom_primary":"EA","alternate_uoms":[],"species":"Galvanized Steel","grade":"Hurricane Tie","treatment":"G90","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":0.5,"cost":1.55,"price":2.5,"contractor_price":2.13,"vip_price":1.88,"target_margin":38.0,"tax_class":"TAXABLE","image_family":"framing_connector","barcode":"2000100004938","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":83,"reorder_point":24,"reorder_qty":83},"WK":{"on_hand":24,"reorder_point":24,"reorder_qty":83},"LK":{"on_hand":8,"reorder_point":24,"reorder_qty":83}}} {"sku":"CN_HD88","description":"ProFrame Hold Down HD88","department":"Yard","category_slug":"hardware.connectors","vendor_key":"PROFRAME","uom_primary":"EA","alternate_uoms":[],"species":"Galvanized Steel","grade":"Hold Down","treatment":"G90","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":1.5,"cost":3.05,"price":4.92,"contractor_price":4.18,"vip_price":3.69,"target_margin":38.0,"tax_class":"TAXABLE","image_family":"framing_connector","barcode":"2000100004945","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":89,"reorder_point":26,"reorder_qty":89},"WK":{"on_hand":26,"reorder_point":26,"reorder_qty":89},"LK":{"on_hand":8,"reorder_point":26,"reorder_qty":89}}} -{"sku":"CN_A35","description":"ProFrame Angle A35","department":"Yard","category_slug":"hardware.connectors","vendor_key":"PROFRAME","uom_primary":"EA","alternate_uoms":[],"species":"Galvanized Steel","grade":"Angle","treatment":"G90","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":0.25,"cost":1.18,"price":1.9,"contractor_price":1.62,"vip_price":1.43,"target_margin":38.0,"tax_class":"TAXABLE","image_family":"framing_connector","barcode":"2000100004952","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":82,"reorder_point":24,"reorder_qty":82},"WK":{"on_hand":24,"reorder_point":24,"reorder_qty":82},"LK":{"on_hand":8,"reorder_point":24,"reorder_qty":82}}} +{"sku":"CN_A35","description":"Framing Angle A35","department":"Yard","category_slug":"hardware.connectors","vendor_key":"PROFRAME","uom_primary":"EA","alternate_uoms":[],"species":"Galvanized Steel","grade":"Angle","treatment":"G90","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":0.25,"cost":1.18,"price":1.9,"contractor_price":1.62,"vip_price":1.43,"target_margin":38.0,"tax_class":"TAXABLE","image_family":"framing_connector","barcode":"2000100004952","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":82,"reorder_point":24,"reorder_qty":82},"WK":{"on_hand":24,"reorder_point":24,"reorder_qty":82},"LK":{"on_hand":8,"reorder_point":24,"reorder_qty":82}}} {"sku":"CN_LUS28","description":"ProFrame LUS28 Hanger","department":"Yard","category_slug":"hardware.connectors","vendor_key":"PROFRAME","uom_primary":"EA","alternate_uoms":[],"species":"Galvanized Steel","grade":"Joist Hanger","treatment":"G90","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":0.4,"cost":1.4,"price":2.26,"contractor_price":1.92,"vip_price":1.7,"target_margin":38.0,"tax_class":"TAXABLE","image_family":"framing_connector","barcode":"2000100004959","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":82,"reorder_point":24,"reorder_qty":82},"WK":{"on_hand":24,"reorder_point":24,"reorder_qty":82},"LK":{"on_hand":8,"reorder_point":24,"reorder_qty":82}}} {"sku":"CN_LUS210","description":"ProFrame LUS210 Hanger","department":"Yard","category_slug":"hardware.connectors","vendor_key":"PROFRAME","uom_primary":"EA","alternate_uoms":[],"species":"Galvanized Steel","grade":"Joist Hanger","treatment":"G90","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":0.55,"cost":1.63,"price":2.62,"contractor_price":2.23,"vip_price":1.97,"target_margin":38.0,"tax_class":"TAXABLE","image_family":"framing_connector","barcode":"2000100004966","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":83,"reorder_point":24,"reorder_qty":83},"WK":{"on_hand":24,"reorder_point":24,"reorder_qty":83},"LK":{"on_hand":8,"reorder_point":24,"reorder_qty":83}}} {"sku":"CN_H10A","description":"ProFrame H10A Anchor","department":"Yard","category_slug":"hardware.connectors","vendor_key":"PROFRAME","uom_primary":"EA","alternate_uoms":[],"species":"Galvanized Steel","grade":"Anchor","treatment":"G90","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":0.4,"cost":1.4,"price":2.26,"contractor_price":1.92,"vip_price":1.7,"target_margin":38.0,"tax_class":"TAXABLE","image_family":"framing_connector","barcode":"2000100004973","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":82,"reorder_point":24,"reorder_qty":82},"WK":{"on_hand":24,"reorder_point":24,"reorder_qty":82},"LK":{"on_hand":8,"reorder_point":24,"reorder_qty":82}}} diff --git a/core/internal/parsing/service_test.go b/core/internal/parsing/service_test.go index 8aae1d444..ac6caf33e 100644 --- a/core/internal/parsing/service_test.go +++ b/core/internal/parsing/service_test.go @@ -116,8 +116,8 @@ func testCatalog() []product.Product { {ID: uuid.MustParse("10000000-0000-0000-0000-000000000003"), SKU: "OSB-716-4X8", Description: "OSB 7/16 4x8 Sheathing", UOMPrimary: "PCS", BasePrice: 18.99}, {ID: uuid.MustParse("10000000-0000-0000-0000-000000000004"), SKU: "CDX-12-4X8", Description: "CDX Plywood 1/2 4x8", UOMPrimary: "PCS", BasePrice: 32.50}, {ID: uuid.MustParse("10000000-0000-0000-0000-000000000005"), SKU: "2X10-16-HF", Description: "2x10x16 Hem Fir #2", UOMPrimary: "PCS", BasePrice: 22.75}, - {ID: uuid.MustParse("10000000-0000-0000-0000-000000000006"), SKU: "QUIK-80", Description: "Quikrete 80lb Concrete Mix", UOMPrimary: "BAG", BasePrice: 5.99}, - {ID: uuid.MustParse("10000000-0000-0000-0000-000000000007"), SKU: "TYVEK-9X150", Description: "Tyvek HomeWrap 9x150", UOMPrimary: "RL", BasePrice: 169.00}, + {ID: uuid.MustParse("10000000-0000-0000-0000-000000000006"), SKU: "CONC-80", Description: "Concrete Mix 80lb", UOMPrimary: "BAG", BasePrice: 5.99}, + {ID: uuid.MustParse("10000000-0000-0000-0000-000000000007"), SKU: "HWRAP-9X150", Description: "House Wrap 9x150", UOMPrimary: "RL", BasePrice: 169.00}, {ID: uuid.MustParse("10000000-0000-0000-0000-000000000008"), SKU: "2X4-PT-8", Description: "2x4x8 Pressure Treated #2", UOMPrimary: "PCS", BasePrice: 8.99}, } } diff --git a/core/internal/pim/service.go b/core/internal/pim/service.go index 07a7e3ee8..d8eb95e57 100644 --- a/core/internal/pim/service.go +++ b/core/internal/pim/service.go @@ -427,8 +427,8 @@ House style, one consistent set: - Flat illustration with gentle shading: two or three tones per material, a soft ground shadow, no outline heavier than 2 units. - Transparent background: no full canvas rectangle. The object's own colours carry it (warm natural wood, neutral greys for metal, one accent colour for a tool body). - One clear silhouette that reads small, the obvious angle, the whole object inside the canvas with at least 16 units of margin. -- No text, except one invented brand word where a label is natural (a box, a bag, a can, a tool body). Never a real brand or logo. -- Use only svg, g, path, rect, circle, ellipse, line, polyline, polygon, defs, linearGradient, radialGradient, stop, clipPath, text and tspan, with plain presentation attributes (fill, stroke, opacity and the like). A gradient has at most two stops. +- No words, labels, brand names or logos anywhere on the product or the canvas: no lettering, no numbers, no marks that read as a brand. +- Use only svg, g, path, rect, circle, ellipse, line, polyline, polygon, defs, linearGradient, radialGradient, stop and clipPath, with plain presentation attributes (fill, stroke, opacity and the like). A gradient has at most two stops. - Never use style, script, image, use, filter, animation, links or any external reference. - Keep it under 12 KB.` From ef9062439276c8747185e8481cd98527d2552cad Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 13:45:31 -0700 Subject: [PATCH 13/39] desk: the provider's refusal in words, and AI on only after a passed test The intake and the PIM generate tabs show the refusal where it happened, the AI settings read the last test (AI on, AI off with the provider named, or not tested yet) and say what a save's test found. The generated types follow the contract. Item 130, N3, N4 and N7. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- docs/refactor/CONTRACT-CHANGES.md | 1 + .../src/components/inventory/PimAiNotice.ts | 23 ++++++++++++++++ .../src/components/quotes/MaterialIntake.ts | 16 ++++++++++- web/apps/desk/src/lib/aiWords.ts | 27 ++++++++++++++++--- .../pages/admin/tech_admin/TechAdminPage.ts | 27 ++++++++++++++----- .../inventory/tabs/ProductCollateralTab.ts | 10 +++++-- .../inventory/tabs/ProductContentTab.test.ts | 15 ++++++----- .../pages/inventory/tabs/ProductContentTab.ts | 7 ++++- .../src/pages/inventory/tabs/ProductSEOTab.ts | 7 ++++- .../desk/src/services/TechAdminService.ts | 4 +++ web/apps/desk/src/services/parsing.service.ts | 16 +++-------- web/packages/api-client/src/schema.d.ts | 22 +++++++++++++++ 12 files changed, 143 insertions(+), 32 deletions(-) create mode 100644 web/apps/desk/src/components/inventory/PimAiNotice.ts diff --git a/docs/refactor/CONTRACT-CHANGES.md b/docs/refactor/CONTRACT-CHANGES.md index 147c70b8f..40fcd4a33 100644 --- a/docs/refactor/CONTRACT-CHANGES.md +++ b/docs/refactor/CONTRACT-CHANGES.md @@ -427,3 +427,4 @@ get an entry. | SEED-HYGIENE (item 108 b) | migration 118 `staff_demo_domain` (operational, no route) | Migration 080 left three demo staff on `@gable.com`, a real domain. | Migration 118 rewrites those three addresses to `@gablebuildingsupply.example`; staff ids and grants are unchanged. | | POS-RECEIPT-1 (item 111) | pos: `GET /api/v1/pos/transactions/{id}/receipt`, `POST /api/v1/pos/transactions/{id}/receipt/email` (new routes) | The Sale complete card's Print receipt and Email receipt called the documents print route and the invoice email route, mounted for admin, owner, sales and finance, so a cashier got a 403 from both. | The counter's own pair, mounted for admin, owner and cashier with the same reach as the sale read (the branch context, the wall and a bound key's pin; a sale the caller cannot read is the sale read's 404). Print answers the sale's invoice document by the documents print route's own renderer (same body and content type); email sends the sale's invoice to the account's contact by the invoice email route's own function (same 202 `{"status":"queued"}`, same delivery record). A sale that is not completed or has no invoice is a 409 blocker `no_receipt`; an email with no contact (a sale that names no customer or the shared walk in customer, whatever address that row holds, or an account with no email address) is a 409 blocker `no_contact` in words, and nothing is sent; only a completed sale has a receipt (a voided or held sale that keeps its invoice is `no_receipt`). Both routes are WALLED in the bound key allowlist. The documents routes and their roles are unchanged. `posTileList`'s description now says a counter sale's own invoice (origin POS) is not counted. | | AI-CLAUDE-FIX1 | `POST /api/v1/parsing/upload`, `POST /api/v1/purchase-orders/{id}/freight`; `GET /api/v1/apps` golden `apps`; pim: `POST /api/v1/products/{id}/pim/generate/image` | A file of a type the chosen provider cannot read (a BMP under Anthropic) got the canned demo list at the intake and a bare 400 at freight. The apps catalog described the AI client as "OpenRouter client". Image generation under Anthropic answered 409 AI off. | The unreadable type answers 415 `unsupported_type` in the wire envelope with the words naming what the provider reads; no canned list. The catalog line reads "Provider routed AI client (OpenRouter or Anthropic), KeyStore, and model routing (library)." and the `apps` golden is re-recorded for that one string. Image generation answers 202 under Anthropic and draws an illustration, stored only after a strict sanitiser. | +| G1-AI-WORDS (item 130, N3, N4, N7) | `POST /api/v1/parsing/upload` and `POST /api/v1/products/{id}/pim/generate/{descriptions,seo,collateral}` (new 502 `ai_refused`); admin: `GET` and `PUT /api/v1/admin/settings/ai` (document gains `test_status`, `test_message`), `POST /api/v1/admin/settings/ai/test` (answer gains `status`); goldens `techadmin_settings`, `machine_key` | A provider's 401 or 403 on the saved key reached the intake as the canned sample list with "No AI reader is set up", PIM Generate as a bare 500, and Tech Admin read "AI on" for any saved key. | A refusal answers 502 in the wire envelope with the code `ai_refused` and the words " refused the saved key. An administrator can enter it again in Tech Admin, AI Settings."; a text list is still read by the rule reader. A save of AI settings runs the test with the provider and stores its result; the document's `test_status` is `none`, `untested`, `passed`, `refused` or `failed` with `test_message` for the last two, and the test route stores its result the same way. The image route is unchanged (generation runs after it answers; a refusal there is the media row's failed status). The characterization harness points the server's provider at a local stand-in so a save's test never reaches the network. | diff --git a/web/apps/desk/src/components/inventory/PimAiNotice.ts b/web/apps/desk/src/components/inventory/PimAiNotice.ts new file mode 100644 index 000000000..517489d3c --- /dev/null +++ b/web/apps/desk/src/components/inventory/PimAiNotice.ts @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Surface-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +import { html, nothing, type TemplateResult } from 'lit'; +import { ApiError } from '../../services/apiError'; + +/** + * The words of a provider's refusal (code ai_refused) from a failed PIM + * generate call, or null for any other failure (those stay a toast). + */ +export function aiRefusalWords(err: unknown): string | null { + return err instanceof ApiError && err.code === 'ai_refused' ? err.message : null; +} + +/** The notice a generate tab shows where the refusal happened, until the next try. */ +export function renderAiNotice(words: string | null): TemplateResult | typeof nothing { + if (!words) return nothing; + return html` + + `; +} diff --git a/web/apps/desk/src/components/quotes/MaterialIntake.ts b/web/apps/desk/src/components/quotes/MaterialIntake.ts index 88b7bb3ab..f17416459 100644 --- a/web/apps/desk/src/components/quotes/MaterialIntake.ts +++ b/web/apps/desk/src/components/quotes/MaterialIntake.ts @@ -7,6 +7,7 @@ import { icon } from '../../lib/icons'; import { Upload, Check, X, AlertTriangle, Loader2, Package, ShieldAlert, ListChecks } from 'lucide'; import type { MatchedProduct, ParseResponse, ParsedItem } from '../../types/parsing'; import { ParsingService } from '../../services/parsing.service'; +import { ApiError } from '../../services/apiError'; /** A line at or above this confidence is accepted by "Accept confident lines". */ const CONFIDENT = 0.9; @@ -28,6 +29,8 @@ export class GableMaterialIntake extends LitElement { @state() private _pasted = ''; @state() private _busy = false; @state() private _error: string | null = null; + /** The provider's refusal of the saved key, in the server's words. */ + @state() private _refused: string | null = null; @state() private _result: ParseResponse | null = null; @state() private _items: ParsedItem[] = []; @state() private _open: Set = new Set(); @@ -36,6 +39,7 @@ export class GableMaterialIntake extends LitElement { private async _run(task: () => Promise) { this._busy = true; this._error = null; + this._refused = null; try { const result = await task(); this._result = result; @@ -44,7 +48,11 @@ export class GableMaterialIntake extends LitElement { this._expanded = true; this.dispatchEvent(new CustomEvent('parse-complete', { detail: result, bubbles: true, composed: true })); } catch (err) { - this._error = err instanceof Error ? err.message : 'Failed to parse the material list'; + if (err instanceof ApiError && err.code === 'ai_refused') { + this._refused = err.message; + } else { + this._error = err instanceof Error ? err.message : 'Failed to parse the material list'; + } } finally { this._busy = false; } @@ -213,6 +221,12 @@ export class GableMaterialIntake extends LitElement { ${this._error ? html`` : nothing} + ${this._refused ? html` + + ` : nothing} ${result ? html` ${result.synthetic ? html` diff --git a/web/apps/desk/src/lib/aiWords.ts b/web/apps/desk/src/lib/aiWords.ts index 9d197ec1a..91f0de840 100644 --- a/web/apps/desk/src/lib/aiWords.ts +++ b/web/apps/desk/src/lib/aiWords.ts @@ -9,11 +9,16 @@ export type AIProvider = 'openrouter' | 'anthropic'; +/** The last test of the saved key, as the settings document reports it. */ +export type AITestStatus = 'none' | 'untested' | 'passed' | 'refused' | 'failed'; + /** The slice of the AI settings document the words read. */ export interface AIState { provider: AIProvider; model: string; configured: boolean; + /** AI is on only after a passed test; absent reads as untested. */ + test_status?: AITestStatus; } /** "Anthropic" or "OpenRouter". */ @@ -29,17 +34,33 @@ export function modelLabel(model: string): string { return m[3] !== undefined ? `${family} ${m[2]}.${m[3]}` : `${family} ${m[2]}`; } -/** The Tech Admin status line: what is on, or what to enter. */ +/** True only for a saved key whose last test passed: the one state AI is on. */ +export function aiIsOn(s: AIState | null): boolean { + return !!s && s.configured && s.test_status === 'passed'; +} + +/** The Tech Admin status line: what is on, what was refused, or what to enter. */ export function aiStatusLine(s: AIState | null): string { if (!s) return 'AI settings are loading.'; const name = providerLabel(s.provider); if (!s.configured) return `Enter your ${name} API key to enable AI-powered features.`; - return s.provider === 'anthropic' ? `AI on: ${name}, ${modelLabel(s.model)}` : `AI on: ${name}`; + switch (s.test_status) { + case 'passed': + return s.provider === 'anthropic' ? `AI on: ${name}, ${modelLabel(s.model)}` : `AI on: ${name}`; + case 'refused': + return `AI off: ${name} refused the saved key`; + case 'failed': + return `AI off: the test of the saved ${name} key did not pass`; + default: + return 'Key saved, not tested yet. Press Test connection to turn AI on.'; + } } /** The status card's headline. */ export function aiHeadline(s: AIState | null): string { - return s?.configured ? 'AI Features Active' : 'AI Features Inactive'; + if (aiIsOn(s)) return 'AI Features Active'; + if (!s?.configured) return 'AI Features Inactive'; + return s.test_status === 'refused' || s.test_status === 'failed' ? 'AI Features Off' : 'AI Features Not Tested'; } /** What PIM's image generation makes with the chosen provider. */ diff --git a/web/apps/desk/src/pages/admin/tech_admin/TechAdminPage.ts b/web/apps/desk/src/pages/admin/tech_admin/TechAdminPage.ts index ba92c40ba..e3f5bbf9d 100644 --- a/web/apps/desk/src/pages/admin/tech_admin/TechAdminPage.ts +++ b/web/apps/desk/src/pages/admin/tech_admin/TechAdminPage.ts @@ -7,7 +7,7 @@ import { icon } from '../../../lib/icons.ts'; import { cn } from '../../../lib/utils.ts'; import './MarketCheckSettings.ts'; import { techAdminService, ediService } from '../../../services/TechAdminService'; -import { aiHeadline, aiStatusLine, imageWords, modelLabel, providerLabel } from '../../../lib/aiWords.ts'; +import { aiHeadline, aiIsOn, aiStatusLine, imageWords, modelLabel, providerLabel } from '../../../lib/aiWords.ts'; import type { APIKey, AISettings, RoutingSettings, EDITradingPartner, StaffMember, ModuleInfo, Readiness } from '../../../services/TechAdminService'; import { Key, Globe, Activity, Plus, Trash2, Copy, Check, Eye, Sparkles, @@ -168,8 +168,8 @@ export class TechAdminPage extends LitElement { this.aiNewKey = ''; this.aiNewBaseUrl = ''; this.aiShowInput = false; - this.aiSuccess = 'API key saved. All AI features are now active.'; await this._loadAISettings(); + this._noteSavedKey('API key saved'); } catch (err) { this.aiError = err instanceof Error ? err.message : 'Failed to save'; } finally { @@ -184,8 +184,8 @@ export class TechAdminPage extends LitElement { try { await techAdminService.saveAIProvider(this.aiProvider, this.aiModel, this.aiAnthropicKey.trim() || undefined); this.aiAnthropicKey = ''; - this.aiSuccess = 'AI provider saved.'; await this._loadAISettings(); + this._noteSavedKey('AI provider saved'); } catch (err) { this.aiError = err instanceof Error ? err.message : 'Failed to save'; } finally { @@ -193,11 +193,25 @@ export class TechAdminPage extends LitElement { } } + /** The saved key was tested by the save: say what that test found. */ + private _noteSavedKey(saved: string) { + const s = this.aiSettings; + if (s?.test_status === 'passed') { + this.aiSuccess = `${saved} and tested. AI is on.`; + } else if (s?.test_status === 'refused' || s?.test_status === 'failed') { + this.aiSuccess = null; + this.aiError = s.test_message || aiStatusLine(s); + } else { + this.aiSuccess = `${saved}.`; + } + } + private async _handleTestAI() { this.aiTesting = true; this.aiTestResult = null; try { this.aiTestResult = await techAdminService.testAIConnection(); + await this._loadAISettings(); } catch (err) { this.aiTestResult = { ok: false, message: err instanceof Error ? err.message : 'Test failed' }; } finally { @@ -597,11 +611,11 @@ export class TechAdminPage extends LitElement { ${this._renderProviderCard()} -
+
-
- ${this.aiSettings?.configured ? icon(Check, 20) : icon(AlertCircle, 20)} +
+ ${aiIsOn(this.aiSettings) ? icon(Check, 20) : icon(AlertCircle, 20)}

@@ -612,6 +626,7 @@ export class TechAdminPage extends LitElement {

Provider: ${providerLabel(savedProvider)}${isAnthropic ? html`, model ${modelLabel(this.aiSettings.model)}` : nothing}

Key: ${isAnthropic ? this.aiSettings.anthropic_key_hint : this.aiSettings.key_hint}

Source: ${this.aiSettings.source === 'admin' ? 'Admin configured' : 'Environment variable'}

+ ${this.aiSettings.test_status === 'refused' || this.aiSettings.test_status === 'failed' ? html`

${this.aiSettings.test_message}

` : nothing} ${this.aiSettings.base_url && !isAnthropic ? html`

Base URL: ${this.aiSettings.base_url}

` : nothing}

` : html`

${aiStatusLine(this.aiSettings)}

`} diff --git a/web/apps/desk/src/pages/inventory/tabs/ProductCollateralTab.ts b/web/apps/desk/src/pages/inventory/tabs/ProductCollateralTab.ts index 802421008..eb9f20b2d 100644 --- a/web/apps/desk/src/pages/inventory/tabs/ProductCollateralTab.ts +++ b/web/apps/desk/src/pages/inventory/tabs/ProductCollateralTab.ts @@ -8,6 +8,7 @@ import { Sparkles, Trash2, Copy, Loader2, FileText, RefreshCw, AlertTriangle } f import { PIMService } from '../../../services/PIMService.ts'; import { ToastService } from '../../../lib/toast-service.ts'; import type { PIMCollateral, CollateralType } from '../../../types/pim.ts'; +import { aiRefusalWords, renderAiNotice } from '../../../components/inventory/PimAiNotice.ts'; const TYPES: { value: CollateralType; label: string }[] = [ { value: 'sell_sheet', label: 'Sell Sheet' }, @@ -31,6 +32,7 @@ export class GableProductCollateralTab extends LitElement { @state() private tone = 'professional'; @state() private audience = 'contractors and builders'; @state() private generating = false; + @state() private aiNotice: string | null = null; @state() private copiedId: string | null = null; @state() private error: string | null = null; @@ -44,12 +46,15 @@ export class GableProductCollateralTab extends LitElement { private async _handleGenerate() { this.generating = true; this.error = null; + this.aiNotice = null; try { await PIMService.generateCollateral(this.productId, { type: this.type, tone: this.tone, audience: this.audience }); this.dispatchEvent(new CustomEvent('collateral-update', { bubbles: true, composed: true })); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : 'Collateral generation failed'; - this.error = msg; + this.aiNotice = aiRefusalWords(err); + if (!this.aiNotice) { + this.error = err instanceof Error ? err.message : 'Collateral generation failed'; + } } finally { this.generating = false; } @@ -78,6 +83,7 @@ export class GableProductCollateralTab extends LitElement { render() { return html`
+ ${renderAiNotice(this.aiNotice)}

diff --git a/web/apps/desk/src/pages/inventory/tabs/ProductContentTab.test.ts b/web/apps/desk/src/pages/inventory/tabs/ProductContentTab.test.ts index de12b466c..9039122c2 100644 --- a/web/apps/desk/src/pages/inventory/tabs/ProductContentTab.test.ts +++ b/web/apps/desk/src/pages/inventory/tabs/ProductContentTab.test.ts @@ -10,8 +10,11 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import './ProductContentTab' import './ProductSEOTab' import './ProductCollateralTab' +import type { LitElement } from 'lit' import { mount, flush, q, text, update } from '../../../test/dom' +type Tab = LitElement & { productId: string } + const WORDS = 'Anthropic refused the saved key. An administrator can enter it again in Tech Admin, AI Settings.' beforeEach(() => { @@ -34,26 +37,26 @@ function press(el: HTMLElement, label: string) { describe('PIM generate shows a provider refusal on the page', () => { it('on the description tab', async () => { - const el = await mount('gable-product-content-tab', { productId: 'p1' }) + const el = await mount('gable-product-content-tab', { productId: 'p1' }) press(el, 'Generate') await flush() - await update(el as never, {}) + await update(el, {}) expect(text(q(el, '[data-testid="pim-ai-notice"]'))).toContain(WORDS) }) it('on the SEO tab', async () => { - const el = await mount('gable-product-seo-tab', { productId: 'p1' }) + const el = await mount('gable-product-seo-tab', { productId: 'p1' }) press(el, 'Generate') await flush() - await update(el as never, {}) + await update(el, {}) expect(text(q(el, '[data-testid="pim-ai-notice"]'))).toContain(WORDS) }) it('on the collateral tab', async () => { - const el = await mount('gable-product-collateral-tab', { productId: 'p1' }) + const el = await mount('gable-product-collateral-tab', { productId: 'p1' }) press(el, 'Generate') await flush() - await update(el as never, {}) + await update(el, {}) expect(text(q(el, '[data-testid="pim-ai-notice"]'))).toContain(WORDS) }) }) diff --git a/web/apps/desk/src/pages/inventory/tabs/ProductContentTab.ts b/web/apps/desk/src/pages/inventory/tabs/ProductContentTab.ts index 2e1a444e6..c07f27cd1 100644 --- a/web/apps/desk/src/pages/inventory/tabs/ProductContentTab.ts +++ b/web/apps/desk/src/pages/inventory/tabs/ProductContentTab.ts @@ -9,6 +9,7 @@ import { PIMService } from '../../../services/PIMService.ts'; import { ToastService } from '../../../lib/toast-service.ts'; import { apiErrorMessage } from '../../../services/apiError'; import type { PIMContent } from '../../../types/pim.ts'; +import { aiRefusalWords, renderAiNotice } from '../../../components/inventory/PimAiNotice.ts'; const TONES = ['professional', 'casual', 'technical', 'persuasive']; const AUDIENCES = ['contractors and builders', 'DIY homeowners', 'architects and designers', 'wholesale buyers']; @@ -23,6 +24,7 @@ export class GableProductContentTab extends LitElement { @state() private tone = 'professional'; @state() private audience = 'contractors and builders'; @state() private generating = false; + @state() private aiNotice: string | null = null; @state() private saving = false; @state() private shortDesc = ''; @state() private longDesc = ''; @@ -43,12 +45,14 @@ export class GableProductContentTab extends LitElement { private async _handleGenerate() { this.generating = true; + this.aiNotice = null; try { const result = await PIMService.generateDescriptions(this.productId, { tone: this.tone, audience: this.audience }); this.dispatchEvent(new CustomEvent('content-update', { detail: result, bubbles: true, composed: true })); } catch (err) { console.error('Generate failed:', err); - ToastService.show(apiErrorMessage(err, 'Failed to generate content'), 'error'); + this.aiNotice = aiRefusalWords(err); + if (!this.aiNotice) ToastService.show(apiErrorMessage(err, 'Failed to generate content'), 'error'); } finally { this.generating = false; } @@ -97,6 +101,7 @@ export class GableProductContentTab extends LitElement { render() { return html`
+ ${renderAiNotice(this.aiNotice)}
diff --git a/web/apps/desk/src/pages/inventory/tabs/ProductSEOTab.ts b/web/apps/desk/src/pages/inventory/tabs/ProductSEOTab.ts index 5eaedbf27..50056a66a 100644 --- a/web/apps/desk/src/pages/inventory/tabs/ProductSEOTab.ts +++ b/web/apps/desk/src/pages/inventory/tabs/ProductSEOTab.ts @@ -9,6 +9,7 @@ import { PIMService } from '../../../services/PIMService.ts'; import { ToastService } from '../../../lib/toast-service.ts'; import { apiErrorMessage } from '../../../services/apiError'; import type { PIMContent } from '../../../types/pim.ts'; +import { aiRefusalWords, renderAiNotice } from '../../../components/inventory/PimAiNotice.ts'; @customElement('gable-product-seo-tab') export class GableProductSEOTab extends LitElement { @@ -18,6 +19,7 @@ export class GableProductSEOTab extends LitElement { @property({ attribute: false }) content: PIMContent | null = null; @state() private generating = false; + @state() private aiNotice: string | null = null; @state() private saving = false; @state() private _seoTitle = ''; @state() private _seoDescription = ''; @@ -39,12 +41,14 @@ export class GableProductSEOTab extends LitElement { private async _handleGenerate() { this.generating = true; + this.aiNotice = null; try { const result = await PIMService.generateSEO(this.productId, { target_keywords: this.keywords.length > 0 ? this.keywords : [] }); this.dispatchEvent(new CustomEvent('content-update', { detail: result, bubbles: true, composed: true })); } catch (err) { console.error('Generate SEO failed:', err); - ToastService.show(apiErrorMessage(err, 'Failed to generate SEO content'), 'error'); + this.aiNotice = aiRefusalWords(err); + if (!this.aiNotice) ToastService.show(apiErrorMessage(err, 'Failed to generate SEO content'), 'error'); } finally { this.generating = false; } @@ -83,6 +87,7 @@ export class GableProductSEOTab extends LitElement { render() { return html`
+ ${renderAiNotice(this.aiNotice)}
diff --git a/web/apps/desk/src/services/TechAdminService.ts b/web/apps/desk/src/services/TechAdminService.ts index accd585da..3da39f2e1 100644 --- a/web/apps/desk/src/services/TechAdminService.ts +++ b/web/apps/desk/src/services/TechAdminService.ts @@ -38,11 +38,15 @@ export interface AISettings { anthropic_configured: boolean; anthropic_key_hint: string | null; vault_present: boolean; + /** The last test of the saved key; a save runs the test. */ + test_status: 'none' | 'untested' | 'passed' | 'refused' | 'failed'; + test_message?: string; } export interface AITestResult { ok: boolean; message: string; + status?: 'passed' | 'refused' | 'failed'; } export interface RoutingSettings { diff --git a/web/apps/desk/src/services/parsing.service.ts b/web/apps/desk/src/services/parsing.service.ts index 6ca015f1f..43e42667a 100644 --- a/web/apps/desk/src/services/parsing.service.ts +++ b/web/apps/desk/src/services/parsing.service.ts @@ -3,6 +3,7 @@ import type { ParseResponse } from '../types/parsing'; import { fetchWithAuth } from './fetchClient'; +import { parseApiError } from './apiError'; const API_URL = import.meta.env.VITE_API_URL || ''; @@ -24,18 +25,9 @@ export const ParsingService = { timeout: AI_PARSE_TIMEOUT, }); - if (!response.ok) { - const errorText = await response.text(); - // The wire error envelope carries the words a person reads. - let words = ''; - try { - const body = JSON.parse(errorText) as { error?: { message?: string } }; - words = body.error?.message ?? ''; - } catch { - words = errorText; - } - throw new Error(words || 'Failed to parse material list'); - } + // The wire error envelope carries the words a person reads and the + // code a caller branches on (ai_refused, unsupported_type). + if (!response.ok) throw await parseApiError(response, 'Failed to parse material list'); return response.json(); }, diff --git a/web/packages/api-client/src/schema.d.ts b/web/packages/api-client/src/schema.d.ts index 84c3cf3fc..b8eb323cf 100644 --- a/web/packages/api-client/src/schema.d.ts +++ b/web/packages/api-client/src/schema.d.ts @@ -7487,11 +7487,20 @@ export interface components { anthropic_configured: boolean; anthropic_key_hint: string | null; vault_present: boolean; + /** + * @description The last test of the saved key: none with no key saved, untested for a key never tested, passed, refused (the provider refused the key) or failed (the test could not finish). A save runs the test. + * @enum {string} + */ + test_status: "none" | "untested" | "passed" | "refused" | "failed"; + /** @description The plain sentence for refused and failed; absent otherwise. */ + test_message?: string; }; /** @description Go type techadmin.TestResult. A provider failure is ok false with its words, never the key. */ AdminTestAIResult: { ok: boolean; message: string; + /** @enum {string} */ + status: "passed" | "refused" | "failed"; }; /** @description Go type techadmin.RoutingSettings, the AI settings' shape without the base URL. */ RoutingSettings: { @@ -14669,6 +14678,15 @@ export interface components { "application/json": components["schemas"]["WireError"]; }; }; + /** @description The AI provider refused the saved key (code ai_refused; a 401 or 403 from the provider). It is not AI off: a key is saved and was not accepted. The message names the provider and says an administrator can enter the key again in Tech Admin, AI Settings. Nothing answers a canned result for it. ADR 0001 envelope. */ + AIRefused: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WireError"]; + }; + }; /** @description AI is off (code ai_off): no key for the chosen provider, a stored key the vault cannot open, or a feature the chosen provider does not offer. The message says that an administrator turns it on in Tech Admin, AI. Or the idempotency layer reports the Idempotency-Key in progress (code idempotency_in_progress). Both are the ADR 0001 envelope. */ AIOffEither: { headers: { @@ -23297,6 +23315,7 @@ export interface operations { 415: components["responses"]["UnsupportedType"]; 422: components["responses"]["UnprocessableEntity"]; 500: components["responses"]["InternalError"]; + 502: components["responses"]["AIRefused"]; }; }; partnerDashboard: { @@ -26891,6 +26910,7 @@ export interface operations { 413: components["responses"]["PayloadTooLarge"]; 422: components["responses"]["UnprocessableEntity"]; 500: components["responses"]["InternalError"]; + 502: components["responses"]["AIRefused"]; }; }; productPimGenerateDescriptions: { @@ -26929,6 +26949,7 @@ export interface operations { 413: components["responses"]["PayloadTooLarge"]; 422: components["responses"]["UnprocessableEntity"]; 500: components["responses"]["InternalError"]; + 502: components["responses"]["AIRefused"]; }; }; productPimGenerateImage: { @@ -27005,6 +27026,7 @@ export interface operations { 413: components["responses"]["PayloadTooLarge"]; 422: components["responses"]["UnprocessableEntity"]; 500: components["responses"]["InternalError"]; + 502: components["responses"]["AIRefused"]; }; }; productUpdateMargins: { From b5172c562ead3506645dd4247ec9cbf758d36196 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 13:45:34 -0700 Subject: [PATCH 14/39] contract: assemble openapi.yaml from the AI fragments Item 130, N3, N4 and N7. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/api/openapi.yaml | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/core/api/openapi.yaml b/core/api/openapi.yaml index d889a3c88..dee856931 100644 --- a/core/api/openapi.yaml +++ b/core/api/openapi.yaml @@ -9085,6 +9085,7 @@ paths: "415": {$ref: "#/components/responses/UnsupportedType"} "422": {$ref: "#/components/responses/UnprocessableEntity"} "500": {$ref: "#/components/responses/InternalError"} + "502": {$ref: "#/components/responses/AIRefused"} /api/partner/v1/dashboard: get: operationId: partnerDashboard @@ -12955,6 +12956,8 @@ paths: $ref: '#/components/responses/UnprocessableEntity' '500': $ref: '#/components/responses/InternalError' + '502': + $ref: '#/components/responses/AIRefused' /api/v1/products/{id}/pim/generate/descriptions: post: operationId: productPimGenerateDescriptions @@ -12998,6 +13001,8 @@ paths: $ref: '#/components/responses/UnprocessableEntity' '500': $ref: '#/components/responses/InternalError' + '502': + $ref: '#/components/responses/AIRefused' /api/v1/products/{id}/pim/generate/image: post: operationId: productPimGenerateImage @@ -13085,6 +13090,8 @@ paths: $ref: '#/components/responses/UnprocessableEntity' '500': $ref: '#/components/responses/InternalError' + '502': + $ref: '#/components/responses/AIRefused' /api/v1/products/{id}/margins: patch: operationId: productUpdateMargins @@ -15798,6 +15805,13 @@ components: application/json: schema: $ref: "#/components/schemas/WireError" + AIRefused: + description: >- + The AI provider refused the saved key (code ai_refused; a 401 or 403 from the provider). It is not AI off: a key is saved and was not accepted. The message names the provider and says an administrator can enter the key again in Tech Admin, AI Settings. Nothing answers a canned result for it. ADR 0001 envelope. + content: + application/json: + schema: + $ref: "#/components/schemas/WireError" AIOffEither: description: >- AI is off (code ai_off): no key for the chosen provider, a stored key the vault cannot open, or a feature the chosen provider does not offer. The message says that an administrator turns it on in Tech Admin, AI. Or the idempotency layer reports the Idempotency-Key in progress (code idempotency_in_progress). Both are the ADR 0001 envelope. @@ -16578,7 +16592,7 @@ components: $ref: "#/components/schemas/ApiKey" AISettings: type: object - required: [configured, source, key_hint, base_url, revision, provider, model, anthropic_configured, anthropic_key_hint, vault_present] + required: [configured, source, key_hint, base_url, revision, provider, model, anthropic_configured, anthropic_key_hint, vault_present, test_status] description: >- Go type techadmin.AISettings. source is admin (a stored override), env (a key from the environment only) or none. key_hint and base_url are null when absent, never omitted. properties: @@ -16604,15 +16618,26 @@ components: type: [string, "null"] vault_present: type: boolean + test_status: + type: string + enum: [none, untested, passed, refused, failed] + description: >- + The last test of the saved key: none with no key saved, untested for a key never tested, passed, refused (the provider refused the key) or failed (the test could not finish). A save runs the test. + test_message: + type: string + description: The plain sentence for refused and failed; absent otherwise. AdminTestAIResult: type: object - required: [ok, message] + required: [ok, message, status] description: Go type techadmin.TestResult. A provider failure is ok false with its words, never the key. properties: ok: type: boolean message: type: string + status: + type: string + enum: [passed, refused, failed] RoutingSettings: type: object required: [configured, source, key_hint, revision] From fe14a3695d7f6f9efb758961862622b8b5eb04b8 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 14:06:40 -0700 Subject: [PATCH 15/39] e2e: a placeholder AI key saved in Tech Admin reads off, not active The save tests the key with the provider, and a placeholder never passes. Item 130, N7. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- web/e2e/admin.spec.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/web/e2e/admin.spec.ts b/web/e2e/admin.spec.ts index c7eb07246..f860bd23a 100644 --- a/web/e2e/admin.spec.ts +++ b/web/e2e/admin.spec.ts @@ -76,8 +76,11 @@ test.describe('Tech admin flow on the new contract', () => { // The saved state shows the admin override with its masked hint and the // base URL it stored; the save carried the revision it read, so it - // cannot have clobbered a second editor's write. - await expect(page.getByText('AI Features Active')).toBeVisible(); + // cannot have clobbered a second editor's write. The save tested the + // key with the provider (item 130, N7): a placeholder key never passes, + // whether the provider refuses it or cannot be reached, so the card + // reads off rather than active. + await expect(page.getByText('AI Features Off')).toBeVisible(); await expect(page.getByText('Admin configured')).toBeVisible(); await page.screenshot({ path: path.join(SHOTS_DIR, 'admin-ai-saved.png') }); From ab73248e74091511d707303e7277d4c14c9fbcc7 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 15:10:28 -0700 Subject: [PATCH 16/39] test: freight invoice reading answers a provider's 401 in words (red) Item 130, review round 1 P2-1. The route answers a bare 400. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- .../purchase_order/ai_refused_wire_test.go | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 core/internal/purchase_order/ai_refused_wire_test.go diff --git a/core/internal/purchase_order/ai_refused_wire_test.go b/core/internal/purchase_order/ai_refused_wire_test.go new file mode 100644 index 000000000..a19a6296f --- /dev/null +++ b/core/internal/purchase_order/ai_refused_wire_test.go @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package purchase_order_test + +import ( + "bytes" + "context" + "encoding/json" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/gablelbm/gable/internal/ai" + "github.com/gablelbm/gable/internal/purchase_order" + "github.com/gablelbm/gable/internal/testutil" + "github.com/gablelbm/gable/pkg/branchctx" + "github.com/google/uuid" +) + +// When the provider refuses the saved key (a 401) the freight invoice upload +// answers 502 with the stable code ai_refused and the sentence naming what to +// do, the same answer as the intake and PIM, never a bare "Bad Request". The +// file is not kept for a read that failed. +func TestWire_FreightUploadAnswersAProviderRefusalInWords(t *testing.T) { + db := testutil.RequireDB(t) + ctx := branchctx.WithSystem(context.Background()) + vendor, po := uuid.New(), uuid.New() + branch := `(SELECT value::uuid FROM system_settings WHERE key = 'default_branch_id')` + must := func(sql string, args ...any) { + t.Helper() + if _, err := db.Pool.Exec(ctx, sql, args...); err != nil { + t.Fatalf("%s: %v", sql, err) + } + } + must(`INSERT INTO vendors (id, name) VALUES ($1, $2)`, vendor, "refused-"+vendor.String()[:6]) + must(`INSERT INTO purchase_orders (id, vendor_id, status, source, branch_id) VALUES ($1, $2, 'RECEIVED', 'MANUAL', `+branch+`)`, po, vendor) + t.Cleanup(func() { + _, _ = db.Pool.Exec(ctx, `DELETE FROM purchase_orders WHERE id = $1`, po) + _, _ = db.Pool.Exec(ctx, `DELETE FROM vendors WHERE id = $1`, vendor) + }) + + // The upload directory is relative to the working directory: keep it out + // of the package folder. + wd, _ := os.Getwd() + dir := t.TempDir() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(wd) }) + + an := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"type":"error","error":{"type":"authentication_error","message":"invalid x-api-key"}}`)) + })) + defer an.Close() + client := ai.NewClient("").WithProvider(ai.ProviderAnthropic). + WithAnthropicKey("test-" + "key-not-real").WithAnthropicBaseURL(an.URL) + + svc := purchase_order.NewService(purchase_order.NewRepository(db), db, nil, nil, nil, nil) + svc.WithAIClient(client) + h := purchase_order.NewHandler(svc, nil) + + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + fw, _ := mw.CreateFormFile("file", "freight.pdf") + _, _ = fw.Write([]byte("%PDF-1.4 not a real invoice")) + _ = mw.Close() + req := httptest.NewRequest(http.MethodPost, "/api/v1/purchase-orders/x/freight", &buf) + req.Header.Set("Content-Type", mw.FormDataContentType()) + req.SetPathValue("id", po.String()) + rec := httptest.NewRecorder() + h.HandleUploadFreight(rec, req) + + if rec.Code != http.StatusBadGateway { + t.Fatalf("status = %d, want 502; body %s", rec.Code, rec.Body) + } + var b struct { + Error struct{ Code, Message string } `json:"error"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &b); err != nil { + t.Fatal(err) + } + if b.Error.Code != ai.CodeAIRefused { + t.Errorf("code = %q, want %q", b.Error.Code, ai.CodeAIRefused) + } + if !strings.Contains(b.Error.Message, "Anthropic refused the saved key") || + !strings.Contains(b.Error.Message, "enter the freight total by hand") { + t.Errorf("message = %q", b.Error.Message) + } + if strings.Contains(rec.Body.String(), "key-not-real") { + t.Error("the answer holds the key") + } + if entries, _ := os.ReadDir(dir + "/uploads/freight"); len(entries) != 0 { + t.Errorf("%d file(s) kept for a refused read, want none", len(entries)) + } +} From 14d4ed489a0635d5cd9c0e50841e1b00a46c0c5c Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 15:10:48 -0700 Subject: [PATCH 17/39] test: a refused key settles the image row refused, not failed (red) Item 130, review round 1 P2-2. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/internal/pim/ai_refused_wire_test.go | 24 +++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/core/internal/pim/ai_refused_wire_test.go b/core/internal/pim/ai_refused_wire_test.go index 428d352f3..666656f1d 100644 --- a/core/internal/pim/ai_refused_wire_test.go +++ b/core/internal/pim/ai_refused_wire_test.go @@ -53,3 +53,27 @@ func TestWire_PIMGenerateRoutesAnswerAProviderRefusalInWords(t *testing.T) { }) } } + +// The image route answers 202 and draws afterwards, so a refusal of the saved +// key cannot be its answer. The row settles "refused", a state of its own that +// the page reads in words, never "failed" with a retry that can never work. +func TestWire_PIMImageSettlesRefusedWhenTheProviderRefusesTheKey(t *testing.T) { + an := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"type":"error","error":{"type":"authentication_error","message":"invalid x-api-key"}}`)) + })) + defer an.Close() + repo := newChanRepo() + mux := http.NewServeMux() + NewHandler(newPIMTestService(repo, anthropicClient(an.URL))).RegisterRoutes(mux) + + rec := postJSON(mux, "/api/v1/products/"+uuid.New().String()+"/pim/generate/image", `{}`) + if rec.Code != http.StatusAccepted { + t.Fatalf("status = %d, want 202; body %s", rec.Code, rec.Body) + } + u := repo.wait(t) + if u.status != "refused" || u.url != "" { + t.Errorf("finalize = %+v, want refused with no url", u) + } +} From dcf9d6ccf79106ea4934837d095cae772f910a8d Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 15:12:38 -0700 Subject: [PATCH 18/39] test: OpenRouter's 403 is not a key refusal, an unreachable provider answers in words (red) Item 130, review round 1 P3-1 and P3-3. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/internal/ai/refused_status_test.go | 69 +++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 core/internal/ai/refused_status_test.go diff --git a/core/internal/ai/refused_status_test.go b/core/internal/ai/refused_status_test.go new file mode 100644 index 000000000..8e4d79bfd --- /dev/null +++ b/core/internal/ai/refused_status_test.go @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package ai + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" +) + +func openRouterAnswering(t *testing.T, status int) error { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write([]byte(`{"error":{"message":"stand-in answer"}}`)) + })) + defer srv.Close() + c := NewClient("or-" + "test-key-not-real").WithBaseURL(srv.URL) + return c.TestConnection(context.Background()) +} + +// Only a 401 is OpenRouter's refusal of the key: a 403 there means the +// model's moderation flagged the input, and the key is fine. +func TestOpenRouterRefusalIsA401Only(t *testing.T) { + if err := openRouterAnswering(t, http.StatusUnauthorized); !errors.Is(err, ErrKeyRefused) { + t.Errorf("401 = %v, want a refusal of the key", err) + } + if err := openRouterAnswering(t, http.StatusForbidden); err == nil || errors.Is(err, ErrKeyRefused) { + t.Errorf("403 = %v, want an error that is not a refusal of the key", err) + } +} + +// Anthropic's 401 and 403 (permission_error) are both a key problem. +func TestAnthropicRefusalIs401And403(t *testing.T) { + for _, status := range []int{http.StatusUnauthorized, http.StatusForbidden} { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(status) + _, _ = w.Write([]byte(`{"type":"error","error":{"type":"permission_error","message":"stand-in"}}`)) + })) + c := NewClient("").WithProvider(ProviderAnthropic).WithAnthropicKey("test-" + "key-not-real").WithAnthropicBaseURL(srv.URL) + err := c.TestConnection(context.Background()) + srv.Close() + if !errors.Is(err, ErrKeyRefused) { + t.Errorf("%d = %v, want a refusal of the key", status, err) + } + } +} + +// A provider that cannot be reached is not a refusal and not a bare transport +// error: it is an answer in words with its own type. +func TestAnUnreachableProviderAnswersInWords(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + url := srv.URL + srv.Close() + c := NewClient("").WithProvider(ProviderAnthropic).WithAnthropicKey("test-" + "key-not-real").WithAnthropicBaseURL(url) + err := c.TestConnection(context.Background()) + var u interface{ Words() string } + if !errors.As(err, &u) { + t.Fatalf("err = %v, want an unreachable answer carrying its words", err) + } + words := u.Words() + if want := "The key is saved. Anthropic could not be reached to test it; press Test connection later."; words != want { + t.Errorf("words = %q, want %q", words, want) + } +} From 137518f8781c8961f4b89d9f4e49e7c589c93b0b Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 15:12:38 -0700 Subject: [PATCH 19/39] test: the sanitiser drops a model's text and tspan with their words (red) Item 130, review round 1 P3-4. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/internal/pim/svg_nowords_test.go | 29 +++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 core/internal/pim/svg_nowords_test.go diff --git a/core/internal/pim/svg_nowords_test.go b/core/internal/pim/svg_nowords_test.go new file mode 100644 index 000000000..447e28a0e --- /dev/null +++ b/core/internal/pim/svg_nowords_test.go @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pim + +import ( + "strings" + "testing" +) + +// FROM-PRINCIPAL 118 a (N8, review round 1 P3-4): the prompt asks for no words +// on a drawing, and the sanitiser is the second guard: a model's text and +// tspan elements are dropped with their words, and the shapes stay. +func TestSanitizeSVG_DropsTheModelsWords(t *testing.T) { + in := `BRAND 100 FTLABEL` + out, err := SanitizeSVG([]byte(in)) + if err != nil { + t.Fatalf("sanitise: %v", err) + } + s := string(out) + for _, bad := range []string{" Date: Wed, 14 Oct 2026 15:12:38 -0700 Subject: [PATCH 20/39] test: the extraction prompt and the tax test hold no brand word (red) Item 130, review round 1 P3-5. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/internal/parsing/brand_words_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/internal/parsing/brand_words_test.go b/core/internal/parsing/brand_words_test.go index d7188c16b..3f255ea64 100644 --- a/core/internal/parsing/brand_words_test.go +++ b/core/internal/parsing/brand_words_test.go @@ -20,7 +20,7 @@ var brandWords = []string{ // tests hold plain product words only. func TestSampleListAndItsTestsHoldNoBrandWord(t *testing.T) { sources := map[string]string{"sample list": generateFallbackMaterialList()} - for _, name := range []string{"service.go", "service_test.go", "service_degradation_test.go"} { + for _, name := range []string{"service.go", "service_test.go", "service_degradation_test.go", "../ai/openrouter.go", "../tax/service_test.go"} { data, err := os.ReadFile(name) if err != nil { t.Fatal(err) From f67c7b8d251ab78db453a50c5a4cb6bd743b498f Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 15:12:38 -0700 Subject: [PATCH 21/39] test: no product description in the import names a maker (red) Item 130, review round 1 item 6, FROM-PRINCIPAL 118 a. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/internal/app/seed/demodata_test.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/core/internal/app/seed/demodata_test.go b/core/internal/app/seed/demodata_test.go index 194afcff5..56e6f71a1 100644 --- a/core/internal/app/seed/demodata_test.go +++ b/core/internal/app/seed/demodata_test.go @@ -708,3 +708,24 @@ func getJSON(t *testing.T, mux http.Handler, path string, dst any) { t.Fatalf("GET %s: %v", path, err) } } + +// FROM-PRINCIPAL 118 a (review round 1): a product is named for what it is, so +// no product description in the import names a maker. The invented vendor +// records and their keys stay; only the descriptions are checked. +func TestImportDescriptionsNameNoMaker(t *testing.T) { + for i, line := range strings.Split(string(catalogData), "\n") { + if strings.TrimSpace(line) == "" { + continue + } + var row struct { + SKU string `json:"sku"` + Description string `json:"description"` + } + if err := json.Unmarshal([]byte(line), &row); err != nil { + t.Fatalf("catalog line %d: %v", i+1, err) + } + if strings.Contains(strings.ToLower(row.Description), "proframe") { + t.Errorf("%s: description %q names a maker", row.SKU, row.Description) + } + } +} From 44aaf6f52bb25097e8948b0bf7c5f772a6adec18 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 15:12:38 -0700 Subject: [PATCH 22/39] test: a stored test result is tied to its revision, an unreachable save speaks in words (red) Item 130, review round 1 P3-2 and P3-3. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- .../techadmin/ai_test_revision_test.go | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 core/internal/techadmin/ai_test_revision_test.go diff --git a/core/internal/techadmin/ai_test_revision_test.go b/core/internal/techadmin/ai_test_revision_test.go new file mode 100644 index 000000000..3235a8fc0 --- /dev/null +++ b/core/internal/techadmin/ai_test_revision_test.go @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package techadmin_test + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gablelbm/gable/internal/ai" + "github.com/gablelbm/gable/internal/techadmin" +) + +// The stored test result names the settings revision it ran on: a result that +// belongs to an older revision of the settings reads as untested, so a refused +// key never reads passed because two tests finished out of order. +func TestWire_AStoredTestResultReadsUntestedOnceTheSettingsMoved(t *testing.T) { + var hits int32 + client := anthropicAnswering(t, http.StatusOK, &hits) + f := newFixtureWith(t, func(s *techadmin.Service) *techadmin.Service { + return s.WithVault(testVault(t)).WithAITester(client) + }) + r := f.do("PUT", "/api/v1/admin/settings/ai", + map[string]any{"provider": "anthropic", "anthropic_api_key": fakeAnthropicKey}, "If-Match", `"1"`) + if r.status != http.StatusOK || r.body["test_status"] != "passed" { + t.Fatalf("save = %d: %s", r.status, r.raw) + } + if _, err := f.db.Pool.Exec(context.Background(), + `UPDATE admin_revisions SET revision = revision + 1 WHERE resource = 'admin.settings.ai'`); err != nil { + t.Fatal(err) + } + get := f.do("GET", "/api/v1/admin/settings/ai", nil) + if get.body["test_status"] != "untested" { + t.Errorf("after the settings moved test_status = %v, want untested: %s", get.body["test_status"], get.raw) + } +} + +// A save whose provider cannot be reached keeps the key, answers 200, and says +// in words that the key could not be tested: never the raw transport error. +func TestWire_ASaveThatCannotReachTheProviderAnswersInWords(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + url := srv.URL + srv.Close() + client := ai.NewClient("").WithProvider(ai.ProviderAnthropic). + WithAnthropicKey(fakeAnthropicKey).WithAnthropicBaseURL(url) + f := newFixtureWith(t, func(s *techadmin.Service) *techadmin.Service { + return s.WithVault(testVault(t)).WithAITester(client) + }) + r := f.do("PUT", "/api/v1/admin/settings/ai", + map[string]any{"provider": "anthropic", "anthropic_api_key": fakeAnthropicKey}, "If-Match", `"1"`) + if r.status != http.StatusOK { + t.Fatalf("save = %d: %s", r.status, r.raw) + } + if r.body["anthropic_configured"] != true { + t.Errorf("the key was not saved: %s", r.raw) + } + if r.body["test_status"] != "failed" { + t.Errorf("test_status = %v, want failed", r.body["test_status"]) + } + want := "The key is saved. Anthropic could not be reached to test it; press Test connection later." + if msg, _ := r.body["test_message"].(string); msg != want { + t.Errorf("test_message = %q, want %q", msg, want) + } + if strings.Contains(string(r.raw), "dial tcp") { + t.Errorf("the answer holds the raw transport error: %s", r.raw) + } +} From 73bb2dce20300073501192ed010b56bd621e2193 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 15:12:38 -0700 Subject: [PATCH 23/39] test: no end to end spec names a third party host (red) Item 130, review round 1 P2-3. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- web/apps/desk/src/test/e2eNoNetwork.test.ts | 26 +++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 web/apps/desk/src/test/e2eNoNetwork.test.ts diff --git a/web/apps/desk/src/test/e2eNoNetwork.test.ts b/web/apps/desk/src/test/e2eNoNetwork.test.ts new file mode 100644 index 000000000..af5df8401 --- /dev/null +++ b/web/apps/desk/src/test/e2eNoNetwork.test.ts @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Surface-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +/** + * No end to end spec sends a request to a third party address (item 130, + * round 1): a base URL or link a spec types must be loopback or a reserved + * example domain, so CI never reaches a provider on the network. + */ +import { describe, it, expect } from 'vitest' +import { readdirSync, readFileSync } from 'node:fs' + +const E2E = new URL('../../../../e2e/', import.meta.url) +const ALLOWED = /^(127\.0\.0\.1|localhost|host\.docker\.internal|www\.w3\.org|([a-z0-9-]+\.)*example(\.com|\.org|\.net)?|[a-z0-9.-]*\.example|[a-z0-9.-]*\.invalid)$/i + +describe('end to end specs', () => { + it('name no third party host', () => { + const offenders: string[] = [] + for (const name of readdirSync(E2E).filter((f) => f.endsWith('.ts'))) { + const body = readFileSync(new URL(name, E2E), 'utf8') + for (const m of body.matchAll(/https?:\/\/([a-z0-9.-]+)/gi)) { + if (!ALLOWED.test(m[1])) offenders.push(`${name}: ${m[1]}`) + } + } + expect(offenders).toEqual([]) + }) +}) From 1f237d95ad83f92e43238083b155167e36c3fc65 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 15:12:38 -0700 Subject: [PATCH 24/39] test: a refused image row reads in words, a refused key dims the AI features (red) Item 130, review round 1 P2-2 and P3-9. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- web/apps/desk/src/lib/aiWords.test.ts | 9 +++++++++ .../pages/admin/tech_admin/AIProviderWords.test.ts | 14 ++++++++++++++ .../pages/inventory/tabs/ProductMediaTab.test.ts | 11 +++++++++++ 3 files changed, 34 insertions(+) diff --git a/web/apps/desk/src/lib/aiWords.test.ts b/web/apps/desk/src/lib/aiWords.test.ts index a2d4fce2c..ad58cbb5d 100644 --- a/web/apps/desk/src/lib/aiWords.test.ts +++ b/web/apps/desk/src/lib/aiWords.test.ts @@ -45,6 +45,15 @@ describe('aiWords', () => { expect(providerLabel('anthropic')).toBe('Anthropic'); }); + it('never tells a person to try again with a key the provider refused', () => { + for (const p of ['anthropic', 'openrouter'] as const) { + const w = imageWords(p); + expect(w.failed).not.toContain('try again'); + expect(w.failed).toContain('Tech Admin, AI Settings'); + expect(w.refused).toBe(`${providerLabel(p)} refused the saved key. An administrator can enter it again in Tech Admin, AI Settings.`); + } + }); + it('says which image kind the button makes', () => { expect(imageWords('anthropic').button).toBe('Generate illustration'); expect(imageWords('anthropic').kind).toBe('illustration'); diff --git a/web/apps/desk/src/pages/admin/tech_admin/AIProviderWords.test.ts b/web/apps/desk/src/pages/admin/tech_admin/AIProviderWords.test.ts index fe4b9dbe2..9d4b26adc 100644 --- a/web/apps/desk/src/pages/admin/tech_admin/AIProviderWords.test.ts +++ b/web/apps/desk/src/pages/admin/tech_admin/AIProviderWords.test.ts @@ -63,6 +63,20 @@ describe('Tech Admin AI words follow the chosen provider', () => { expect(body).not.toContain('AI Features Active') }) + it('dims the features grid for a key the provider refused and lights it for a passed one', async () => { + const card = (body: string) => { + const span = Array.from(document.querySelectorAll('span')).find((s) => text(s) === 'Product Illustrations') + return span?.closest('div.border') as HTMLElement + } + serve({ ...BASE, provider: 'anthropic', model: 'claude-sonnet-5-5', configured: true, anthropic_configured: true, anthropic_key_hint: '****dmin', source: 'admin', test_status: 'refused' }) + await openAITab() + expect(card('refused').className).toContain('opacity-60') + document.body.innerHTML = '' + serve({ ...BASE, provider: 'anthropic', model: 'claude-sonnet-5-5', configured: true, anthropic_configured: true, anthropic_key_hint: '****dmin', source: 'admin', test_status: 'passed' }) + await openAITab() + expect(card('passed').className).not.toContain('opacity-60') + }) + it('says a saved key was not tested yet', async () => { serve({ ...BASE, provider: 'anthropic', model: 'claude-sonnet-5-5', configured: true, anthropic_configured: true, anthropic_key_hint: '****real', source: 'admin', test_status: 'untested' }) const body = text(await openAITab()) diff --git a/web/apps/desk/src/pages/inventory/tabs/ProductMediaTab.test.ts b/web/apps/desk/src/pages/inventory/tabs/ProductMediaTab.test.ts index 6f77a2fec..5071a98ba 100644 --- a/web/apps/desk/src/pages/inventory/tabs/ProductMediaTab.test.ts +++ b/web/apps/desk/src/pages/inventory/tabs/ProductMediaTab.test.ts @@ -11,6 +11,7 @@ import { describe, it, expect, afterEach, vi } from 'vitest' import './ProductMediaTab' import type { GableProductMediaTab } from './ProductMediaTab' import { mountAsync, text } from '../../../test/dom' +import type { PIMMedia } from '../../../types/pim' function stubSettings(provider: 'openrouter' | 'anthropic' | null) { vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => { @@ -50,4 +51,14 @@ describe('the product media tab generation words', () => { const el = await mountAsync('gable-product-media-tab', { productId: 'p1' }) expect(text(el)).toContain('Generate image') }) + + it('says in words that the provider refused the key when an image row settled refused', async () => { + stubSettings('anthropic') + const refused = { id: 'm1', product_id: 'p1', media_type: 'hero', url: '', alt_text: '', status: 'refused', is_primary: false } as unknown as PIMMedia + const el = await mountAsync('gable-product-media-tab', { productId: 'p1', media: [refused] }) + const notice = el.querySelector('[data-testid="pim-ai-notice"]') + expect(text(notice)).toContain('Anthropic refused the saved key') + expect(text(notice)).toContain('Tech Admin, AI Settings') + expect(text(el)).not.toContain('Please try again') + }) }) From 7430ae72a4017d9f05edfb0d74e826efffab984b Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 15:17:53 -0700 Subject: [PATCH 25/39] freight: a refused key is answered in words, and a refused read keeps no file The freight invoice route answers 502 ai_refused with the sentence and "enter the freight total by hand"; the uploaded file is kept only once the read succeeded. Item 130, review round 1 P2-1. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/api/fragments/purchase_order.yaml | 1 + core/internal/purchase_order/handler.go | 5 +++++ core/internal/purchase_order/service.go | 30 +++++++++++++------------ 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/core/api/fragments/purchase_order.yaml b/core/api/fragments/purchase_order.yaml index 2a3b6b417..4eb688730 100644 --- a/core/api/fragments/purchase_order.yaml +++ b/core/api/fragments/purchase_order.yaml @@ -298,6 +298,7 @@ paths: "413": { $ref: "#/components/responses/PayloadTooLarge" } "415": { $ref: "#/components/responses/UnsupportedType" } "500": { $ref: "#/components/responses/InternalError" } + "502": { $ref: "#/components/responses/AIRefused" } "409": { $ref: "#/components/responses/ConflictEither" } "422": $ref: "#/components/responses/UnprocessableEntity" diff --git a/core/internal/purchase_order/handler.go b/core/internal/purchase_order/handler.go index 6768bd011..2f03fcff9 100644 --- a/core/internal/purchase_order/handler.go +++ b/core/internal/purchase_order/handler.go @@ -450,6 +450,11 @@ func (h *Handler) HandleUploadFreight(w http.ResponseWriter, r *http.Request) { httpx.WriteError(w, r, &httpx.Error{Status: http.StatusUnsupportedMediaType, Code: "unsupported_type", Message: msg}) return } + if msg, ok := ai.RefusedMessage(err); ok { + httpx.WriteError(w, r, &httpx.Error{Status: http.StatusBadGateway, Code: ai.CodeAIRefused, + Message: msg + " Until then, enter the freight total by hand.", Operator: true}) + return + } httputil.RespondError(w, r, "failed to upload freight invoice", http.StatusBadRequest, err) return } diff --git a/core/internal/purchase_order/service.go b/core/internal/purchase_order/service.go index 658a0551f..96a2fcfa7 100644 --- a/core/internal/purchase_order/service.go +++ b/core/internal/purchase_order/service.go @@ -740,7 +740,22 @@ func (s *Service) UploadFreightInvoice(ctx context.Context, poID uuid.UUID, file return nil, ai.ErrAIOff } - // Save the file to disk + // Detect content type for AI if needed + aiContentType := contentType + if aiContentType == "" || aiContentType == "application/octet-stream" { + aiContentType = http.DetectContentType(fileBytes) + } + + // Extract freight data via AI. The degradation contract — unconfigured AI is a + // hard error so the user enters the total manually — lives in extractFreight, + // which is unit-tested independently of the DB-backed PO lookup above. + result, aiRaw, err := s.extractFreight(ctx, fileBytes, aiContentType) + if err != nil { + return nil, err + } + + // Keep the file only for a read that succeeded: a refused or failed + // read leaves nothing behind. ext := strings.ToLower(filepath.Ext(filename)) if ext == "" { ext = ".bin" @@ -755,19 +770,6 @@ func (s *Service) UploadFreightInvoice(ctx context.Context, poID uuid.UUID, file return nil, fmt.Errorf("failed to save freight file: %w", err) } - // Detect content type for AI if needed - aiContentType := contentType - if aiContentType == "" || aiContentType == "application/octet-stream" { - aiContentType = http.DetectContentType(fileBytes) - } - - // Extract freight data via AI. The degradation contract — unconfigured AI is a - // hard error so the user enters the total manually — lives in extractFreight, - // which is unit-tested independently of the DB-backed PO lookup above. - result, aiRaw, err := s.extractFreight(ctx, fileBytes, aiContentType) - if err != nil { - return nil, err - } carrierName := result.CarrierName invoiceNumber := result.InvoiceNumber totalAmountCents := int64(math.Round(result.TotalAmount * 100)) From 8ddab6c24bee56673b0334dcee7749d378d6aa5b Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 15:17:53 -0700 Subject: [PATCH 26/39] pim: a refused key settles the image row refused and the page says so No retry is offered for a refusal; the media tab shows the sentence naming Tech Admin, AI Settings, and a plain failure no longer says try again. Item 130, review round 1 P2-2. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/api/fragments/product.yaml | 5 +++++ core/internal/pim/model.go | 2 +- core/internal/pim/service.go | 19 +++++++++++++++++-- web/apps/desk/src/lib/aiWords.ts | 8 ++++++-- .../pages/inventory/tabs/ProductMediaTab.ts | 8 +++++--- web/apps/desk/src/types/pim.ts | 2 +- 6 files changed, 35 insertions(+), 9 deletions(-) diff --git a/core/api/fragments/product.yaml b/core/api/fragments/product.yaml index 4ff1f5b04..04724bf3e 100644 --- a/core/api/fragments/product.yaml +++ b/core/api/fragments/product.yaml @@ -1488,6 +1488,11 @@ components: - generating - ready - failed + - refused + description: >- + refused is a generation the provider refused for the saved key + (a retry cannot work; an administrator enters the key again in + Tech Admin, AI Settings). gen_model: type: string gen_prompt: diff --git a/core/internal/pim/model.go b/core/internal/pim/model.go index 3537dfb88..4c8f67327 100644 --- a/core/internal/pim/model.go +++ b/core/internal/pim/model.go @@ -38,7 +38,7 @@ type PIMMedia struct { AltText string `json:"alt_text"` SortOrder int `json:"sort_order"` IsPrimary bool `json:"is_primary"` - Status string `json:"status"` // generating | ready | failed (async image gen) + Status string `json:"status"` // generating | ready | failed | refused (async image gen) GenModel string `json:"gen_model"` GenPrompt string `json:"gen_prompt"` GenStyle string `json:"gen_style"` diff --git a/core/internal/pim/service.go b/core/internal/pim/service.go index d8eb95e57..13e7d0d1e 100644 --- a/core/internal/pim/service.go +++ b/core/internal/pim/service.go @@ -7,6 +7,7 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "fmt" "log/slog" "strings" @@ -345,6 +346,16 @@ func (s *Service) GenerateImage(ctx context.Context, productID uuid.UUID, style, return media, nil } +// failureStatus is the settled status of a media row whose generation failed +// with err: "refused" when the provider refused the saved key (a retry cannot +// work and the page says what to do), else "failed". +func failureStatus(err error) string { + if errors.Is(err, ai.ErrKeyRefused) { + return "refused" + } + return "failed" +} + // generateImageAsync runs on a DETACHED context (the request context is canceled // once GenerateImage returns) and finalizes the placeholder media row. func (s *Service) generateImageAsync(mediaID uuid.UUID, p *product.Product, style, prompt, kind string) { @@ -364,7 +375,7 @@ func (s *Service) generateImageAsync(mediaID uuid.UUID, p *product.Product, styl dataURI, model, err = s.generateIllustrationDataURI(ctx, p, style, prompt) if err != nil { slog.Error("illustration generation failed", "media_id", mediaID, "error", err) - _ = s.repo.UpdateMediaResult(context.Background(), mediaID, "", "", style, "failed") + _ = s.repo.UpdateMediaResult(context.Background(), mediaID, "", "", style, failureStatus(err)) return } } else { @@ -376,7 +387,11 @@ func (s *Service) generateImageAsync(mediaID uuid.UUID, p *product.Product, styl svgURI, svgModel, svgErr := s.generateIllustrationDataURI(ctx, p, style, prompt) if svgErr != nil { slog.Error("image generation failed (image model + illustration fallback)", "media_id", mediaID, "image_error", genErr, "svg_error", svgErr) - _ = s.repo.UpdateMediaResult(context.Background(), mediaID, "", "", style, "failed") + status := failureStatus(svgErr) + if status == "failed" { + status = failureStatus(genErr) + } + _ = s.repo.UpdateMediaResult(context.Background(), mediaID, "", "", style, status) return } dataURI, model = svgURI, svgModel diff --git a/web/apps/desk/src/lib/aiWords.ts b/web/apps/desk/src/lib/aiWords.ts index 91f0de840..f95e10459 100644 --- a/web/apps/desk/src/lib/aiWords.ts +++ b/web/apps/desk/src/lib/aiWords.ts @@ -72,6 +72,8 @@ export interface ImageWords { feature: string; featureDescription: string; failed: string; + /** A generation the provider refused the saved key for: no retry can work. */ + refused: string; empty: string; } @@ -85,7 +87,8 @@ export function imageWords(provider: AIProvider | null | undefined): ImageWords heading: 'AI Illustration', feature: 'Product Illustrations', featureDescription: 'Claude draws a flat product illustration in the catalog style for each product', - failed: 'The illustration could not be drawn or was refused. Please try again.', + failed: 'The illustration could not be drawn. If it keeps failing, an administrator checks the key in Tech Admin, AI Settings.', + refused: 'Anthropic refused the saved key. An administrator can enter it again in Tech Admin, AI Settings.', empty: 'No images yet. Generate an illustration using AI above.', }; } @@ -96,7 +99,8 @@ export function imageWords(provider: AIProvider | null | undefined): ImageWords heading: 'AI Image Generation', feature: 'Product Image Generation', featureDescription: 'Generate professional product images for your catalog', - failed: 'Image generation failed. Please try again.', + failed: 'Image generation failed. If it keeps failing, an administrator checks the key in Tech Admin, AI Settings.', + refused: 'OpenRouter refused the saved key. An administrator can enter it again in Tech Admin, AI Settings.', empty: 'No images yet. Generate one using AI above.', }; } diff --git a/web/apps/desk/src/pages/inventory/tabs/ProductMediaTab.ts b/web/apps/desk/src/pages/inventory/tabs/ProductMediaTab.ts index 9fcc588f9..560c0dd60 100644 --- a/web/apps/desk/src/pages/inventory/tabs/ProductMediaTab.ts +++ b/web/apps/desk/src/pages/inventory/tabs/ProductMediaTab.ts @@ -10,6 +10,7 @@ import { ToastService } from '../../../lib/toast-service.ts'; import type { PIMMedia } from '../../../types/pim.ts'; import { techAdminService } from '../../../services/TechAdminService'; import { imageWords, type AIProvider } from '../../../lib/aiWords.ts'; +import { renderAiNotice } from '../../../components/inventory/PimAiNotice.ts'; const STYLES = ['', 'photographic', 'digital-art', 'cinematic', '3d-model', 'isometric']; @@ -94,6 +95,7 @@ export class GableProductMediaTab extends LitElement { const words = imageWords(this.provider); return html`
+ ${renderAiNotice(this.media.some(m => m.status === 'refused') ? words.refused : null)}

@@ -160,10 +162,10 @@ export class GableProductMediaTab extends LitElement { ${icon(Loader2, 28, 'w-7 h-7 animate-spin text-amber-400')} Generating…

` - : m.status === 'failed' + : m.status === 'failed' || m.status === 'refused' ? html`
${icon(AlertTriangle, 28, 'w-7 h-7')} - Generation failed + ${m.status === 'refused' ? 'Key refused' : 'Generation failed'}
` : html`${m.alt_text}` } @@ -176,7 +178,7 @@ export class GableProductMediaTab extends LitElement { ${m.status !== 'generating' ? html`
- ${!m.is_primary && m.status !== 'failed' ? html` + ${!m.is_primary && m.status !== 'failed' && m.status !== 'refused' ? html` diff --git a/web/apps/desk/src/types/pim.ts b/web/apps/desk/src/types/pim.ts index 2dce4f9d9..a20fa625c 100644 --- a/web/apps/desk/src/types/pim.ts +++ b/web/apps/desk/src/types/pim.ts @@ -29,7 +29,7 @@ export interface PIMMedia { alt_text: string; sort_order: number; is_primary: boolean; - status: 'generating' | 'ready' | 'failed'; + status: 'generating' | 'ready' | 'failed' | 'refused'; gen_model: string; gen_prompt: string; gen_style: string; From 5aef8bfe1e5fc1c8d5d30e3bfd70f8ead12e9aa0 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 15:17:53 -0700 Subject: [PATCH 27/39] AI: only a 401 refuses OpenRouter's key, a save's test names its revision and speaks in words A 403 from OpenRouter is moderation, not the key. The stored test result carries the settings revision it ran on, a mismatch reads untested. An unreachable provider answers in a sentence, the save's test has a timeout of its own, and a storage error after the commit is logged, not answered. Item 130, review round 1 P3-1, P3-2, P3-3. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/internal/ai/anthropic.go | 2 +- core/internal/ai/openrouter.go | 4 +-- core/internal/ai/refused.go | 34 +++++++++++++++++++++++- core/internal/techadmin/service.go | 42 +++++++++++++++++++++++++++--- 4 files changed, 74 insertions(+), 8 deletions(-) diff --git a/core/internal/ai/anthropic.go b/core/internal/ai/anthropic.go index 5c424642f..1c09f199c 100644 --- a/core/internal/ai/anthropic.go +++ b/core/internal/ai/anthropic.go @@ -182,7 +182,7 @@ func (c *Client) anthropicMessages(ctx context.Context, req anthropicRequest) (* resp, err := c.httpClient.Do(httpReq) if err != nil { - return nil, fmt.Errorf("Anthropic request failed: %s", scrub(err.Error())) + return nil, &UnreachableError{Provider: "Anthropic", Detail: "Anthropic request failed: " + scrub(err.Error())} } defer resp.Body.Close() respBody, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) diff --git a/core/internal/ai/openrouter.go b/core/internal/ai/openrouter.go index b088d142c..f18277a69 100644 --- a/core/internal/ai/openrouter.go +++ b/core/internal/ai/openrouter.go @@ -508,7 +508,7 @@ func (c *Client) chatCompletion(ctx context.Context, req chatRequest) (*chatResp resp, err := c.httpClient.Do(httpReq) if err != nil { - return nil, fmt.Errorf("AI request failed: %s", providerWords(err.Error(), apiKey)) + return nil, &UnreachableError{Provider: "OpenRouter", Detail: "AI request failed: " + providerWords(err.Error(), apiKey)} } defer resp.Body.Close() @@ -720,7 +720,7 @@ Example output: 50 pcs - 2x4x8 SPF Stud 25 pcs - 2x6x12 Doug Fir #2 30 sheets - OSB 7/16 4x8 -20 bags - Quikrete 80lb` +20 bags - Concrete mix 80lb` // ExtractMaterialList sends a file to the vision model for material list // extraction. Supports images, PDFs, and pre-processed text/csv. Returns the raw diff --git a/core/internal/ai/refused.go b/core/internal/ai/refused.go index 3c71e6b41..36f651cc7 100644 --- a/core/internal/ai/refused.go +++ b/core/internal/ai/refused.go @@ -39,7 +39,10 @@ func refusalWords(provider string) string { // refusedIfAuth answers a RefusedError for a provider answer that means the // key is not accepted (401 unauthorized, 403 forbidden), and nil otherwise. func refusedIfAuth(provider string, status int, detail string) error { - if status != http.StatusUnauthorized && status != http.StatusForbidden { + // A 401 is a refusal of the key from either provider. A 403 is one from + // Anthropic (a permission_error is a key problem); from OpenRouter it means + // the model's moderation flagged the input and the key is fine. + if status != http.StatusUnauthorized && !(status == http.StatusForbidden && provider == "Anthropic") { return nil } return &RefusedError{Provider: provider, Message: refusalWords(provider), Detail: fmt.Sprintf("%s API error (%d): %s", provider, status, detail)} @@ -54,3 +57,32 @@ func RefusedMessage(err error) (string, bool) { } return "", false } + +// UnreachableError is a provider that could not be reached (a transport error +// or a timeout): not a refusal of the key, and not a raw network error for a +// person to read. Error() is the transport text for logs; Words() is the plain +// sentence. +type UnreachableError struct { + Provider string + Detail string +} + +func (e *UnreachableError) Error() string { return e.Detail } + +// Words is what a person reads when the saved key could not be tested. +func (e *UnreachableError) Words() string { return UnreachableWords(e.Provider) } + +// UnreachableWords is the sentence for provider (a display name). +func UnreachableWords(provider string) string { + return fmt.Sprintf("The key is saved. %s could not be reached to test it; press Test connection later.", provider) +} + +// UnreachableMessage reports whether err is an unreachable provider and the +// sentence to show. A wrapped error is found through errors.As. +func UnreachableMessage(err error) (string, bool) { + var ue *UnreachableError + if errors.As(err, &ue) { + return ue.Words(), true + } + return "", false +} diff --git a/core/internal/techadmin/service.go b/core/internal/techadmin/service.go index 91d5fa017..b36efdf88 100644 --- a/core/internal/techadmin/service.go +++ b/core/internal/techadmin/service.go @@ -472,8 +472,16 @@ func (s *Service) GetAISettings(ctx context.Context) (*AISettings, error) { type aiTestRow struct { Status string `json:"status"` Message string `json:"message,omitempty"` + // Revision is the AI settings revision the test ran on. A result from an + // older revision belongs to a key that may have changed since, so it + // reads as untested. + Revision int64 `json:"revision"` } +// saveTestTimeout bounds the test a save runs, so a provider that does not +// answer cannot hold the save's response for the client's whole timeout. +const saveTestTimeout = 20 * time.Second + // readAITest fills the document's test status from the stored last test: // "none" when no key is saved for the chosen provider, "untested" when one is // saved and was never tested, else the stored result. @@ -494,6 +502,9 @@ func (s *Service) readAITest(ctx context.Context, out *AISettings) error { if json.Unmarshal([]byte(raw), &row) != nil { return nil } + if row.Revision != out.Revision { + return nil + } switch row.Status { case AITestPassed, AITestRefused, AITestFailed: out.TestStatus, out.TestMessage = row.Status, row.Message @@ -514,6 +525,10 @@ func (s *Service) runAITest(ctx context.Context) (TestResult, error) { if err := s.aiTester.TestConnection(ctx); err != nil { if words, refused := ai.RefusedMessage(err); refused { res = TestResult{Status: AITestRefused, Message: words} + } else if words, unreachable := ai.UnreachableMessage(err); unreachable { + res = TestResult{Status: AITestFailed, Message: words} + } else if errors.Is(err, context.DeadlineExceeded) { + res = TestResult{Status: AITestFailed, Message: ai.UnreachableWords(s.aiProviderLabel(ctx))} } else { res = TestResult{Status: AITestFailed, Message: err.Error()} } @@ -522,7 +537,11 @@ func (s *Service) runAITest(ctx context.Context) (TestResult, error) { if s.aiTester == nil || s.repo == nil { return res, nil } - stored := aiTestRow{Status: res.Status, Message: res.Message} + rev, err := s.repo.ReadRevision(ctx, ResourceAISettings) + if err != nil { + return res, err + } + stored := aiTestRow{Status: res.Status, Message: res.Message, Revision: rev} if res.Status == AITestPassed { stored.Message = "" } @@ -647,10 +666,17 @@ func (s *Service) SaveAISettingsFull(ctx context.Context, in AISave, pre Precond // admin reads says whether the key works, not only that one is saved. The // test is the provider's answer, never a reason to refuse the save. if out.Configured && s.aiTester != nil { - if _, terr := s.runAITest(ctx); terr != nil { - return nil, terr + tctx, cancel := context.WithTimeout(ctx, saveTestTimeout) + _, terr := s.runAITest(tctx) + cancel() + if terr != nil { + // The save is committed: a result that could not be stored is + // logged, never answered as a failed save. + slog.Error("failed to store the AI test result after a save", "error", terr) + } + if fresh, gerr := s.GetAISettings(ctx); gerr == nil { + return fresh, nil } - return s.GetAISettings(ctx) } return out, nil } @@ -740,6 +766,14 @@ func (s *Service) DeleteAISettings(ctx context.Context, pre Precondition) error return err } +// aiProviderLabel is the display name of the chosen provider. +func (s *Service) aiProviderLabel(ctx context.Context) string { + if p, _, err := s.repo.ReadSetting(ctx, ai.SettingProvider); err == nil && p == ai.ProviderAnthropic { + return "Anthropic" + } + return "OpenRouter" +} + // invalidateAI drops the AI client's cached settings after a committed save // or delete, so the very next call (the test connection included) uses what // was saved. The tester is the AI client in serve; a tester with no cache has From feda70ecd0c837bc8b49b3ab41519cb5ac52f780 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 15:17:53 -0700 Subject: [PATCH 28/39] pim: the sanitiser drops a model's text and tspan A second guard for no words on a drawing. The two older cases that pinned a label are gone with the labels. Item 130, review round 1 P3-4. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/internal/pim/svg.go | 7 ++++--- core/internal/pim/svg_test.go | 4 +--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/core/internal/pim/svg.go b/core/internal/pim/svg.go index ebc6b432d..9e3a504f5 100644 --- a/core/internal/pim/svg.go +++ b/core/internal/pim/svg.go @@ -33,17 +33,18 @@ const ( ) // svgElements is the allowlist of elements that are kept. Drawing elements -// carry the picture; text and tspan only carry a short label. +// carry the picture. text and tspan are not kept: a drawn product carries no +// words (FROM-PRINCIPAL 118 a), and the prompt's ask is not the only guard. var svgElements = map[string]bool{ "svg": true, "g": true, "path": true, "rect": true, "circle": true, "ellipse": true, "line": true, "polyline": true, "polygon": true, "defs": true, "linearGradient": true, - "radialGradient": true, "stop": true, "clipPath": true, "text": true, "tspan": true, + "radialGradient": true, "stop": true, "clipPath": true, } // drawing elements: at least one must survive, or nothing was drawn. var svgDrawing = map[string]bool{ "path": true, "rect": true, "circle": true, "ellipse": true, "line": true, - "polyline": true, "polygon": true, "text": true, + "polyline": true, "polygon": true, } // svgAttributes is the allowlist of attributes that are kept: geometry, diff --git a/core/internal/pim/svg_test.go b/core/internal/pim/svg_test.go index 0c91e0521..591fb169c 100644 --- a/core/internal/pim/svg_test.go +++ b/core/internal/pim/svg_test.go @@ -34,7 +34,7 @@ func TestSanitizeSVG_KeepsCleanDrawing(t *testing.T) { t.Fatalf("clean drawing refused: %v", err) } s := string(out) - for _, want := range []string{`viewBox="0 0 256 256"`, `SPF 2x4`, `fill="url(#wood)"`, `xmlns="http://www.w3.org/2000/svg"`} { + for _, want := range []string{`viewBox="0 0 256 256"`, ``)}, {"foreign namespace element", wrap(`x`)}, - {"long label", wrap(`` + strings.Repeat("A", 80) + ``)}, {"css escaped url", wrap(``)}, {"css escaped url short form", wrap(``)}, {"css escaped url as a character reference", wrap(``)}, {"url with a comment inside", wrap(``)}, - {"escape in font-family", wrap(`a`)}, {"output over the cap", wrap(``)}, {"escaped url in clip-path", wrap(``)}, {"paint that is not a colour or a reference", wrap(``)}, From 6d059f7d9c5efe5b90dfcbe257b9dbb224e3aca5 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 15:17:54 -0700 Subject: [PATCH 29/39] seed: product descriptions name no maker, the tax test names no brand The 74 descriptions that named ProFrame take plain names. The invented vendor records and keys stay, and so do the model designations for now. Item 130, review round 1 P3-5 and item 6. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/internal/app/seed/demodata/catalog.jsonl | 148 +++++++++--------- core/internal/tax/service_test.go | 6 +- 2 files changed, 77 insertions(+), 77 deletions(-) diff --git a/core/internal/app/seed/demodata/catalog.jsonl b/core/internal/app/seed/demodata/catalog.jsonl index d2638f3ba..8c69be96f 100644 --- a/core/internal/app/seed/demodata/catalog.jsonl +++ b/core/internal/app/seed/demodata/catalog.jsonl @@ -265,56 +265,56 @@ {"sku":"DL_RL_SPF_2X6_U","description":"2x6 RL SPF Utility","department":"Yard","category_slug":"lumber.framing_lumber","vendor_key":"OKN_LBR_MILL","uom_primary":"LF","alternate_uoms":[{"uom":"BF","unit_qty":1,"stock_qty":1.0},{"uom":"MBF","unit_qty":1,"stock_qty":0.001}],"species":"SPF","grade":"Utility","treatment":"KD","board_thickness_in":2,"board_width_in":6,"board_length_ft":null,"random_length":true,"length_in":null,"width_in":5.5,"height_in":1.5,"stackable":true,"weight_lbs":2.4,"cost":0.72,"price":0.91,"contractor_price":0.77,"vip_price":0.68,"target_margin":20.5,"tax_class":"TAXABLE","image_family":"spf_framing_lumber","barcode":"2000100001858","is_commodity":true,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":94,"reorder_point":28,"reorder_qty":94},"WK":{"on_hand":28,"reorder_point":28,"reorder_qty":94},"LK":{"on_hand":9,"reorder_point":28,"reorder_qty":94}}} {"sku":"DL_RL_SPF_1X4_S","description":"1x4 RL SPF #2&BTR","department":"Yard","category_slug":"lumber.framing_lumber","vendor_key":"OKN_LBR_MILL","uom_primary":"LF","alternate_uoms":[{"uom":"BF","unit_qty":1,"stock_qty":0.3333},{"uom":"MBF","unit_qty":1,"stock_qty":0.0003}],"species":"SPF","grade":"#2&BTR","treatment":"KD","board_thickness_in":1,"board_width_in":4,"board_length_ft":null,"random_length":true,"length_in":null,"width_in":3.5,"height_in":0.75,"stackable":true,"weight_lbs":0.8,"cost":0.28,"price":0.36,"contractor_price":0.31,"vip_price":0.27,"target_margin":20.5,"tax_class":"TAXABLE","image_family":"spf_framing_lumber","barcode":"2000100001865","is_commodity":true,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":85,"reorder_point":25,"reorder_qty":85},"WK":{"on_hand":25,"reorder_point":25,"reorder_qty":85},"LK":{"on_hand":8,"reorder_point":25,"reorder_qty":85}}} {"sku":"DL_RL_SPF_1X4_U","description":"1x4 RL SPF Utility","department":"Yard","category_slug":"lumber.framing_lumber","vendor_key":"OKN_LBR_MILL","uom_primary":"LF","alternate_uoms":[{"uom":"BF","unit_qty":1,"stock_qty":0.3333},{"uom":"MBF","unit_qty":1,"stock_qty":0.0003}],"species":"SPF","grade":"Utility","treatment":"KD","board_thickness_in":1,"board_width_in":4,"board_length_ft":null,"random_length":true,"length_in":null,"width_in":3.5,"height_in":0.75,"stackable":true,"weight_lbs":0.8,"cost":0.24,"price":0.3,"contractor_price":0.26,"vip_price":0.23,"target_margin":20.5,"tax_class":"TAXABLE","image_family":"spf_framing_lumber","barcode":"2000100001872","is_commodity":true,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":85,"reorder_point":25,"reorder_qty":85},"WK":{"on_hand":25,"reorder_point":25,"reorder_qty":85},"LK":{"on_hand":8,"reorder_point":25,"reorder_qty":85}}} -{"sku":"EW_LVL_W09500_L16","description":"1.75x9.500 LVL 16' ProFrame","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":16}],"species":"LVL","grade":"ProFrame","treatment":"SCL","board_thickness_in":1.75,"board_width_in":9.5,"board_length_ft":16,"random_length":false,"length_in":192.0,"width_in":9.5,"height_in":1.75,"stackable":true,"weight_lbs":79.8,"cost":18.12,"price":24.99,"contractor_price":21.24,"vip_price":18.74,"target_margin":27.5,"tax_class":"TAXABLE","image_family":"lvl_beam","barcode":"2000100001879","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":559,"reorder_point":167,"reorder_qty":559},"WK":{"on_hand":167,"reorder_point":167,"reorder_qty":559},"LK":{"on_hand":55,"reorder_point":167,"reorder_qty":559}}} -{"sku":"EW_LVL_W09500_L20","description":"1.75x9.500 LVL 20' ProFrame","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":20}],"species":"LVL","grade":"ProFrame","treatment":"SCL","board_thickness_in":1.75,"board_width_in":9.5,"board_length_ft":20,"random_length":false,"length_in":240.0,"width_in":9.5,"height_in":1.75,"stackable":true,"weight_lbs":99.75,"cost":20.4,"price":28.14,"contractor_price":23.92,"vip_price":21.11,"target_margin":27.5,"tax_class":"TAXABLE","image_family":"lvl_beam","barcode":"2000100001886","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":678,"reorder_point":203,"reorder_qty":678},"WK":{"on_hand":203,"reorder_point":203,"reorder_qty":678},"LK":{"on_hand":67,"reorder_point":203,"reorder_qty":678}}} -{"sku":"EW_LVL_W09500_L24","description":"1.75x9.500 LVL 24' ProFrame","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":24}],"species":"LVL","grade":"ProFrame","treatment":"SCL","board_thickness_in":1.75,"board_width_in":9.5,"board_length_ft":24,"random_length":false,"length_in":288.0,"width_in":9.5,"height_in":1.75,"stackable":true,"weight_lbs":119.7,"cost":22.68,"price":31.28,"contractor_price":26.59,"vip_price":23.46,"target_margin":27.5,"tax_class":"TAXABLE","image_family":"lvl_beam","barcode":"2000100001893","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":798,"reorder_point":239,"reorder_qty":798},"WK":{"on_hand":239,"reorder_point":239,"reorder_qty":798},"LK":{"on_hand":79,"reorder_point":239,"reorder_qty":798}}} -{"sku":"EW_LVL_W09500_L28","description":"1.75x9.500 LVL 28' ProFrame","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":28}],"species":"LVL","grade":"ProFrame","treatment":"SCL","board_thickness_in":1.75,"board_width_in":9.5,"board_length_ft":28,"random_length":false,"length_in":336.0,"width_in":9.5,"height_in":1.75,"stackable":true,"weight_lbs":139.65,"cost":24.96,"price":34.43,"contractor_price":29.27,"vip_price":25.82,"target_margin":27.5,"tax_class":"TAXABLE","image_family":"lvl_beam","barcode":"2000100001900","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":918,"reorder_point":275,"reorder_qty":918},"WK":{"on_hand":275,"reorder_point":275,"reorder_qty":918},"LK":{"on_hand":91,"reorder_point":275,"reorder_qty":918}}} -{"sku":"EW_LVL_W09500_L32","description":"1.75x9.500 LVL 32' ProFrame","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":32}],"species":"LVL","grade":"ProFrame","treatment":"SCL","board_thickness_in":1.75,"board_width_in":9.5,"board_length_ft":32,"random_length":false,"length_in":384.0,"width_in":9.5,"height_in":1.75,"stackable":true,"weight_lbs":159.6,"cost":27.24,"price":37.57,"contractor_price":31.93,"vip_price":28.18,"target_margin":27.5,"tax_class":"TAXABLE","image_family":"lvl_beam","barcode":"2000100001907","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":1038,"reorder_point":311,"reorder_qty":1038},"WK":{"on_hand":311,"reorder_point":311,"reorder_qty":1038},"LK":{"on_hand":103,"reorder_point":311,"reorder_qty":1038}}} -{"sku":"EW_LVL_W11875_L16","description":"1.75x11.875 LVL 16' ProFrame","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":16}],"species":"LVL","grade":"ProFrame","treatment":"SCL","board_thickness_in":1.75,"board_width_in":11.875,"board_length_ft":16,"random_length":false,"length_in":192.0,"width_in":11.875,"height_in":1.75,"stackable":true,"weight_lbs":99.75,"cost":20.4,"price":28.14,"contractor_price":23.92,"vip_price":21.11,"target_margin":27.5,"tax_class":"TAXABLE","image_family":"lvl_beam","barcode":"2000100001914","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":678,"reorder_point":203,"reorder_qty":678},"WK":{"on_hand":203,"reorder_point":203,"reorder_qty":678},"LK":{"on_hand":67,"reorder_point":203,"reorder_qty":678}}} -{"sku":"EW_LVL_W11875_L20","description":"1.75x11.875 LVL 20' ProFrame","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":20}],"species":"LVL","grade":"ProFrame","treatment":"SCL","board_thickness_in":1.75,"board_width_in":11.875,"board_length_ft":20,"random_length":false,"length_in":240.0,"width_in":11.875,"height_in":1.75,"stackable":true,"weight_lbs":124.69,"cost":23.25,"price":32.07,"contractor_price":27.26,"vip_price":24.05,"target_margin":27.5,"tax_class":"TAXABLE","image_family":"lvl_beam","barcode":"2000100001921","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":828,"reorder_point":248,"reorder_qty":828},"WK":{"on_hand":248,"reorder_point":248,"reorder_qty":828},"LK":{"on_hand":82,"reorder_point":248,"reorder_qty":828}}} -{"sku":"EW_LVL_W11875_L24","description":"1.75x11.875 LVL 24' ProFrame","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":24}],"species":"LVL","grade":"ProFrame","treatment":"SCL","board_thickness_in":1.75,"board_width_in":11.875,"board_length_ft":24,"random_length":false,"length_in":288.0,"width_in":11.875,"height_in":1.75,"stackable":true,"weight_lbs":149.63,"cost":26.1,"price":36.0,"contractor_price":30.6,"vip_price":27.0,"target_margin":27.5,"tax_class":"TAXABLE","image_family":"lvl_beam","barcode":"2000100001928","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":978,"reorder_point":293,"reorder_qty":978},"WK":{"on_hand":293,"reorder_point":293,"reorder_qty":978},"LK":{"on_hand":97,"reorder_point":293,"reorder_qty":978}}} -{"sku":"EW_LVL_W11875_L28","description":"1.75x11.875 LVL 28' ProFrame","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":28}],"species":"LVL","grade":"ProFrame","treatment":"SCL","board_thickness_in":1.75,"board_width_in":11.875,"board_length_ft":28,"random_length":false,"length_in":336.0,"width_in":11.875,"height_in":1.75,"stackable":true,"weight_lbs":174.56,"cost":28.95,"price":39.93,"contractor_price":33.94,"vip_price":29.95,"target_margin":27.5,"tax_class":"TAXABLE","image_family":"lvl_beam","barcode":"2000100001935","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":1127,"reorder_point":338,"reorder_qty":1127},"WK":{"on_hand":338,"reorder_point":338,"reorder_qty":1127},"LK":{"on_hand":112,"reorder_point":338,"reorder_qty":1127}}} -{"sku":"EW_LVL_W11875_L32","description":"1.75x11.875 LVL 32' ProFrame","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":32}],"species":"LVL","grade":"ProFrame","treatment":"SCL","board_thickness_in":1.75,"board_width_in":11.875,"board_length_ft":32,"random_length":false,"length_in":384.0,"width_in":11.875,"height_in":1.75,"stackable":true,"weight_lbs":199.5,"cost":31.8,"price":43.86,"contractor_price":37.28,"vip_price":32.9,"target_margin":27.5,"tax_class":"TAXABLE","image_family":"lvl_beam","barcode":"2000100001942","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":1277,"reorder_point":383,"reorder_qty":1277},"WK":{"on_hand":383,"reorder_point":383,"reorder_qty":1277},"LK":{"on_hand":127,"reorder_point":383,"reorder_qty":1277}}} -{"sku":"EW_LVL_W14000_L16","description":"1.75x14.000 LVL 16' ProFrame","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":16}],"species":"LVL","grade":"ProFrame","treatment":"SCL","board_thickness_in":1.75,"board_width_in":14,"board_length_ft":16,"random_length":false,"length_in":192.0,"width_in":14,"height_in":1.75,"stackable":true,"weight_lbs":117.6,"cost":22.44,"price":30.95,"contractor_price":26.31,"vip_price":23.21,"target_margin":27.5,"tax_class":"TAXABLE","image_family":"lvl_beam","barcode":"2000100001949","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":786,"reorder_point":235,"reorder_qty":786},"WK":{"on_hand":235,"reorder_point":235,"reorder_qty":786},"LK":{"on_hand":78,"reorder_point":235,"reorder_qty":786}}} -{"sku":"EW_LVL_W14000_L20","description":"1.75x14.000 LVL 20' ProFrame","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":20}],"species":"LVL","grade":"ProFrame","treatment":"SCL","board_thickness_in":1.75,"board_width_in":14,"board_length_ft":20,"random_length":false,"length_in":240.0,"width_in":14,"height_in":1.75,"stackable":true,"weight_lbs":147.0,"cost":25.8,"price":35.59,"contractor_price":30.25,"vip_price":26.69,"target_margin":27.5,"tax_class":"TAXABLE","image_family":"lvl_beam","barcode":"2000100001956","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":962,"reorder_point":288,"reorder_qty":962},"WK":{"on_hand":288,"reorder_point":288,"reorder_qty":962},"LK":{"on_hand":96,"reorder_point":288,"reorder_qty":962}}} -{"sku":"EW_LVL_W14000_L24","description":"1.75x14.000 LVL 24' ProFrame","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":24}],"species":"LVL","grade":"ProFrame","treatment":"SCL","board_thickness_in":1.75,"board_width_in":14,"board_length_ft":24,"random_length":false,"length_in":288.0,"width_in":14,"height_in":1.75,"stackable":true,"weight_lbs":176.4,"cost":29.16,"price":40.22,"contractor_price":34.19,"vip_price":30.17,"target_margin":27.5,"tax_class":"TAXABLE","image_family":"lvl_beam","barcode":"2000100001963","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":1138,"reorder_point":341,"reorder_qty":1138},"WK":{"on_hand":341,"reorder_point":341,"reorder_qty":1138},"LK":{"on_hand":113,"reorder_point":341,"reorder_qty":1138}}} -{"sku":"EW_LVL_W14000_L28","description":"1.75x14.000 LVL 28' ProFrame","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":28}],"species":"LVL","grade":"ProFrame","treatment":"SCL","board_thickness_in":1.75,"board_width_in":14,"board_length_ft":28,"random_length":false,"length_in":336.0,"width_in":14,"height_in":1.75,"stackable":true,"weight_lbs":205.8,"cost":32.52,"price":44.86,"contractor_price":38.13,"vip_price":33.65,"target_margin":27.5,"tax_class":"TAXABLE","image_family":"lvl_beam","barcode":"2000100001970","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":1315,"reorder_point":394,"reorder_qty":1315},"WK":{"on_hand":394,"reorder_point":394,"reorder_qty":1315},"LK":{"on_hand":131,"reorder_point":394,"reorder_qty":1315}}} -{"sku":"EW_LVL_W14000_L32","description":"1.75x14.000 LVL 32' ProFrame","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":32}],"species":"LVL","grade":"ProFrame","treatment":"SCL","board_thickness_in":1.75,"board_width_in":14,"board_length_ft":32,"random_length":false,"length_in":384.0,"width_in":14,"height_in":1.75,"stackable":true,"weight_lbs":235.2,"cost":35.88,"price":49.49,"contractor_price":42.07,"vip_price":37.12,"target_margin":27.5,"tax_class":"TAXABLE","image_family":"lvl_beam","barcode":"2000100001977","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":1491,"reorder_point":447,"reorder_qty":1491},"WK":{"on_hand":447,"reorder_point":447,"reorder_qty":1491},"LK":{"on_hand":149,"reorder_point":447,"reorder_qty":1491}}} -{"sku":"EW_LVL_W16000_L16","description":"1.75x16.000 LVL 16' ProFrame","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":16}],"species":"LVL","grade":"ProFrame","treatment":"SCL","board_thickness_in":1.75,"board_width_in":16,"board_length_ft":16,"random_length":false,"length_in":192.0,"width_in":16,"height_in":1.75,"stackable":true,"weight_lbs":134.4,"cost":24.36,"price":33.6,"contractor_price":28.56,"vip_price":25.2,"target_margin":27.5,"tax_class":"TAXABLE","image_family":"lvl_beam","barcode":"2000100001984","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":886,"reorder_point":265,"reorder_qty":886},"WK":{"on_hand":265,"reorder_point":265,"reorder_qty":886},"LK":{"on_hand":88,"reorder_point":265,"reorder_qty":886}}} -{"sku":"EW_LVL_W16000_L20","description":"1.75x16.000 LVL 20' ProFrame","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":20}],"species":"LVL","grade":"ProFrame","treatment":"SCL","board_thickness_in":1.75,"board_width_in":16,"board_length_ft":20,"random_length":false,"length_in":240.0,"width_in":16,"height_in":1.75,"stackable":true,"weight_lbs":168.0,"cost":28.2,"price":38.9,"contractor_price":33.07,"vip_price":29.18,"target_margin":27.5,"tax_class":"TAXABLE","image_family":"lvl_beam","barcode":"2000100001991","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":1088,"reorder_point":326,"reorder_qty":1088},"WK":{"on_hand":326,"reorder_point":326,"reorder_qty":1088},"LK":{"on_hand":108,"reorder_point":326,"reorder_qty":1088}}} -{"sku":"EW_LVL_W16000_L24","description":"1.75x16.000 LVL 24' ProFrame","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":24}],"species":"LVL","grade":"ProFrame","treatment":"SCL","board_thickness_in":1.75,"board_width_in":16,"board_length_ft":24,"random_length":false,"length_in":288.0,"width_in":16,"height_in":1.75,"stackable":true,"weight_lbs":201.6,"cost":32.04,"price":44.19,"contractor_price":37.56,"vip_price":33.14,"target_margin":27.5,"tax_class":"TAXABLE","image_family":"lvl_beam","barcode":"2000100001998","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":1290,"reorder_point":387,"reorder_qty":1290},"WK":{"on_hand":387,"reorder_point":387,"reorder_qty":1290},"LK":{"on_hand":129,"reorder_point":387,"reorder_qty":1290}}} -{"sku":"EW_LVL_W16000_L28","description":"1.75x16.000 LVL 28' ProFrame","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":28}],"species":"LVL","grade":"ProFrame","treatment":"SCL","board_thickness_in":1.75,"board_width_in":16,"board_length_ft":28,"random_length":false,"length_in":336.0,"width_in":16,"height_in":1.75,"stackable":true,"weight_lbs":235.2,"cost":35.88,"price":49.49,"contractor_price":42.07,"vip_price":37.12,"target_margin":27.5,"tax_class":"TAXABLE","image_family":"lvl_beam","barcode":"2000100002005","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":1491,"reorder_point":447,"reorder_qty":1491},"WK":{"on_hand":447,"reorder_point":447,"reorder_qty":1491},"LK":{"on_hand":149,"reorder_point":447,"reorder_qty":1491}}} -{"sku":"EW_LVL_W16000_L32","description":"1.75x16.000 LVL 32' ProFrame","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":32}],"species":"LVL","grade":"ProFrame","treatment":"SCL","board_thickness_in":1.75,"board_width_in":16,"board_length_ft":32,"random_length":false,"length_in":384.0,"width_in":16,"height_in":1.75,"stackable":true,"weight_lbs":268.8,"cost":39.72,"price":54.79,"contractor_price":46.57,"vip_price":41.09,"target_margin":27.5,"tax_class":"TAXABLE","image_family":"lvl_beam","barcode":"2000100002012","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":1693,"reorder_point":507,"reorder_qty":1693},"WK":{"on_hand":507,"reorder_point":507,"reorder_qty":1693},"LK":{"on_hand":169,"reorder_point":507,"reorder_qty":1693}}} -{"sku":"EW_LVL_W18000_L16","description":"1.75x18.000 LVL 16' ProFrame","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":16}],"species":"LVL","grade":"ProFrame","treatment":"SCL","board_thickness_in":1.75,"board_width_in":18,"board_length_ft":16,"random_length":false,"length_in":192.0,"width_in":18,"height_in":1.75,"stackable":true,"weight_lbs":151.2,"cost":26.28,"price":36.25,"contractor_price":30.81,"vip_price":27.19,"target_margin":27.5,"tax_class":"TAXABLE","image_family":"lvl_beam","barcode":"2000100002019","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":987,"reorder_point":296,"reorder_qty":987},"WK":{"on_hand":296,"reorder_point":296,"reorder_qty":987},"LK":{"on_hand":98,"reorder_point":296,"reorder_qty":987}}} -{"sku":"EW_LVL_W18000_L20","description":"1.75x18.000 LVL 20' ProFrame","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":20}],"species":"LVL","grade":"ProFrame","treatment":"SCL","board_thickness_in":1.75,"board_width_in":18,"board_length_ft":20,"random_length":false,"length_in":240.0,"width_in":18,"height_in":1.75,"stackable":true,"weight_lbs":189.0,"cost":30.6,"price":42.21,"contractor_price":35.88,"vip_price":31.66,"target_margin":27.5,"tax_class":"TAXABLE","image_family":"lvl_beam","barcode":"2000100002026","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":1214,"reorder_point":364,"reorder_qty":1214},"WK":{"on_hand":364,"reorder_point":364,"reorder_qty":1214},"LK":{"on_hand":121,"reorder_point":364,"reorder_qty":1214}}} -{"sku":"EW_LVL_W18000_L24","description":"1.75x18.000 LVL 24' ProFrame","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":24}],"species":"LVL","grade":"ProFrame","treatment":"SCL","board_thickness_in":1.75,"board_width_in":18,"board_length_ft":24,"random_length":false,"length_in":288.0,"width_in":18,"height_in":1.75,"stackable":true,"weight_lbs":226.8,"cost":34.92,"price":48.17,"contractor_price":40.94,"vip_price":36.13,"target_margin":27.5,"tax_class":"TAXABLE","image_family":"lvl_beam","barcode":"2000100002033","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":1441,"reorder_point":432,"reorder_qty":1441},"WK":{"on_hand":432,"reorder_point":432,"reorder_qty":1441},"LK":{"on_hand":144,"reorder_point":432,"reorder_qty":1441}}} -{"sku":"EW_LVL_W18000_L28","description":"1.75x18.000 LVL 28' ProFrame","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":28}],"species":"LVL","grade":"ProFrame","treatment":"SCL","board_thickness_in":1.75,"board_width_in":18,"board_length_ft":28,"random_length":false,"length_in":336.0,"width_in":18,"height_in":1.75,"stackable":true,"weight_lbs":264.6,"cost":39.24,"price":54.12,"contractor_price":46.0,"vip_price":40.59,"target_margin":27.5,"tax_class":"TAXABLE","image_family":"lvl_beam","barcode":"2000100002040","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":1668,"reorder_point":500,"reorder_qty":1668},"WK":{"on_hand":500,"reorder_point":500,"reorder_qty":1668},"LK":{"on_hand":166,"reorder_point":500,"reorder_qty":1668}}} -{"sku":"EW_LVL_W18000_L32","description":"1.75x18.000 LVL 32' ProFrame","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":32}],"species":"LVL","grade":"ProFrame","treatment":"SCL","board_thickness_in":1.75,"board_width_in":18,"board_length_ft":32,"random_length":false,"length_in":384.0,"width_in":18,"height_in":1.75,"stackable":true,"weight_lbs":302.4,"cost":43.56,"price":60.08,"contractor_price":51.07,"vip_price":45.06,"target_margin":27.5,"tax_class":"TAXABLE","image_family":"lvl_beam","barcode":"2000100002047","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":1894,"reorder_point":568,"reorder_qty":1894},"WK":{"on_hand":568,"reorder_point":568,"reorder_qty":1894},"LK":{"on_hand":189,"reorder_point":568,"reorder_qty":1894}}} -{"sku":"EW_IJ_D09500_L16","description":"I-Joist 9.500 x 16' ProFrame","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":16}],"species":"I-Joist","grade":"ProFrame","treatment":"SCL","board_thickness_in":9.5,"board_width_in":2.5,"board_length_ft":16,"random_length":false,"length_in":192.0,"width_in":9.5,"height_in":2.5,"stackable":false,"weight_lbs":27.36,"cost":16.1,"price":22.05,"contractor_price":18.74,"vip_price":16.54,"target_margin":27.0,"tax_class":"TAXABLE","image_family":"i_joist","barcode":"2000100002054","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":244,"reorder_point":73,"reorder_qty":244},"WK":{"on_hand":73,"reorder_point":73,"reorder_qty":244},"LK":{"on_hand":24,"reorder_point":73,"reorder_qty":244}}} -{"sku":"EW_IJ_D09500_L20","description":"I-Joist 9.500 x 20' ProFrame","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":20}],"species":"I-Joist","grade":"ProFrame","treatment":"SCL","board_thickness_in":9.5,"board_width_in":2.5,"board_length_ft":20,"random_length":false,"length_in":240.0,"width_in":9.5,"height_in":2.5,"stackable":false,"weight_lbs":34.2,"cost":18.0,"price":24.66,"contractor_price":20.96,"vip_price":18.5,"target_margin":27.0,"tax_class":"TAXABLE","image_family":"i_joist","barcode":"2000100002061","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":285,"reorder_point":85,"reorder_qty":285},"WK":{"on_hand":85,"reorder_point":85,"reorder_qty":285},"LK":{"on_hand":28,"reorder_point":85,"reorder_qty":285}}} -{"sku":"EW_IJ_D09500_L24","description":"I-Joist 9.500 x 24' ProFrame","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":24}],"species":"I-Joist","grade":"ProFrame","treatment":"SCL","board_thickness_in":9.5,"board_width_in":2.5,"board_length_ft":24,"random_length":false,"length_in":288.0,"width_in":9.5,"height_in":2.5,"stackable":false,"weight_lbs":41.04,"cost":19.9,"price":27.26,"contractor_price":23.17,"vip_price":20.45,"target_margin":27.0,"tax_class":"TAXABLE","image_family":"i_joist","barcode":"2000100002068","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":326,"reorder_point":97,"reorder_qty":326},"WK":{"on_hand":97,"reorder_point":97,"reorder_qty":326},"LK":{"on_hand":32,"reorder_point":97,"reorder_qty":326}}} -{"sku":"EW_IJ_D09500_L28","description":"I-Joist 9.500 x 28' ProFrame","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":28}],"species":"I-Joist","grade":"ProFrame","treatment":"SCL","board_thickness_in":9.5,"board_width_in":2.5,"board_length_ft":28,"random_length":false,"length_in":336.0,"width_in":9.5,"height_in":2.5,"stackable":false,"weight_lbs":47.88,"cost":21.8,"price":29.86,"contractor_price":25.38,"vip_price":22.4,"target_margin":27.0,"tax_class":"TAXABLE","image_family":"i_joist","barcode":"2000100002075","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":367,"reorder_point":110,"reorder_qty":367},"WK":{"on_hand":110,"reorder_point":110,"reorder_qty":367},"LK":{"on_hand":36,"reorder_point":110,"reorder_qty":367}}} -{"sku":"EW_IJ_D09500_L32","description":"I-Joist 9.500 x 32' ProFrame","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":32}],"species":"I-Joist","grade":"ProFrame","treatment":"SCL","board_thickness_in":9.5,"board_width_in":2.5,"board_length_ft":32,"random_length":false,"length_in":384.0,"width_in":9.5,"height_in":2.5,"stackable":false,"weight_lbs":54.72,"cost":23.7,"price":32.47,"contractor_price":27.6,"vip_price":24.35,"target_margin":27.0,"tax_class":"TAXABLE","image_family":"i_joist","barcode":"2000100002082","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":408,"reorder_point":122,"reorder_qty":408},"WK":{"on_hand":122,"reorder_point":122,"reorder_qty":408},"LK":{"on_hand":40,"reorder_point":122,"reorder_qty":408}}} -{"sku":"EW_IJ_D11875_L16","description":"I-Joist 11.875 x 16' ProFrame","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":16}],"species":"I-Joist","grade":"ProFrame","treatment":"SCL","board_thickness_in":11.875,"board_width_in":2.5,"board_length_ft":16,"random_length":false,"length_in":192.0,"width_in":11.875,"height_in":2.5,"stackable":false,"weight_lbs":34.2,"cost":18.0,"price":24.66,"contractor_price":20.96,"vip_price":18.5,"target_margin":27.0,"tax_class":"TAXABLE","image_family":"i_joist","barcode":"2000100002089","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":285,"reorder_point":85,"reorder_qty":285},"WK":{"on_hand":85,"reorder_point":85,"reorder_qty":285},"LK":{"on_hand":28,"reorder_point":85,"reorder_qty":285}}} -{"sku":"EW_IJ_D11875_L20","description":"I-Joist 11.875 x 20' ProFrame","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":20}],"species":"I-Joist","grade":"ProFrame","treatment":"SCL","board_thickness_in":11.875,"board_width_in":2.5,"board_length_ft":20,"random_length":false,"length_in":240.0,"width_in":11.875,"height_in":2.5,"stackable":false,"weight_lbs":42.75,"cost":20.38,"price":27.91,"contractor_price":23.72,"vip_price":20.93,"target_margin":27.0,"tax_class":"TAXABLE","image_family":"i_joist","barcode":"2000100002096","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":336,"reorder_point":100,"reorder_qty":336},"WK":{"on_hand":100,"reorder_point":100,"reorder_qty":336},"LK":{"on_hand":33,"reorder_point":100,"reorder_qty":336}}} -{"sku":"EW_IJ_D11875_L24","description":"I-Joist 11.875 x 24' ProFrame","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":24}],"species":"I-Joist","grade":"ProFrame","treatment":"SCL","board_thickness_in":11.875,"board_width_in":2.5,"board_length_ft":24,"random_length":false,"length_in":288.0,"width_in":11.875,"height_in":2.5,"stackable":false,"weight_lbs":51.3,"cost":22.75,"price":31.16,"contractor_price":26.49,"vip_price":23.37,"target_margin":27.0,"tax_class":"TAXABLE","image_family":"i_joist","barcode":"2000100002103","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":388,"reorder_point":116,"reorder_qty":388},"WK":{"on_hand":116,"reorder_point":116,"reorder_qty":388},"LK":{"on_hand":38,"reorder_point":116,"reorder_qty":388}}} -{"sku":"EW_IJ_D11875_L28","description":"I-Joist 11.875 x 28' ProFrame","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":28}],"species":"I-Joist","grade":"ProFrame","treatment":"SCL","board_thickness_in":11.875,"board_width_in":2.5,"board_length_ft":28,"random_length":false,"length_in":336.0,"width_in":11.875,"height_in":2.5,"stackable":false,"weight_lbs":59.85,"cost":25.13,"price":34.42,"contractor_price":29.26,"vip_price":25.82,"target_margin":27.0,"tax_class":"TAXABLE","image_family":"i_joist","barcode":"2000100002110","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":439,"reorder_point":131,"reorder_qty":439},"WK":{"on_hand":131,"reorder_point":131,"reorder_qty":439},"LK":{"on_hand":43,"reorder_point":131,"reorder_qty":439}}} -{"sku":"EW_IJ_D11875_L32","description":"I-Joist 11.875 x 32' ProFrame","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":32}],"species":"I-Joist","grade":"ProFrame","treatment":"SCL","board_thickness_in":11.875,"board_width_in":2.5,"board_length_ft":32,"random_length":false,"length_in":384.0,"width_in":11.875,"height_in":2.5,"stackable":false,"weight_lbs":68.4,"cost":27.5,"price":37.67,"contractor_price":32.02,"vip_price":28.25,"target_margin":27.0,"tax_class":"TAXABLE","image_family":"i_joist","barcode":"2000100002117","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":490,"reorder_point":147,"reorder_qty":490},"WK":{"on_hand":147,"reorder_point":147,"reorder_qty":490},"LK":{"on_hand":49,"reorder_point":147,"reorder_qty":490}}} -{"sku":"EW_IJ_D14000_L16","description":"I-Joist 14.000 x 16' ProFrame","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":16}],"species":"I-Joist","grade":"ProFrame","treatment":"SCL","board_thickness_in":14,"board_width_in":2.5,"board_length_ft":16,"random_length":false,"length_in":192.0,"width_in":14,"height_in":2.5,"stackable":false,"weight_lbs":40.32,"cost":19.7,"price":26.99,"contractor_price":22.94,"vip_price":20.24,"target_margin":27.0,"tax_class":"TAXABLE","image_family":"i_joist","barcode":"2000100002124","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":322,"reorder_point":96,"reorder_qty":322},"WK":{"on_hand":96,"reorder_point":96,"reorder_qty":322},"LK":{"on_hand":32,"reorder_point":96,"reorder_qty":322}}} -{"sku":"EW_IJ_D14000_L20","description":"I-Joist 14.000 x 20' ProFrame","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":20}],"species":"I-Joist","grade":"ProFrame","treatment":"SCL","board_thickness_in":14,"board_width_in":2.5,"board_length_ft":20,"random_length":false,"length_in":240.0,"width_in":14,"height_in":2.5,"stackable":false,"weight_lbs":50.4,"cost":22.5,"price":30.82,"contractor_price":26.2,"vip_price":23.12,"target_margin":27.0,"tax_class":"TAXABLE","image_family":"i_joist","barcode":"2000100002131","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":382,"reorder_point":114,"reorder_qty":382},"WK":{"on_hand":114,"reorder_point":114,"reorder_qty":382},"LK":{"on_hand":38,"reorder_point":114,"reorder_qty":382}}} -{"sku":"EW_IJ_D14000_L24","description":"I-Joist 14.000 x 24' ProFrame","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":24}],"species":"I-Joist","grade":"ProFrame","treatment":"SCL","board_thickness_in":14,"board_width_in":2.5,"board_length_ft":24,"random_length":false,"length_in":288.0,"width_in":14,"height_in":2.5,"stackable":false,"weight_lbs":60.48,"cost":25.3,"price":34.66,"contractor_price":29.46,"vip_price":26.0,"target_margin":27.0,"tax_class":"TAXABLE","image_family":"i_joist","barcode":"2000100002138","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":443,"reorder_point":132,"reorder_qty":443},"WK":{"on_hand":132,"reorder_point":132,"reorder_qty":443},"LK":{"on_hand":44,"reorder_point":132,"reorder_qty":443}}} -{"sku":"EW_IJ_D14000_L28","description":"I-Joist 14.000 x 28' ProFrame","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":28}],"species":"I-Joist","grade":"ProFrame","treatment":"SCL","board_thickness_in":14,"board_width_in":2.5,"board_length_ft":28,"random_length":false,"length_in":336.0,"width_in":14,"height_in":2.5,"stackable":false,"weight_lbs":70.56,"cost":28.1,"price":38.49,"contractor_price":32.72,"vip_price":28.87,"target_margin":27.0,"tax_class":"TAXABLE","image_family":"i_joist","barcode":"2000100002145","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":503,"reorder_point":150,"reorder_qty":503},"WK":{"on_hand":150,"reorder_point":150,"reorder_qty":503},"LK":{"on_hand":50,"reorder_point":150,"reorder_qty":503}}} -{"sku":"EW_IJ_D14000_L32","description":"I-Joist 14.000 x 32' ProFrame","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":32}],"species":"I-Joist","grade":"ProFrame","treatment":"SCL","board_thickness_in":14,"board_width_in":2.5,"board_length_ft":32,"random_length":false,"length_in":384.0,"width_in":14,"height_in":2.5,"stackable":false,"weight_lbs":80.64,"cost":30.9,"price":42.33,"contractor_price":35.98,"vip_price":31.75,"target_margin":27.0,"tax_class":"TAXABLE","image_family":"i_joist","barcode":"2000100002152","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":564,"reorder_point":169,"reorder_qty":564},"WK":{"on_hand":169,"reorder_point":169,"reorder_qty":564},"LK":{"on_hand":56,"reorder_point":169,"reorder_qty":564}}} -{"sku":"EW_IJ_D16000_L16","description":"I-Joist 16.000 x 16' ProFrame","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":16}],"species":"I-Joist","grade":"ProFrame","treatment":"SCL","board_thickness_in":16,"board_width_in":2.5,"board_length_ft":16,"random_length":false,"length_in":192.0,"width_in":16,"height_in":2.5,"stackable":false,"weight_lbs":46.08,"cost":21.3,"price":29.18,"contractor_price":24.8,"vip_price":21.89,"target_margin":27.0,"tax_class":"TAXABLE","image_family":"i_joist","barcode":"2000100002159","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":356,"reorder_point":106,"reorder_qty":356},"WK":{"on_hand":106,"reorder_point":106,"reorder_qty":356},"LK":{"on_hand":35,"reorder_point":106,"reorder_qty":356}}} -{"sku":"EW_IJ_D16000_L20","description":"I-Joist 16.000 x 20' ProFrame","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":20}],"species":"I-Joist","grade":"ProFrame","treatment":"SCL","board_thickness_in":16,"board_width_in":2.5,"board_length_ft":20,"random_length":false,"length_in":240.0,"width_in":16,"height_in":2.5,"stackable":false,"weight_lbs":57.6,"cost":24.5,"price":33.56,"contractor_price":28.53,"vip_price":25.17,"target_margin":27.0,"tax_class":"TAXABLE","image_family":"i_joist","barcode":"2000100002166","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":426,"reorder_point":127,"reorder_qty":426},"WK":{"on_hand":127,"reorder_point":127,"reorder_qty":426},"LK":{"on_hand":42,"reorder_point":127,"reorder_qty":426}}} -{"sku":"EW_IJ_D16000_L24","description":"I-Joist 16.000 x 24' ProFrame","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":24}],"species":"I-Joist","grade":"ProFrame","treatment":"SCL","board_thickness_in":16,"board_width_in":2.5,"board_length_ft":24,"random_length":false,"length_in":288.0,"width_in":16,"height_in":2.5,"stackable":false,"weight_lbs":69.12,"cost":27.7,"price":37.95,"contractor_price":32.26,"vip_price":28.46,"target_margin":27.0,"tax_class":"TAXABLE","image_family":"i_joist","barcode":"2000100002173","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":495,"reorder_point":148,"reorder_qty":495},"WK":{"on_hand":148,"reorder_point":148,"reorder_qty":495},"LK":{"on_hand":49,"reorder_point":148,"reorder_qty":495}}} -{"sku":"EW_IJ_D16000_L28","description":"I-Joist 16.000 x 28' ProFrame","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":28}],"species":"I-Joist","grade":"ProFrame","treatment":"SCL","board_thickness_in":16,"board_width_in":2.5,"board_length_ft":28,"random_length":false,"length_in":336.0,"width_in":16,"height_in":2.5,"stackable":false,"weight_lbs":80.64,"cost":30.9,"price":42.33,"contractor_price":35.98,"vip_price":31.75,"target_margin":27.0,"tax_class":"TAXABLE","image_family":"i_joist","barcode":"2000100002180","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":564,"reorder_point":169,"reorder_qty":564},"WK":{"on_hand":169,"reorder_point":169,"reorder_qty":564},"LK":{"on_hand":56,"reorder_point":169,"reorder_qty":564}}} -{"sku":"EW_IJ_D16000_L32","description":"I-Joist 16.000 x 32' ProFrame","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":32}],"species":"I-Joist","grade":"ProFrame","treatment":"SCL","board_thickness_in":16,"board_width_in":2.5,"board_length_ft":32,"random_length":false,"length_in":384.0,"width_in":16,"height_in":2.5,"stackable":false,"weight_lbs":92.16,"cost":34.1,"price":46.71,"contractor_price":39.7,"vip_price":35.03,"target_margin":27.0,"tax_class":"TAXABLE","image_family":"i_joist","barcode":"2000100002187","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":633,"reorder_point":189,"reorder_qty":633},"WK":{"on_hand":189,"reorder_point":189,"reorder_qty":633},"LK":{"on_hand":63,"reorder_point":189,"reorder_qty":633}}} -{"sku":"EW_IJ_D18000_L16","description":"I-Joist 18.000 x 16' ProFrame","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":16}],"species":"I-Joist","grade":"ProFrame","treatment":"SCL","board_thickness_in":18,"board_width_in":2.5,"board_length_ft":16,"random_length":false,"length_in":192.0,"width_in":18,"height_in":2.5,"stackable":false,"weight_lbs":51.84,"cost":22.9,"price":31.37,"contractor_price":26.66,"vip_price":23.53,"target_margin":27.0,"tax_class":"TAXABLE","image_family":"i_joist","barcode":"2000100002194","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":391,"reorder_point":117,"reorder_qty":391},"WK":{"on_hand":117,"reorder_point":117,"reorder_qty":391},"LK":{"on_hand":39,"reorder_point":117,"reorder_qty":391}}} -{"sku":"EW_IJ_D18000_L20","description":"I-Joist 18.000 x 20' ProFrame","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":20}],"species":"I-Joist","grade":"ProFrame","treatment":"SCL","board_thickness_in":18,"board_width_in":2.5,"board_length_ft":20,"random_length":false,"length_in":240.0,"width_in":18,"height_in":2.5,"stackable":false,"weight_lbs":64.8,"cost":26.5,"price":36.3,"contractor_price":30.86,"vip_price":27.23,"target_margin":27.0,"tax_class":"TAXABLE","image_family":"i_joist","barcode":"2000100002201","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":469,"reorder_point":140,"reorder_qty":469},"WK":{"on_hand":140,"reorder_point":140,"reorder_qty":469},"LK":{"on_hand":46,"reorder_point":140,"reorder_qty":469}}} -{"sku":"EW_IJ_D18000_L24","description":"I-Joist 18.000 x 24' ProFrame","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":24}],"species":"I-Joist","grade":"ProFrame","treatment":"SCL","board_thickness_in":18,"board_width_in":2.5,"board_length_ft":24,"random_length":false,"length_in":288.0,"width_in":18,"height_in":2.5,"stackable":false,"weight_lbs":77.76,"cost":30.1,"price":41.23,"contractor_price":35.05,"vip_price":30.92,"target_margin":27.0,"tax_class":"TAXABLE","image_family":"i_joist","barcode":"2000100002208","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":547,"reorder_point":164,"reorder_qty":547},"WK":{"on_hand":164,"reorder_point":164,"reorder_qty":547},"LK":{"on_hand":54,"reorder_point":164,"reorder_qty":547}}} -{"sku":"EW_IJ_D18000_L28","description":"I-Joist 18.000 x 28' ProFrame","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":28}],"species":"I-Joist","grade":"ProFrame","treatment":"SCL","board_thickness_in":18,"board_width_in":2.5,"board_length_ft":28,"random_length":false,"length_in":336.0,"width_in":18,"height_in":2.5,"stackable":false,"weight_lbs":90.72,"cost":33.7,"price":46.16,"contractor_price":39.24,"vip_price":34.62,"target_margin":27.0,"tax_class":"TAXABLE","image_family":"i_joist","barcode":"2000100002215","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":624,"reorder_point":187,"reorder_qty":624},"WK":{"on_hand":187,"reorder_point":187,"reorder_qty":624},"LK":{"on_hand":62,"reorder_point":187,"reorder_qty":624}}} -{"sku":"EW_IJ_D18000_L32","description":"I-Joist 18.000 x 32' ProFrame","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":32}],"species":"I-Joist","grade":"ProFrame","treatment":"SCL","board_thickness_in":18,"board_width_in":2.5,"board_length_ft":32,"random_length":false,"length_in":384.0,"width_in":18,"height_in":2.5,"stackable":false,"weight_lbs":103.68,"cost":37.3,"price":51.1,"contractor_price":43.44,"vip_price":38.33,"target_margin":27.0,"tax_class":"TAXABLE","image_family":"i_joist","barcode":"2000100002222","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":702,"reorder_point":210,"reorder_qty":702},"WK":{"on_hand":210,"reorder_point":210,"reorder_qty":702},"LK":{"on_hand":70,"reorder_point":210,"reorder_qty":702}}} +{"sku":"EW_LVL_W09500_L16","description":"1.75x9.500 LVL 16'","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":16}],"species":"LVL","grade":"ProFrame","treatment":"SCL","board_thickness_in":1.75,"board_width_in":9.5,"board_length_ft":16,"random_length":false,"length_in":192.0,"width_in":9.5,"height_in":1.75,"stackable":true,"weight_lbs":79.8,"cost":18.12,"price":24.99,"contractor_price":21.24,"vip_price":18.74,"target_margin":27.5,"tax_class":"TAXABLE","image_family":"lvl_beam","barcode":"2000100001879","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":559,"reorder_point":167,"reorder_qty":559},"WK":{"on_hand":167,"reorder_point":167,"reorder_qty":559},"LK":{"on_hand":55,"reorder_point":167,"reorder_qty":559}}} +{"sku":"EW_LVL_W09500_L20","description":"1.75x9.500 LVL 20'","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":20}],"species":"LVL","grade":"ProFrame","treatment":"SCL","board_thickness_in":1.75,"board_width_in":9.5,"board_length_ft":20,"random_length":false,"length_in":240.0,"width_in":9.5,"height_in":1.75,"stackable":true,"weight_lbs":99.75,"cost":20.4,"price":28.14,"contractor_price":23.92,"vip_price":21.11,"target_margin":27.5,"tax_class":"TAXABLE","image_family":"lvl_beam","barcode":"2000100001886","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":678,"reorder_point":203,"reorder_qty":678},"WK":{"on_hand":203,"reorder_point":203,"reorder_qty":678},"LK":{"on_hand":67,"reorder_point":203,"reorder_qty":678}}} +{"sku":"EW_LVL_W09500_L24","description":"1.75x9.500 LVL 24'","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":24}],"species":"LVL","grade":"ProFrame","treatment":"SCL","board_thickness_in":1.75,"board_width_in":9.5,"board_length_ft":24,"random_length":false,"length_in":288.0,"width_in":9.5,"height_in":1.75,"stackable":true,"weight_lbs":119.7,"cost":22.68,"price":31.28,"contractor_price":26.59,"vip_price":23.46,"target_margin":27.5,"tax_class":"TAXABLE","image_family":"lvl_beam","barcode":"2000100001893","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":798,"reorder_point":239,"reorder_qty":798},"WK":{"on_hand":239,"reorder_point":239,"reorder_qty":798},"LK":{"on_hand":79,"reorder_point":239,"reorder_qty":798}}} +{"sku":"EW_LVL_W09500_L28","description":"1.75x9.500 LVL 28'","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":28}],"species":"LVL","grade":"ProFrame","treatment":"SCL","board_thickness_in":1.75,"board_width_in":9.5,"board_length_ft":28,"random_length":false,"length_in":336.0,"width_in":9.5,"height_in":1.75,"stackable":true,"weight_lbs":139.65,"cost":24.96,"price":34.43,"contractor_price":29.27,"vip_price":25.82,"target_margin":27.5,"tax_class":"TAXABLE","image_family":"lvl_beam","barcode":"2000100001900","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":918,"reorder_point":275,"reorder_qty":918},"WK":{"on_hand":275,"reorder_point":275,"reorder_qty":918},"LK":{"on_hand":91,"reorder_point":275,"reorder_qty":918}}} +{"sku":"EW_LVL_W09500_L32","description":"1.75x9.500 LVL 32'","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":32}],"species":"LVL","grade":"ProFrame","treatment":"SCL","board_thickness_in":1.75,"board_width_in":9.5,"board_length_ft":32,"random_length":false,"length_in":384.0,"width_in":9.5,"height_in":1.75,"stackable":true,"weight_lbs":159.6,"cost":27.24,"price":37.57,"contractor_price":31.93,"vip_price":28.18,"target_margin":27.5,"tax_class":"TAXABLE","image_family":"lvl_beam","barcode":"2000100001907","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":1038,"reorder_point":311,"reorder_qty":1038},"WK":{"on_hand":311,"reorder_point":311,"reorder_qty":1038},"LK":{"on_hand":103,"reorder_point":311,"reorder_qty":1038}}} +{"sku":"EW_LVL_W11875_L16","description":"1.75x11.875 LVL 16'","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":16}],"species":"LVL","grade":"ProFrame","treatment":"SCL","board_thickness_in":1.75,"board_width_in":11.875,"board_length_ft":16,"random_length":false,"length_in":192.0,"width_in":11.875,"height_in":1.75,"stackable":true,"weight_lbs":99.75,"cost":20.4,"price":28.14,"contractor_price":23.92,"vip_price":21.11,"target_margin":27.5,"tax_class":"TAXABLE","image_family":"lvl_beam","barcode":"2000100001914","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":678,"reorder_point":203,"reorder_qty":678},"WK":{"on_hand":203,"reorder_point":203,"reorder_qty":678},"LK":{"on_hand":67,"reorder_point":203,"reorder_qty":678}}} +{"sku":"EW_LVL_W11875_L20","description":"1.75x11.875 LVL 20'","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":20}],"species":"LVL","grade":"ProFrame","treatment":"SCL","board_thickness_in":1.75,"board_width_in":11.875,"board_length_ft":20,"random_length":false,"length_in":240.0,"width_in":11.875,"height_in":1.75,"stackable":true,"weight_lbs":124.69,"cost":23.25,"price":32.07,"contractor_price":27.26,"vip_price":24.05,"target_margin":27.5,"tax_class":"TAXABLE","image_family":"lvl_beam","barcode":"2000100001921","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":828,"reorder_point":248,"reorder_qty":828},"WK":{"on_hand":248,"reorder_point":248,"reorder_qty":828},"LK":{"on_hand":82,"reorder_point":248,"reorder_qty":828}}} +{"sku":"EW_LVL_W11875_L24","description":"1.75x11.875 LVL 24'","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":24}],"species":"LVL","grade":"ProFrame","treatment":"SCL","board_thickness_in":1.75,"board_width_in":11.875,"board_length_ft":24,"random_length":false,"length_in":288.0,"width_in":11.875,"height_in":1.75,"stackable":true,"weight_lbs":149.63,"cost":26.1,"price":36.0,"contractor_price":30.6,"vip_price":27.0,"target_margin":27.5,"tax_class":"TAXABLE","image_family":"lvl_beam","barcode":"2000100001928","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":978,"reorder_point":293,"reorder_qty":978},"WK":{"on_hand":293,"reorder_point":293,"reorder_qty":978},"LK":{"on_hand":97,"reorder_point":293,"reorder_qty":978}}} +{"sku":"EW_LVL_W11875_L28","description":"1.75x11.875 LVL 28'","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":28}],"species":"LVL","grade":"ProFrame","treatment":"SCL","board_thickness_in":1.75,"board_width_in":11.875,"board_length_ft":28,"random_length":false,"length_in":336.0,"width_in":11.875,"height_in":1.75,"stackable":true,"weight_lbs":174.56,"cost":28.95,"price":39.93,"contractor_price":33.94,"vip_price":29.95,"target_margin":27.5,"tax_class":"TAXABLE","image_family":"lvl_beam","barcode":"2000100001935","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":1127,"reorder_point":338,"reorder_qty":1127},"WK":{"on_hand":338,"reorder_point":338,"reorder_qty":1127},"LK":{"on_hand":112,"reorder_point":338,"reorder_qty":1127}}} +{"sku":"EW_LVL_W11875_L32","description":"1.75x11.875 LVL 32'","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":32}],"species":"LVL","grade":"ProFrame","treatment":"SCL","board_thickness_in":1.75,"board_width_in":11.875,"board_length_ft":32,"random_length":false,"length_in":384.0,"width_in":11.875,"height_in":1.75,"stackable":true,"weight_lbs":199.5,"cost":31.8,"price":43.86,"contractor_price":37.28,"vip_price":32.9,"target_margin":27.5,"tax_class":"TAXABLE","image_family":"lvl_beam","barcode":"2000100001942","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":1277,"reorder_point":383,"reorder_qty":1277},"WK":{"on_hand":383,"reorder_point":383,"reorder_qty":1277},"LK":{"on_hand":127,"reorder_point":383,"reorder_qty":1277}}} +{"sku":"EW_LVL_W14000_L16","description":"1.75x14.000 LVL 16'","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":16}],"species":"LVL","grade":"ProFrame","treatment":"SCL","board_thickness_in":1.75,"board_width_in":14,"board_length_ft":16,"random_length":false,"length_in":192.0,"width_in":14,"height_in":1.75,"stackable":true,"weight_lbs":117.6,"cost":22.44,"price":30.95,"contractor_price":26.31,"vip_price":23.21,"target_margin":27.5,"tax_class":"TAXABLE","image_family":"lvl_beam","barcode":"2000100001949","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":786,"reorder_point":235,"reorder_qty":786},"WK":{"on_hand":235,"reorder_point":235,"reorder_qty":786},"LK":{"on_hand":78,"reorder_point":235,"reorder_qty":786}}} +{"sku":"EW_LVL_W14000_L20","description":"1.75x14.000 LVL 20'","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":20}],"species":"LVL","grade":"ProFrame","treatment":"SCL","board_thickness_in":1.75,"board_width_in":14,"board_length_ft":20,"random_length":false,"length_in":240.0,"width_in":14,"height_in":1.75,"stackable":true,"weight_lbs":147.0,"cost":25.8,"price":35.59,"contractor_price":30.25,"vip_price":26.69,"target_margin":27.5,"tax_class":"TAXABLE","image_family":"lvl_beam","barcode":"2000100001956","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":962,"reorder_point":288,"reorder_qty":962},"WK":{"on_hand":288,"reorder_point":288,"reorder_qty":962},"LK":{"on_hand":96,"reorder_point":288,"reorder_qty":962}}} +{"sku":"EW_LVL_W14000_L24","description":"1.75x14.000 LVL 24'","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":24}],"species":"LVL","grade":"ProFrame","treatment":"SCL","board_thickness_in":1.75,"board_width_in":14,"board_length_ft":24,"random_length":false,"length_in":288.0,"width_in":14,"height_in":1.75,"stackable":true,"weight_lbs":176.4,"cost":29.16,"price":40.22,"contractor_price":34.19,"vip_price":30.17,"target_margin":27.5,"tax_class":"TAXABLE","image_family":"lvl_beam","barcode":"2000100001963","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":1138,"reorder_point":341,"reorder_qty":1138},"WK":{"on_hand":341,"reorder_point":341,"reorder_qty":1138},"LK":{"on_hand":113,"reorder_point":341,"reorder_qty":1138}}} +{"sku":"EW_LVL_W14000_L28","description":"1.75x14.000 LVL 28'","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":28}],"species":"LVL","grade":"ProFrame","treatment":"SCL","board_thickness_in":1.75,"board_width_in":14,"board_length_ft":28,"random_length":false,"length_in":336.0,"width_in":14,"height_in":1.75,"stackable":true,"weight_lbs":205.8,"cost":32.52,"price":44.86,"contractor_price":38.13,"vip_price":33.65,"target_margin":27.5,"tax_class":"TAXABLE","image_family":"lvl_beam","barcode":"2000100001970","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":1315,"reorder_point":394,"reorder_qty":1315},"WK":{"on_hand":394,"reorder_point":394,"reorder_qty":1315},"LK":{"on_hand":131,"reorder_point":394,"reorder_qty":1315}}} +{"sku":"EW_LVL_W14000_L32","description":"1.75x14.000 LVL 32'","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":32}],"species":"LVL","grade":"ProFrame","treatment":"SCL","board_thickness_in":1.75,"board_width_in":14,"board_length_ft":32,"random_length":false,"length_in":384.0,"width_in":14,"height_in":1.75,"stackable":true,"weight_lbs":235.2,"cost":35.88,"price":49.49,"contractor_price":42.07,"vip_price":37.12,"target_margin":27.5,"tax_class":"TAXABLE","image_family":"lvl_beam","barcode":"2000100001977","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":1491,"reorder_point":447,"reorder_qty":1491},"WK":{"on_hand":447,"reorder_point":447,"reorder_qty":1491},"LK":{"on_hand":149,"reorder_point":447,"reorder_qty":1491}}} +{"sku":"EW_LVL_W16000_L16","description":"1.75x16.000 LVL 16'","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":16}],"species":"LVL","grade":"ProFrame","treatment":"SCL","board_thickness_in":1.75,"board_width_in":16,"board_length_ft":16,"random_length":false,"length_in":192.0,"width_in":16,"height_in":1.75,"stackable":true,"weight_lbs":134.4,"cost":24.36,"price":33.6,"contractor_price":28.56,"vip_price":25.2,"target_margin":27.5,"tax_class":"TAXABLE","image_family":"lvl_beam","barcode":"2000100001984","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":886,"reorder_point":265,"reorder_qty":886},"WK":{"on_hand":265,"reorder_point":265,"reorder_qty":886},"LK":{"on_hand":88,"reorder_point":265,"reorder_qty":886}}} +{"sku":"EW_LVL_W16000_L20","description":"1.75x16.000 LVL 20'","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":20}],"species":"LVL","grade":"ProFrame","treatment":"SCL","board_thickness_in":1.75,"board_width_in":16,"board_length_ft":20,"random_length":false,"length_in":240.0,"width_in":16,"height_in":1.75,"stackable":true,"weight_lbs":168.0,"cost":28.2,"price":38.9,"contractor_price":33.07,"vip_price":29.18,"target_margin":27.5,"tax_class":"TAXABLE","image_family":"lvl_beam","barcode":"2000100001991","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":1088,"reorder_point":326,"reorder_qty":1088},"WK":{"on_hand":326,"reorder_point":326,"reorder_qty":1088},"LK":{"on_hand":108,"reorder_point":326,"reorder_qty":1088}}} +{"sku":"EW_LVL_W16000_L24","description":"1.75x16.000 LVL 24'","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":24}],"species":"LVL","grade":"ProFrame","treatment":"SCL","board_thickness_in":1.75,"board_width_in":16,"board_length_ft":24,"random_length":false,"length_in":288.0,"width_in":16,"height_in":1.75,"stackable":true,"weight_lbs":201.6,"cost":32.04,"price":44.19,"contractor_price":37.56,"vip_price":33.14,"target_margin":27.5,"tax_class":"TAXABLE","image_family":"lvl_beam","barcode":"2000100001998","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":1290,"reorder_point":387,"reorder_qty":1290},"WK":{"on_hand":387,"reorder_point":387,"reorder_qty":1290},"LK":{"on_hand":129,"reorder_point":387,"reorder_qty":1290}}} +{"sku":"EW_LVL_W16000_L28","description":"1.75x16.000 LVL 28'","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":28}],"species":"LVL","grade":"ProFrame","treatment":"SCL","board_thickness_in":1.75,"board_width_in":16,"board_length_ft":28,"random_length":false,"length_in":336.0,"width_in":16,"height_in":1.75,"stackable":true,"weight_lbs":235.2,"cost":35.88,"price":49.49,"contractor_price":42.07,"vip_price":37.12,"target_margin":27.5,"tax_class":"TAXABLE","image_family":"lvl_beam","barcode":"2000100002005","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":1491,"reorder_point":447,"reorder_qty":1491},"WK":{"on_hand":447,"reorder_point":447,"reorder_qty":1491},"LK":{"on_hand":149,"reorder_point":447,"reorder_qty":1491}}} +{"sku":"EW_LVL_W16000_L32","description":"1.75x16.000 LVL 32'","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":32}],"species":"LVL","grade":"ProFrame","treatment":"SCL","board_thickness_in":1.75,"board_width_in":16,"board_length_ft":32,"random_length":false,"length_in":384.0,"width_in":16,"height_in":1.75,"stackable":true,"weight_lbs":268.8,"cost":39.72,"price":54.79,"contractor_price":46.57,"vip_price":41.09,"target_margin":27.5,"tax_class":"TAXABLE","image_family":"lvl_beam","barcode":"2000100002012","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":1693,"reorder_point":507,"reorder_qty":1693},"WK":{"on_hand":507,"reorder_point":507,"reorder_qty":1693},"LK":{"on_hand":169,"reorder_point":507,"reorder_qty":1693}}} +{"sku":"EW_LVL_W18000_L16","description":"1.75x18.000 LVL 16'","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":16}],"species":"LVL","grade":"ProFrame","treatment":"SCL","board_thickness_in":1.75,"board_width_in":18,"board_length_ft":16,"random_length":false,"length_in":192.0,"width_in":18,"height_in":1.75,"stackable":true,"weight_lbs":151.2,"cost":26.28,"price":36.25,"contractor_price":30.81,"vip_price":27.19,"target_margin":27.5,"tax_class":"TAXABLE","image_family":"lvl_beam","barcode":"2000100002019","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":987,"reorder_point":296,"reorder_qty":987},"WK":{"on_hand":296,"reorder_point":296,"reorder_qty":987},"LK":{"on_hand":98,"reorder_point":296,"reorder_qty":987}}} +{"sku":"EW_LVL_W18000_L20","description":"1.75x18.000 LVL 20'","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":20}],"species":"LVL","grade":"ProFrame","treatment":"SCL","board_thickness_in":1.75,"board_width_in":18,"board_length_ft":20,"random_length":false,"length_in":240.0,"width_in":18,"height_in":1.75,"stackable":true,"weight_lbs":189.0,"cost":30.6,"price":42.21,"contractor_price":35.88,"vip_price":31.66,"target_margin":27.5,"tax_class":"TAXABLE","image_family":"lvl_beam","barcode":"2000100002026","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":1214,"reorder_point":364,"reorder_qty":1214},"WK":{"on_hand":364,"reorder_point":364,"reorder_qty":1214},"LK":{"on_hand":121,"reorder_point":364,"reorder_qty":1214}}} +{"sku":"EW_LVL_W18000_L24","description":"1.75x18.000 LVL 24'","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":24}],"species":"LVL","grade":"ProFrame","treatment":"SCL","board_thickness_in":1.75,"board_width_in":18,"board_length_ft":24,"random_length":false,"length_in":288.0,"width_in":18,"height_in":1.75,"stackable":true,"weight_lbs":226.8,"cost":34.92,"price":48.17,"contractor_price":40.94,"vip_price":36.13,"target_margin":27.5,"tax_class":"TAXABLE","image_family":"lvl_beam","barcode":"2000100002033","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":1441,"reorder_point":432,"reorder_qty":1441},"WK":{"on_hand":432,"reorder_point":432,"reorder_qty":1441},"LK":{"on_hand":144,"reorder_point":432,"reorder_qty":1441}}} +{"sku":"EW_LVL_W18000_L28","description":"1.75x18.000 LVL 28'","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":28}],"species":"LVL","grade":"ProFrame","treatment":"SCL","board_thickness_in":1.75,"board_width_in":18,"board_length_ft":28,"random_length":false,"length_in":336.0,"width_in":18,"height_in":1.75,"stackable":true,"weight_lbs":264.6,"cost":39.24,"price":54.12,"contractor_price":46.0,"vip_price":40.59,"target_margin":27.5,"tax_class":"TAXABLE","image_family":"lvl_beam","barcode":"2000100002040","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":1668,"reorder_point":500,"reorder_qty":1668},"WK":{"on_hand":500,"reorder_point":500,"reorder_qty":1668},"LK":{"on_hand":166,"reorder_point":500,"reorder_qty":1668}}} +{"sku":"EW_LVL_W18000_L32","description":"1.75x18.000 LVL 32'","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":32}],"species":"LVL","grade":"ProFrame","treatment":"SCL","board_thickness_in":1.75,"board_width_in":18,"board_length_ft":32,"random_length":false,"length_in":384.0,"width_in":18,"height_in":1.75,"stackable":true,"weight_lbs":302.4,"cost":43.56,"price":60.08,"contractor_price":51.07,"vip_price":45.06,"target_margin":27.5,"tax_class":"TAXABLE","image_family":"lvl_beam","barcode":"2000100002047","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":1894,"reorder_point":568,"reorder_qty":1894},"WK":{"on_hand":568,"reorder_point":568,"reorder_qty":1894},"LK":{"on_hand":189,"reorder_point":568,"reorder_qty":1894}}} +{"sku":"EW_IJ_D09500_L16","description":"I-Joist 9.500 x 16'","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":16}],"species":"I-Joist","grade":"ProFrame","treatment":"SCL","board_thickness_in":9.5,"board_width_in":2.5,"board_length_ft":16,"random_length":false,"length_in":192.0,"width_in":9.5,"height_in":2.5,"stackable":false,"weight_lbs":27.36,"cost":16.1,"price":22.05,"contractor_price":18.74,"vip_price":16.54,"target_margin":27.0,"tax_class":"TAXABLE","image_family":"i_joist","barcode":"2000100002054","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":244,"reorder_point":73,"reorder_qty":244},"WK":{"on_hand":73,"reorder_point":73,"reorder_qty":244},"LK":{"on_hand":24,"reorder_point":73,"reorder_qty":244}}} +{"sku":"EW_IJ_D09500_L20","description":"I-Joist 9.500 x 20'","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":20}],"species":"I-Joist","grade":"ProFrame","treatment":"SCL","board_thickness_in":9.5,"board_width_in":2.5,"board_length_ft":20,"random_length":false,"length_in":240.0,"width_in":9.5,"height_in":2.5,"stackable":false,"weight_lbs":34.2,"cost":18.0,"price":24.66,"contractor_price":20.96,"vip_price":18.5,"target_margin":27.0,"tax_class":"TAXABLE","image_family":"i_joist","barcode":"2000100002061","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":285,"reorder_point":85,"reorder_qty":285},"WK":{"on_hand":85,"reorder_point":85,"reorder_qty":285},"LK":{"on_hand":28,"reorder_point":85,"reorder_qty":285}}} +{"sku":"EW_IJ_D09500_L24","description":"I-Joist 9.500 x 24'","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":24}],"species":"I-Joist","grade":"ProFrame","treatment":"SCL","board_thickness_in":9.5,"board_width_in":2.5,"board_length_ft":24,"random_length":false,"length_in":288.0,"width_in":9.5,"height_in":2.5,"stackable":false,"weight_lbs":41.04,"cost":19.9,"price":27.26,"contractor_price":23.17,"vip_price":20.45,"target_margin":27.0,"tax_class":"TAXABLE","image_family":"i_joist","barcode":"2000100002068","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":326,"reorder_point":97,"reorder_qty":326},"WK":{"on_hand":97,"reorder_point":97,"reorder_qty":326},"LK":{"on_hand":32,"reorder_point":97,"reorder_qty":326}}} +{"sku":"EW_IJ_D09500_L28","description":"I-Joist 9.500 x 28'","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":28}],"species":"I-Joist","grade":"ProFrame","treatment":"SCL","board_thickness_in":9.5,"board_width_in":2.5,"board_length_ft":28,"random_length":false,"length_in":336.0,"width_in":9.5,"height_in":2.5,"stackable":false,"weight_lbs":47.88,"cost":21.8,"price":29.86,"contractor_price":25.38,"vip_price":22.4,"target_margin":27.0,"tax_class":"TAXABLE","image_family":"i_joist","barcode":"2000100002075","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":367,"reorder_point":110,"reorder_qty":367},"WK":{"on_hand":110,"reorder_point":110,"reorder_qty":367},"LK":{"on_hand":36,"reorder_point":110,"reorder_qty":367}}} +{"sku":"EW_IJ_D09500_L32","description":"I-Joist 9.500 x 32'","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":32}],"species":"I-Joist","grade":"ProFrame","treatment":"SCL","board_thickness_in":9.5,"board_width_in":2.5,"board_length_ft":32,"random_length":false,"length_in":384.0,"width_in":9.5,"height_in":2.5,"stackable":false,"weight_lbs":54.72,"cost":23.7,"price":32.47,"contractor_price":27.6,"vip_price":24.35,"target_margin":27.0,"tax_class":"TAXABLE","image_family":"i_joist","barcode":"2000100002082","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":408,"reorder_point":122,"reorder_qty":408},"WK":{"on_hand":122,"reorder_point":122,"reorder_qty":408},"LK":{"on_hand":40,"reorder_point":122,"reorder_qty":408}}} +{"sku":"EW_IJ_D11875_L16","description":"I-Joist 11.875 x 16'","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":16}],"species":"I-Joist","grade":"ProFrame","treatment":"SCL","board_thickness_in":11.875,"board_width_in":2.5,"board_length_ft":16,"random_length":false,"length_in":192.0,"width_in":11.875,"height_in":2.5,"stackable":false,"weight_lbs":34.2,"cost":18.0,"price":24.66,"contractor_price":20.96,"vip_price":18.5,"target_margin":27.0,"tax_class":"TAXABLE","image_family":"i_joist","barcode":"2000100002089","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":285,"reorder_point":85,"reorder_qty":285},"WK":{"on_hand":85,"reorder_point":85,"reorder_qty":285},"LK":{"on_hand":28,"reorder_point":85,"reorder_qty":285}}} +{"sku":"EW_IJ_D11875_L20","description":"I-Joist 11.875 x 20'","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":20}],"species":"I-Joist","grade":"ProFrame","treatment":"SCL","board_thickness_in":11.875,"board_width_in":2.5,"board_length_ft":20,"random_length":false,"length_in":240.0,"width_in":11.875,"height_in":2.5,"stackable":false,"weight_lbs":42.75,"cost":20.38,"price":27.91,"contractor_price":23.72,"vip_price":20.93,"target_margin":27.0,"tax_class":"TAXABLE","image_family":"i_joist","barcode":"2000100002096","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":336,"reorder_point":100,"reorder_qty":336},"WK":{"on_hand":100,"reorder_point":100,"reorder_qty":336},"LK":{"on_hand":33,"reorder_point":100,"reorder_qty":336}}} +{"sku":"EW_IJ_D11875_L24","description":"I-Joist 11.875 x 24'","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":24}],"species":"I-Joist","grade":"ProFrame","treatment":"SCL","board_thickness_in":11.875,"board_width_in":2.5,"board_length_ft":24,"random_length":false,"length_in":288.0,"width_in":11.875,"height_in":2.5,"stackable":false,"weight_lbs":51.3,"cost":22.75,"price":31.16,"contractor_price":26.49,"vip_price":23.37,"target_margin":27.0,"tax_class":"TAXABLE","image_family":"i_joist","barcode":"2000100002103","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":388,"reorder_point":116,"reorder_qty":388},"WK":{"on_hand":116,"reorder_point":116,"reorder_qty":388},"LK":{"on_hand":38,"reorder_point":116,"reorder_qty":388}}} +{"sku":"EW_IJ_D11875_L28","description":"I-Joist 11.875 x 28'","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":28}],"species":"I-Joist","grade":"ProFrame","treatment":"SCL","board_thickness_in":11.875,"board_width_in":2.5,"board_length_ft":28,"random_length":false,"length_in":336.0,"width_in":11.875,"height_in":2.5,"stackable":false,"weight_lbs":59.85,"cost":25.13,"price":34.42,"contractor_price":29.26,"vip_price":25.82,"target_margin":27.0,"tax_class":"TAXABLE","image_family":"i_joist","barcode":"2000100002110","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":439,"reorder_point":131,"reorder_qty":439},"WK":{"on_hand":131,"reorder_point":131,"reorder_qty":439},"LK":{"on_hand":43,"reorder_point":131,"reorder_qty":439}}} +{"sku":"EW_IJ_D11875_L32","description":"I-Joist 11.875 x 32'","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":32}],"species":"I-Joist","grade":"ProFrame","treatment":"SCL","board_thickness_in":11.875,"board_width_in":2.5,"board_length_ft":32,"random_length":false,"length_in":384.0,"width_in":11.875,"height_in":2.5,"stackable":false,"weight_lbs":68.4,"cost":27.5,"price":37.67,"contractor_price":32.02,"vip_price":28.25,"target_margin":27.0,"tax_class":"TAXABLE","image_family":"i_joist","barcode":"2000100002117","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":490,"reorder_point":147,"reorder_qty":490},"WK":{"on_hand":147,"reorder_point":147,"reorder_qty":490},"LK":{"on_hand":49,"reorder_point":147,"reorder_qty":490}}} +{"sku":"EW_IJ_D14000_L16","description":"I-Joist 14.000 x 16'","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":16}],"species":"I-Joist","grade":"ProFrame","treatment":"SCL","board_thickness_in":14,"board_width_in":2.5,"board_length_ft":16,"random_length":false,"length_in":192.0,"width_in":14,"height_in":2.5,"stackable":false,"weight_lbs":40.32,"cost":19.7,"price":26.99,"contractor_price":22.94,"vip_price":20.24,"target_margin":27.0,"tax_class":"TAXABLE","image_family":"i_joist","barcode":"2000100002124","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":322,"reorder_point":96,"reorder_qty":322},"WK":{"on_hand":96,"reorder_point":96,"reorder_qty":322},"LK":{"on_hand":32,"reorder_point":96,"reorder_qty":322}}} +{"sku":"EW_IJ_D14000_L20","description":"I-Joist 14.000 x 20'","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":20}],"species":"I-Joist","grade":"ProFrame","treatment":"SCL","board_thickness_in":14,"board_width_in":2.5,"board_length_ft":20,"random_length":false,"length_in":240.0,"width_in":14,"height_in":2.5,"stackable":false,"weight_lbs":50.4,"cost":22.5,"price":30.82,"contractor_price":26.2,"vip_price":23.12,"target_margin":27.0,"tax_class":"TAXABLE","image_family":"i_joist","barcode":"2000100002131","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":382,"reorder_point":114,"reorder_qty":382},"WK":{"on_hand":114,"reorder_point":114,"reorder_qty":382},"LK":{"on_hand":38,"reorder_point":114,"reorder_qty":382}}} +{"sku":"EW_IJ_D14000_L24","description":"I-Joist 14.000 x 24'","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":24}],"species":"I-Joist","grade":"ProFrame","treatment":"SCL","board_thickness_in":14,"board_width_in":2.5,"board_length_ft":24,"random_length":false,"length_in":288.0,"width_in":14,"height_in":2.5,"stackable":false,"weight_lbs":60.48,"cost":25.3,"price":34.66,"contractor_price":29.46,"vip_price":26.0,"target_margin":27.0,"tax_class":"TAXABLE","image_family":"i_joist","barcode":"2000100002138","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":443,"reorder_point":132,"reorder_qty":443},"WK":{"on_hand":132,"reorder_point":132,"reorder_qty":443},"LK":{"on_hand":44,"reorder_point":132,"reorder_qty":443}}} +{"sku":"EW_IJ_D14000_L28","description":"I-Joist 14.000 x 28'","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":28}],"species":"I-Joist","grade":"ProFrame","treatment":"SCL","board_thickness_in":14,"board_width_in":2.5,"board_length_ft":28,"random_length":false,"length_in":336.0,"width_in":14,"height_in":2.5,"stackable":false,"weight_lbs":70.56,"cost":28.1,"price":38.49,"contractor_price":32.72,"vip_price":28.87,"target_margin":27.0,"tax_class":"TAXABLE","image_family":"i_joist","barcode":"2000100002145","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":503,"reorder_point":150,"reorder_qty":503},"WK":{"on_hand":150,"reorder_point":150,"reorder_qty":503},"LK":{"on_hand":50,"reorder_point":150,"reorder_qty":503}}} +{"sku":"EW_IJ_D14000_L32","description":"I-Joist 14.000 x 32'","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":32}],"species":"I-Joist","grade":"ProFrame","treatment":"SCL","board_thickness_in":14,"board_width_in":2.5,"board_length_ft":32,"random_length":false,"length_in":384.0,"width_in":14,"height_in":2.5,"stackable":false,"weight_lbs":80.64,"cost":30.9,"price":42.33,"contractor_price":35.98,"vip_price":31.75,"target_margin":27.0,"tax_class":"TAXABLE","image_family":"i_joist","barcode":"2000100002152","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":564,"reorder_point":169,"reorder_qty":564},"WK":{"on_hand":169,"reorder_point":169,"reorder_qty":564},"LK":{"on_hand":56,"reorder_point":169,"reorder_qty":564}}} +{"sku":"EW_IJ_D16000_L16","description":"I-Joist 16.000 x 16'","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":16}],"species":"I-Joist","grade":"ProFrame","treatment":"SCL","board_thickness_in":16,"board_width_in":2.5,"board_length_ft":16,"random_length":false,"length_in":192.0,"width_in":16,"height_in":2.5,"stackable":false,"weight_lbs":46.08,"cost":21.3,"price":29.18,"contractor_price":24.8,"vip_price":21.89,"target_margin":27.0,"tax_class":"TAXABLE","image_family":"i_joist","barcode":"2000100002159","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":356,"reorder_point":106,"reorder_qty":356},"WK":{"on_hand":106,"reorder_point":106,"reorder_qty":356},"LK":{"on_hand":35,"reorder_point":106,"reorder_qty":356}}} +{"sku":"EW_IJ_D16000_L20","description":"I-Joist 16.000 x 20'","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":20}],"species":"I-Joist","grade":"ProFrame","treatment":"SCL","board_thickness_in":16,"board_width_in":2.5,"board_length_ft":20,"random_length":false,"length_in":240.0,"width_in":16,"height_in":2.5,"stackable":false,"weight_lbs":57.6,"cost":24.5,"price":33.56,"contractor_price":28.53,"vip_price":25.17,"target_margin":27.0,"tax_class":"TAXABLE","image_family":"i_joist","barcode":"2000100002166","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":426,"reorder_point":127,"reorder_qty":426},"WK":{"on_hand":127,"reorder_point":127,"reorder_qty":426},"LK":{"on_hand":42,"reorder_point":127,"reorder_qty":426}}} +{"sku":"EW_IJ_D16000_L24","description":"I-Joist 16.000 x 24'","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":24}],"species":"I-Joist","grade":"ProFrame","treatment":"SCL","board_thickness_in":16,"board_width_in":2.5,"board_length_ft":24,"random_length":false,"length_in":288.0,"width_in":16,"height_in":2.5,"stackable":false,"weight_lbs":69.12,"cost":27.7,"price":37.95,"contractor_price":32.26,"vip_price":28.46,"target_margin":27.0,"tax_class":"TAXABLE","image_family":"i_joist","barcode":"2000100002173","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":495,"reorder_point":148,"reorder_qty":495},"WK":{"on_hand":148,"reorder_point":148,"reorder_qty":495},"LK":{"on_hand":49,"reorder_point":148,"reorder_qty":495}}} +{"sku":"EW_IJ_D16000_L28","description":"I-Joist 16.000 x 28'","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":28}],"species":"I-Joist","grade":"ProFrame","treatment":"SCL","board_thickness_in":16,"board_width_in":2.5,"board_length_ft":28,"random_length":false,"length_in":336.0,"width_in":16,"height_in":2.5,"stackable":false,"weight_lbs":80.64,"cost":30.9,"price":42.33,"contractor_price":35.98,"vip_price":31.75,"target_margin":27.0,"tax_class":"TAXABLE","image_family":"i_joist","barcode":"2000100002180","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":564,"reorder_point":169,"reorder_qty":564},"WK":{"on_hand":169,"reorder_point":169,"reorder_qty":564},"LK":{"on_hand":56,"reorder_point":169,"reorder_qty":564}}} +{"sku":"EW_IJ_D16000_L32","description":"I-Joist 16.000 x 32'","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":32}],"species":"I-Joist","grade":"ProFrame","treatment":"SCL","board_thickness_in":16,"board_width_in":2.5,"board_length_ft":32,"random_length":false,"length_in":384.0,"width_in":16,"height_in":2.5,"stackable":false,"weight_lbs":92.16,"cost":34.1,"price":46.71,"contractor_price":39.7,"vip_price":35.03,"target_margin":27.0,"tax_class":"TAXABLE","image_family":"i_joist","barcode":"2000100002187","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":633,"reorder_point":189,"reorder_qty":633},"WK":{"on_hand":189,"reorder_point":189,"reorder_qty":633},"LK":{"on_hand":63,"reorder_point":189,"reorder_qty":633}}} +{"sku":"EW_IJ_D18000_L16","description":"I-Joist 18.000 x 16'","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":16}],"species":"I-Joist","grade":"ProFrame","treatment":"SCL","board_thickness_in":18,"board_width_in":2.5,"board_length_ft":16,"random_length":false,"length_in":192.0,"width_in":18,"height_in":2.5,"stackable":false,"weight_lbs":51.84,"cost":22.9,"price":31.37,"contractor_price":26.66,"vip_price":23.53,"target_margin":27.0,"tax_class":"TAXABLE","image_family":"i_joist","barcode":"2000100002194","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":391,"reorder_point":117,"reorder_qty":391},"WK":{"on_hand":117,"reorder_point":117,"reorder_qty":391},"LK":{"on_hand":39,"reorder_point":117,"reorder_qty":391}}} +{"sku":"EW_IJ_D18000_L20","description":"I-Joist 18.000 x 20'","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":20}],"species":"I-Joist","grade":"ProFrame","treatment":"SCL","board_thickness_in":18,"board_width_in":2.5,"board_length_ft":20,"random_length":false,"length_in":240.0,"width_in":18,"height_in":2.5,"stackable":false,"weight_lbs":64.8,"cost":26.5,"price":36.3,"contractor_price":30.86,"vip_price":27.23,"target_margin":27.0,"tax_class":"TAXABLE","image_family":"i_joist","barcode":"2000100002201","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":469,"reorder_point":140,"reorder_qty":469},"WK":{"on_hand":140,"reorder_point":140,"reorder_qty":469},"LK":{"on_hand":46,"reorder_point":140,"reorder_qty":469}}} +{"sku":"EW_IJ_D18000_L24","description":"I-Joist 18.000 x 24'","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":24}],"species":"I-Joist","grade":"ProFrame","treatment":"SCL","board_thickness_in":18,"board_width_in":2.5,"board_length_ft":24,"random_length":false,"length_in":288.0,"width_in":18,"height_in":2.5,"stackable":false,"weight_lbs":77.76,"cost":30.1,"price":41.23,"contractor_price":35.05,"vip_price":30.92,"target_margin":27.0,"tax_class":"TAXABLE","image_family":"i_joist","barcode":"2000100002208","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":547,"reorder_point":164,"reorder_qty":547},"WK":{"on_hand":164,"reorder_point":164,"reorder_qty":547},"LK":{"on_hand":54,"reorder_point":164,"reorder_qty":547}}} +{"sku":"EW_IJ_D18000_L28","description":"I-Joist 18.000 x 28'","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":28}],"species":"I-Joist","grade":"ProFrame","treatment":"SCL","board_thickness_in":18,"board_width_in":2.5,"board_length_ft":28,"random_length":false,"length_in":336.0,"width_in":18,"height_in":2.5,"stackable":false,"weight_lbs":90.72,"cost":33.7,"price":46.16,"contractor_price":39.24,"vip_price":34.62,"target_margin":27.0,"tax_class":"TAXABLE","image_family":"i_joist","barcode":"2000100002215","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":624,"reorder_point":187,"reorder_qty":624},"WK":{"on_hand":187,"reorder_point":187,"reorder_qty":624},"LK":{"on_hand":62,"reorder_point":187,"reorder_qty":624}}} +{"sku":"EW_IJ_D18000_L32","description":"I-Joist 18.000 x 32'","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"VAL_ENG","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":32}],"species":"I-Joist","grade":"ProFrame","treatment":"SCL","board_thickness_in":18,"board_width_in":2.5,"board_length_ft":32,"random_length":false,"length_in":384.0,"width_in":18,"height_in":2.5,"stackable":false,"weight_lbs":103.68,"cost":37.3,"price":51.1,"contractor_price":43.44,"vip_price":38.33,"target_margin":27.0,"tax_class":"TAXABLE","image_family":"i_joist","barcode":"2000100002222","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":702,"reorder_point":210,"reorder_qty":702},"WK":{"on_hand":210,"reorder_point":210,"reorder_qty":702},"LK":{"on_hand":70,"reorder_point":210,"reorder_qty":702}}} {"sku":"EW_GLM_W06000_L16","description":"3.125x6.000 Glulam 16' Pacific","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"GLULAM_PAC","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":16}],"species":"Glulam","grade":"24F-V4","treatment":"SCL","board_thickness_in":3.125,"board_width_in":6,"board_length_ft":16,"random_length":false,"length_in":192.0,"width_in":6,"height_in":3.125,"stackable":true,"weight_lbs":40.32,"cost":27.6,"price":38.07,"contractor_price":32.36,"vip_price":28.55,"target_margin":27.5,"tax_class":"TAXABLE","image_family":"glulam_beam","barcode":"2000100002229","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":322,"reorder_point":96,"reorder_qty":322},"WK":{"on_hand":96,"reorder_point":96,"reorder_qty":322},"LK":{"on_hand":32,"reorder_point":96,"reorder_qty":322}}} {"sku":"EW_GLM_W06000_L20","description":"3.125x6.000 Glulam 20' Pacific","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"GLULAM_PAC","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":20}],"species":"Glulam","grade":"24F-V4","treatment":"SCL","board_thickness_in":3.125,"board_width_in":6,"board_length_ft":20,"random_length":false,"length_in":240.0,"width_in":6,"height_in":3.125,"stackable":true,"weight_lbs":50.4,"cost":30.0,"price":41.38,"contractor_price":35.17,"vip_price":31.04,"target_margin":27.5,"tax_class":"TAXABLE","image_family":"glulam_beam","barcode":"2000100002236","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":382,"reorder_point":114,"reorder_qty":382},"WK":{"on_hand":114,"reorder_point":114,"reorder_qty":382},"LK":{"on_hand":38,"reorder_point":114,"reorder_qty":382}}} {"sku":"EW_GLM_W06000_L24","description":"3.125x6.000 Glulam 24' Pacific","department":"Yard","category_slug":"lumber.engineered_wood","vendor_key":"GLULAM_PAC","uom_primary":"PCS","alternate_uoms":[{"uom":"LF","unit_qty":1,"stock_qty":24}],"species":"Glulam","grade":"24F-V4","treatment":"SCL","board_thickness_in":3.125,"board_width_in":6,"board_length_ft":24,"random_length":false,"length_in":288.0,"width_in":6,"height_in":3.125,"stackable":true,"weight_lbs":60.48,"cost":32.4,"price":44.69,"contractor_price":37.99,"vip_price":33.52,"target_margin":27.5,"tax_class":"TAXABLE","image_family":"glulam_beam","barcode":"2000100002243","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":443,"reorder_point":132,"reorder_qty":443},"WK":{"on_hand":132,"reorder_point":132,"reorder_qty":443},"LK":{"on_hand":44,"reorder_point":132,"reorder_qty":443}}} @@ -697,21 +697,21 @@ {"sku":"BL_BLT12","description":"1/2\" Carriage Bolt 6\" w/ nut and washer","department":"Yard","category_slug":"hardware.fasteners","vendor_key":"FAST_GR","uom_primary":"EA","alternate_uoms":[{"uom":"BOX","unit_qty":50,"stock_qty":1}],"species":"Steel","grade":"Carriage","treatment":"Zinc","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":6.0,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":1.0,"cost":4.0,"price":6.45,"contractor_price":5.48,"vip_price":4.84,"target_margin":38.0,"tax_class":"TAXABLE","image_family":"bolt_pack","barcode":"2000100004882","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":86,"reorder_point":25,"reorder_qty":86},"WK":{"on_hand":25,"reorder_point":25,"reorder_qty":86},"LK":{"on_hand":8,"reorder_point":25,"reorder_qty":86}}} {"sku":"BL_BLT58","description":"5/8\" Carriage Bolt 8\" w/ nut and washer","department":"Yard","category_slug":"hardware.fasteners","vendor_key":"FAST_GR","uom_primary":"EA","alternate_uoms":[{"uom":"BOX","unit_qty":50,"stock_qty":1}],"species":"Steel","grade":"Carriage","treatment":"Zinc","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":8.0,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":1.5,"cost":5.0,"price":8.06,"contractor_price":6.85,"vip_price":6.05,"target_margin":38.0,"tax_class":"TAXABLE","image_family":"bolt_pack","barcode":"2000100004889","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":89,"reorder_point":26,"reorder_qty":89},"WK":{"on_hand":26,"reorder_point":26,"reorder_qty":89},"LK":{"on_hand":8,"reorder_point":26,"reorder_qty":89}}} {"sku":"BL_LGBLT","description":"1/2\"x6\" Lag Bolt","department":"Yard","category_slug":"hardware.fasteners","vendor_key":"FAST_GR","uom_primary":"EA","alternate_uoms":[{"uom":"BOX","unit_qty":50,"stock_qty":1}],"species":"Steel","grade":"Lag","treatment":"Zinc","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":6.0,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":0.8,"cost":4.0,"price":6.45,"contractor_price":5.48,"vip_price":4.84,"target_margin":38.0,"tax_class":"TAXABLE","image_family":"bolt_pack","barcode":"2000100004896","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":85,"reorder_point":25,"reorder_qty":85},"WK":{"on_hand":25,"reorder_point":25,"reorder_qty":85},"LK":{"on_hand":8,"reorder_point":25,"reorder_qty":85}}} -{"sku":"CN_JH26","description":"ProFrame Joist Hanger 2x6","department":"Yard","category_slug":"hardware.connectors","vendor_key":"PROFRAME","uom_primary":"EA","alternate_uoms":[],"species":"Galvanized Steel","grade":"Joist Hanger","treatment":"G90","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":0.6,"cost":1.7,"price":2.74,"contractor_price":2.33,"vip_price":2.06,"target_margin":38.0,"tax_class":"TAXABLE","image_family":"joist_hanger","barcode":"2000100004903","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":84,"reorder_point":25,"reorder_qty":84},"WK":{"on_hand":25,"reorder_point":25,"reorder_qty":84},"LK":{"on_hand":8,"reorder_point":25,"reorder_qty":84}}} -{"sku":"CN_JH28","description":"ProFrame Joist Hanger 2x8","department":"Yard","category_slug":"hardware.connectors","vendor_key":"PROFRAME","uom_primary":"EA","alternate_uoms":[],"species":"Galvanized Steel","grade":"Joist Hanger","treatment":"G90","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":0.7,"cost":1.85,"price":2.98,"contractor_price":2.53,"vip_price":2.24,"target_margin":38.0,"tax_class":"TAXABLE","image_family":"joist_hanger","barcode":"2000100004910","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":84,"reorder_point":25,"reorder_qty":84},"WK":{"on_hand":25,"reorder_point":25,"reorder_qty":84},"LK":{"on_hand":8,"reorder_point":25,"reorder_qty":84}}} -{"sku":"CN_JH210","description":"ProFrame Joist Hanger 2x10","department":"Yard","category_slug":"hardware.connectors","vendor_key":"PROFRAME","uom_primary":"EA","alternate_uoms":[],"species":"Galvanized Steel","grade":"Joist Hanger","treatment":"G90","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":0.85,"cost":2.08,"price":3.35,"contractor_price":2.85,"vip_price":2.51,"target_margin":38.0,"tax_class":"TAXABLE","image_family":"joist_hanger","barcode":"2000100004917","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":85,"reorder_point":25,"reorder_qty":85},"WK":{"on_hand":25,"reorder_point":25,"reorder_qty":85},"LK":{"on_hand":8,"reorder_point":25,"reorder_qty":85}}} -{"sku":"CN_JH212","description":"ProFrame Joist Hanger 2x12","department":"Yard","category_slug":"hardware.connectors","vendor_key":"PROFRAME","uom_primary":"EA","alternate_uoms":[],"species":"Galvanized Steel","grade":"Joist Hanger","treatment":"G90","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":1.0,"cost":2.3,"price":3.71,"contractor_price":3.15,"vip_price":2.78,"target_margin":38.0,"tax_class":"TAXABLE","image_family":"joist_hanger","barcode":"2000100004924","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":86,"reorder_point":25,"reorder_qty":86},"WK":{"on_hand":25,"reorder_point":25,"reorder_qty":86},"LK":{"on_hand":8,"reorder_point":25,"reorder_qty":86}}} -{"sku":"CN_HT1","description":"ProFrame Hurricane Tie H1","department":"Yard","category_slug":"hardware.connectors","vendor_key":"PROFRAME","uom_primary":"EA","alternate_uoms":[],"species":"Galvanized Steel","grade":"Hurricane Tie","treatment":"G90","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":0.3,"cost":1.25,"price":2.02,"contractor_price":1.72,"vip_price":1.52,"target_margin":38.0,"tax_class":"TAXABLE","image_family":"framing_connector","barcode":"2000100004931","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":82,"reorder_point":24,"reorder_qty":82},"WK":{"on_hand":24,"reorder_point":24,"reorder_qty":82},"LK":{"on_hand":8,"reorder_point":24,"reorder_qty":82}}} -{"sku":"CN_HT2","description":"ProFrame Hurricane Tie H2.5","department":"Yard","category_slug":"hardware.connectors","vendor_key":"PROFRAME","uom_primary":"EA","alternate_uoms":[],"species":"Galvanized Steel","grade":"Hurricane Tie","treatment":"G90","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":0.5,"cost":1.55,"price":2.5,"contractor_price":2.13,"vip_price":1.88,"target_margin":38.0,"tax_class":"TAXABLE","image_family":"framing_connector","barcode":"2000100004938","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":83,"reorder_point":24,"reorder_qty":83},"WK":{"on_hand":24,"reorder_point":24,"reorder_qty":83},"LK":{"on_hand":8,"reorder_point":24,"reorder_qty":83}}} -{"sku":"CN_HD88","description":"ProFrame Hold Down HD88","department":"Yard","category_slug":"hardware.connectors","vendor_key":"PROFRAME","uom_primary":"EA","alternate_uoms":[],"species":"Galvanized Steel","grade":"Hold Down","treatment":"G90","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":1.5,"cost":3.05,"price":4.92,"contractor_price":4.18,"vip_price":3.69,"target_margin":38.0,"tax_class":"TAXABLE","image_family":"framing_connector","barcode":"2000100004945","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":89,"reorder_point":26,"reorder_qty":89},"WK":{"on_hand":26,"reorder_point":26,"reorder_qty":89},"LK":{"on_hand":8,"reorder_point":26,"reorder_qty":89}}} +{"sku":"CN_JH26","description":"Joist Hanger 2x6","department":"Yard","category_slug":"hardware.connectors","vendor_key":"PROFRAME","uom_primary":"EA","alternate_uoms":[],"species":"Galvanized Steel","grade":"Joist Hanger","treatment":"G90","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":0.6,"cost":1.7,"price":2.74,"contractor_price":2.33,"vip_price":2.06,"target_margin":38.0,"tax_class":"TAXABLE","image_family":"joist_hanger","barcode":"2000100004903","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":84,"reorder_point":25,"reorder_qty":84},"WK":{"on_hand":25,"reorder_point":25,"reorder_qty":84},"LK":{"on_hand":8,"reorder_point":25,"reorder_qty":84}}} +{"sku":"CN_JH28","description":"Joist Hanger 2x8","department":"Yard","category_slug":"hardware.connectors","vendor_key":"PROFRAME","uom_primary":"EA","alternate_uoms":[],"species":"Galvanized Steel","grade":"Joist Hanger","treatment":"G90","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":0.7,"cost":1.85,"price":2.98,"contractor_price":2.53,"vip_price":2.24,"target_margin":38.0,"tax_class":"TAXABLE","image_family":"joist_hanger","barcode":"2000100004910","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":84,"reorder_point":25,"reorder_qty":84},"WK":{"on_hand":25,"reorder_point":25,"reorder_qty":84},"LK":{"on_hand":8,"reorder_point":25,"reorder_qty":84}}} +{"sku":"CN_JH210","description":"Joist Hanger 2x10","department":"Yard","category_slug":"hardware.connectors","vendor_key":"PROFRAME","uom_primary":"EA","alternate_uoms":[],"species":"Galvanized Steel","grade":"Joist Hanger","treatment":"G90","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":0.85,"cost":2.08,"price":3.35,"contractor_price":2.85,"vip_price":2.51,"target_margin":38.0,"tax_class":"TAXABLE","image_family":"joist_hanger","barcode":"2000100004917","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":85,"reorder_point":25,"reorder_qty":85},"WK":{"on_hand":25,"reorder_point":25,"reorder_qty":85},"LK":{"on_hand":8,"reorder_point":25,"reorder_qty":85}}} +{"sku":"CN_JH212","description":"Joist Hanger 2x12","department":"Yard","category_slug":"hardware.connectors","vendor_key":"PROFRAME","uom_primary":"EA","alternate_uoms":[],"species":"Galvanized Steel","grade":"Joist Hanger","treatment":"G90","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":1.0,"cost":2.3,"price":3.71,"contractor_price":3.15,"vip_price":2.78,"target_margin":38.0,"tax_class":"TAXABLE","image_family":"joist_hanger","barcode":"2000100004924","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":86,"reorder_point":25,"reorder_qty":86},"WK":{"on_hand":25,"reorder_point":25,"reorder_qty":86},"LK":{"on_hand":8,"reorder_point":25,"reorder_qty":86}}} +{"sku":"CN_HT1","description":"Hurricane Tie H1","department":"Yard","category_slug":"hardware.connectors","vendor_key":"PROFRAME","uom_primary":"EA","alternate_uoms":[],"species":"Galvanized Steel","grade":"Hurricane Tie","treatment":"G90","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":0.3,"cost":1.25,"price":2.02,"contractor_price":1.72,"vip_price":1.52,"target_margin":38.0,"tax_class":"TAXABLE","image_family":"framing_connector","barcode":"2000100004931","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":82,"reorder_point":24,"reorder_qty":82},"WK":{"on_hand":24,"reorder_point":24,"reorder_qty":82},"LK":{"on_hand":8,"reorder_point":24,"reorder_qty":82}}} +{"sku":"CN_HT2","description":"Hurricane Tie H2.5","department":"Yard","category_slug":"hardware.connectors","vendor_key":"PROFRAME","uom_primary":"EA","alternate_uoms":[],"species":"Galvanized Steel","grade":"Hurricane Tie","treatment":"G90","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":0.5,"cost":1.55,"price":2.5,"contractor_price":2.13,"vip_price":1.88,"target_margin":38.0,"tax_class":"TAXABLE","image_family":"framing_connector","barcode":"2000100004938","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":83,"reorder_point":24,"reorder_qty":83},"WK":{"on_hand":24,"reorder_point":24,"reorder_qty":83},"LK":{"on_hand":8,"reorder_point":24,"reorder_qty":83}}} +{"sku":"CN_HD88","description":"Hold Down HD88","department":"Yard","category_slug":"hardware.connectors","vendor_key":"PROFRAME","uom_primary":"EA","alternate_uoms":[],"species":"Galvanized Steel","grade":"Hold Down","treatment":"G90","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":1.5,"cost":3.05,"price":4.92,"contractor_price":4.18,"vip_price":3.69,"target_margin":38.0,"tax_class":"TAXABLE","image_family":"framing_connector","barcode":"2000100004945","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":89,"reorder_point":26,"reorder_qty":89},"WK":{"on_hand":26,"reorder_point":26,"reorder_qty":89},"LK":{"on_hand":8,"reorder_point":26,"reorder_qty":89}}} {"sku":"CN_A35","description":"Framing Angle A35","department":"Yard","category_slug":"hardware.connectors","vendor_key":"PROFRAME","uom_primary":"EA","alternate_uoms":[],"species":"Galvanized Steel","grade":"Angle","treatment":"G90","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":0.25,"cost":1.18,"price":1.9,"contractor_price":1.62,"vip_price":1.43,"target_margin":38.0,"tax_class":"TAXABLE","image_family":"framing_connector","barcode":"2000100004952","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":82,"reorder_point":24,"reorder_qty":82},"WK":{"on_hand":24,"reorder_point":24,"reorder_qty":82},"LK":{"on_hand":8,"reorder_point":24,"reorder_qty":82}}} -{"sku":"CN_LUS28","description":"ProFrame LUS28 Hanger","department":"Yard","category_slug":"hardware.connectors","vendor_key":"PROFRAME","uom_primary":"EA","alternate_uoms":[],"species":"Galvanized Steel","grade":"Joist Hanger","treatment":"G90","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":0.4,"cost":1.4,"price":2.26,"contractor_price":1.92,"vip_price":1.7,"target_margin":38.0,"tax_class":"TAXABLE","image_family":"framing_connector","barcode":"2000100004959","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":82,"reorder_point":24,"reorder_qty":82},"WK":{"on_hand":24,"reorder_point":24,"reorder_qty":82},"LK":{"on_hand":8,"reorder_point":24,"reorder_qty":82}}} -{"sku":"CN_LUS210","description":"ProFrame LUS210 Hanger","department":"Yard","category_slug":"hardware.connectors","vendor_key":"PROFRAME","uom_primary":"EA","alternate_uoms":[],"species":"Galvanized Steel","grade":"Joist Hanger","treatment":"G90","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":0.55,"cost":1.63,"price":2.62,"contractor_price":2.23,"vip_price":1.97,"target_margin":38.0,"tax_class":"TAXABLE","image_family":"framing_connector","barcode":"2000100004966","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":83,"reorder_point":24,"reorder_qty":83},"WK":{"on_hand":24,"reorder_point":24,"reorder_qty":83},"LK":{"on_hand":8,"reorder_point":24,"reorder_qty":83}}} -{"sku":"CN_H10A","description":"ProFrame H10A Anchor","department":"Yard","category_slug":"hardware.connectors","vendor_key":"PROFRAME","uom_primary":"EA","alternate_uoms":[],"species":"Galvanized Steel","grade":"Anchor","treatment":"G90","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":0.4,"cost":1.4,"price":2.26,"contractor_price":1.92,"vip_price":1.7,"target_margin":38.0,"tax_class":"TAXABLE","image_family":"framing_connector","barcode":"2000100004973","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":82,"reorder_point":24,"reorder_qty":82},"WK":{"on_hand":24,"reorder_point":24,"reorder_qty":82},"LK":{"on_hand":8,"reorder_point":24,"reorder_qty":82}}} -{"sku":"CN_TP35","description":"ProFrame Tie Plate TP35","department":"Yard","category_slug":"hardware.connectors","vendor_key":"PROFRAME","uom_primary":"EA","alternate_uoms":[],"species":"Galvanized Steel","grade":"Tie Plate","treatment":"G90","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":0.3,"cost":1.25,"price":2.02,"contractor_price":1.72,"vip_price":1.52,"target_margin":38.0,"tax_class":"TAXABLE","image_family":"framing_connector","barcode":"2000100004980","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":82,"reorder_point":24,"reorder_qty":82},"WK":{"on_hand":24,"reorder_point":24,"reorder_qty":82},"LK":{"on_hand":8,"reorder_point":24,"reorder_qty":82}}} -{"sku":"CN_PHD2","description":"ProFrame Post Hold Down PHD2","department":"Yard","category_slug":"hardware.connectors","vendor_key":"PROFRAME","uom_primary":"EA","alternate_uoms":[],"species":"Galvanized Steel","grade":"Hold Down","treatment":"G90","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":1.8,"cost":3.5,"price":5.65,"contractor_price":4.8,"vip_price":4.24,"target_margin":38.0,"tax_class":"TAXABLE","image_family":"framing_connector","barcode":"2000100004987","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":91,"reorder_point":27,"reorder_qty":91},"WK":{"on_hand":27,"reorder_point":27,"reorder_qty":91},"LK":{"on_hand":9,"reorder_point":27,"reorder_qty":91}}} -{"sku":"CN_STN24","description":"ProFrame Strap Nail 24\"","department":"Yard","category_slug":"hardware.connectors","vendor_key":"PROFRAME","uom_primary":"EA","alternate_uoms":[],"species":"Galvanized Steel","grade":"Strap","treatment":"G90","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":0.85,"cost":2.08,"price":3.35,"contractor_price":2.85,"vip_price":2.51,"target_margin":38.0,"tax_class":"TAXABLE","image_family":"metal_strap","barcode":"2000100004994","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":85,"reorder_point":25,"reorder_qty":85},"WK":{"on_hand":25,"reorder_point":25,"reorder_qty":85},"LK":{"on_hand":8,"reorder_point":25,"reorder_qty":85}}} -{"sku":"CN_CS16","description":"ProFrame Coiled Strap 16'","department":"Yard","category_slug":"hardware.connectors","vendor_key":"PROFRAME","uom_primary":"EA","alternate_uoms":[],"species":"Galvanized Steel","grade":"Strap","treatment":"G90","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":3.0,"cost":5.3,"price":8.55,"contractor_price":7.27,"vip_price":6.41,"target_margin":38.0,"tax_class":"TAXABLE","image_family":"metal_strap","barcode":"2000100005001","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":98,"reorder_point":29,"reorder_qty":98},"WK":{"on_hand":29,"reorder_point":29,"reorder_qty":98},"LK":{"on_hand":9,"reorder_point":29,"reorder_qty":98}}} +{"sku":"CN_LUS28","description":"LUS28 Hanger","department":"Yard","category_slug":"hardware.connectors","vendor_key":"PROFRAME","uom_primary":"EA","alternate_uoms":[],"species":"Galvanized Steel","grade":"Joist Hanger","treatment":"G90","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":0.4,"cost":1.4,"price":2.26,"contractor_price":1.92,"vip_price":1.7,"target_margin":38.0,"tax_class":"TAXABLE","image_family":"framing_connector","barcode":"2000100004959","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":82,"reorder_point":24,"reorder_qty":82},"WK":{"on_hand":24,"reorder_point":24,"reorder_qty":82},"LK":{"on_hand":8,"reorder_point":24,"reorder_qty":82}}} +{"sku":"CN_LUS210","description":"LUS210 Hanger","department":"Yard","category_slug":"hardware.connectors","vendor_key":"PROFRAME","uom_primary":"EA","alternate_uoms":[],"species":"Galvanized Steel","grade":"Joist Hanger","treatment":"G90","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":0.55,"cost":1.63,"price":2.62,"contractor_price":2.23,"vip_price":1.97,"target_margin":38.0,"tax_class":"TAXABLE","image_family":"framing_connector","barcode":"2000100004966","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":83,"reorder_point":24,"reorder_qty":83},"WK":{"on_hand":24,"reorder_point":24,"reorder_qty":83},"LK":{"on_hand":8,"reorder_point":24,"reorder_qty":83}}} +{"sku":"CN_H10A","description":"H10A Anchor","department":"Yard","category_slug":"hardware.connectors","vendor_key":"PROFRAME","uom_primary":"EA","alternate_uoms":[],"species":"Galvanized Steel","grade":"Anchor","treatment":"G90","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":0.4,"cost":1.4,"price":2.26,"contractor_price":1.92,"vip_price":1.7,"target_margin":38.0,"tax_class":"TAXABLE","image_family":"framing_connector","barcode":"2000100004973","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":82,"reorder_point":24,"reorder_qty":82},"WK":{"on_hand":24,"reorder_point":24,"reorder_qty":82},"LK":{"on_hand":8,"reorder_point":24,"reorder_qty":82}}} +{"sku":"CN_TP35","description":"Tie Plate TP35","department":"Yard","category_slug":"hardware.connectors","vendor_key":"PROFRAME","uom_primary":"EA","alternate_uoms":[],"species":"Galvanized Steel","grade":"Tie Plate","treatment":"G90","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":0.3,"cost":1.25,"price":2.02,"contractor_price":1.72,"vip_price":1.52,"target_margin":38.0,"tax_class":"TAXABLE","image_family":"framing_connector","barcode":"2000100004980","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":82,"reorder_point":24,"reorder_qty":82},"WK":{"on_hand":24,"reorder_point":24,"reorder_qty":82},"LK":{"on_hand":8,"reorder_point":24,"reorder_qty":82}}} +{"sku":"CN_PHD2","description":"Post Hold Down PHD2","department":"Yard","category_slug":"hardware.connectors","vendor_key":"PROFRAME","uom_primary":"EA","alternate_uoms":[],"species":"Galvanized Steel","grade":"Hold Down","treatment":"G90","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":1.8,"cost":3.5,"price":5.65,"contractor_price":4.8,"vip_price":4.24,"target_margin":38.0,"tax_class":"TAXABLE","image_family":"framing_connector","barcode":"2000100004987","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":91,"reorder_point":27,"reorder_qty":91},"WK":{"on_hand":27,"reorder_point":27,"reorder_qty":91},"LK":{"on_hand":9,"reorder_point":27,"reorder_qty":91}}} +{"sku":"CN_STN24","description":"Strap Nail 24\"","department":"Yard","category_slug":"hardware.connectors","vendor_key":"PROFRAME","uom_primary":"EA","alternate_uoms":[],"species":"Galvanized Steel","grade":"Strap","treatment":"G90","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":0.85,"cost":2.08,"price":3.35,"contractor_price":2.85,"vip_price":2.51,"target_margin":38.0,"tax_class":"TAXABLE","image_family":"metal_strap","barcode":"2000100004994","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":85,"reorder_point":25,"reorder_qty":85},"WK":{"on_hand":25,"reorder_point":25,"reorder_qty":85},"LK":{"on_hand":8,"reorder_point":25,"reorder_qty":85}}} +{"sku":"CN_CS16","description":"Coiled Strap 16'","department":"Yard","category_slug":"hardware.connectors","vendor_key":"PROFRAME","uom_primary":"EA","alternate_uoms":[],"species":"Galvanized Steel","grade":"Strap","treatment":"G90","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":3.0,"cost":5.3,"price":8.55,"contractor_price":7.27,"vip_price":6.41,"target_margin":38.0,"tax_class":"TAXABLE","image_family":"metal_strap","barcode":"2000100005001","is_commodity":false,"yards_stocked":["KEL-MAIN","WK","LK"],"inventory":{"KEL-MAIN":{"on_hand":98,"reorder_point":29,"reorder_qty":98},"WK":{"on_hand":29,"reorder_point":29,"reorder_qty":98},"LK":{"on_hand":9,"reorder_point":29,"reorder_qty":98}}} {"sku":"HT_HAM16","description":"Stone Hammer 16 oz Claw","department":"Home Center","category_slug":"hardware_tools.hand_tools","vendor_key":"HARD_WON","uom_primary":"EA","alternate_uoms":[],"species":"Tool","grade":"Standard","treatment":"Forged","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":2.5,"cost":11.75,"price":18.36,"contractor_price":15.61,"vip_price":13.77,"target_margin":36.0,"tax_class":"TAXABLE","image_family":"claw_hammer","barcode":"2000100005008","is_commodity":false,"yards_stocked":["KEL-MAIN","WK"],"inventory":{"KEL-MAIN":{"on_hand":95,"reorder_point":28,"reorder_qty":95},"WK":{"on_hand":28,"reorder_point":28,"reorder_qty":95},"LK":{"on_hand":0,"reorder_point":0,"reorder_qty":0}}} {"sku":"HT_HAM22","description":"Stone Hammer 22 oz Framing","department":"Home Center","category_slug":"hardware_tools.hand_tools","vendor_key":"HARD_WON","uom_primary":"EA","alternate_uoms":[],"species":"Tool","grade":"Standard","treatment":"Forged","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":3.5,"cost":13.25,"price":20.7,"contractor_price":17.6,"vip_price":15.53,"target_margin":36.0,"tax_class":"TAXABLE","image_family":"claw_hammer","barcode":"2000100005015","is_commodity":false,"yards_stocked":["KEL-MAIN","WK"],"inventory":{"KEL-MAIN":{"on_hand":101,"reorder_point":30,"reorder_qty":101},"WK":{"on_hand":30,"reorder_point":30,"reorder_qty":101},"LK":{"on_hand":0,"reorder_point":0,"reorder_qty":0}}} {"sku":"HT_WBAR","description":"Stone Wrecking Bar 36\"","department":"Home Center","category_slug":"hardware_tools.hand_tools","vendor_key":"HARD_WON","uom_primary":"EA","alternate_uoms":[],"species":"Tool","grade":"Standard","treatment":"Forged","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":7.0,"cost":18.5,"price":28.91,"contractor_price":24.57,"vip_price":21.68,"target_margin":36.0,"tax_class":"TAXABLE","image_family":"wrecking_bar","barcode":"2000100005022","is_commodity":false,"yards_stocked":["KEL-MAIN","WK"],"inventory":{"KEL-MAIN":{"on_hand":122,"reorder_point":36,"reorder_qty":122},"WK":{"on_hand":36,"reorder_point":36,"reorder_qty":122},"LK":{"on_hand":0,"reorder_point":0,"reorder_qty":0}}} @@ -725,16 +725,16 @@ {"sku":"HT_UTIL","description":"Stone Utility Knife","department":"Home Center","category_slug":"hardware_tools.hand_tools","vendor_key":"HARD_WON","uom_primary":"EA","alternate_uoms":[],"species":"Tool","grade":"Standard","treatment":"Forged","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":0.3,"cost":8.45,"price":13.2,"contractor_price":11.22,"vip_price":9.9,"target_margin":36.0,"tax_class":"TAXABLE","image_family":"utility_knife","barcode":"2000100005078","is_commodity":false,"yards_stocked":["KEL-MAIN","WK"],"inventory":{"KEL-MAIN":{"on_hand":82,"reorder_point":24,"reorder_qty":82},"WK":{"on_hand":24,"reorder_point":24,"reorder_qty":82},"LK":{"on_hand":0,"reorder_point":0,"reorder_qty":0}}} {"sku":"HT_SCRD","description":"Stone Screwdriver Set 6 pc","department":"Home Center","category_slug":"hardware_tools.hand_tools","vendor_key":"HARD_WON","uom_primary":"EA","alternate_uoms":[],"species":"Tool","grade":"Standard","treatment":"Forged","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":1.2,"cost":9.8,"price":15.31,"contractor_price":13.01,"vip_price":11.48,"target_margin":36.0,"tax_class":"TAXABLE","image_family":"utility_knife","barcode":"2000100005085","is_commodity":false,"yards_stocked":["KEL-MAIN","WK"],"inventory":{"KEL-MAIN":{"on_hand":87,"reorder_point":26,"reorder_qty":87},"WK":{"on_hand":26,"reorder_point":26,"reorder_qty":87},"LK":{"on_hand":0,"reorder_point":0,"reorder_qty":0}}} {"sku":"HT_ADJ","description":"Stone Adjustable Wrench 10\"","department":"Home Center","category_slug":"hardware_tools.hand_tools","vendor_key":"HARD_WON","uom_primary":"EA","alternate_uoms":[],"species":"Tool","grade":"Standard","treatment":"Forged","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":0.7,"cost":9.05,"price":14.14,"contractor_price":12.02,"vip_price":10.61,"target_margin":36.0,"tax_class":"TAXABLE","image_family":"adjustable_wrench","barcode":"2000100005092","is_commodity":false,"yards_stocked":["KEL-MAIN","WK"],"inventory":{"KEL-MAIN":{"on_hand":84,"reorder_point":25,"reorder_qty":84},"WK":{"on_hand":25,"reorder_point":25,"reorder_qty":84},"LK":{"on_hand":0,"reorder_point":0,"reorder_qty":0}}} -{"sku":"PT_DRD18","description":"ProFrame 18V Drill Driver","department":"Home Center","category_slug":"hardware_tools.power_tools","vendor_key":"PROFRAME_TOOL","uom_primary":"EA","alternate_uoms":[],"species":"Tool","grade":"ProFrame","treatment":"Brushless","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":3.5,"cost":122.0,"price":179.41,"contractor_price":152.5,"vip_price":134.56,"target_margin":32.0,"tax_class":"TAXABLE","image_family":"cordless_drill","barcode":"2000100005099","is_commodity":false,"yards_stocked":["KEL-MAIN","WK"],"inventory":{"KEL-MAIN":{"on_hand":101,"reorder_point":30,"reorder_qty":101},"WK":{"on_hand":30,"reorder_point":30,"reorder_qty":101},"LK":{"on_hand":0,"reorder_point":0,"reorder_qty":0}}} -{"sku":"PT_IMP18","description":"ProFrame 18V Impact Driver","department":"Home Center","category_slug":"hardware_tools.power_tools","vendor_key":"PROFRAME_TOOL","uom_primary":"EA","alternate_uoms":[],"species":"Tool","grade":"ProFrame","treatment":"Brushless","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":3.0,"cost":116.0,"price":170.59,"contractor_price":145.0,"vip_price":127.94,"target_margin":32.0,"tax_class":"TAXABLE","image_family":"cordless_drill","barcode":"2000100005106","is_commodity":false,"yards_stocked":["KEL-MAIN","WK"],"inventory":{"KEL-MAIN":{"on_hand":98,"reorder_point":29,"reorder_qty":98},"WK":{"on_hand":29,"reorder_point":29,"reorder_qty":98},"LK":{"on_hand":0,"reorder_point":0,"reorder_qty":0}}} -{"sku":"PT_CIRC","description":"ProFrame 7-1/4\" Circular Saw","department":"Home Center","category_slug":"hardware_tools.power_tools","vendor_key":"PROFRAME_TOOL","uom_primary":"EA","alternate_uoms":[],"species":"Tool","grade":"ProFrame","treatment":"Brushless","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":9.0,"cost":188.0,"price":276.47,"contractor_price":235.0,"vip_price":207.35,"target_margin":32.0,"tax_class":"TAXABLE","image_family":"power_saw","barcode":"2000100005113","is_commodity":false,"yards_stocked":["KEL-MAIN","WK"],"inventory":{"KEL-MAIN":{"on_hand":134,"reorder_point":40,"reorder_qty":134},"WK":{"on_hand":40,"reorder_point":40,"reorder_qty":134},"LK":{"on_hand":0,"reorder_point":0,"reorder_qty":0}}} -{"sku":"PT_RECIP","description":"ProFrame Reciprocating Saw","department":"Home Center","category_slug":"hardware_tools.power_tools","vendor_key":"PROFRAME_TOOL","uom_primary":"EA","alternate_uoms":[],"species":"Tool","grade":"ProFrame","treatment":"Brushless","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":6.0,"cost":152.0,"price":223.53,"contractor_price":190.0,"vip_price":167.65,"target_margin":32.0,"tax_class":"TAXABLE","image_family":"power_saw","barcode":"2000100005120","is_commodity":false,"yards_stocked":["KEL-MAIN","WK"],"inventory":{"KEL-MAIN":{"on_hand":116,"reorder_point":34,"reorder_qty":116},"WK":{"on_hand":34,"reorder_point":34,"reorder_qty":116},"LK":{"on_hand":0,"reorder_point":0,"reorder_qty":0}}} -{"sku":"PT_JIG","description":"ProFrame Jigsaw","department":"Home Center","category_slug":"hardware_tools.power_tools","vendor_key":"PROFRAME_TOOL","uom_primary":"EA","alternate_uoms":[],"species":"Tool","grade":"ProFrame","treatment":"Brushless","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":5.0,"cost":140.0,"price":205.88,"contractor_price":175.0,"vip_price":154.41,"target_margin":32.0,"tax_class":"TAXABLE","image_family":"power_saw","barcode":"2000100005127","is_commodity":false,"yards_stocked":["KEL-MAIN","WK"],"inventory":{"KEL-MAIN":{"on_hand":110,"reorder_point":33,"reorder_qty":110},"WK":{"on_hand":33,"reorder_point":33,"reorder_qty":110},"LK":{"on_hand":0,"reorder_point":0,"reorder_qty":0}}} -{"sku":"PT_ROTS","description":"ProFrame Rotary Hammer","department":"Home Center","category_slug":"hardware_tools.power_tools","vendor_key":"PROFRAME_TOOL","uom_primary":"EA","alternate_uoms":[],"species":"Tool","grade":"ProFrame","treatment":"Brushless","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":12.0,"cost":224.0,"price":329.41,"contractor_price":280.0,"vip_price":247.06,"target_margin":32.0,"tax_class":"TAXABLE","image_family":"specialty_power_tool","barcode":"2000100005134","is_commodity":false,"yards_stocked":["KEL-MAIN","WK"],"inventory":{"KEL-MAIN":{"on_hand":152,"reorder_point":45,"reorder_qty":152},"WK":{"on_hand":45,"reorder_point":45,"reorder_qty":152},"LK":{"on_hand":0,"reorder_point":0,"reorder_qty":0}}} -{"sku":"PT_OSC","description":"ProFrame Oscillating Multi-Tool","department":"Home Center","category_slug":"hardware_tools.power_tools","vendor_key":"PROFRAME_TOOL","uom_primary":"EA","alternate_uoms":[],"species":"Tool","grade":"ProFrame","treatment":"Brushless","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":4.0,"cost":128.0,"price":188.24,"contractor_price":160.0,"vip_price":141.18,"target_margin":32.0,"tax_class":"TAXABLE","image_family":"specialty_power_tool","barcode":"2000100005141","is_commodity":false,"yards_stocked":["KEL-MAIN","WK"],"inventory":{"KEL-MAIN":{"on_hand":104,"reorder_point":31,"reorder_qty":104},"WK":{"on_hand":31,"reorder_point":31,"reorder_qty":104},"LK":{"on_hand":0,"reorder_point":0,"reorder_qty":0}}} -{"sku":"PT_SAND","description":"ProFrame Random Orbit Sander","department":"Home Center","category_slug":"hardware_tools.power_tools","vendor_key":"PROFRAME_TOOL","uom_primary":"EA","alternate_uoms":[],"species":"Tool","grade":"ProFrame","treatment":"Brushless","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":4.5,"cost":134.0,"price":197.06,"contractor_price":167.5,"vip_price":147.8,"target_margin":32.0,"tax_class":"TAXABLE","image_family":"specialty_power_tool","barcode":"2000100005148","is_commodity":false,"yards_stocked":["KEL-MAIN","WK"],"inventory":{"KEL-MAIN":{"on_hand":107,"reorder_point":32,"reorder_qty":107},"WK":{"on_hand":32,"reorder_point":32,"reorder_qty":107},"LK":{"on_hand":0,"reorder_point":0,"reorder_qty":0}}} -{"sku":"PT_NAILER","description":"ProFrame Framing Nailer","department":"Home Center","category_slug":"hardware_tools.power_tools","vendor_key":"PROFRAME_TOOL","uom_primary":"EA","alternate_uoms":[],"species":"Tool","grade":"ProFrame","treatment":"Brushless","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":9.0,"cost":188.0,"price":276.47,"contractor_price":235.0,"vip_price":207.35,"target_margin":32.0,"tax_class":"TAXABLE","image_family":"pneumatic_nailer","barcode":"2000100005155","is_commodity":false,"yards_stocked":["KEL-MAIN","WK"],"inventory":{"KEL-MAIN":{"on_hand":134,"reorder_point":40,"reorder_qty":134},"WK":{"on_hand":40,"reorder_point":40,"reorder_qty":134},"LK":{"on_hand":0,"reorder_point":0,"reorder_qty":0}}} -{"sku":"PT_ROOFNAIL","description":"ProFrame Roofing Nailer","department":"Home Center","category_slug":"hardware_tools.power_tools","vendor_key":"PROFRAME_TOOL","uom_primary":"EA","alternate_uoms":[],"species":"Tool","grade":"ProFrame","treatment":"Brushless","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":5.5,"cost":146.0,"price":214.71,"contractor_price":182.5,"vip_price":161.03,"target_margin":32.0,"tax_class":"TAXABLE","image_family":"pneumatic_nailer","barcode":"2000100005162","is_commodity":false,"yards_stocked":["KEL-MAIN","WK"],"inventory":{"KEL-MAIN":{"on_hand":113,"reorder_point":33,"reorder_qty":113},"WK":{"on_hand":33,"reorder_point":33,"reorder_qty":113},"LK":{"on_hand":0,"reorder_point":0,"reorder_qty":0}}} +{"sku":"PT_DRD18","description":"18V Drill Driver","department":"Home Center","category_slug":"hardware_tools.power_tools","vendor_key":"PROFRAME_TOOL","uom_primary":"EA","alternate_uoms":[],"species":"Tool","grade":"ProFrame","treatment":"Brushless","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":3.5,"cost":122.0,"price":179.41,"contractor_price":152.5,"vip_price":134.56,"target_margin":32.0,"tax_class":"TAXABLE","image_family":"cordless_drill","barcode":"2000100005099","is_commodity":false,"yards_stocked":["KEL-MAIN","WK"],"inventory":{"KEL-MAIN":{"on_hand":101,"reorder_point":30,"reorder_qty":101},"WK":{"on_hand":30,"reorder_point":30,"reorder_qty":101},"LK":{"on_hand":0,"reorder_point":0,"reorder_qty":0}}} +{"sku":"PT_IMP18","description":"18V Impact Driver","department":"Home Center","category_slug":"hardware_tools.power_tools","vendor_key":"PROFRAME_TOOL","uom_primary":"EA","alternate_uoms":[],"species":"Tool","grade":"ProFrame","treatment":"Brushless","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":3.0,"cost":116.0,"price":170.59,"contractor_price":145.0,"vip_price":127.94,"target_margin":32.0,"tax_class":"TAXABLE","image_family":"cordless_drill","barcode":"2000100005106","is_commodity":false,"yards_stocked":["KEL-MAIN","WK"],"inventory":{"KEL-MAIN":{"on_hand":98,"reorder_point":29,"reorder_qty":98},"WK":{"on_hand":29,"reorder_point":29,"reorder_qty":98},"LK":{"on_hand":0,"reorder_point":0,"reorder_qty":0}}} +{"sku":"PT_CIRC","description":"7-1/4\" Circular Saw","department":"Home Center","category_slug":"hardware_tools.power_tools","vendor_key":"PROFRAME_TOOL","uom_primary":"EA","alternate_uoms":[],"species":"Tool","grade":"ProFrame","treatment":"Brushless","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":9.0,"cost":188.0,"price":276.47,"contractor_price":235.0,"vip_price":207.35,"target_margin":32.0,"tax_class":"TAXABLE","image_family":"power_saw","barcode":"2000100005113","is_commodity":false,"yards_stocked":["KEL-MAIN","WK"],"inventory":{"KEL-MAIN":{"on_hand":134,"reorder_point":40,"reorder_qty":134},"WK":{"on_hand":40,"reorder_point":40,"reorder_qty":134},"LK":{"on_hand":0,"reorder_point":0,"reorder_qty":0}}} +{"sku":"PT_RECIP","description":"Reciprocating Saw","department":"Home Center","category_slug":"hardware_tools.power_tools","vendor_key":"PROFRAME_TOOL","uom_primary":"EA","alternate_uoms":[],"species":"Tool","grade":"ProFrame","treatment":"Brushless","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":6.0,"cost":152.0,"price":223.53,"contractor_price":190.0,"vip_price":167.65,"target_margin":32.0,"tax_class":"TAXABLE","image_family":"power_saw","barcode":"2000100005120","is_commodity":false,"yards_stocked":["KEL-MAIN","WK"],"inventory":{"KEL-MAIN":{"on_hand":116,"reorder_point":34,"reorder_qty":116},"WK":{"on_hand":34,"reorder_point":34,"reorder_qty":116},"LK":{"on_hand":0,"reorder_point":0,"reorder_qty":0}}} +{"sku":"PT_JIG","description":"Jigsaw","department":"Home Center","category_slug":"hardware_tools.power_tools","vendor_key":"PROFRAME_TOOL","uom_primary":"EA","alternate_uoms":[],"species":"Tool","grade":"ProFrame","treatment":"Brushless","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":5.0,"cost":140.0,"price":205.88,"contractor_price":175.0,"vip_price":154.41,"target_margin":32.0,"tax_class":"TAXABLE","image_family":"power_saw","barcode":"2000100005127","is_commodity":false,"yards_stocked":["KEL-MAIN","WK"],"inventory":{"KEL-MAIN":{"on_hand":110,"reorder_point":33,"reorder_qty":110},"WK":{"on_hand":33,"reorder_point":33,"reorder_qty":110},"LK":{"on_hand":0,"reorder_point":0,"reorder_qty":0}}} +{"sku":"PT_ROTS","description":"Rotary Hammer","department":"Home Center","category_slug":"hardware_tools.power_tools","vendor_key":"PROFRAME_TOOL","uom_primary":"EA","alternate_uoms":[],"species":"Tool","grade":"ProFrame","treatment":"Brushless","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":12.0,"cost":224.0,"price":329.41,"contractor_price":280.0,"vip_price":247.06,"target_margin":32.0,"tax_class":"TAXABLE","image_family":"specialty_power_tool","barcode":"2000100005134","is_commodity":false,"yards_stocked":["KEL-MAIN","WK"],"inventory":{"KEL-MAIN":{"on_hand":152,"reorder_point":45,"reorder_qty":152},"WK":{"on_hand":45,"reorder_point":45,"reorder_qty":152},"LK":{"on_hand":0,"reorder_point":0,"reorder_qty":0}}} +{"sku":"PT_OSC","description":"Oscillating Multi-Tool","department":"Home Center","category_slug":"hardware_tools.power_tools","vendor_key":"PROFRAME_TOOL","uom_primary":"EA","alternate_uoms":[],"species":"Tool","grade":"ProFrame","treatment":"Brushless","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":4.0,"cost":128.0,"price":188.24,"contractor_price":160.0,"vip_price":141.18,"target_margin":32.0,"tax_class":"TAXABLE","image_family":"specialty_power_tool","barcode":"2000100005141","is_commodity":false,"yards_stocked":["KEL-MAIN","WK"],"inventory":{"KEL-MAIN":{"on_hand":104,"reorder_point":31,"reorder_qty":104},"WK":{"on_hand":31,"reorder_point":31,"reorder_qty":104},"LK":{"on_hand":0,"reorder_point":0,"reorder_qty":0}}} +{"sku":"PT_SAND","description":"Random Orbit Sander","department":"Home Center","category_slug":"hardware_tools.power_tools","vendor_key":"PROFRAME_TOOL","uom_primary":"EA","alternate_uoms":[],"species":"Tool","grade":"ProFrame","treatment":"Brushless","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":4.5,"cost":134.0,"price":197.06,"contractor_price":167.5,"vip_price":147.8,"target_margin":32.0,"tax_class":"TAXABLE","image_family":"specialty_power_tool","barcode":"2000100005148","is_commodity":false,"yards_stocked":["KEL-MAIN","WK"],"inventory":{"KEL-MAIN":{"on_hand":107,"reorder_point":32,"reorder_qty":107},"WK":{"on_hand":32,"reorder_point":32,"reorder_qty":107},"LK":{"on_hand":0,"reorder_point":0,"reorder_qty":0}}} +{"sku":"PT_NAILER","description":"Framing Nailer","department":"Home Center","category_slug":"hardware_tools.power_tools","vendor_key":"PROFRAME_TOOL","uom_primary":"EA","alternate_uoms":[],"species":"Tool","grade":"ProFrame","treatment":"Brushless","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":9.0,"cost":188.0,"price":276.47,"contractor_price":235.0,"vip_price":207.35,"target_margin":32.0,"tax_class":"TAXABLE","image_family":"pneumatic_nailer","barcode":"2000100005155","is_commodity":false,"yards_stocked":["KEL-MAIN","WK"],"inventory":{"KEL-MAIN":{"on_hand":134,"reorder_point":40,"reorder_qty":134},"WK":{"on_hand":40,"reorder_point":40,"reorder_qty":134},"LK":{"on_hand":0,"reorder_point":0,"reorder_qty":0}}} +{"sku":"PT_ROOFNAIL","description":"Roofing Nailer","department":"Home Center","category_slug":"hardware_tools.power_tools","vendor_key":"PROFRAME_TOOL","uom_primary":"EA","alternate_uoms":[],"species":"Tool","grade":"ProFrame","treatment":"Brushless","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":5.5,"cost":146.0,"price":214.71,"contractor_price":182.5,"vip_price":161.03,"target_margin":32.0,"tax_class":"TAXABLE","image_family":"pneumatic_nailer","barcode":"2000100005162","is_commodity":false,"yards_stocked":["KEL-MAIN","WK"],"inventory":{"KEL-MAIN":{"on_hand":113,"reorder_point":33,"reorder_qty":113},"WK":{"on_hand":33,"reorder_point":33,"reorder_qty":113},"LK":{"on_hand":0,"reorder_point":0,"reorder_qty":0}}} {"sku":"BH_KNOB","description":"Door Knob Brushed Nickel","department":"Home Center","category_slug":"hardware_tools.builders_hardware","vendor_key":"OK_HDW","uom_primary":"EA","alternate_uoms":[],"species":"Steel","grade":"Knob","treatment":"Brushed","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":0.6,"cost":7.6,"price":12.67,"contractor_price":10.77,"vip_price":9.5,"target_margin":40.0,"tax_class":"TAXABLE","image_family":"door_handle","barcode":"2000100005169","is_commodity":false,"yards_stocked":["KEL-MAIN","WK"],"inventory":{"KEL-MAIN":{"on_hand":84,"reorder_point":25,"reorder_qty":84},"WK":{"on_hand":25,"reorder_point":25,"reorder_qty":84},"LK":{"on_hand":0,"reorder_point":0,"reorder_qty":0}}} {"sku":"BH_LEVR","description":"Door Lever Satin Chrome","department":"Home Center","category_slug":"hardware_tools.builders_hardware","vendor_key":"OK_HDW","uom_primary":"EA","alternate_uoms":[],"species":"Steel","grade":"Lever","treatment":"Brushed","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":0.5,"cost":7.0,"price":11.67,"contractor_price":9.92,"vip_price":8.75,"target_margin":40.0,"tax_class":"TAXABLE","image_family":"door_handle","barcode":"2000100005176","is_commodity":false,"yards_stocked":["KEL-MAIN","WK"],"inventory":{"KEL-MAIN":{"on_hand":83,"reorder_point":24,"reorder_qty":83},"WK":{"on_hand":24,"reorder_point":24,"reorder_qty":83},"LK":{"on_hand":0,"reorder_point":0,"reorder_qty":0}}} {"sku":"BH_DEAD","description":"Deadbolt Brushed Nickel","department":"Home Center","category_slug":"hardware_tools.builders_hardware","vendor_key":"OK_HDW","uom_primary":"EA","alternate_uoms":[],"species":"Steel","grade":"Deadbolt","treatment":"Brushed","board_thickness_in":null,"board_width_in":null,"board_length_ft":null,"random_length":false,"length_in":null,"width_in":null,"height_in":null,"stackable":true,"weight_lbs":0.8,"cost":8.8,"price":14.67,"contractor_price":12.47,"vip_price":11.0,"target_margin":40.0,"tax_class":"TAXABLE","image_family":"deadbolt","barcode":"2000100005183","is_commodity":false,"yards_stocked":["KEL-MAIN","WK"],"inventory":{"KEL-MAIN":{"on_hand":85,"reorder_point":25,"reorder_qty":85},"WK":{"on_hand":25,"reorder_point":25,"reorder_qty":85},"LK":{"on_hand":0,"reorder_point":0,"reorder_qty":0}}} diff --git a/core/internal/tax/service_test.go b/core/internal/tax/service_test.go index 9f5f889c9..33c557865 100644 --- a/core/internal/tax/service_test.go +++ b/core/internal/tax/service_test.go @@ -62,9 +62,9 @@ func TestFlatRateCalculation(t *testing.T) { req := &TaxPreviewRequest{ Lines: []TaxLineInput{ - {LineNumber: 1, ItemCode: "2x4x8", Description: "2x4x8 SPF #2", Quantity: 10, Amount: 6500}, // $65.00 - {LineNumber: 2, ItemCode: "2x6x12", Description: "2x6x12 DF #1", Quantity: 4, Amount: 8800}, // $88.00 - {LineNumber: 3, ItemCode: "HD2A", Description: "Simpson HD2A", Quantity: 20, Amount: 9400}, // $94.00 + {LineNumber: 1, ItemCode: "2x4x8", Description: "2x4x8 SPF #2", Quantity: 10, Amount: 6500}, // $65.00 + {LineNumber: 2, ItemCode: "2x6x12", Description: "2x6x12 DF #1", Quantity: 4, Amount: 8800}, // $88.00 + {LineNumber: 3, ItemCode: "HD2A", Description: "Hold down HD2A", Quantity: 20, Amount: 9400}, // $94.00 }, } From 79634132edb98f95ae14608989e5d954e4d4c9e1 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 15:17:54 -0700 Subject: [PATCH 30/39] desk: the AI feature grid follows a passed test, e2e reaches no third party The features grid lights on aiIsOn, the intake's refusal leg drives the file input, the Tech Admin save spec types a loopback base URL, and the guard reads the specs folder by path. Item 130, review round 1 P2-3, P3-7, P3-9. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- .../desk/src/components/quotes/MaterialIntake.test.ts | 8 +++++++- .../src/pages/admin/tech_admin/AIProviderWords.test.ts | 6 +++--- web/apps/desk/src/pages/admin/tech_admin/TechAdminPage.ts | 4 ++-- web/apps/desk/src/test/e2eNoNetwork.test.ts | 6 ++++-- web/e2e/admin.spec.ts | 8 ++++---- 5 files changed, 20 insertions(+), 12 deletions(-) diff --git a/web/apps/desk/src/components/quotes/MaterialIntake.test.ts b/web/apps/desk/src/components/quotes/MaterialIntake.test.ts index 5862ee90e..da4557d5d 100644 --- a/web/apps/desk/src/components/quotes/MaterialIntake.test.ts +++ b/web/apps/desk/src/components/quotes/MaterialIntake.test.ts @@ -150,7 +150,13 @@ describe('MaterialIntake', () => { { status: 502, headers: { 'Content-Type': 'application/json' } }, )) const el = await mount('gable-material-intake') - await paste(el, 'x') + // A photo goes through the file input: a pasted text list is read by the + // server's rules under a refusal and never answers 502. + const input = q(el, 'input[type="file"]') + Object.defineProperty(input, 'files', { value: [new File(['x'], 'list.png', { type: 'image/png' })], configurable: true }) + input.dispatchEvent(new Event('change', { bubbles: true })) + await flush() + await update(el, {}) const refused = q(el, '[data-testid="intake-ai-refused"]') expect(text(refused)).toContain(words) expect(el.querySelector('[data-testid="intake-sample-banner"]')).toBeNull() diff --git a/web/apps/desk/src/pages/admin/tech_admin/AIProviderWords.test.ts b/web/apps/desk/src/pages/admin/tech_admin/AIProviderWords.test.ts index 9d4b26adc..d179adcbb 100644 --- a/web/apps/desk/src/pages/admin/tech_admin/AIProviderWords.test.ts +++ b/web/apps/desk/src/pages/admin/tech_admin/AIProviderWords.test.ts @@ -64,17 +64,17 @@ describe('Tech Admin AI words follow the chosen provider', () => { }) it('dims the features grid for a key the provider refused and lights it for a passed one', async () => { - const card = (body: string) => { + const card = () => { const span = Array.from(document.querySelectorAll('span')).find((s) => text(s) === 'Product Illustrations') return span?.closest('div.border') as HTMLElement } serve({ ...BASE, provider: 'anthropic', model: 'claude-sonnet-5-5', configured: true, anthropic_configured: true, anthropic_key_hint: '****dmin', source: 'admin', test_status: 'refused' }) await openAITab() - expect(card('refused').className).toContain('opacity-60') + expect(card().className).toContain('opacity-60') document.body.innerHTML = '' serve({ ...BASE, provider: 'anthropic', model: 'claude-sonnet-5-5', configured: true, anthropic_configured: true, anthropic_key_hint: '****dmin', source: 'admin', test_status: 'passed' }) await openAITab() - expect(card('passed').className).not.toContain('opacity-60') + expect(card().className).not.toContain('opacity-60') }) it('says a saved key was not tested yet', async () => { diff --git a/web/apps/desk/src/pages/admin/tech_admin/TechAdminPage.ts b/web/apps/desk/src/pages/admin/tech_admin/TechAdminPage.ts index e3f5bbf9d..d1ca4b732 100644 --- a/web/apps/desk/src/pages/admin/tech_admin/TechAdminPage.ts +++ b/web/apps/desk/src/pages/admin/tech_admin/TechAdminPage.ts @@ -686,9 +686,9 @@ export class TechAdminPage extends LitElement {

Features Powered by AI

${features.map((f) => html` -
+
- ${icon(f.iconData, 16, cn('w-4 h-4', this.aiSettings?.configured ? 'text-violet-400' : 'text-slate-600'))} + ${icon(f.iconData, 16, cn('w-4 h-4', aiIsOn(this.aiSettings) ? 'text-violet-400' : 'text-slate-600'))} ${f.name}

${f.description}

diff --git a/web/apps/desk/src/test/e2eNoNetwork.test.ts b/web/apps/desk/src/test/e2eNoNetwork.test.ts index af5df8401..d13e6c994 100644 --- a/web/apps/desk/src/test/e2eNoNetwork.test.ts +++ b/web/apps/desk/src/test/e2eNoNetwork.test.ts @@ -8,15 +8,17 @@ */ import { describe, it, expect } from 'vitest' import { readdirSync, readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' -const E2E = new URL('../../../../e2e/', import.meta.url) +// The desk workspace is the working directory of its tests: the specs are two folders up. +const E2E = resolve(process.cwd(), '../../e2e') const ALLOWED = /^(127\.0\.0\.1|localhost|host\.docker\.internal|www\.w3\.org|([a-z0-9-]+\.)*example(\.com|\.org|\.net)?|[a-z0-9.-]*\.example|[a-z0-9.-]*\.invalid)$/i describe('end to end specs', () => { it('name no third party host', () => { const offenders: string[] = [] for (const name of readdirSync(E2E).filter((f) => f.endsWith('.ts'))) { - const body = readFileSync(new URL(name, E2E), 'utf8') + const body = readFileSync(join(E2E, name), 'utf8') for (const m of body.matchAll(/https?:\/\/([a-z0-9.-]+)/gi)) { if (!ALLOWED.test(m[1])) offenders.push(`${name}: ${m[1]}`) } diff --git a/web/e2e/admin.spec.ts b/web/e2e/admin.spec.ts index f860bd23a..71b818196 100644 --- a/web/e2e/admin.spec.ts +++ b/web/e2e/admin.spec.ts @@ -70,16 +70,16 @@ test.describe('Tech admin flow on the new contract', () => { await page.getByRole('button', { name: /add key|update key/i }).click(); await page.getByPlaceholder('sk-or-...').fill(`sk-or-e2e-${RUN}`); - await page.getByPlaceholder(/openrouter\.ai/).fill('https://openrouter.ai/api/v1'); + await page.getByPlaceholder(/openrouter\.ai/).fill('http://127.0.0.1:9/api/v1'); await page.screenshot({ path: path.join(SHOTS_DIR, 'admin-ai-save.png') }); await page.getByRole('button', { name: 'Save Key' }).click(); // The saved state shows the admin override with its masked hint and the // base URL it stored; the save carried the revision it read, so it // cannot have clobbered a second editor's write. The save tested the - // key with the provider (item 130, N7): a placeholder key never passes, - // whether the provider refuses it or cannot be reached, so the card - // reads off rather than active. + // key against the loopback address typed above, which nothing answers, so + // the test failed at once (item 130, N7) and the card reads off rather + // than active. No request left the machine. await expect(page.getByText('AI Features Off')).toBeVisible(); await expect(page.getByText('Admin configured')).toBeVisible(); await page.screenshot({ path: path.join(SHOTS_DIR, 'admin-ai-saved.png') }); From 7af7c106a843dc49559e7bb07983c6c529f6ebf2 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 15:17:54 -0700 Subject: [PATCH 31/39] contract: freight 502, refused media status, generated types, the round's row Item 130, review round 1. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/api/openapi.yaml | 4 ++++ docs/refactor/CONTRACT-CHANGES.md | 1 + web/packages/api-client/src/schema.d.ts | 8 ++++++-- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/core/api/openapi.yaml b/core/api/openapi.yaml index dee856931..e2c0eca7a 100644 --- a/core/api/openapi.yaml +++ b/core/api/openapi.yaml @@ -13877,6 +13877,7 @@ paths: "422": $ref: "#/components/responses/UnprocessableEntity" "500": {$ref: "#/components/responses/InternalError"} + "502": {$ref: "#/components/responses/AIRefused"} /api/v1/purchase-orders/{id}/freight/{freightId}/apply: post: operationId: purchaseOrderFreightApply @@ -25855,6 +25856,9 @@ components: - generating - ready - failed + - refused + description: >- + refused is a generation the provider refused for the saved key (a retry cannot work; an administrator enters the key again in Tech Admin, AI Settings). gen_model: type: string gen_prompt: diff --git a/docs/refactor/CONTRACT-CHANGES.md b/docs/refactor/CONTRACT-CHANGES.md index 40fcd4a33..df9706f94 100644 --- a/docs/refactor/CONTRACT-CHANGES.md +++ b/docs/refactor/CONTRACT-CHANGES.md @@ -428,3 +428,4 @@ get an entry. | POS-RECEIPT-1 (item 111) | pos: `GET /api/v1/pos/transactions/{id}/receipt`, `POST /api/v1/pos/transactions/{id}/receipt/email` (new routes) | The Sale complete card's Print receipt and Email receipt called the documents print route and the invoice email route, mounted for admin, owner, sales and finance, so a cashier got a 403 from both. | The counter's own pair, mounted for admin, owner and cashier with the same reach as the sale read (the branch context, the wall and a bound key's pin; a sale the caller cannot read is the sale read's 404). Print answers the sale's invoice document by the documents print route's own renderer (same body and content type); email sends the sale's invoice to the account's contact by the invoice email route's own function (same 202 `{"status":"queued"}`, same delivery record). A sale that is not completed or has no invoice is a 409 blocker `no_receipt`; an email with no contact (a sale that names no customer or the shared walk in customer, whatever address that row holds, or an account with no email address) is a 409 blocker `no_contact` in words, and nothing is sent; only a completed sale has a receipt (a voided or held sale that keeps its invoice is `no_receipt`). Both routes are WALLED in the bound key allowlist. The documents routes and their roles are unchanged. `posTileList`'s description now says a counter sale's own invoice (origin POS) is not counted. | | AI-CLAUDE-FIX1 | `POST /api/v1/parsing/upload`, `POST /api/v1/purchase-orders/{id}/freight`; `GET /api/v1/apps` golden `apps`; pim: `POST /api/v1/products/{id}/pim/generate/image` | A file of a type the chosen provider cannot read (a BMP under Anthropic) got the canned demo list at the intake and a bare 400 at freight. The apps catalog described the AI client as "OpenRouter client". Image generation under Anthropic answered 409 AI off. | The unreadable type answers 415 `unsupported_type` in the wire envelope with the words naming what the provider reads; no canned list. The catalog line reads "Provider routed AI client (OpenRouter or Anthropic), KeyStore, and model routing (library)." and the `apps` golden is re-recorded for that one string. Image generation answers 202 under Anthropic and draws an illustration, stored only after a strict sanitiser. | | G1-AI-WORDS (item 130, N3, N4, N7) | `POST /api/v1/parsing/upload` and `POST /api/v1/products/{id}/pim/generate/{descriptions,seo,collateral}` (new 502 `ai_refused`); admin: `GET` and `PUT /api/v1/admin/settings/ai` (document gains `test_status`, `test_message`), `POST /api/v1/admin/settings/ai/test` (answer gains `status`); goldens `techadmin_settings`, `machine_key` | A provider's 401 or 403 on the saved key reached the intake as the canned sample list with "No AI reader is set up", PIM Generate as a bare 500, and Tech Admin read "AI on" for any saved key. | A refusal answers 502 in the wire envelope with the code `ai_refused` and the words " refused the saved key. An administrator can enter it again in Tech Admin, AI Settings."; a text list is still read by the rule reader. A save of AI settings runs the test with the provider and stores its result; the document's `test_status` is `none`, `untested`, `passed`, `refused` or `failed` with `test_message` for the last two, and the test route stores its result the same way. The image route is unchanged (generation runs after it answers; a refusal there is the media row's failed status). The characterization harness points the server's provider at a local stand-in so a save's test never reaches the network. | +| G1-AI-WORDS-R1 (item 130, review round 1) | purchase_order: `POST /api/v1/purchase-orders/{id}/freight` (new 502 `ai_refused`); pim: media `status` gains `refused` (`GET /api/v1/products/{id}/pim/media`, the 202 of `POST .../pim/generate/image`); the AI settings document's `test_status` is tied to the settings revision it ran on | Freight invoice reading answered a refused key as a bare 400; the image route settled a refusal as `failed`, which the page told a person to try again; a stored test result could outlive the key it tested. | Freight answers 502 `ai_refused` with the same sentence plus "Until then, enter the freight total by hand.", and keeps the uploaded file only for a read that succeeded. A refusal on the image route settles the row `refused` (words on the PIM page, no retry); only a 401 is OpenRouter's refusal (a 403 is not), Anthropic's 401 and 403 both are. A stored test result from an older settings revision reads `untested`. A save that cannot reach the provider keeps the key and reads `failed` with "The key is saved. could not be reached to test it; press Test connection later."; its test has a timeout of its own. The sanitiser drops a model's `text` and `tspan` elements. | diff --git a/web/packages/api-client/src/schema.d.ts b/web/packages/api-client/src/schema.d.ts index b8eb323cf..c31f506c0 100644 --- a/web/packages/api-client/src/schema.d.ts +++ b/web/packages/api-client/src/schema.d.ts @@ -13376,8 +13376,11 @@ export interface components { alt_text: string; sort_order: number; is_primary: boolean; - /** @enum {string} */ - status: "generating" | "ready" | "failed"; + /** + * @description refused is a generation the provider refused for the saved key (a retry cannot work; an administrator enters the key again in Tech Admin, AI Settings). + * @enum {string} + */ + status: "generating" | "ready" | "failed" | "refused"; gen_model: string; gen_prompt: string; gen_style: string; @@ -27753,6 +27756,7 @@ export interface operations { 415: components["responses"]["UnsupportedType"]; 422: components["responses"]["UnprocessableEntity"]; 500: components["responses"]["InternalError"]; + 502: components["responses"]["AIRefused"]; }; }; purchaseOrderFreightApply: { From 01e0474382f90d216213ded7a23deb6bce8821bd Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 15:26:09 -0700 Subject: [PATCH 32/39] purchase_order: plain punctuation in the moved freight comment Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/internal/purchase_order/service.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/internal/purchase_order/service.go b/core/internal/purchase_order/service.go index 96a2fcfa7..5526db32f 100644 --- a/core/internal/purchase_order/service.go +++ b/core/internal/purchase_order/service.go @@ -746,8 +746,8 @@ func (s *Service) UploadFreightInvoice(ctx context.Context, poID uuid.UUID, file aiContentType = http.DetectContentType(fileBytes) } - // Extract freight data via AI. The degradation contract — unconfigured AI is a - // hard error so the user enters the total manually — lives in extractFreight, + // Extract freight data via AI. The degradation contract (unconfigured AI is a + // hard error so the user enters the total manually) lives in extractFreight, // which is unit-tested independently of the DB-backed PO lookup above. result, aiRaw, err := s.extractFreight(ctx, fileBytes, aiContentType) if err != nil { From bddc0eb3d33abd8bd1311ae779a22f929a66cfcf Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 16:23:58 -0700 Subject: [PATCH 33/39] test: out of order tests keep the last key's result, a pressed test is not a save (red) Item 130, PR 152 round 2. Save A's slow test passing after save B's refused test reads passed for B's key; a pressed Test connection on an unreachable provider says the key is saved. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/internal/techadmin/ai_test_race_test.go | 100 ++++++++++++++++++ .../techadmin/ai_test_revision_test.go | 17 ++- 2 files changed, 112 insertions(+), 5 deletions(-) create mode 100644 core/internal/techadmin/ai_test_race_test.go diff --git a/core/internal/techadmin/ai_test_race_test.go b/core/internal/techadmin/ai_test_race_test.go new file mode 100644 index 000000000..7ef4283a0 --- /dev/null +++ b/core/internal/techadmin/ai_test_race_test.go @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package techadmin_test + +import ( + "context" + "net/http" + "strings" + "sync" + "sync/atomic" + "testing" + + "github.com/gablelbm/gable/internal/ai" + "github.com/gablelbm/gable/internal/techadmin" +) + +// orderedTester answers its first call only once released (the save whose test +// is slow) and refuses every later call (the save that came after it). +type orderedTester struct { + calls int32 + started chan struct{} + release chan struct{} +} + +func (o *orderedTester) TestConnection(context.Context) error { + if atomic.AddInt32(&o.calls, 1) == 1 { + close(o.started) + <-o.release + return nil + } + return &ai.RefusedError{Provider: "Anthropic", Detail: "stand-in refusal", + Message: "Anthropic refused the saved key. An administrator can enter it again in Tech Admin, AI Settings."} +} + +// RULE (N7): two saves whose tests finish out of order never leave the older +// key's pass standing over the newer key's refusal. Save A's test is slow and +// passes; save B commits after it and its test is refused; A's test then ends. +// The last save's key reads refused. +func TestWire_TwoSavesWhoseTestsFinishOutOfOrderReadTheLastKeysResult(t *testing.T) { + tester := &orderedTester{started: make(chan struct{}), release: make(chan struct{})} + f := newFixtureWith(t, func(s *techadmin.Service) *techadmin.Service { + return s.WithVault(testVault(t)).WithAITester(tester) + }) + ctx := context.Background() + cur, err := f.svc.GetAISettings(ctx) + if err != nil { + t.Fatal(err) + } + rev := cur.Revision + + var wg sync.WaitGroup + wg.Add(1) + var aErr error + go func() { + defer wg.Done() + _, aErr = f.svc.SaveAISettingsFull(ctx, + techadmin.AISave{Provider: "anthropic", AnthropicAPIKey: fakeAnthropicKey}, + techadmin.Precondition{Revision: &rev}) + }() + <-tester.started // A has committed (revision rev+1) and its test is in flight + + next := rev + 1 + if _, err := f.svc.SaveAISettingsFull(ctx, + techadmin.AISave{Provider: "anthropic", AnthropicAPIKey: fakeAnthropicKey + "b"}, + techadmin.Precondition{Revision: &next}); err != nil { + t.Fatalf("save B: %v", err) + } + close(tester.release) // A's test ends last + wg.Wait() + if aErr != nil { + t.Fatalf("save A: %v", aErr) + } + + got := f.do("GET", "/api/v1/admin/settings/ai", nil) + if got.status != http.StatusOK || got.body["test_status"] != "refused" { + t.Fatalf("the last save's key reads %v, want refused: %s", got.body["test_status"], got.raw) + } + if strings.Contains(string(got.raw), fakeAnthropicKey) { + t.Error("the document holds a key") + } +} + +// A pressed Test connection against a provider that cannot be reached does not +// say the key is saved (only a save says that): it says the provider could not +// be reached and to try again later. +func TestWire_APressedTestOnAnUnreachableProviderDoesNotSayTheKeyIsSaved(t *testing.T) { + client := unreachableClient(t) + f := newFixtureWith(t, func(s *techadmin.Service) *techadmin.Service { + return s.WithVault(testVault(t)).WithAITester(client) + }) + r := f.do("POST", "/api/v1/admin/settings/ai/test", nil) + if r.status != http.StatusOK { + t.Fatalf("test = %d: %s", r.status, r.raw) + } + msg, _ := r.body["message"].(string) + if want := "Anthropic could not be reached; try Test connection again later."; msg != want { + t.Errorf("message = %q, want %q", msg, want) + } +} diff --git a/core/internal/techadmin/ai_test_revision_test.go b/core/internal/techadmin/ai_test_revision_test.go index 3235a8fc0..1107964e9 100644 --- a/core/internal/techadmin/ai_test_revision_test.go +++ b/core/internal/techadmin/ai_test_revision_test.go @@ -41,11 +41,7 @@ func TestWire_AStoredTestResultReadsUntestedOnceTheSettingsMoved(t *testing.T) { // A save whose provider cannot be reached keeps the key, answers 200, and says // in words that the key could not be tested: never the raw transport error. func TestWire_ASaveThatCannotReachTheProviderAnswersInWords(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) - url := srv.URL - srv.Close() - client := ai.NewClient("").WithProvider(ai.ProviderAnthropic). - WithAnthropicKey(fakeAnthropicKey).WithAnthropicBaseURL(url) + client := unreachableClient(t) f := newFixtureWith(t, func(s *techadmin.Service) *techadmin.Service { return s.WithVault(testVault(t)).WithAITester(client) }) @@ -68,3 +64,14 @@ func TestWire_ASaveThatCannotReachTheProviderAnswersInWords(t *testing.T) { t.Errorf("the answer holds the raw transport error: %s", r.raw) } } + +// unreachableClient is an Anthropic client whose provider address refuses +// every connection. +func unreachableClient(t *testing.T) *ai.Client { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + url := srv.URL + srv.Close() + return ai.NewClient("").WithProvider(ai.ProviderAnthropic). + WithAnthropicKey(fakeAnthropicKey).WithAnthropicBaseURL(url) +} From eef3a1345dc4d12a7219d4e7cbad02e2fc854373 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 16:24:16 -0700 Subject: [PATCH 34/39] test: the refusal notice stands only while the newest attempt is refused (red) Item 130, PR 152 round 2. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- .../inventory/tabs/ProductMediaTab.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/web/apps/desk/src/pages/inventory/tabs/ProductMediaTab.test.ts b/web/apps/desk/src/pages/inventory/tabs/ProductMediaTab.test.ts index 5071a98ba..0e8f0c47e 100644 --- a/web/apps/desk/src/pages/inventory/tabs/ProductMediaTab.test.ts +++ b/web/apps/desk/src/pages/inventory/tabs/ProductMediaTab.test.ts @@ -61,4 +61,21 @@ describe('the product media tab generation words', () => { expect(text(notice)).toContain('Tech Admin, AI Settings') expect(text(el)).not.toContain('Please try again') }) + + it('shows the refusal notice only while the newest attempt is refused', async () => { + stubSettings('anthropic') + const row = (id: string, status: string, created: string) => + ({ id, product_id: 'p1', media_type: 'hero', url: status === 'ready' ? 'data:image/svg+xml;base64,AA==' : '', alt_text: '', status, is_primary: false, created_at: created, updated_at: created }) as unknown as PIMMedia + // An old refused row, then a newer one that was drawn once the key was fixed. + const fixed = await mountAsync('gable-product-media-tab', { + productId: 'p1', media: [row('new', 'ready', '2026-02-01T00:00:00Z'), row('old', 'refused', '2026-01-01T00:00:00Z')], + }) + expect(fixed.querySelector('[data-testid="pim-ai-notice"]')).toBeNull() + document.body.innerHTML = '' + // The newest attempt refused: the notice stands. + const refused = await mountAsync('gable-product-media-tab', { + productId: 'p1', media: [row('old', 'ready', '2026-01-01T00:00:00Z'), row('new', 'refused', '2026-02-01T00:00:00Z')], + }) + expect(text(refused.querySelector('[data-testid="pim-ai-notice"]'))).toContain('refused the saved key') + }) }) From e28579096a85facd1df4bac91e9d12c8d2d75062 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 16:28:39 -0700 Subject: [PATCH 35/39] AI settings: a test is stored against the revision it started on, and a press does not say saved The revision is read before the provider is asked, and a result of an older revision never replaces a later one, so out of order tests leave the last key's result. A pressed test on an unreachable provider says it could not be reached, not that the key is saved. The revision leg's comment no longer says the race cannot happen. Item 130, PR 152 round 2. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/internal/ai/refused.go | 10 ++++ .../techadmin/ai_test_revision_test.go | 5 +- core/internal/techadmin/service.go | 53 +++++++++++++++---- 3 files changed, 56 insertions(+), 12 deletions(-) diff --git a/core/internal/ai/refused.go b/core/internal/ai/refused.go index 36f651cc7..f259516c3 100644 --- a/core/internal/ai/refused.go +++ b/core/internal/ai/refused.go @@ -69,6 +69,9 @@ type UnreachableError struct { func (e *UnreachableError) Error() string { return e.Detail } +// ProviderName is the display name of the provider that could not be reached. +func (e *UnreachableError) ProviderName() string { return e.Provider } + // Words is what a person reads when the saved key could not be tested. func (e *UnreachableError) Words() string { return UnreachableWords(e.Provider) } @@ -77,6 +80,13 @@ func UnreachableWords(provider string) string { return fmt.Sprintf("The key is saved. %s could not be reached to test it; press Test connection later.", provider) } +// PressedUnreachableWords is the sentence for a pressed Test connection that +// could not reach provider: nothing was saved by the press, so it does not say +// the key is saved. +func PressedUnreachableWords(provider string) string { + return fmt.Sprintf("%s could not be reached; try Test connection again later.", provider) +} + // UnreachableMessage reports whether err is an unreachable provider and the // sentence to show. A wrapped error is found through errors.As. func UnreachableMessage(err error) (string, bool) { diff --git a/core/internal/techadmin/ai_test_revision_test.go b/core/internal/techadmin/ai_test_revision_test.go index 1107964e9..b3748198b 100644 --- a/core/internal/techadmin/ai_test_revision_test.go +++ b/core/internal/techadmin/ai_test_revision_test.go @@ -15,8 +15,9 @@ import ( ) // The stored test result names the settings revision it ran on: a result that -// belongs to an older revision of the settings reads as untested, so a refused -// key never reads passed because two tests finished out of order. +// belongs to an older revision of the settings reads as untested. (Two tests +// that finish out of order are the next leg's: the result of the older +// revision never replaces the later one.) func TestWire_AStoredTestResultReadsUntestedOnceTheSettingsMoved(t *testing.T) { var hits int32 client := anthropicAnswering(t, http.StatusOK, &hits) diff --git a/core/internal/techadmin/service.go b/core/internal/techadmin/service.go index b36efdf88..325135ac8 100644 --- a/core/internal/techadmin/service.go +++ b/core/internal/techadmin/service.go @@ -516,7 +516,17 @@ func (s *Service) readAITest(ctx context.Context, out *AISettings) error { // document. A provider failure is an answer, not an error; only a storage // failure is returned. The message is the provider's refusal in words, never // the key. -func (s *Service) runAITest(ctx context.Context) (TestResult, error) { +func (s *Service) runAITest(ctx context.Context, fromSave bool) (TestResult, error) { + // The revision the test runs on is read BEFORE the provider is asked: the + // result belongs to that revision, whenever the provider answers. + var rev int64 + if s.aiTester != nil && s.repo != nil { + r, err := s.repo.ReadRevision(ctx, ResourceAISettings) + if err != nil { + return TestResult{}, err + } + rev = r + } res := TestResult{OK: true, Status: AITestPassed, Message: "The provider answered."} switch { case s.aiTester == nil: @@ -525,10 +535,10 @@ func (s *Service) runAITest(ctx context.Context) (TestResult, error) { if err := s.aiTester.TestConnection(ctx); err != nil { if words, refused := ai.RefusedMessage(err); refused { res = TestResult{Status: AITestRefused, Message: words} - } else if words, unreachable := ai.UnreachableMessage(err); unreachable { - res = TestResult{Status: AITestFailed, Message: words} + } else if unreachable := unreachableProvider(err); unreachable != "" { + res = TestResult{Status: AITestFailed, Message: unreachableWords(unreachable, fromSave)} } else if errors.Is(err, context.DeadlineExceeded) { - res = TestResult{Status: AITestFailed, Message: ai.UnreachableWords(s.aiProviderLabel(ctx))} + res = TestResult{Status: AITestFailed, Message: unreachableWords(s.aiProviderLabel(ctx), fromSave)} } else { res = TestResult{Status: AITestFailed, Message: err.Error()} } @@ -537,14 +547,18 @@ func (s *Service) runAITest(ctx context.Context) (TestResult, error) { if s.aiTester == nil || s.repo == nil { return res, nil } - rev, err := s.repo.ReadRevision(ctx, ResourceAISettings) - if err != nil { - return res, err - } stored := aiTestRow{Status: res.Status, Message: res.Message, Revision: rev} if res.Status == AITestPassed { stored.Message = "" } + // A result of an older revision never replaces one of a later revision: + // two tests that finish out of order leave the later key's result. + if raw, ok, rerr := s.repo.ReadSetting(ctx, settingAITest); rerr == nil && ok { + var prev aiTestRow + if json.Unmarshal([]byte(raw), &prev) == nil && prev.Revision > rev { + return res, nil + } + } row, err := json.Marshal(stored) if err != nil { return res, err @@ -667,7 +681,7 @@ func (s *Service) SaveAISettingsFull(ctx context.Context, in AISave, pre Precond // test is the provider's answer, never a reason to refuse the save. if out.Configured && s.aiTester != nil { tctx, cancel := context.WithTimeout(ctx, saveTestTimeout) - _, terr := s.runAITest(tctx) + _, terr := s.runAITest(tctx, true) cancel() if terr != nil { // The save is committed: a result that could not be stored is @@ -685,7 +699,7 @@ func (s *Service) SaveAISettingsFull(ctx context.Context, in AISave, pre Precond // the last test of the saved key. A provider failure is an answer (ok false // and its words), not an error. func (s *Service) TestAIConnection(ctx context.Context) TestResult { - res, err := s.runAITest(ctx) + res, err := s.runAITest(ctx, false) if err != nil { slog.Error("failed to store the AI test result", "error", err) } @@ -766,6 +780,25 @@ func (s *Service) DeleteAISettings(ctx context.Context, pre Precondition) error return err } +// unreachableProvider is the provider's name when err is an unreachable +// provider, else "". +func unreachableProvider(err error) string { + var ue *ai.UnreachableError + if errors.As(err, &ue) { + return ue.ProviderName() + } + return "" +} + +// unreachableWords is the sentence for a test that could not reach provider: a +// save says the key is saved, a pressed test does not. +func unreachableWords(provider string, fromSave bool) string { + if fromSave { + return ai.UnreachableWords(provider) + } + return ai.PressedUnreachableWords(provider) +} + // aiProviderLabel is the display name of the chosen provider. func (s *Service) aiProviderLabel(ctx context.Context) string { if p, _, err := s.repo.ReadSetting(ctx, ai.SettingProvider); err == nil && p == ai.ProviderAnthropic { From 2d91cc47be2b819e2d123747ff46c3b885092739 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 16:28:39 -0700 Subject: [PATCH 36/39] desk: the refusal notice stands only while the newest attempt is refused Item 130, PR 152 round 2. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- .../desk/src/pages/inventory/tabs/ProductMediaTab.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/web/apps/desk/src/pages/inventory/tabs/ProductMediaTab.ts b/web/apps/desk/src/pages/inventory/tabs/ProductMediaTab.ts index 560c0dd60..c6af2f0de 100644 --- a/web/apps/desk/src/pages/inventory/tabs/ProductMediaTab.ts +++ b/web/apps/desk/src/pages/inventory/tabs/ProductMediaTab.ts @@ -71,6 +71,14 @@ export class GableProductMediaTab extends LitElement { } } + /** The refusal notice stands only while the newest attempt is the refused one. */ + private _newestRefused(): boolean { + const settled = this.media.filter(m => m.status !== 'generating'); + if (settled.length === 0) return false; + const newest = settled.reduce((a, b) => (b.created_at > a.created_at ? b : a)); + return newest.status === 'refused'; + } + private async _handleDelete(mediaId: string) { try { await PIMService.deleteMedia(this.productId, mediaId); @@ -95,7 +103,7 @@ export class GableProductMediaTab extends LitElement { const words = imageWords(this.provider); return html`
- ${renderAiNotice(this.media.some(m => m.status === 'refused') ? words.refused : null)} + ${renderAiNotice(this._newestRefused() ? words.refused : null)}

From b8e190675baf5a59ec7cb9fca0aed6423cb9f220 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 16:28:39 -0700 Subject: [PATCH 37/39] contract: the round's row Item 130, PR 152 round 2. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- docs/refactor/CONTRACT-CHANGES.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/refactor/CONTRACT-CHANGES.md b/docs/refactor/CONTRACT-CHANGES.md index df9706f94..ef95f4bbb 100644 --- a/docs/refactor/CONTRACT-CHANGES.md +++ b/docs/refactor/CONTRACT-CHANGES.md @@ -429,3 +429,4 @@ get an entry. | AI-CLAUDE-FIX1 | `POST /api/v1/parsing/upload`, `POST /api/v1/purchase-orders/{id}/freight`; `GET /api/v1/apps` golden `apps`; pim: `POST /api/v1/products/{id}/pim/generate/image` | A file of a type the chosen provider cannot read (a BMP under Anthropic) got the canned demo list at the intake and a bare 400 at freight. The apps catalog described the AI client as "OpenRouter client". Image generation under Anthropic answered 409 AI off. | The unreadable type answers 415 `unsupported_type` in the wire envelope with the words naming what the provider reads; no canned list. The catalog line reads "Provider routed AI client (OpenRouter or Anthropic), KeyStore, and model routing (library)." and the `apps` golden is re-recorded for that one string. Image generation answers 202 under Anthropic and draws an illustration, stored only after a strict sanitiser. | | G1-AI-WORDS (item 130, N3, N4, N7) | `POST /api/v1/parsing/upload` and `POST /api/v1/products/{id}/pim/generate/{descriptions,seo,collateral}` (new 502 `ai_refused`); admin: `GET` and `PUT /api/v1/admin/settings/ai` (document gains `test_status`, `test_message`), `POST /api/v1/admin/settings/ai/test` (answer gains `status`); goldens `techadmin_settings`, `machine_key` | A provider's 401 or 403 on the saved key reached the intake as the canned sample list with "No AI reader is set up", PIM Generate as a bare 500, and Tech Admin read "AI on" for any saved key. | A refusal answers 502 in the wire envelope with the code `ai_refused` and the words " refused the saved key. An administrator can enter it again in Tech Admin, AI Settings."; a text list is still read by the rule reader. A save of AI settings runs the test with the provider and stores its result; the document's `test_status` is `none`, `untested`, `passed`, `refused` or `failed` with `test_message` for the last two, and the test route stores its result the same way. The image route is unchanged (generation runs after it answers; a refusal there is the media row's failed status). The characterization harness points the server's provider at a local stand-in so a save's test never reaches the network. | | G1-AI-WORDS-R1 (item 130, review round 1) | purchase_order: `POST /api/v1/purchase-orders/{id}/freight` (new 502 `ai_refused`); pim: media `status` gains `refused` (`GET /api/v1/products/{id}/pim/media`, the 202 of `POST .../pim/generate/image`); the AI settings document's `test_status` is tied to the settings revision it ran on | Freight invoice reading answered a refused key as a bare 400; the image route settled a refusal as `failed`, which the page told a person to try again; a stored test result could outlive the key it tested. | Freight answers 502 `ai_refused` with the same sentence plus "Until then, enter the freight total by hand.", and keeps the uploaded file only for a read that succeeded. A refusal on the image route settles the row `refused` (words on the PIM page, no retry); only a 401 is OpenRouter's refusal (a 403 is not), Anthropic's 401 and 403 both are. A stored test result from an older settings revision reads `untested`. A save that cannot reach the provider keeps the key and reads `failed` with "The key is saved. could not be reached to test it; press Test connection later."; its test has a timeout of its own. The sanitiser drops a model's `text` and `tspan` elements. | +| G1-AI-WORDS-R2 (item 130, PR 152 round 2) | admin: `POST /api/v1/admin/settings/ai/test` (the answer's words), `GET`/`PUT /api/v1/admin/settings/ai` (`test_status` ordering) | Two saves whose provider tests finished out of order could leave a refused key reading passed; a pressed test on an unreachable provider said the key is saved. | A test is stored against the settings revision read before the provider is asked, and never replaces a result of a later revision. A pressed test that cannot reach the provider answers " could not be reached; try Test connection again later."; only a save says the key is saved. No shape moved. | From 525f33e22b1b17e23614158fb31652277aa28131 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 20:07:44 -0700 Subject: [PATCH 38/39] test: attempts are ordered by the moment, not the timestamp text (red) Item 130, PR 152 merge round. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- .../src/pages/inventory/tabs/ProductMediaTab.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/web/apps/desk/src/pages/inventory/tabs/ProductMediaTab.test.ts b/web/apps/desk/src/pages/inventory/tabs/ProductMediaTab.test.ts index 0e8f0c47e..825f90878 100644 --- a/web/apps/desk/src/pages/inventory/tabs/ProductMediaTab.test.ts +++ b/web/apps/desk/src/pages/inventory/tabs/ProductMediaTab.test.ts @@ -78,4 +78,15 @@ describe('the product media tab generation words', () => { }) expect(text(refused.querySelector('[data-testid="pim-ai-notice"]'))).toContain('refused the saved key') }) + + it('orders attempts by the moment, not the text, when one falls on a whole second', async () => { + stubSettings('anthropic') + const row = (id: string, status: string, created: string) => + ({ id, product_id: 'p1', media_type: 'hero', url: '', alt_text: '', status, is_primary: false, created_at: created, updated_at: created }) as unknown as PIMMedia + // The refused attempt is half a second AFTER the ready one, though its text sorts before it. + const el = await mountAsync('gable-product-media-tab', { + productId: 'p1', media: [row('a', 'ready', '2026-02-01T00:00:00Z'), row('b', 'refused', '2026-02-01T00:00:00.500Z')], + }) + expect(text(el.querySelector('[data-testid="pim-ai-notice"]'))).toContain('refused the saved key') + }) }) From d9a1952c79af24b6e732df386f8133b961b2112f Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 20:11:02 -0700 Subject: [PATCH 39/39] desk: the media tab orders attempts by the parsed moment Item 130, PR 152 merge round. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- web/apps/desk/src/pages/inventory/tabs/ProductMediaTab.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/web/apps/desk/src/pages/inventory/tabs/ProductMediaTab.ts b/web/apps/desk/src/pages/inventory/tabs/ProductMediaTab.ts index e4509aad5..08b8505b9 100644 --- a/web/apps/desk/src/pages/inventory/tabs/ProductMediaTab.ts +++ b/web/apps/desk/src/pages/inventory/tabs/ProductMediaTab.ts @@ -75,7 +75,10 @@ export class GableProductMediaTab extends LitElement { private _newestRefused(): boolean { const settled = this.media.filter(m => m.status !== 'generating'); if (settled.length === 0) return false; - const newest = settled.reduce((a, b) => (b.created_at > a.created_at ? b : a)); + // Compare the moments: timestamps that fall on a whole second omit the fraction, + // and their text sorts after a later moment that carries one. + const at = (m: PIMMedia) => Date.parse(m.created_at) || 0; + const newest = settled.reduce((a, b) => (at(b) > at(a) ? b : a)); return newest.status === 'refused'; }