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/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/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..04724bf3e 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: @@ -1482,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/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/api/openapi.yaml b/core/api/openapi.yaml index d889a3c88..e2c0eca7a 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 @@ -13870,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 @@ -15798,6 +15806,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 +16593,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 +16619,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] @@ -25830,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/core/internal/ai/anthropic.go b/core/internal/ai/anthropic.go index cdd21f1ba..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)) @@ -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..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() @@ -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)) } @@ -717,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 new file mode 100644 index 000000000..f259516c3 --- /dev/null +++ b/core/internal/ai/refused.go @@ -0,0 +1,98 @@ +// 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 { + // 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)} +} + +// 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 +} + +// 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 } + +// 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) } + +// 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) +} + +// 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) { + var ue *UnreachableError + if errors.As(err, &ue) { + return ue.Words(), true + } + return "", false +} 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) + } +} diff --git a/core/internal/app/seed/demodata/catalog.jsonl b/core/internal/app/seed/demodata/catalog.jsonl index b02a203c5..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_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_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_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":"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/app/seed/demodata_test.go b/core/internal/app/seed/demodata_test.go index 84f1f27ed..56e6f71a1 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", } @@ -707,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) + } + } +} 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/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) + } +} 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") + } +} diff --git a/core/internal/parsing/brand_words_test.go b/core/internal/parsing/brand_words_test.go new file mode 100644 index 000000000..3f255ea64 --- /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", "../ai/openrouter.go", "../tax/service_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) + } + } + } +} 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/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/ai_refused_wire_test.go b/core/internal/pim/ai_refused_wire_test.go new file mode 100644 index 000000000..666656f1d --- /dev/null +++ b/core/internal/pim/ai_refused_wire_test.go @@ -0,0 +1,79 @@ +// 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) + } + }) + } +} + +// 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) + } +} 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 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") + } +} 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 07a7e3ee8..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 @@ -427,8 +442,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.` 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_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{"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(``)}, 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)) + } +} 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..5526db32f 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)) 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 }, } 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/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 new file mode 100644 index 000000000..b3748198b --- /dev/null +++ b/core/internal/techadmin/ai_test_revision_test.go @@ -0,0 +1,78 @@ +// 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. (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) + 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) { + client := unreachableClient(t) + 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) + } +} + +// 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) +} 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"]) + } +} 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..325135ac8 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,110 @@ 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"` + // 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. +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 + } + if row.Revision != out.Revision { + 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, 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: + 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 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: unreachableWords(s.aiProviderLabel(ctx), fromSave)} + } 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, 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 + } + 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 +676,34 @@ 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 { + tctx, cancel := context.WithTimeout(ctx, saveTestTimeout) + _, terr := s.runAITest(tctx, true) + 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 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, false) + 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 +752,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 } @@ -663,6 +780,33 @@ 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 { + 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 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'`) } diff --git a/docs/refactor/CONTRACT-CHANGES.md b/docs/refactor/CONTRACT-CHANGES.md index 147c70b8f..ef95f4bbb 100644 --- a/docs/refactor/CONTRACT-CHANGES.md +++ b/docs/refactor/CONTRACT-CHANGES.md @@ -427,3 +427,6 @@ 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. | +| 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. | 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.test.ts b/web/apps/desk/src/components/quotes/MaterialIntake.test.ts index 7ebdf3654..da4557d5d 100644 --- a/web/apps/desk/src/components/quotes/MaterialIntake.test.ts +++ b/web/apps/desk/src/components/quotes/MaterialIntake.test.ts @@ -143,6 +143,27 @@ 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') + // 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() + 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') 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.test.ts b/web/apps/desk/src/lib/aiWords.test.ts index a3cd0be55..ad58cbb5d 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'); }); @@ -29,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/lib/aiWords.ts b/web/apps/desk/src/lib/aiWords.ts index 9d197ec1a..f95e10459 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. */ @@ -51,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; } @@ -64,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.', }; } @@ -75,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/admin/tech_admin/AIProviderWords.test.ts b/web/apps/desk/src/pages/admin/tech_admin/AIProviderWords.test.ts index 82dd7c561..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 @@ -48,13 +48,42 @@ 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('dims the features grid for a key the provider refused and lights it for a passed one', async () => { + 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().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().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()) + 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()) 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 ebc3d3fd7..013edeeb9 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)}

