尚未收集儲存庫資訊。
尚未偵測到更新。GitHub 發布版本後會顯示在這裡。
製作者的介紹僅有上面的一句話。 更多詳情請閱讀儲存庫 README。 儲存庫 README ↗
正在載入完整 README。
檢視完整 README ↗Next.js app for Voltship clients. Supabase is login, Postgres, and file storage. Airtable remains the source of truth for products and sourcing.
See prd.md and architecture.md.
next-intl)product-uploads bucket)anon key, and service_role key.cp .env.example .env.local
Fill in:
NEXT_PUBLIC_SUPABASE_URLNEXT_PUBLIC_SUPABASE_ANON_KEYSUPABASE_SERVICE_ROLE_KEY (server only — never expose to the browser)In the Supabase SQL editor, run in order:
supabase/migrations/00001_init.sql — clients, profiles, RLS, storagesupabase/migrations/00002_app_tables.sql — caches, audit, impersonation, rate gridsupabase/migrations/00003_impersonation_nullable.sql — view-as-client before the first invitesupabase/migrations/00004_phase1_foundation.sql — pricing delivery ranges, sourcing work, Shopify caches and atomic sales updatessupabase/migrations/00005_orders_cache_rls.sql — RLS on shopify_orders_cachesupabase/migrations/00006_shopify_images.sql — Shopify product imagessupabase/migrations/00007_rate_grid_ai.sql — carrier line / tax flag / RMB cost on rate cells, rate_grid_imports, pricing_settingssupabase/migrations/00008_eccang.sql — ECCANG WMS integration (per-client keys, orders, stock, ASN)supabase/migrations/00009_matrix_ioss_lines.sql — rate_grid_cells.ioss_required, line_name in the cell unique key (several lines per carrier), supplement defaulted to 0Authentication → Users → Add user (email + password).
Then in SQL (replace the email):
update public.profiles
set role = 'voltship_admin', client_id = null
where email = '[email protected]';
If the profile row is missing (trigger didn’t fire), insert it using the user’s UUID from Authentication.
Also set Auth redirect URLs:
http://localhost:3000/auth/callbackhttps://app.voltship.com/auth/callbacknpm install
npm run dev
Open http://localhost:3000 → client login at /login.
/login then /dashboard (tenant-scoped, no admin access)/staff/login then /admin or /sourcerFrench UI: http://localhost:3000/fr/login. Staff: http://localhost:3000/staff/login.
Invite a user from the client page. If you leave the password blank, the admin screen shows a generated password once — give it to the client so they can sign in.
External integrations activate when their server-only values from .env.example are
configured. Without Airtable or n8n credentials, local development keeps requests in
a visible pending/fallback state rather than exposing secrets to the browser.
/admin/pricing/update ("Mettre à jour les tarifs") lets a Voltship admin turn a carrier
price list into a new versioned rate grid through one of two paths:
China_Carriers_Matrix_Vxx.xlsx
("TOUT COMPRIS"): one sheet per DESTINATION-CATEGORY (FRANCE-STANDARD,
USA-ELECTRONICS…), a header block (Carrier / Tier / Service / Delivery /
Size limits / Weight (g) rows) and gram-by-gram prices in RMB per carrier column.
src/lib/pricing/matrix-import.ts (parseVoltshipMatrix) is used when the
"Matrice Voltship (import direct)" box is ticked or a sheet name matches the convention.
Rules: sheet name → ISO-2 destination + shipping_channel (CLOTHING/TEXTILE → standard,
note "textile"); carrier from the header text (Tongyou/通邮/TK, YunExpress/云途/CHC,
Huahan/华翰/智, 4PX/递四方/O5/JW); line_name = Carrier + tier without the
"(direct 24/09)" / "[tout compris]" notes; delivery "9-14 d" → "9-14 j", "wd"/"工作日" →
"j ouvrés"; columns marked OBSOLÈTE/old skipped; gram rows compressed into brackets then
normalised to the engine's 50 g steps (500 g above 2 kg) priced at the bracket's max
gram (never under-bills); blank cells = not offered; "max 1 kg" in the notes caps the
line. Every price is the final carrier cost per parcel: all lines are
tax_included = true, vat_extra = false, no supplement is ever added and every carrier
competes for the cheapest pick. ioss_required (informational badge "IOSS requis", never
a cost) is set when the notes say "IOSS obligatoire" / "TVA via IOSS" or the line is
YunExpress CHC / 4PX EU (S5667, S5664, S5682, 欧盟专线). Two lines of one carrier on a
sheet (USA YunExpress THPHR vs 商派 YTSPTHPH) are imported as distinct cells —
line_name is part of the cell key — and findRateCell picks the cheaper.
"Changement de palier (normal)" notices flag a price that drops at a tier boundary; they
never block activation. The draft is stored with carrier = "matrix" (multi-carrier).Environment:
ANTHROPIC_API_KEY=sk-ant-… # required; without it the actions return
# "Assistant tarifs non configuré (ANTHROPIC_API_KEY)"
ANTHROPIC_MODEL=claude-sonnet-4-5 # optional
Flow:
startRateImportAction sends the files (≤ 3 × 8 MB; images and PDFs as
native blocks, CSV/text inline) plus the carrier and a note ("Grille VIP du 24/09") to
Claude with a JSON-only contract (.xlsx/.xls/.csv are converted server-side to a text
table per sheet by src/lib/pricing/spreadsheet-text.ts; gram-by-gram sheets longer than
400 rows are sampled every 50 g instead of truncated) (src/lib/pricing/ai-import.ts,
PRICE_LIST_JSON_SCHEMA). The reply is normalised (ISO-2 destinations, category →
shipping_channel, per-kg+fee vs. tabular pricing, tax_included, vat_extra,
confidence 0–1) and stored as a rate_grid_imports draft.reviewRateImportAction recomputes the cells and
a before/after comparison (cheapest carrier at 100 / 250 / 500 / 1000 g per destination
and channel against the active grid).activateRateImportAction writes rate_grids (source = 'ai_import' or
'matrix_import', version V13-2026-10-04-tongyou / -matrix, settings snapshot) with
the imported carriers' cells plus every other carrier's cells carried forward from the
active grid (mergeGridCells: every carrier covered by the import is replaced, the rest
is kept), sets
pricing_meta.active_grid_version, marks the import activated and logs
pricing.grid_activated in audit_log. Lines with confidence < 0.5 need an explicit
confirmation. Accepted client quotes stay frozen.Margin rule (pricing_meta.pricing_settings, editable from the review screen):
cost_eur = carrier_cost_rmb / fx_rmb_per_eur (default 7.50)
price = round_0.05( cost_eur
+ max(cost_eur × margin_pct / 100, (default 12 %)
min_margin_eur_per_parcel) (default 1.50 €)
+ (tax_included ? 0 : eu_parcel_tax_eur) ) (default 0 — "Supplément
manuel par colis", assistant
imports only)
Weight tiers: per-kg lines are expanded in 50 g steps to 2 000 g then 500 g steps to 5 000 g (priced at the bracket's max weight; USA minimum billable 50 g); tabular sources keep their brackets. For assistant imports, "VAT on declared value extra" lines (Huahan 智惠选) are excluded by default; matrix imports never exclude a line.
Billed weight (src/lib/domain/pricing.ts): when the product has dimensions in
quote_json (length_cm / width_cm / height_cm, the same keys as the ECCANG mapping),
billed_g = max(actual_g × n, n × L×W×H / divisor × 1000) with the divisor per carrier from
pricing_settings.volumetric_divisors (default Huahan 6000, others 8000) — each carrier is
matched on its own billed weight; USA parcels bill at least 50 g; without dimensions the
actual weight is used. The quote detail and the COGS matrix tooltip show "poids facturé"
when it differs from the actual weight. Every quote shown to a client displays carrier + line, weight tier, parcel price
and delivery range taken from the grid cell.
Clients connect their own store through Shopify OAuth — there is no per-store custom app.
SHOPIFY_API_KEY / SHOPIFY_API_SECRET.SHOPIFY_SCOPES to the scope list from .env.example (it must match the scopes
declared on the app).NEXT_PUBLIC_APP_URL +
/api/shopify/callback (production: https://app.voltshiplogistics.com/api/shopify/callback).
Shopify rejects the install if the two differ, including trailing slashes or http/https.customers/data_request, customers/redact,
shop/redact) in the app configuration pointing at
NEXT_PUBLIC_APP_URL/api/webhooks/shopify/compliance. These topics cannot be subscribed
from the Admin API; order webhooks are registered automatically at connect time.How a client connects: Settings → “Boutique Shopify” → enter monstore.myshopify.com →
“Connecter ma boutique”. The browser goes to /api/shopify/connect?from=client&shop=…, which
redirects to Shopify's consent screen, then back to /api/shopify/callback. The callback
verifies the HMAC and signed state, stores the encrypted offline token, registers order
webhooks, backfills 90 days of orders and the catalogue, and returns to /settings?connected=1.
Owners only (staff cannot connect or disconnect); max 10 stores per client. Admins can
connect a store on behalf of a client from /admin/shops (from=admin&client_id=…); the
legacy “custom app already installed” path remains available with &mode=installed.
Go-live 8 October 2026. The integration is flag-gated per client: with
clients.eccang_enabled = false (default) nothing changes. Migration: supabase/migrations/00008_eccang.sql.
Per-client keys. ECCANG issues one appKey / appToken per end customer (no global key).
Admin → Clients → client → card Entrepôt (ECCANG): paste the appKey and appToken (the token
is AES-GCM encrypted with ECCANG_TOKEN_ENCRYPTION_KEY, falling back to
SHOPIFY_TOKEN_ENCRYPTION_KEY, and never returned to the browser), click Tester la connexion
(getWarehouse + getShippingMethod), pick the warehouse code, tick Activer, save. Then
Pousser tous les produits validés creates the catalogue in ECCANG and Synchroniser
maintenant pulls stock, orders and inbound notices. ECCANG_API_HOST is a single server env
(one OMS instance).
Shipping-method mapping (same card, global table in pricing_meta.eccang_shipping_methods):
Carrier or Carrier|Line as written in the active rate grid → ECCANG shipping_method code
from getShippingMethod. default / default:<ISO2> act as fallbacks. For every Shopify order the
pricing engine picks the carrier/line for the destination + channel of the product, then the map
gives the ECCANG code. Without a match the order is not pushed and the client gets an in-app
notification.
Flows (src/lib/eccang/sync.ts).
| Trigger | ECCANG service | Writes |
|---|---|---|
| Quote accepted / "Pousser…" | createProduct (then modifyProduct if the SKU exists) | products_cache.quote_json._eccang |
Shopify orders/create webhook (client enabled, SKUs known in sku_maps / products_cache) | createOrder with reference_no = VS-<client code>-<order number> | eccang_orders |
Callback order / cron | getOrderByRefCode → status, tracking, carrier, order_weight (= poids facturé, kg → g), fees; then Shopify fulfillmentCreate with the tracking | eccang_orders, Shopify fulfillment |
| "Lancer un restock" with a quantity | createAsn (VSIN-<code>-<date>-<id>) | inbound_cache |
Callback receiving / cron | getAsnList → announced / arrived / contrôlé / en stock | inbound_cache |
Callback stock / cron | getProductInventory (warehouse) → sellable, reserved, onway + pending | stock_cache (feeds the restock alerts) |
Callback URL to declare in the ECCANG OMS (消息订阅 → 订阅回调, types order, receiving,
stock):
https://app.voltshiplogistics.com/api/webhooks/eccang?token=<ECCANG_WEBHOOK_SECRET>
ECCANG validates the URL with GET ?random=<n> (the route echoes {"random":"<n>"}). POST
payloads are stored in webhook_events (idempotent on msg_id), answered immediately, then the
latest state is pulled with the services above. The tenant is resolved from the app_key in the
body.
Cron /api/cron/eccang-sync every 15 min (vercel.json, deploy/crontab): for each enabled
client → inventory + pending orders + ASNs; clients.eccang_last_sync_at /
eccang_sync_error are shown on the admin card and in the admin "Points d'attention" (sync
errors, orders pushed > 48 h without tracking).
Migration : supabase/migrations/00011_support.sql (ajoute order_number, placed_at,
customer_email_key à shopify_orders_cache, les tables api_keys, support_mailboxes,
support_threads, support_messages, support_drafts). Les commandes importées avant la
migration n'ont pas de order_number : relancer un backfill (reconnecter la boutique ou
attendre les webhooks orders/updated).
/api/public/v1)Pour les outils SAV externes (Repline, Gorgias, Zendesk…). Clé créée par le owner dans Réglages → « API & intégrations SAV » (affichée une seule fois ; seul le SHA-256 est stocké).
GET /api/public/v1/healthGET /api/public/v1/orders/{lookup} — lookup = #1234, 1234, VS-ACME-1234
(référence entrepôt), id Shopify ou uuid du cache. ?email= optionnel : second facteur
(comparé au hash, jamais renvoyé).GET /api/public/v1/orders?order=1234&email=…Authorization: Bearer vs_live_… → 401 clé invalide/révoquée · 404 commande
inconnue · 429 au-delà de 60 req/min par clé.curl -H "Authorization: Bearer vs_live_…" \
"https://app.voltshiplogistics.com/api/public/v1/orders/1234"
Réponse : { order: { number, placed_at, shop }, fulfillment: { status, shipped_at, carrier, service, tracking_number, tracking_url, billed_weight_g, last_event_at }, items: [{ sku, title, qty }], warehouse: { reference } }. status ∈ received · preparing · shipped ·
delivered · cancelled · unknown (mapping ECCANG : C/pending → received, W/H → preparing,
D → shipped, X → cancelled, N/P → unknown ; delivered est réservé aux événements
transporteur, non suivis pour l'instant).
Le rate-limit est un token bucket en mémoire : sur Vercel il est par instance (60/min par clé et par instance chaude) — suffisant pour freiner une intégration qui boucle, à remplacer par Upstash/Redis si une limite globale stricte devient nécessaire.
Chaque client connecte sa boîte Gmail support (owner uniquement, Réglages → « SAV · Boîte
Gmail »). Toutes les 10 min le cron lit les nouveaux messages (7 derniers jours à la première
synchro, puis history.list), les range en tickets (/support), identifie la commande
(numéro #1234 / référence VS-… dans le mail, sinon la commande la plus récente du même
e-mail client — comparé par hash, l'adresse n'est jamais stockée en clair) et, si
ANTHROPIC_API_KEY est défini, propose une réponse FR/EN dans la langue du client avec le
statut, le transporteur, le lien de suivi et le délai du devis. Rien ne part sans le clic
« Envoyer » (réponse envoyée via Gmail dans le même fil, In-Reply-To/References).
voltship-sav).voltshiplogistics.com. Scopes : ajouter
https://www.googleapis.com/auth/gmail.modify. Tant que l'app est en Testing, ajouter
chaque boîte client dans Test users (max 100, refresh tokens expirent après 7 jours) ;
passer en Production (vérification Google requise pour un scope Gmail restreint)
pour un usage réel.https://app.voltshiplogistics.com/api/support/gmail/callback
(et http://localhost:3000/api/support/gmail/callback en dev).GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET
(+ SUPPORT_TOKEN_ENCRYPTION_KEY optionnel, sinon SHOPIFY_TOKEN_ENCRYPTION_KEY).Déconnexion : Réglages → Déconnecter (le refresh token est effacé, les tickets restent). Révoquer aussi côté Google si besoin : myaccount.google.com → Sécurité → Accès tiers.
On Vercel, vercel.json schedules both cron routes (Vercel sends Authorization: Bearer $CRON_SECRET automatically — set CRON_SECRET in the project env). On Docker, production runs these from the cron service in docker-compose.yml. Put the same secrets in .env on the server (CRON_SECRET must be set), then:
docker compose up -d --build
The cron container calls the app on the Docker network:
/api/cron/airtable-reconcile every 10 minutes/api/cron/shopify-sync nightly at 02:15 UTC/api/cron/eccang-sync every 15 minutes (no-op until ECCANG_API_HOST is set and a client is enabled)/api/cron/support-sync every 10 minutes (no-op until GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET are set and a client connected Gmail)Both routes require Authorization: Bearer $CRON_SECRET. Order webhooks update Shopify between the nightly run.
Run npm run test, npm run lint, npm run build, and optionally
npm run test:e2e before deployment.