@sadrmanesh/commerce-agent-skills 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -0
- package/LICENSE +21 -0
- package/README.md +141 -2
- package/bin/commerce-agent-skills.mjs +368 -0
- package/package.json +52 -4
- package/rules/b2b-pricing.md +11 -0
- package/rules/catalog-import-and-migration.md +11 -0
- package/rules/checkout-ux-and-conversion.md +12 -0
- package/rules/conversion-event-tracking.md +11 -0
- package/rules/ecommerce-seo-structured-data.md +11 -0
- package/rules/fraud-and-risk-basics.md +11 -0
- package/rules/google-merchant-product-feeds.md +11 -0
- package/rules/headless-nextjs-storefront.md +10 -0
- package/rules/inventory-and-multi-warehouse.md +10 -0
- package/rules/iranian-payment-gateways.md +10 -0
- package/rules/marketplace-search-and-ranking.md +10 -0
- package/rules/mcp-store-tooling.md +11 -0
- package/rules/multi-vendor-payouts.md +11 -0
- package/rules/order-and-webhook-reliability.md +12 -0
- package/rules/payment-gateway-integration.md +12 -0
- package/rules/product-catalog-modeling.md +11 -0
- package/rules/product-description-guardrails.md +10 -0
- package/rules/returns-and-refunds.md +10 -0
- package/rules/rtl-and-persian-localization.md +11 -0
- package/rules/shopify-app-development.md +10 -0
- package/rules/storefront-accessibility.md +11 -0
- package/rules/storefront-performance.md +10 -0
- package/rules/stripe-payments.md +10 -0
- package/rules/subscription-billing.md +10 -0
- package/rules/tax-and-shipping.md +10 -0
- package/rules/vector-product-search.md +10 -0
- package/rules/woocommerce-plugin-development.md +10 -0
- package/rules/woocommerce-rest-api.md +10 -0
- package/rules/wordpress-plugin-security.md +10 -0
- package/scripts/lib/frontmatter.mjs +218 -0
- package/skills/b2b-pricing/SKILL.md +55 -0
- package/skills/b2b-pricing/evals/examples/net-terms.md +3 -0
- package/skills/b2b-pricing/evals/examples/tiered-wholesale.md +3 -0
- package/skills/b2b-pricing/evals/tasks.json +19 -0
- package/skills/b2b-pricing/references/price-lists.md +46 -0
- package/skills/b2b-pricing/scripts/resolve-price.mjs +93 -0
- package/skills/catalog-import-and-migration/SKILL.md +58 -0
- package/skills/catalog-import-and-migration/evals/examples/platform-migration-urls.md +3 -0
- package/skills/catalog-import-and-migration/evals/examples/rerunnable-import.md +3 -0
- package/skills/catalog-import-and-migration/evals/tasks.json +20 -0
- package/skills/catalog-import-and-migration/references/field-mapping.md +43 -0
- package/skills/catalog-import-and-migration/scripts/check-import-rows.mjs +75 -0
- package/skills/catalog-order.txt +29 -0
- package/skills/checkout-ux-and-conversion/SKILL.md +86 -0
- package/skills/checkout-ux-and-conversion/evals/examples/guest-total.md +3 -0
- package/skills/checkout-ux-and-conversion/evals/examples/single-submit.md +3 -0
- package/skills/checkout-ux-and-conversion/evals/tasks.json +30 -0
- package/skills/checkout-ux-and-conversion/references/checkout-review.md +41 -0
- package/skills/conversion-event-tracking/SKILL.md +64 -0
- package/skills/conversion-event-tracking/evals/examples/purchase-once.md +3 -0
- package/skills/conversion-event-tracking/evals/examples/variant-item-id.md +3 -0
- package/skills/conversion-event-tracking/evals/tasks.json +30 -0
- package/skills/conversion-event-tracking/references/events.md +36 -0
- package/skills/conversion-event-tracking/scripts/check-event.mjs +79 -0
- package/skills/ecommerce-seo-structured-data/SKILL.md +84 -0
- package/skills/ecommerce-seo-structured-data/assets/example-product.jsonld +15 -0
- package/skills/ecommerce-seo-structured-data/evals/examples/extend-woocommerce-schema.md +8 -0
- package/skills/ecommerce-seo-structured-data/evals/examples/variant-offer.md +7 -0
- package/skills/ecommerce-seo-structured-data/evals/tasks.json +29 -0
- package/skills/ecommerce-seo-structured-data/references/product-jsonld.md +83 -0
- package/skills/ecommerce-seo-structured-data/scripts/check-product-jsonld.mjs +211 -0
- package/skills/fraud-and-risk-basics/SKILL.md +66 -0
- package/skills/fraud-and-risk-basics/evals/examples/review-queue.md +3 -0
- package/skills/fraud-and-risk-basics/evals/examples/wait-for-3ds.md +3 -0
- package/skills/fraud-and-risk-basics/evals/tasks.json +30 -0
- package/skills/google-merchant-product-feeds/SKILL.md +70 -0
- package/skills/google-merchant-product-feeds/assets/example-item.json +11 -0
- package/skills/google-merchant-product-feeds/evals/examples/generated-title.md +3 -0
- package/skills/google-merchant-product-feeds/evals/examples/stable-variant-id.md +3 -0
- package/skills/google-merchant-product-feeds/evals/tasks.json +30 -0
- package/skills/google-merchant-product-feeds/references/attributes.md +39 -0
- package/skills/google-merchant-product-feeds/scripts/check-feed-item.mjs +92 -0
- package/skills/headless-nextjs-storefront/SKILL.md +69 -0
- package/skills/headless-nextjs-storefront/evals/examples/revalidate-stock.md +7 -0
- package/skills/headless-nextjs-storefront/evals/examples/server-price.md +3 -0
- package/skills/headless-nextjs-storefront/evals/tasks.json +30 -0
- package/skills/headless-nextjs-storefront/references/cache-and-cart.md +39 -0
- package/skills/inventory-and-multi-warehouse/SKILL.md +61 -0
- package/skills/inventory-and-multi-warehouse/evals/examples/reserve-last-unit.md +3 -0
- package/skills/inventory-and-multi-warehouse/evals/examples/split-order.md +3 -0
- package/skills/inventory-and-multi-warehouse/evals/tasks.json +20 -0
- package/skills/inventory-and-multi-warehouse/references/platforms.md +40 -0
- package/skills/inventory-and-multi-warehouse/scripts/allocate-stock.mjs +72 -0
- package/skills/iranian-payment-gateways/SKILL.md +64 -0
- package/skills/iranian-payment-gateways/evals/examples/toman-store.md +3 -0
- package/skills/iranian-payment-gateways/evals/examples/zarinpal-callback.md +3 -0
- package/skills/iranian-payment-gateways/evals/tasks.json +20 -0
- package/skills/iranian-payment-gateways/references/zarinpal-v4.md +44 -0
- package/skills/iranian-payment-gateways/scripts/check-verify.mjs +62 -0
- package/skills/marketplace-search-and-ranking/SKILL.md +93 -0
- package/skills/marketplace-search-and-ranking/evals/examples/index-variant.md +3 -0
- package/skills/marketplace-search-and-ranking/evals/examples/normalize-query.md +7 -0
- package/skills/marketplace-search-and-ranking/evals/tasks.json +29 -0
- package/skills/marketplace-search-and-ranking/references/document-and-ranking.md +56 -0
- package/skills/marketplace-search-and-ranking/scripts/normalize-search-text.mjs +40 -0
- package/skills/mcp-store-tooling/SKILL.md +49 -0
- package/skills/mcp-store-tooling/evals/examples/refund-tool-design.md +3 -0
- package/skills/mcp-store-tooling/evals/examples/remote-server-auth.md +3 -0
- package/skills/mcp-store-tooling/evals/tasks.json +20 -0
- package/skills/mcp-store-tooling/references/mcp-notes.md +67 -0
- package/skills/mcp-store-tooling/scripts/check-tool-definitions.mjs +69 -0
- package/skills/multi-vendor-payouts/SKILL.md +71 -0
- package/skills/multi-vendor-payouts/evals/examples/retry-payout.md +3 -0
- package/skills/multi-vendor-payouts/evals/examples/split-paid-order.md +3 -0
- package/skills/multi-vendor-payouts/evals/tasks.json +30 -0
- package/skills/multi-vendor-payouts/references/ledger.md +35 -0
- package/skills/order-and-webhook-reliability/SKILL.md +99 -0
- package/skills/order-and-webhook-reliability/evals/examples/dedupe-resource-version.md +3 -0
- package/skills/order-and-webhook-reliability/evals/examples/verify-raw-body.md +3 -0
- package/skills/order-and-webhook-reliability/evals/tasks.json +29 -0
- package/skills/order-and-webhook-reliability/references/woocommerce-webhooks.md +58 -0
- package/skills/order-and-webhook-reliability/scripts/verify-hmac-signature.mjs +78 -0
- package/skills/payment-gateway-integration/SKILL.md +90 -0
- package/skills/payment-gateway-integration/evals/examples/confirmed-capture.md +9 -0
- package/skills/payment-gateway-integration/evals/examples/replayed-webhook.md +3 -0
- package/skills/payment-gateway-integration/evals/tasks.json +30 -0
- package/skills/payment-gateway-integration/references/woocommerce-gateway.md +67 -0
- package/skills/product-catalog-modeling/SKILL.md +91 -0
- package/skills/product-catalog-modeling/assets/example-catalog.json +45 -0
- package/skills/product-catalog-modeling/evals/examples/create-variation.md +10 -0
- package/skills/product-catalog-modeling/evals/examples/sync-parent.md +7 -0
- package/skills/product-catalog-modeling/evals/tasks.json +29 -0
- package/skills/product-catalog-modeling/references/variation-model.md +71 -0
- package/skills/product-catalog-modeling/scripts/validate-catalog.mjs +234 -0
- package/skills/product-description-guardrails/SKILL.md +84 -0
- package/skills/product-description-guardrails/evals/examples/facts-only.md +3 -0
- package/skills/product-description-guardrails/evals/examples/label-generated.md +3 -0
- package/skills/product-description-guardrails/evals/tasks.json +30 -0
- package/skills/product-description-guardrails/references/claims.md +24 -0
- package/skills/product-description-guardrails/scripts/check-description.mjs +61 -0
- package/skills/returns-and-refunds/SKILL.md +55 -0
- package/skills/returns-and-refunds/evals/examples/refund-after-inspection.md +3 -0
- package/skills/returns-and-refunds/evals/examples/woocommerce-partial-refund.md +3 -0
- package/skills/returns-and-refunds/evals/tasks.json +20 -0
- package/skills/returns-and-refunds/references/rma-states.md +37 -0
- package/skills/rtl-and-persian-localization/SKILL.md +81 -0
- package/skills/rtl-and-persian-localization/evals/examples/document-direction.md +7 -0
- package/skills/rtl-and-persian-localization/evals/examples/rial-display.md +7 -0
- package/skills/rtl-and-persian-localization/evals/tasks.json +30 -0
- package/skills/rtl-and-persian-localization/references/persian-text.md +51 -0
- package/skills/rtl-and-persian-localization/scripts/normalize-persian.mjs +62 -0
- package/skills/shopify-app-development/SKILL.md +73 -0
- package/skills/shopify-app-development/evals/examples/dedupe-delivery.md +3 -0
- package/skills/shopify-app-development/evals/examples/verify-webhook.md +3 -0
- package/skills/shopify-app-development/evals/tasks.json +29 -0
- package/skills/shopify-app-development/references/auth-and-webhooks.md +44 -0
- package/skills/storefront-accessibility/SKILL.md +59 -0
- package/skills/storefront-accessibility/evals/examples/add-to-cart-announcement.md +3 -0
- package/skills/storefront-accessibility/evals/examples/checkout-errors.md +3 -0
- package/skills/storefront-accessibility/evals/tasks.json +20 -0
- package/skills/storefront-accessibility/references/wcag-checklist.md +41 -0
- package/skills/storefront-performance/SKILL.md +59 -0
- package/skills/storefront-performance/evals/examples/cache-without-leaking-carts.md +3 -0
- package/skills/storefront-performance/evals/examples/slow-product-lcp.md +3 -0
- package/skills/storefront-performance/evals/tasks.json +20 -0
- package/skills/storefront-performance/references/web-vitals.md +55 -0
- package/skills/storefront-performance/scripts/check-web-vitals.mjs +49 -0
- package/skills/stripe-payments/SKILL.md +72 -0
- package/skills/stripe-payments/evals/examples/checkout-session-delayed.md +3 -0
- package/skills/stripe-payments/evals/examples/mark-paid-from-webhook.md +5 -0
- package/skills/stripe-payments/evals/examples/partial-refund.md +3 -0
- package/skills/stripe-payments/evals/tasks.json +27 -0
- package/skills/stripe-payments/references/stripe-objects.md +54 -0
- package/skills/stripe-payments/scripts/verify-stripe-signature.mjs +62 -0
- package/skills/subscription-billing/SKILL.md +76 -0
- package/skills/subscription-billing/evals/examples/cancel-at-period-end.md +3 -0
- package/skills/subscription-billing/evals/examples/one-renewal.md +3 -0
- package/skills/subscription-billing/evals/tasks.json +30 -0
- package/skills/subscription-billing/references/states.md +27 -0
- package/skills/tax-and-shipping/SKILL.md +61 -0
- package/skills/tax-and-shipping/evals/examples/store-tax-lines.md +3 -0
- package/skills/tax-and-shipping/evals/examples/unknown-zone.md +3 -0
- package/skills/tax-and-shipping/evals/tasks.json +30 -0
- package/skills/tax-and-shipping/references/woocommerce.md +22 -0
- package/skills/vector-product-search/SKILL.md +66 -0
- package/skills/vector-product-search/evals/examples/reembed-on-title.md +3 -0
- package/skills/vector-product-search/evals/examples/sku-before-vector.md +3 -0
- package/skills/vector-product-search/evals/tasks.json +30 -0
- package/skills/vector-product-search/references/index.md +40 -0
- package/skills/woocommerce-plugin-development/SKILL.md +84 -0
- package/skills/woocommerce-plugin-development/assets/main-plugin-file.php +28 -0
- package/skills/woocommerce-plugin-development/evals/examples/declare-hpos.md +9 -0
- package/skills/woocommerce-plugin-development/evals/examples/save-order-meta.md +9 -0
- package/skills/woocommerce-plugin-development/evals/tasks.json +32 -0
- package/skills/woocommerce-plugin-development/references/hooks.md +46 -0
- package/skills/woocommerce-plugin-development/references/hpos.md +84 -0
- package/skills/woocommerce-rest-api/SKILL.md +90 -0
- package/skills/woocommerce-rest-api/evals/examples/https-basic-auth.md +8 -0
- package/skills/woocommerce-rest-api/evals/examples/idempotent-order-create.md +7 -0
- package/skills/woocommerce-rest-api/evals/tasks.json +30 -0
- package/skills/woocommerce-rest-api/references/endpoints.md +66 -0
- package/skills/wordpress-plugin-security/SKILL.md +121 -0
- package/skills/wordpress-plugin-security/evals/examples/admin-form-nonce.md +9 -0
- package/skills/wordpress-plugin-security/evals/examples/private-rest-route.md +9 -0
- package/skills/wordpress-plugin-security/evals/tasks.json +31 -0
- package/skills/wordpress-plugin-security/references/review-checklist.md +31 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"tasks": [
|
|
3
|
+
{
|
|
4
|
+
"id": "wait-for-3ds",
|
|
5
|
+
"task": "Do not capture a card payment while 3-D Secure is still waiting for the shopper.",
|
|
6
|
+
"expects": {
|
|
7
|
+
"must_include": [
|
|
8
|
+
"3-D Secure",
|
|
9
|
+
"do not capture"
|
|
10
|
+
],
|
|
11
|
+
"must_not_include": [
|
|
12
|
+
"4111111111111111"
|
|
13
|
+
]
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"id": "review-queue",
|
|
18
|
+
"task": "Queue a high-amount AVS mismatch for a person to capture or void, and ignore a risk flag from the browser.",
|
|
19
|
+
"expects": {
|
|
20
|
+
"must_include": [
|
|
21
|
+
"review",
|
|
22
|
+
"AVS"
|
|
23
|
+
],
|
|
24
|
+
"must_not_include": [
|
|
25
|
+
"risk: low"
|
|
26
|
+
]
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
]
|
|
30
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: google-merchant-product-feeds
|
|
3
|
+
description: "Build a Google Merchant Center product feed item with the required id, title, description, link, image, availability, and price fields. Use when exporting products to Merchant Center, Content API items, or a product feed file."
|
|
4
|
+
license: MIT
|
|
5
|
+
compatibility: "The feed item checker requires Node.js 20 or newer. No network access."
|
|
6
|
+
metadata:
|
|
7
|
+
author: Naser Sadrmanesh
|
|
8
|
+
version: "0.3.0"
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Google Merchant product feeds
|
|
12
|
+
|
|
13
|
+
Use this skill when a catalog becomes a Merchant Center item. On-page JSON-LD is the structured-data skill. Generated titles and descriptions follow the description-guardrails skill, then the structured-title rules below.
|
|
14
|
+
|
|
15
|
+
Attribute names and limits below follow Google's Merchant Center product data specification. Re-check that specification when a disapproval cites an attribute this skill does not list. The local checker does not call Google.
|
|
16
|
+
|
|
17
|
+
## Procedure
|
|
18
|
+
|
|
19
|
+
1. Emit one item per purchasable unit. A variant gets its own `id`. Variants that shoppers choose among share `item_group_id`.
|
|
20
|
+
2. Fill the required attributes: `id`, `title` or `structured_title`, `description` or `structured_description`, `link`, `image_link`, `availability`, and `price`.
|
|
21
|
+
3. Match the landing page. The title, description, price, and availability a shopper sees are the values in the feed.
|
|
22
|
+
4. Run `node scripts/check-feed-item.mjs --file item.json` from this skill directory.
|
|
23
|
+
5. Keep `id` stable for the life of that unit. A new id on every export creates a new product and drops history.
|
|
24
|
+
|
|
25
|
+
Field limits and the variant rules are in [the attribute notes](references/attributes.md). An item that passes the checker is in [`assets/example-item.json`](assets/example-item.json). Worked examples and checks are in [evals/tasks.json](evals/tasks.json).
|
|
26
|
+
|
|
27
|
+
## Required values
|
|
28
|
+
|
|
29
|
+
| Attribute | Rule |
|
|
30
|
+
| --- | --- |
|
|
31
|
+
| `id` | Unique, stable, at most 50 characters. Use the SKU when the SKU fits. |
|
|
32
|
+
| `title` | Plain text, at most 150 characters, or `structured_title` when the title was generated. |
|
|
33
|
+
| `description` | Plain text, at most 5000 characters, or `structured_description` when the description was generated. |
|
|
34
|
+
| `link` | `http` or `https` URL of the product landing page on the verified domain. |
|
|
35
|
+
| `image_link` | `http` or `https` URL of the main product image. |
|
|
36
|
+
| `availability` | `in_stock`, `out_of_stock`, `preorder`, or `backorder`. |
|
|
37
|
+
| `price` | Decimal major units with a period, a space, and an ISO 4217 code, such as `15.00 USD`. |
|
|
38
|
+
|
|
39
|
+
`availability_date` is required when `availability` is `preorder`. Provide it for `backorder` as well. The product data specification asks for that date on both.
|
|
40
|
+
|
|
41
|
+
## Variants
|
|
42
|
+
|
|
43
|
+
Set `item_group_id` to the parent id for every variant. Google's specification marks `item_group_id` as required for variants in free listings, and for variants targeted at Brazil, France, Germany, Japan, the United Kingdom, and the United States. Put the variant's color or size in `color` and `size` when those attributes distinguish the unit. Apparel in those countries has extra required attributes (`color`, `size`, `gender`, `age_group`). Do not guess them. Read them from the catalog or leave the item out of the feed until they exist.
|
|
44
|
+
|
|
45
|
+
## Generated text
|
|
46
|
+
|
|
47
|
+
If a title or description was produced by a generative system, do not put it only in `title` or `description`. Use `structured_title` or `structured_description` and set `digital_source_type` to `trained_algorithmic_media`. The ordinary `title` and `description` attributes are for text that was not created that way. The content still has to match the landing page and stay within 150 and 5000 characters.
|
|
48
|
+
|
|
49
|
+
## Images
|
|
50
|
+
|
|
51
|
+
Merchant Center has announced a minimum image size of 500 by 500 pixels, with enforcement beginning 31 January 2027. Export images that already meet that size instead of waiting for the disapproval. The checker validates the URL, not the pixel size. Check dimensions in the image pipeline.
|
|
52
|
+
|
|
53
|
+
## Gotchas
|
|
54
|
+
|
|
55
|
+
- A formatted price such as `$15.00` or `15,00 USD` is not the `price` attribute. Use `15.00 USD`.
|
|
56
|
+
- Changing `id` when the SKU gains a prefix splits the product in Merchant Center.
|
|
57
|
+
- Out-of-stock items still need a price, and the landing page still needs to show that price.
|
|
58
|
+
- Do not put "free shipping" or a competitor name in `title` or `description`.
|
|
59
|
+
- GTIN is not universally required. Submit a real GTIN when the product has one. Do not invent digits. `identifier_exists` is the attribute Google documents when you do not have a GTIN; confirm the current rule for that category before setting it false.
|
|
60
|
+
|
|
61
|
+
## Verification
|
|
62
|
+
|
|
63
|
+
- The checker exits 0 on the feed file.
|
|
64
|
+
- Each variant id is unique and stable, and shares `item_group_id` with its siblings.
|
|
65
|
+
- A spot-check opens `link` and sees the same price text and availability.
|
|
66
|
+
- A generated description is in `structured_description`, not only in `description`.
|
|
67
|
+
|
|
68
|
+
## Evaluation
|
|
69
|
+
|
|
70
|
+
Worked examples and the checks they must satisfy are in [evals/tasks.json](evals/tasks.json).
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "SHIRT-BLUE",
|
|
3
|
+
"title": "Cotton shirt, blue",
|
|
4
|
+
"description": "Short-sleeve cotton shirt in blue.",
|
|
5
|
+
"link": "https://shop.example/shirt?color=blue",
|
|
6
|
+
"image_link": "https://shop.example/shirt-blue.jpg",
|
|
7
|
+
"availability": "in_stock",
|
|
8
|
+
"price": "19.00 USD",
|
|
9
|
+
"item_group_id": "shirt",
|
|
10
|
+
"color": "blue"
|
|
11
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"tasks": [
|
|
3
|
+
{
|
|
4
|
+
"id": "stable-variant-id",
|
|
5
|
+
"task": "Export the blue shirt variant to Merchant Center with a stable id, a grouped parent, and a decimal price.",
|
|
6
|
+
"expects": {
|
|
7
|
+
"must_include": [
|
|
8
|
+
"item_group_id",
|
|
9
|
+
"19.00 USD"
|
|
10
|
+
],
|
|
11
|
+
"must_not_include": [
|
|
12
|
+
"$19.00"
|
|
13
|
+
]
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"id": "generated-title",
|
|
18
|
+
"task": "Label a generated product title so Merchant Center can tell it from a title a person wrote.",
|
|
19
|
+
"expects": {
|
|
20
|
+
"must_include": [
|
|
21
|
+
"trained_algorithmic_media",
|
|
22
|
+
"structured_title"
|
|
23
|
+
],
|
|
24
|
+
"must_not_include": [
|
|
25
|
+
"free shipping"
|
|
26
|
+
]
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
]
|
|
30
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Merchant Center item attributes
|
|
2
|
+
|
|
3
|
+
Source: Google Merchant Center product data specification. Limits below are the ones that specification states for the common required attributes.
|
|
4
|
+
|
|
5
|
+
## Identity
|
|
6
|
+
|
|
7
|
+
- `id`: at most 50 characters, unique per product, stable across updates, same id for the same product across countries or languages when it is the same unit.
|
|
8
|
+
- `item_group_id`: shared by variants. Required for variant free listings, and for variants in Brazil, France, Germany, Japan, the United Kingdom, and the United States.
|
|
9
|
+
- `gtin`: submit only a real GTIN. The specification strongly recommends it when one exists. Maximum 50 numeric characters, at most 14 digits per value, ignoring added spaces and dashes.
|
|
10
|
+
|
|
11
|
+
## Text
|
|
12
|
+
|
|
13
|
+
- `title`: at most 150 characters. No promotional text such as "free shipping", and no all-capital slogans.
|
|
14
|
+
- `description`: at most 5000 characters. Product information only. No store links, sale copy, or other products.
|
|
15
|
+
- Generated text uses `structured_title` or `structured_description`. Set `digital_source_type` to `trained_algorithmic_media` and put the words in `content`. `default` means the text was not generated. If you omit `digital_source_type`, Google treats it as `default`.
|
|
16
|
+
|
|
17
|
+
## Offer
|
|
18
|
+
|
|
19
|
+
- `price`: `19.00 USD`. Period as the decimal separator. ISO 4217 currency code.
|
|
20
|
+
- `sale_price`: same format, lower than `price`, and only while the landing page shows that sale.
|
|
21
|
+
- `availability`: `in_stock`, `out_of_stock`, `preorder`, `backorder`.
|
|
22
|
+
- `availability_date`: required for `preorder`. Also send it for `backorder`.
|
|
23
|
+
- `link` and `image_link`: absolute `http` or `https` URLs. `link` is the landing page on the verified domain, not an interstitial.
|
|
24
|
+
|
|
25
|
+
## Condition
|
|
26
|
+
|
|
27
|
+
`condition` is required when the product is used or refurbished. Allowed values are `new`, `refurbished`, and `used`. Do not omit `new` on a new product if the target program asks for it; check the current specification for that program rather than guessing.
|
|
28
|
+
|
|
29
|
+
## Checker
|
|
30
|
+
|
|
31
|
+
`scripts/check-feed-item.mjs` checks one JSON object:
|
|
32
|
+
|
|
33
|
+
- required keys `id`, `title` or structured title content, `description` or structured description content, `link`, `image_link`, `availability`, `price`
|
|
34
|
+
- length limits for `id`, `title`, and `description`
|
|
35
|
+
- price pattern and availability vocabulary
|
|
36
|
+
- `availability_date` when availability is `preorder` or `backorder`
|
|
37
|
+
- `https` or `http` URLs
|
|
38
|
+
|
|
39
|
+
It does not upload the item, fetch the image, or know whether a category needs `color` or `gender`.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
|
|
4
|
+
const AVAILABILITY = new Set(["in_stock", "out_of_stock", "preorder", "backorder"]);
|
|
5
|
+
|
|
6
|
+
export function checkFeedItem(item) {
|
|
7
|
+
const errors = [];
|
|
8
|
+
if (!item || typeof item !== "object") {
|
|
9
|
+
return ["Item must be an object."];
|
|
10
|
+
}
|
|
11
|
+
requireText(item.id, "id", 50, errors);
|
|
12
|
+
const title = textValue(item, "title", "structured_title");
|
|
13
|
+
const description = textValue(item, "description", "structured_description");
|
|
14
|
+
if (title === undefined) {
|
|
15
|
+
errors.push("title or structured_title.content is required");
|
|
16
|
+
} else if (title.length > 150) {
|
|
17
|
+
errors.push(`title is ${title.length} characters; the maximum is 150`);
|
|
18
|
+
}
|
|
19
|
+
if (description === undefined) {
|
|
20
|
+
errors.push("description or structured_description.content is required");
|
|
21
|
+
} else if (description.length > 5000) {
|
|
22
|
+
errors.push(`description is ${description.length} characters; the maximum is 5000`);
|
|
23
|
+
}
|
|
24
|
+
requireUrl(item.link, "link", errors);
|
|
25
|
+
requireUrl(item.image_link, "image_link", errors);
|
|
26
|
+
if (!AVAILABILITY.has(item.availability)) {
|
|
27
|
+
errors.push("availability must be in_stock, out_of_stock, preorder, or backorder");
|
|
28
|
+
}
|
|
29
|
+
if ((item.availability === "preorder" || item.availability === "backorder") && !nonEmpty(item.availability_date)) {
|
|
30
|
+
errors.push("availability_date is required for preorder and backorder");
|
|
31
|
+
}
|
|
32
|
+
if (typeof item.price !== "string" || !/^\d+\.\d{2} [A-Z]{3}$/.test(item.price)) {
|
|
33
|
+
errors.push('price must look like "15.00 USD"');
|
|
34
|
+
}
|
|
35
|
+
if (item.gtin !== undefined && !/^\d{8,14}$/.test(String(item.gtin))) {
|
|
36
|
+
errors.push("gtin must be 8 to 14 digits when present");
|
|
37
|
+
}
|
|
38
|
+
return errors;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function textValue(item, plainKey, structuredKey) {
|
|
42
|
+
if (nonEmpty(item[plainKey])) {
|
|
43
|
+
return String(item[plainKey]);
|
|
44
|
+
}
|
|
45
|
+
const structured = item[structuredKey];
|
|
46
|
+
if (structured && nonEmpty(structured.content)) {
|
|
47
|
+
return String(structured.content);
|
|
48
|
+
}
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function requireText(value, label, max, errors) {
|
|
53
|
+
if (!nonEmpty(value)) {
|
|
54
|
+
errors.push(`${label} is required`);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (String(value).length > max) {
|
|
58
|
+
errors.push(`${label} is ${String(value).length} characters; the maximum is ${max}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function requireUrl(value, label, errors) {
|
|
63
|
+
if (typeof value !== "string" || !/^https?:\/\/\S+$/.test(value)) {
|
|
64
|
+
errors.push(`${label} must be an http or https URL`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function nonEmpty(value) {
|
|
69
|
+
return typeof value === "string" && value.trim() !== "";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function readOption(name) {
|
|
73
|
+
const index = process.argv.indexOf(name);
|
|
74
|
+
return index === -1 ? undefined : process.argv[index + 1];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const isCli = process.argv[1] && process.argv[1].endsWith("check-feed-item.mjs");
|
|
78
|
+
if (isCli) {
|
|
79
|
+
const file = readOption("--file");
|
|
80
|
+
if (!file) {
|
|
81
|
+
console.error("Usage: node check-feed-item.mjs --file item.json");
|
|
82
|
+
process.exit(2);
|
|
83
|
+
}
|
|
84
|
+
const errors = checkFeedItem(JSON.parse(await readFile(file, "utf8")));
|
|
85
|
+
if (errors.length > 0) {
|
|
86
|
+
for (const error of errors) {
|
|
87
|
+
console.error(`ERROR ${error}`);
|
|
88
|
+
}
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
console.log("Feed item passed the local checks.");
|
|
92
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: headless-nextjs-storefront
|
|
3
|
+
description: "Build a Next.js storefront that loads catalog data on the server, keeps admin secrets off the client, and revalidates price and stock. Use when adding product, cart, or checkout UI in a Next.js App Router storefront for WooCommerce or another headless commerce API."
|
|
4
|
+
license: MIT
|
|
5
|
+
metadata:
|
|
6
|
+
author: Naser Sadrmanesh
|
|
7
|
+
version: "0.3.0"
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Headless Next.js storefront
|
|
11
|
+
|
|
12
|
+
Use this skill for a Next.js storefront that talks to a commerce API. WooCommerce HTTP details are in the REST API skill. Shopify admin apps are a different skill. Checkout field behavior is the checkout skill.
|
|
13
|
+
|
|
14
|
+
This skill assumes the App Router (`app/`). Do not pin a Next.js major version you have not installed. Use the cache and route APIs that version documents.
|
|
15
|
+
|
|
16
|
+
## Procedure
|
|
17
|
+
|
|
18
|
+
1. Render product and category pages from the server. The HTML should contain the price and availability a shopper and a crawler see.
|
|
19
|
+
2. Keep admin credentials and WooCommerce consumer secrets in server environment variables. Never import them into a client component.
|
|
20
|
+
3. Use the public storefront API for the cart. For WooCommerce that is the Store API (`/wp-json/wc/store/v1/`), with its cart token and nonce. It is not `wc/v3`.
|
|
21
|
+
4. Cache catalog reads. Invalidate that cache when price or stock changes. Do not cache a cart response as if it were a public product.
|
|
22
|
+
5. Re-check the charged total on the server when checkout starts. The figure in React state is not the amount to capture.
|
|
23
|
+
|
|
24
|
+
Cache and cart boundaries are in [the cache notes](references/cache-and-cart.md). Worked examples and checks are in [evals/tasks.json](evals/tasks.json).
|
|
25
|
+
|
|
26
|
+
## Data split
|
|
27
|
+
|
|
28
|
+
| Data | Where it is read | Cache |
|
|
29
|
+
| --- | --- | --- |
|
|
30
|
+
| Product title, price, stock, images | Server component or a route handler | Public cache with a tag such as `product:<id>` |
|
|
31
|
+
| Cart and checkout session | Server route that forwards the shopper's cart token | Do not use the public product cache |
|
|
32
|
+
| Admin writes | Server only, with the admin credential | Do not expose the route to the browser without your own auth |
|
|
33
|
+
|
|
34
|
+
Call `revalidateTag('product:<id>')` or `revalidatePath` from the server path that handles the catalog webhook. A client component cannot import `next/cache` revalidation and run it in the browser.
|
|
35
|
+
|
|
36
|
+
## WooCommerce storefront
|
|
37
|
+
|
|
38
|
+
- Product pages may use a read-only integration on the server. Prefer a key that cannot write, or a public product endpoint your store already exposes.
|
|
39
|
+
- Cart requests send the `Cart-Token` the Store API returned. Store that token in an HTTP-only cookie set by your server route, not in `localStorage`, if the token would let someone else modify the cart.
|
|
40
|
+
- The browser calls your Next.js route. Your route calls WooCommerce. Do not point the browser at `wc/v3` with a consumer secret.
|
|
41
|
+
|
|
42
|
+
## Shopify storefront
|
|
43
|
+
|
|
44
|
+
- Use the Storefront API for product and cart reads. The Storefront access token is still a secret if it can create checkouts. Follow the token type Shopify documents for that API.
|
|
45
|
+
- Do not send the Admin API token to the browser.
|
|
46
|
+
|
|
47
|
+
## Rendering
|
|
48
|
+
|
|
49
|
+
- Format money with the storefront locale on the server so the HTML and the JSON-LD match. Persian digits belong in visible text, not in the amount you post to checkout. See the RTL skill.
|
|
50
|
+
- Variant selection updates the visible price to that variant before add-to-cart.
|
|
51
|
+
- Out-of-stock variants stay disabled even if a cached page is briefly stale. The add-to-cart route checks stock again.
|
|
52
|
+
|
|
53
|
+
## Gotchas
|
|
54
|
+
|
|
55
|
+
- `cache: 'force-cache'` on a cart GET will show one shopper another shopper's cart if the URL does not vary. Do not cache cart responses on a shared key.
|
|
56
|
+
- A webhook that updates stock must revalidate the product tag. Restarting the Next.js process is not an invalidation strategy.
|
|
57
|
+
- Client-side `fetch` to a route that embeds the admin secret in the response will leak it into the browser. Return only the fields the UI needs.
|
|
58
|
+
- Draft-mode or preview cookies should not be cached as public product HTML.
|
|
59
|
+
|
|
60
|
+
## Verification
|
|
61
|
+
|
|
62
|
+
- The built HTML for a product contains the price without requiring client JavaScript.
|
|
63
|
+
- Searching the client bundle finds no consumer secret and no Admin API token.
|
|
64
|
+
- Updating a price and delivering the catalog webhook changes the next product response without a redeploy.
|
|
65
|
+
- Two browsers do not share a cart token.
|
|
66
|
+
|
|
67
|
+
## Evaluation
|
|
68
|
+
|
|
69
|
+
Worked examples and the checks they must satisfy are in [evals/tasks.json](evals/tasks.json).
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"tasks": [
|
|
3
|
+
{
|
|
4
|
+
"id": "server-price",
|
|
5
|
+
"task": "Render a WooCommerce product price in a Next.js server component without shipping the consumer secret to the browser.",
|
|
6
|
+
"expects": {
|
|
7
|
+
"must_include": [
|
|
8
|
+
"server",
|
|
9
|
+
"consumer secret"
|
|
10
|
+
],
|
|
11
|
+
"must_not_include": [
|
|
12
|
+
"localStorage"
|
|
13
|
+
]
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"id": "revalidate-stock",
|
|
18
|
+
"task": "Refresh a cached product page when a verified webhook says the variant stock changed.",
|
|
19
|
+
"expects": {
|
|
20
|
+
"must_include": [
|
|
21
|
+
"revalidateTag",
|
|
22
|
+
"product:"
|
|
23
|
+
],
|
|
24
|
+
"must_not_include": [
|
|
25
|
+
"force-cache"
|
|
26
|
+
]
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
]
|
|
30
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Cache and cart boundaries
|
|
2
|
+
|
|
3
|
+
## Product cache
|
|
4
|
+
|
|
5
|
+
Tag each product fetch with a stable id:
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
const response = await fetch(productUrl, {
|
|
9
|
+
next: { tags: [`product:${id}`] },
|
|
10
|
+
});
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The exact `fetch` cache options depend on the installed Next.js version. Read that version's data-cache docs before copying the option names. The rule that does not change: a public product response may be cached; a cart response may not share that cache entry.
|
|
14
|
+
|
|
15
|
+
Invalidate from a server route that has already verified the catalog webhook:
|
|
16
|
+
|
|
17
|
+
```js
|
|
18
|
+
import { revalidateTag } from "next/cache";
|
|
19
|
+
|
|
20
|
+
revalidateTag(`product:${id}`);
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Revalidate the parent and each variant tag when a variable product changes. Revalidating only the parent leaves a variant URL stale.
|
|
24
|
+
|
|
25
|
+
## Cart route
|
|
26
|
+
|
|
27
|
+
```text
|
|
28
|
+
Browser -> Next.js route -> Store API
|
|
29
|
+
| |
|
|
30
|
+
HTTP-only cart cookie Cart-Token header
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The route reads the cookie, calls the Store API, and returns the cart JSON the UI needs: lines, totals, and messages. It does not return the commerce admin credential.
|
|
34
|
+
|
|
35
|
+
Set the cookie with `HttpOnly`, `Secure`, and `SameSite=Lax` (or `Strict` if your checkout flow stays on the same site). `SameSite=None` requires `Secure` and is only for a checkout that is actually cross-site.
|
|
36
|
+
|
|
37
|
+
## Checkout handoff
|
|
38
|
+
|
|
39
|
+
When the shopper pays, create or update the order on the server and pass the server-calculated total to the payment skill. If the Store API total and your cached product prices disagree, trust the cart or order total from the commerce server for the charge, then revalidate the product.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: inventory-and-multi-warehouse
|
|
3
|
+
description: "Track stock per location with on-hand, reserved, and available quantities, reserve at checkout, and allocate orders across warehouses without overselling. Use when adding warehouses or stores as stock locations, reservations, transfers, or order routing."
|
|
4
|
+
license: MIT
|
|
5
|
+
compatibility: "The allocation checker needs Node.js 20 or newer and no packages."
|
|
6
|
+
metadata:
|
|
7
|
+
author: Naser Sadrmanesh
|
|
8
|
+
version: "0.3.0"
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Inventory and multi-warehouse
|
|
12
|
+
|
|
13
|
+
A single stock number per product stops working once there are two places that ship, or a checkout that holds stock while the shopper pays. Model quantities per location and per state, and change them only through movements. Platform notes are in [the platform notes](references/platforms.md).
|
|
14
|
+
|
|
15
|
+
## Procedure
|
|
16
|
+
|
|
17
|
+
1. Key stock by purchasable unit (SKU or variation) **and** location. A product with sizes has one row per size per location.
|
|
18
|
+
2. Keep separate quantities: `on_hand` (physically there), `reserved` (held for unpaid or unshipped orders), and `available = on_hand - reserved - safety_stock`. Sell only from `available`.
|
|
19
|
+
3. Change stock only by writing a movement row: receipt, sale reservation, release, shipment, return, adjustment, or transfer. The current quantity is the sum of movements, or a cached total updated in the same transaction.
|
|
20
|
+
4. Reserve at order creation with a conditional update, such as `UPDATE stock SET reserved = reserved + :qty WHERE sku = :sku AND location = :loc AND on_hand - reserved - safety_stock >= :qty`. Zero rows updated means not enough stock.
|
|
21
|
+
5. Give each reservation an expiry. Release it when payment fails or the hold expires. Convert it to a shipment when the order ships.
|
|
22
|
+
6. Allocate an order to locations with an explicit policy, then reserve at those locations. Store the allocation on the order lines.
|
|
23
|
+
|
|
24
|
+
## Allocation
|
|
25
|
+
|
|
26
|
+
A common policy, in order:
|
|
27
|
+
|
|
28
|
+
1. Only locations that can ship to the destination and are active.
|
|
29
|
+
2. One location that can fill every line, the closest or cheapest first.
|
|
30
|
+
3. Otherwise split by line across the fewest locations.
|
|
31
|
+
4. Otherwise back-order or reject the line, as the store policy says. Never reserve more than `available`.
|
|
32
|
+
|
|
33
|
+
Run `node scripts/allocate-stock.mjs order.json stock.json` to test a policy on sample data. It returns the allocation per line and any shortfall, and never allocates more than a location has available.
|
|
34
|
+
|
|
35
|
+
## Transfers
|
|
36
|
+
|
|
37
|
+
A transfer is two movements: out of the source when it ships, and into the destination when it is received. Stock in transit is in neither `available` figure. Record `incoming` at the destination if buyers need to see it.
|
|
38
|
+
|
|
39
|
+
## Counts and adjustments
|
|
40
|
+
|
|
41
|
+
A stock count writes an adjustment movement for the difference, with a reason and the user. Do not overwrite `on_hand` directly, or the history and any reservations made during the count are lost.
|
|
42
|
+
|
|
43
|
+
## Gotchas
|
|
44
|
+
|
|
45
|
+
- Reading available stock and then writing it in a second statement oversells under load. Check and reserve in one conditional update or a row lock.
|
|
46
|
+
- Decrementing `on_hand` at checkout and again at shipment counts the sale twice. Reserve at checkout, then move reserved to shipped.
|
|
47
|
+
- A parent product's total is not a sellable quantity. Sell from the variation's row.
|
|
48
|
+
- Safety stock that is not subtracted from `available` lets the last units sell online while the store floor still shows them.
|
|
49
|
+
- Returns go back into stock only after inspection. Use a separate location or state such as `quality_control` until then.
|
|
50
|
+
|
|
51
|
+
## Verification
|
|
52
|
+
|
|
53
|
+
- Two concurrent checkouts for the last unit: one reserves, the other is refused.
|
|
54
|
+
- An expired unpaid reservation returns its quantity to `available`.
|
|
55
|
+
- An order for two lines that one warehouse can fill is not split.
|
|
56
|
+
- A transfer in transit does not appear as available at either location.
|
|
57
|
+
- The movement log sums to the current `on_hand` for every SKU and location.
|
|
58
|
+
|
|
59
|
+
## Evaluation
|
|
60
|
+
|
|
61
|
+
Worked examples and the checks they must satisfy are in [evals/tasks.json](evals/tasks.json).
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
Keep `on_hand` and `reserved` per SKU and location, and compute `available` as on hand minus reserved minus safety stock.
|
|
2
|
+
|
|
3
|
+
Reserve with one conditional update: `UPDATE stock_levels SET reserved = reserved + 1 WHERE sku = 'MUG-RED' AND location_id = 'main' AND on_hand - reserved - safety_stock >= 1`. The first checkout updates one row and continues. The second updates zero rows and is told the item is out of stock. Insert a `reserve` movement in the same transaction, and release it if payment fails.
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
The policy fills the order from one location when any eligible location can ship every line, because a split doubles shipping cost and parcels. Warehouse A can, so the allocation is both lines from A, even though B is closer.
|
|
2
|
+
|
|
3
|
+
Only when no single location can fill the order do we split, using the fewest locations and preferring ones already used. Store the allocation on each order line and reserve at A in the same transaction.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"tasks": [
|
|
3
|
+
{
|
|
4
|
+
"id": "reserve-last-unit",
|
|
5
|
+
"task": "Two shoppers check out the last unit of a SKU at the same time. Write the stock reservation so only one succeeds.",
|
|
6
|
+
"expects": {
|
|
7
|
+
"must_include": ["reserved", "available", "conditional update"],
|
|
8
|
+
"must_not_include": ["SELECT stock_quantity then UPDATE"]
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"id": "split-order",
|
|
13
|
+
"task": "An order has two lines. Warehouse A has both items, warehouse B is closer but has only one. Decide the allocation and explain the policy.",
|
|
14
|
+
"expects": {
|
|
15
|
+
"must_include": ["one location", "split", "allocation"],
|
|
16
|
+
"must_not_include": ["always split"]
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Platform notes
|
|
2
|
+
|
|
3
|
+
## WooCommerce
|
|
4
|
+
|
|
5
|
+
WooCommerce core keeps one `stock_quantity` per product or variation, with `manage_stock` and `stock_status`. A variation with `manage_stock` set to `parent` uses the parent's quantity. Core has no locations. Multi-location stock comes from an extension or an external inventory system that writes the summed `available` back to WooCommerce.
|
|
6
|
+
|
|
7
|
+
- Core reduces stock when an order is paid or moves to processing, completed, or on hold. During checkout it reserves stock for pending orders, and the **Hold stock** setting cancels unpaid pending orders after that many minutes.
|
|
8
|
+
- When an external system is the source of truth, turn off core stock reduction for those products or the store will reduce twice.
|
|
9
|
+
- Write stock through the REST API `products/{id}` or `products/{id}/variations/{id}` with `stock_quantity`, or the batch endpoint for many items.
|
|
10
|
+
|
|
11
|
+
## Shopify
|
|
12
|
+
|
|
13
|
+
Shopify tracks quantities per inventory item per location with named states, including `available`, `committed`, `incoming`, `on_hand`, and `reserved`. Change them with the Admin GraphQL inventory mutations, such as `inventoryAdjustQuantities` for deltas, and give every change a reason. Fulfillment orders assign order lines to locations, and a merchant can move them.
|
|
14
|
+
|
|
15
|
+
## Your own system
|
|
16
|
+
|
|
17
|
+
A minimal schema:
|
|
18
|
+
|
|
19
|
+
```sql
|
|
20
|
+
CREATE TABLE stock_levels (
|
|
21
|
+
sku TEXT NOT NULL,
|
|
22
|
+
location_id TEXT NOT NULL,
|
|
23
|
+
on_hand INTEGER NOT NULL DEFAULT 0 CHECK (on_hand >= 0),
|
|
24
|
+
reserved INTEGER NOT NULL DEFAULT 0 CHECK (reserved >= 0),
|
|
25
|
+
safety_stock INTEGER NOT NULL DEFAULT 0,
|
|
26
|
+
PRIMARY KEY (sku, location_id)
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
CREATE TABLE stock_movements (
|
|
30
|
+
id BIGSERIAL PRIMARY KEY,
|
|
31
|
+
sku TEXT NOT NULL,
|
|
32
|
+
location_id TEXT NOT NULL,
|
|
33
|
+
kind TEXT NOT NULL, -- receipt, reserve, release, ship, return, adjust, transfer_out, transfer_in
|
|
34
|
+
quantity INTEGER NOT NULL, -- signed
|
|
35
|
+
reference TEXT, -- order, transfer, or count id
|
|
36
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
37
|
+
);
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Update `stock_levels` and insert the movement in one transaction.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Allocate order lines to stock locations: one location if possible, otherwise the fewest.
|
|
3
|
+
// Usage: node allocate-stock.mjs <order.json> <stock.json>
|
|
4
|
+
// order.json: { "lines": [{ "sku": "TEE-M", "quantity": 2 }], "destination": "DE" }
|
|
5
|
+
// stock.json: [{ "location": "berlin", "ships": ["DE", "AT"], "rank": 1, "levels": { "TEE-M": { "onHand": 5, "reserved": 1, "safetyStock": 0 } } }]
|
|
6
|
+
import { readFile } from "node:fs/promises";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
|
|
10
|
+
export function availableAt(location, sku) {
|
|
11
|
+
const level = location.levels?.[sku];
|
|
12
|
+
if (!level) return 0;
|
|
13
|
+
return Math.max(0, (level.onHand ?? 0) - (level.reserved ?? 0) - (level.safetyStock ?? 0));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Returns { allocations: [{ sku, location, quantity }], shortfalls: [{ sku, quantity }], locations }.
|
|
18
|
+
* Never allocates more than a location has available.
|
|
19
|
+
*/
|
|
20
|
+
export function allocateOrder(order, stock) {
|
|
21
|
+
const eligible = stock
|
|
22
|
+
.filter((location) => location.active !== false)
|
|
23
|
+
.filter((location) => !order.destination || !location.ships || location.ships.includes(order.destination))
|
|
24
|
+
.sort((a, b) => (a.rank ?? 0) - (b.rank ?? 0));
|
|
25
|
+
const lines = order.lines ?? [];
|
|
26
|
+
for (const line of lines) {
|
|
27
|
+
if (!Number.isInteger(line.quantity) || line.quantity <= 0) throw new Error(`${line.sku}: quantity must be a positive whole number`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const single = eligible.find((location) => lines.every((line) => availableAt(location, line.sku) >= line.quantity));
|
|
31
|
+
if (single) {
|
|
32
|
+
const allocations = lines.map((line) => ({ sku: line.sku, location: single.location, quantity: line.quantity }));
|
|
33
|
+
return { allocations, shortfalls: [], locations: [single.location] };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const remaining = new Map(eligible.map((location) => [location.location, { ...location.levels }]));
|
|
37
|
+
const allocations = [];
|
|
38
|
+
const shortfalls = [];
|
|
39
|
+
const used = new Set();
|
|
40
|
+
for (const line of lines) {
|
|
41
|
+
let needed = line.quantity;
|
|
42
|
+
// Prefer locations already used for this order, then by rank.
|
|
43
|
+
const ordered = [...eligible].sort((a, b) => Number(used.has(b.location)) - Number(used.has(a.location)));
|
|
44
|
+
for (const location of ordered) {
|
|
45
|
+
if (needed === 0) break;
|
|
46
|
+
const levels = remaining.get(location.location);
|
|
47
|
+
const free = availableAt({ levels }, line.sku);
|
|
48
|
+
if (free === 0) continue;
|
|
49
|
+
const take = Math.min(free, needed);
|
|
50
|
+
allocations.push({ sku: line.sku, location: location.location, quantity: take });
|
|
51
|
+
levels[line.sku] = { ...levels[line.sku], reserved: (levels[line.sku].reserved ?? 0) + take };
|
|
52
|
+
used.add(location.location);
|
|
53
|
+
needed -= take;
|
|
54
|
+
}
|
|
55
|
+
if (needed > 0) shortfalls.push({ sku: line.sku, quantity: needed });
|
|
56
|
+
}
|
|
57
|
+
return { allocations, shortfalls, locations: [...used] };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const isCli = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
61
|
+
if (isCli) {
|
|
62
|
+
const [orderFile, stockFile] = process.argv.slice(2);
|
|
63
|
+
if (!orderFile || !stockFile) {
|
|
64
|
+
console.error("Usage: node allocate-stock.mjs <order.json> <stock.json>");
|
|
65
|
+
process.exit(2);
|
|
66
|
+
}
|
|
67
|
+
const order = JSON.parse(await readFile(orderFile, "utf8"));
|
|
68
|
+
const stock = JSON.parse(await readFile(stockFile, "utf8"));
|
|
69
|
+
const result = allocateOrder(order, stock);
|
|
70
|
+
console.log(JSON.stringify(result, null, 2));
|
|
71
|
+
process.exit(result.shortfalls.length > 0 ? 1 : 0);
|
|
72
|
+
}
|