`} @@ -671,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/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 new file mode 100644 index 000000000..9039122c2 --- /dev/null +++ b/web/apps/desk/src/pages/inventory/tabs/ProductContentTab.test.ts @@ -0,0 +1,62 @@ +// 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 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(() => { + 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, {}) + 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, {}) + 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, {}) + 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/ProductMediaTab.test.ts b/web/apps/desk/src/pages/inventory/tabs/ProductMediaTab.test.ts index 6f77a2fec..825f90878 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,42 @@ 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') + }) + + 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') + }) + + 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') + }) }) diff --git a/web/apps/desk/src/pages/inventory/tabs/ProductMediaTab.ts b/web/apps/desk/src/pages/inventory/tabs/ProductMediaTab.ts index 031700cb0..08b8505b9 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']; @@ -70,6 +71,17 @@ 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; + // 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'; + } + private async _handleDelete(mediaId: string) { try { await PIMService.deleteMedia(this.productId, mediaId); @@ -94,6 +106,7 @@ export class GableProductMediaTab extends LitElement { const words = imageWords(this.provider); return html`
+ ${renderAiNotice(this._newestRefused() ? words.refused : null)}

@@ -160,10 +173,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 +189,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/pages/inventory/tabs/ProductSEOTab.ts b/web/apps/desk/src/pages/inventory/tabs/ProductSEOTab.ts index b31668c83..1818f860e 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/apps/desk/src/test/e2eNoNetwork.test.ts b/web/apps/desk/src/test/e2eNoNetwork.test.ts new file mode 100644 index 000000000..d13e6c994 --- /dev/null +++ b/web/apps/desk/src/test/e2eNoNetwork.test.ts @@ -0,0 +1,28 @@ +// 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' +import { join, resolve } from 'node:path' + +// 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(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]}`) + } + } + expect(offenders).toEqual([]) + }) +}) 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; diff --git a/web/e2e/admin.spec.ts b/web/e2e/admin.spec.ts index c7eb07246..71b818196 100644 --- a/web/e2e/admin.spec.ts +++ b/web/e2e/admin.spec.ts @@ -70,14 +70,17 @@ 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. - await expect(page.getByText('AI Features Active')).toBeVisible(); + // cannot have clobbered a second editor's write. The save tested the + // 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') }); diff --git a/web/packages/api-client/src/schema.d.ts b/web/packages/api-client/src/schema.d.ts index 84c3cf3fc..c31f506c0 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: { @@ -13367,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; @@ -14669,6 +14681,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 +23318,7 @@ export interface operations { 415: components["responses"]["UnsupportedType"]; 422: components["responses"]["UnprocessableEntity"]; 500: components["responses"]["InternalError"]; + 502: components["responses"]["AIRefused"]; }; }; partnerDashboard: { @@ -26891,6 +26913,7 @@ export interface operations { 413: components["responses"]["PayloadTooLarge"]; 422: components["responses"]["UnprocessableEntity"]; 500: components["responses"]["InternalError"]; + 502: components["responses"]["AIRefused"]; }; }; productPimGenerateDescriptions: { @@ -26929,6 +26952,7 @@ export interface operations { 413: components["responses"]["PayloadTooLarge"]; 422: components["responses"]["UnprocessableEntity"]; 500: components["responses"]["InternalError"]; + 502: components["responses"]["AIRefused"]; }; }; productPimGenerateImage: { @@ -27005,6 +27029,7 @@ export interface operations { 413: components["responses"]["PayloadTooLarge"]; 422: components["responses"]["UnprocessableEntity"]; 500: components["responses"]["InternalError"]; + 502: components["responses"]["AIRefused"]; }; }; productUpdateMargins: { @@ -27731,6 +27756,7 @@ export interface operations { 415: components["responses"]["UnsupportedType"]; 422: components["responses"]["UnprocessableEntity"]; 500: components["responses"]["InternalError"]; + 502: components["responses"]["AIRefused"]; }; }; purchaseOrderFreightApply: {