Add frontend commerce management

This commit is contained in:
lizerk 2026-08-17 18:38:22 +03:00
parent a041d4844a
commit 1e0146b0ea
9 changed files with 855 additions and 18 deletions

View File

@ -77,6 +77,7 @@ class SaleLineSerializer(serializers.ModelSerializer):
model = SaleLine
fields = [
"id",
"sale",
"product",
"external_id",
"external_product_id",
@ -90,6 +91,17 @@ class SaleLineSerializer(serializers.ModelSerializer):
"metadata",
]
def validate(self, attrs):
gross = attrs.get("gross_amount", getattr(self.instance, "gross_amount", 0))
discount = attrs.get(
"discount_amount", getattr(self.instance, "discount_amount", 0)
)
if discount > gross:
raise serializers.ValidationError(
{"discount_amount": "Скидка не может превышать сумму позиции."}
)
return attrs
class SaleSerializer(serializers.ModelSerializer):
source_code = serializers.CharField(source="source.code", read_only=True)
@ -137,6 +149,35 @@ class CostSerializer(serializers.ModelSerializer):
"metadata",
"created_at",
]
validators = []
def validate(self, attrs):
product = attrs.get("product", getattr(self.instance, "product", None))
sale_line = attrs.get("sale_line", getattr(self.instance, "sale_line", None))
if product is None and sale_line is None:
raise serializers.ValidationError(
"Нужно указать товар или позицию продажи."
)
external_id = attrs.get(
"external_id", getattr(self.instance, "external_id", "")
)
if external_id:
source = attrs.get("source", getattr(self.instance, "source", None))
cost_type = attrs.get(
"cost_type", getattr(self.instance, "cost_type", None)
)
duplicate = Cost.objects.filter(
source=source,
external_id=external_id,
cost_type=cost_type,
)
if self.instance:
duplicate = duplicate.exclude(pk=self.instance.pk)
if duplicate.exists():
raise serializers.ValidationError(
{"external_id": "Расход с таким внешним ID уже существует."}
)
return attrs
class StockSnapshotSerializer(serializers.ModelSerializer):

View File

@ -5,6 +5,7 @@ from .viewsets import (
ProductBindingViewSet,
ProductViewSet,
SaleViewSet,
SaleLineViewSet,
SourceSystemViewSet,
StockSnapshotViewSet,
)
@ -16,6 +17,7 @@ router.register(r"products", ProductViewSet, basename="product")
router.register(r"sources", SourceSystemViewSet, basename="source")
router.register(r"product-bindings", ProductBindingViewSet, basename="productbinding")
router.register(r"sales", SaleViewSet, basename="sale")
router.register(r"sale-lines", SaleLineViewSet, basename="saleline")
router.register(r"costs", CostViewSet, basename="cost")
router.register(r"stock-snapshots", StockSnapshotViewSet, basename="stocksnapshot")

View File

@ -9,6 +9,7 @@ from commerce.models import (
Product,
ProductBinding,
Sale,
SaleLine,
SourceSystem,
StockSnapshot,
)
@ -21,6 +22,7 @@ from .serializers import (
ProductMetricsQuerySerializer,
ProductSerializer,
SaleSerializer,
SaleLineSerializer,
SourceSystemSerializer,
StockSnapshotSerializer,
)
@ -77,7 +79,7 @@ class ProductBindingViewSet(viewsets.ModelViewSet):
search_fields = ["external_id", "external_sku", "product__sku", "product__name"]
class SaleViewSet(viewsets.ReadOnlyModelViewSet):
class SaleViewSet(viewsets.ModelViewSet):
queryset = Sale.objects.select_related("source", "source_record").prefetch_related(
"lines__product"
)
@ -101,7 +103,16 @@ class SaleViewSet(viewsets.ReadOnlyModelViewSet):
ordering = ["-occurred_at"]
class CostViewSet(viewsets.ReadOnlyModelViewSet):
class SaleLineViewSet(viewsets.ModelViewSet):
queryset = SaleLine.objects.select_related("sale", "product", "sale__source")
serializer_class = SaleLineSerializer
permission_classes = [CommerceAccessPermission]
filter_backends = [DjangoFilterBackend, filters.SearchFilter]
filterset_fields = ["sale", "product"]
search_fields = ["external_id", "external_product_id", "external_sku", "name"]
class CostViewSet(viewsets.ModelViewSet):
queryset = Cost.objects.select_related("source", "product", "sale_line")
serializer_class = CostSerializer
permission_classes = [CommerceAccessPermission]
@ -111,7 +122,7 @@ class CostViewSet(viewsets.ReadOnlyModelViewSet):
ordering = ["-occurred_at"]
class StockSnapshotViewSet(viewsets.ReadOnlyModelViewSet):
class StockSnapshotViewSet(viewsets.ModelViewSet):
queryset = StockSnapshot.objects.select_related("source", "product")
serializer_class = StockSnapshotSerializer
permission_classes = [CommerceAccessPermission]

View File

@ -41,9 +41,11 @@ The authenticated API is mounted at `/api/v1/commerce/`:
- `sources/`
- `product-bindings/`
- `sales/`
- `sale-lines/`
- `costs/`
- `stock-snapshots/`
All employees may read. Writes to the canonical catalogue and bindings require
staff status or the corresponding Django model permission. Normalized fact
endpoints are read-only; integrations write them through application services.
All employees may read. Writes require staff status or the corresponding
Django model permission. The frontend management screen can bootstrap and
correct normalized records manually; integrations should still write them
through application services so raw provenance and idempotency are preserved.

View File

@ -5,7 +5,7 @@ from django.urls import reverse
from django.utils import timezone
from rest_framework import status
from commerce.models import Product, Sale, SaleLine, SourceSystem
from commerce.models import Cost, Product, Sale, SaleLine, SourceSystem, StockSnapshot
pytestmark = pytest.mark.django_db
@ -46,6 +46,69 @@ def test_staff_can_create_product(api_client, user_factory):
assert Product.objects.filter(sku="NEW").exists()
def test_staff_can_manage_normalized_commerce_facts(api_client, user_factory):
user = user_factory(staff=True)
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
product = Product.objects.create(sku="FISHKA", name="Фишка")
api_client.force_authenticate(user=user)
sale_response = api_client.post(
reverse("api:v1:commerce:sale-list"),
{
"source": source.pk,
"external_id": "order-1",
"occurred_at": timezone.now().isoformat(),
},
format="json",
)
assert sale_response.status_code == status.HTTP_201_CREATED
line_response = api_client.post(
reverse("api:v1:commerce:saleline-list"),
{
"sale": sale_response.data["id"],
"product": product.pk,
"external_id": "line-1",
"quantity": "2",
"gross_amount": "500.00",
"discount_amount": "50.00",
"cost_amount": "200.00",
},
format="json",
)
assert line_response.status_code == status.HTTP_201_CREATED
cost_response = api_client.post(
reverse("api:v1:commerce:cost-list"),
{
"source": source.pk,
"product": product.pk,
"cost_type": "commission",
"amount": "25.00",
"occurred_at": timezone.now().isoformat(),
},
format="json",
)
assert cost_response.status_code == status.HTTP_201_CREATED, cost_response.data
stock_response = api_client.post(
reverse("api:v1:commerce:stocksnapshot-list"),
{
"source": source.pk,
"product": product.pk,
"warehouse_external_id": "main",
"quantity": "10",
"reserved": "2",
"captured_at": timezone.now().isoformat(),
},
format="json",
)
assert stock_response.status_code == status.HTTP_201_CREATED
assert SaleLine.objects.filter(pk=line_response.data["id"]).exists()
assert Cost.objects.filter(pk=cost_response.data["id"]).exists()
assert StockSnapshot.objects.filter(pk=stock_response.data["id"]).exists()
def test_product_analytics_endpoint(api_client, user_factory):
user = user_factory()
source = SourceSystem.objects.create(code="insales", name="InSales")

View File

@ -0,0 +1,429 @@
"use client";
import { AppShell } from "@/components/AppShell";
import { Modal } from "@/components/ui";
import { useUser } from "@/contexts/UserContext";
import { apiClient } from "@/lib/api";
import type {
CommerceCost,
CommerceProduct,
CommerceSale,
CommerceSaleLine,
CommerceStockSnapshot,
ProductBinding,
SourceSystem,
} from "@/types/commerce";
import {
ArrowLeft,
Boxes,
CircleDollarSign,
Database,
Link2,
ListTree,
Pencil,
Plus,
ReceiptText,
RefreshCw,
Trash2,
} from "lucide-react";
import Link from "next/link";
import {
useCallback,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
type Tab = "products" | "sources" | "bindings" | "sales" | "costs" | "stock";
type EditorKind = "product" | "source" | "binding" | "sale" | "line" | "cost" | "stock";
type Editor = { kind: EditorKind; id?: string | number } | null;
type FormState = Record<string, string | boolean>;
const tabs: Array<{ id: Tab; label: string; icon: typeof Boxes }> = [
{ id: "products", label: "Товары", icon: Boxes },
{ id: "sources", label: "Источники", icon: Database },
{ id: "bindings", label: "Привязки", icon: Link2 },
{ id: "sales", label: "Продажи", icon: ReceiptText },
{ id: "costs", label: "Расходы", icon: CircleDollarSign },
{ id: "stock", label: "Остатки", icon: ListTree },
];
const costTypeLabels: Record<string, string> = {
commission: "Комиссия",
delivery: "Доставка",
storage: "Хранение",
advertising: "Реклама",
acquiring: "Эквайринг",
return: "Возврат",
other: "Прочее",
};
function localDateTime(value?: string | null) {
const date = value ? new Date(value) : new Date();
const offset = date.getTimezoneOffset() * 60_000;
return new Date(date.getTime() - offset).toISOString().slice(0, 16);
}
function apiDateTime(value: string) {
return new Date(value).toISOString();
}
function displayDate(value: string) {
return new Intl.DateTimeFormat("ru-RU", {
dateStyle: "short",
timeStyle: "short",
}).format(new Date(value));
}
function displayNumber(value: string, digits = 2) {
return new Intl.NumberFormat("ru-RU", { maximumFractionDigits: digits }).format(
Number(value),
);
}
export default function CommerceManagementPage() {
const { user, loading: userLoading } = useUser();
const [tab, setTab] = useState<Tab>("products");
const [products, setProducts] = useState<CommerceProduct[]>([]);
const [sources, setSources] = useState<SourceSystem[]>([]);
const [bindings, setBindings] = useState<ProductBinding[]>([]);
const [sales, setSales] = useState<CommerceSale[]>([]);
const [costs, setCosts] = useState<CommerceCost[]>([]);
const [stock, setStock] = useState<CommerceStockSnapshot[]>([]);
const [selectedSaleId, setSelectedSaleId] = useState<string | null>(null);
const [editor, setEditor] = useState<Editor>(null);
const [form, setForm] = useState<FormState>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const permissions = user?.auth?.permissions_by_app?.commerce ?? [];
const canManage = Boolean(
user?.auth?.is_staff ||
user?.auth?.is_superuser ||
permissions.some(
(permission) =>
permission.startsWith("add_") ||
permission.startsWith("change_") ||
permission.startsWith("delete_"),
),
);
const loadData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [productData, sourceData, bindingData, saleData, costData, stockData] =
await Promise.all([
apiClient.getCommerceProducts({ page_size: 200, ordering: "name" }),
apiClient.getCommerceSources(),
apiClient.getCommerceBindings({ page_size: 200 }),
apiClient.getCommerceSales({ page_size: 200, ordering: "-occurred_at" }),
apiClient.getCommerceCosts({ page_size: 200, ordering: "-occurred_at" }),
apiClient.getCommerceStockSnapshots({
page_size: 200,
ordering: "-captured_at",
}),
]);
setProducts(productData.results);
setSources(sourceData.results);
setBindings(bindingData.results);
setSales(saleData.results);
setCosts(costData.results);
setStock(stockData.results);
setSelectedSaleId((current) =>
current && saleData.results.some((sale) => sale.id === current)
? current
: null,
);
} catch (requestError) {
setError(
requestError instanceof Error
? requestError.message
: "Не удалось загрузить данные.",
);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (!canManage) return;
const refreshTimer = window.setTimeout(() => void loadData(), 0);
return () => window.clearTimeout(refreshTimer);
}, [canManage, loadData]);
const productMap = useMemo(
() => new Map(products.map((product) => [product.id, product])),
[products],
);
const sourceMap = useMemo(
() => new Map(sources.map((source) => [source.id, source])),
[sources],
);
const selectedSale = sales.find((sale) => sale.id === selectedSaleId) ?? null;
function openCreate(kind: EditorKind) {
const now = localDateTime();
const defaults: Record<EditorKind, FormState> = {
product: { kind: "product", sku: "", name: "", barcode: "", description: "", parent: "", is_active: true },
source: { code: "", name: "", is_active: true },
binding: { product: "", source: "", external_id: "", external_sku: "", is_primary: false },
sale: { source: "", external_id: "", number: "", event_type: "sale", channel: "", status: "", currency: "RUB", occurred_at: now, counts_as_sale: true },
line: { sale: selectedSaleId ?? "", product: "", external_id: "", external_product_id: "", external_sku: "", name: "", quantity: "1", gross_amount: "0", discount_amount: "0", cost_amount: "0" },
cost: { source: "", product: "", external_id: "", channel: "", cost_type: "commission", amount: "0", currency: "RUB", occurred_at: now },
stock: { source: "", product: "", warehouse_external_id: "", warehouse_name: "", quantity: "0", reserved: "0", in_transit: "0", captured_at: now },
};
setForm(defaults[kind]);
setEditor({ kind });
}
function openEdit(kind: EditorKind, item: unknown) {
if (kind === "product") {
const value = item as CommerceProduct;
setForm({ kind: value.kind, sku: value.sku, name: value.name, barcode: value.barcode, description: value.description, parent: value.parent ?? "", is_active: value.is_active });
setEditor({ kind, id: value.id });
} else if (kind === "source") {
const value = item as SourceSystem;
setForm({ code: value.code, name: value.name, is_active: value.is_active });
setEditor({ kind, id: value.id });
} else if (kind === "binding") {
const value = item as ProductBinding;
setForm({ product: value.product, source: String(value.source), external_id: value.external_id, external_sku: value.external_sku, is_primary: value.is_primary });
setEditor({ kind, id: value.id });
} else if (kind === "sale") {
const value = item as CommerceSale;
setForm({ source: String(value.source), external_id: value.external_id, number: value.number, event_type: value.event_type, channel: value.channel, status: value.status, currency: value.currency, occurred_at: localDateTime(value.occurred_at), counts_as_sale: value.counts_as_sale });
setEditor({ kind, id: value.id });
} else if (kind === "line") {
const value = item as CommerceSaleLine;
setForm({ sale: value.sale, product: value.product ?? "", external_id: value.external_id, external_product_id: value.external_product_id, external_sku: value.external_sku, name: value.name, quantity: value.quantity, gross_amount: value.gross_amount, discount_amount: value.discount_amount, cost_amount: value.cost_amount });
setEditor({ kind, id: value.id });
} else if (kind === "cost") {
const value = item as CommerceCost;
setForm({ source: String(value.source), product: value.product ?? "", external_id: value.external_id, channel: value.channel, cost_type: value.cost_type, amount: value.amount, currency: value.currency, occurred_at: localDateTime(value.occurred_at) });
setEditor({ kind, id: value.id });
} else {
const value = item as CommerceStockSnapshot;
setForm({ source: String(value.source), product: value.product, warehouse_external_id: value.warehouse_external_id, warehouse_name: value.warehouse_name, quantity: value.quantity, reserved: value.reserved, in_transit: value.in_transit, captured_at: localDateTime(value.captured_at) });
setEditor({ kind, id: value.id });
}
}
async function submitEditor() {
if (!editor) return;
setSaving(true);
setError(null);
try {
if (editor.kind === "product") {
const payload = { kind: String(form.kind) as CommerceProduct["kind"], sku: String(form.sku).trim(), name: String(form.name).trim(), barcode: String(form.barcode || "").trim(), description: String(form.description || "").trim(), parent: form.parent ? String(form.parent) : null, is_active: Boolean(form.is_active) };
if (editor.id) await apiClient.updateCommerceProduct(String(editor.id), payload);
else await apiClient.createCommerceProduct(payload);
} else if (editor.kind === "source") {
const payload = { code: String(form.code).trim(), name: String(form.name).trim(), is_active: Boolean(form.is_active) };
if (editor.id) await apiClient.updateCommerceSource(Number(editor.id), payload);
else await apiClient.createCommerceSource(payload);
} else if (editor.kind === "binding") {
const payload = { product: String(form.product), source: Number(form.source), external_id: String(form.external_id).trim(), external_sku: String(form.external_sku || "").trim(), is_primary: Boolean(form.is_primary) };
if (editor.id) await apiClient.updateCommerceBinding(String(editor.id), payload);
else await apiClient.createCommerceBinding(payload);
} else if (editor.kind === "sale") {
const payload = { source: Number(form.source), external_id: String(form.external_id).trim(), number: String(form.number || "").trim(), event_type: String(form.event_type) as CommerceSale["event_type"], channel: String(form.channel || "").trim(), status: String(form.status || "").trim(), counts_as_sale: Boolean(form.counts_as_sale), currency: String(form.currency || "RUB").toUpperCase(), occurred_at: apiDateTime(String(form.occurred_at)) };
if (editor.id) await apiClient.updateCommerceSale(String(editor.id), payload);
else await apiClient.createCommerceSale(payload);
} else if (editor.kind === "line") {
const payload = { sale: String(form.sale), product: form.product ? String(form.product) : null, external_id: String(form.external_id).trim(), external_product_id: String(form.external_product_id || "").trim(), external_sku: String(form.external_sku || "").trim(), name: String(form.name || "").trim(), quantity: String(form.quantity), gross_amount: String(form.gross_amount), discount_amount: String(form.discount_amount), cost_amount: String(form.cost_amount) };
if (editor.id) await apiClient.updateCommerceSaleLine(String(editor.id), payload);
else await apiClient.createCommerceSaleLine(payload);
} else if (editor.kind === "cost") {
const payload = { source: Number(form.source), product: form.product ? String(form.product) : null, external_id: String(form.external_id || "").trim(), channel: String(form.channel || "").trim(), cost_type: String(form.cost_type), amount: String(form.amount), currency: String(form.currency || "RUB").toUpperCase(), occurred_at: apiDateTime(String(form.occurred_at)) };
if (editor.id) await apiClient.updateCommerceCost(String(editor.id), payload);
else await apiClient.createCommerceCost(payload);
} else {
const payload = { source: Number(form.source), product: String(form.product), warehouse_external_id: String(form.warehouse_external_id).trim(), warehouse_name: String(form.warehouse_name || "").trim(), quantity: String(form.quantity), reserved: String(form.reserved), in_transit: String(form.in_transit), captured_at: apiDateTime(String(form.captured_at)) };
if (editor.id) await apiClient.updateCommerceStockSnapshot(String(editor.id), payload);
else await apiClient.createCommerceStockSnapshot(payload);
}
setEditor(null);
await loadData();
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : "Не удалось сохранить запись.");
} finally {
setSaving(false);
}
}
async function remove(kind: EditorKind, id: string | number) {
if (!window.confirm("Удалить запись? Это действие нельзя отменить.")) return;
setError(null);
try {
if (kind === "product") await apiClient.deleteCommerceProduct(String(id));
else if (kind === "source") await apiClient.deleteCommerceSource(Number(id));
else if (kind === "binding") await apiClient.deleteCommerceBinding(String(id));
else if (kind === "sale") await apiClient.deleteCommerceSale(String(id));
else if (kind === "line") await apiClient.deleteCommerceSaleLine(String(id));
else if (kind === "cost") await apiClient.deleteCommerceCost(String(id));
else await apiClient.deleteCommerceStockSnapshot(String(id));
await loadData();
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : "Не удалось удалить запись.");
}
}
if (userLoading) {
return <AppShell><main className="p-6 text-sm text-[var(--muted-foreground)]">Проверка прав…</main></AppShell>;
}
if (!canManage) {
return (
<AppShell>
<main className="mx-auto max-w-3xl p-6">
<div className="app-feedback-danger rounded-xl p-5 text-sm">У вас нет прав на управление товарной аналитикой.</div>
</main>
</AppShell>
);
}
return (
<AppShell desktopWideMode>
<main className="mx-auto w-full max-w-[1600px] space-y-5 p-4 sm:p-6">
<header className="flex flex-wrap items-start justify-between gap-4">
<div>
<Link href="/products" className="mb-2 inline-flex items-center gap-2 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)]"><ArrowLeft className="h-4 w-4" /> К каталогу</Link>
<h1 className="text-2xl font-semibold">Управление товарами и аналитикой</h1>
<p className="mt-1 text-sm text-[var(--muted-foreground)]">Ручное управление справочниками и нормализованными фактами.</p>
</div>
<button type="button" onClick={() => void loadData()} className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm"><RefreshCw className="h-4 w-4" /> Обновить</button>
</header>
{error ? <div className="app-feedback-danger rounded-xl p-4 text-sm">{error}</div> : null}
<nav className="app-surface flex gap-1 overflow-x-auto rounded-xl p-1.5">
{tabs.map((item) => {
const Icon = item.icon;
return <button key={item.id} type="button" onClick={() => setTab(item.id)} className={`inline-flex shrink-0 items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium ${tab === item.id ? "app-selected" : "hover:bg-[var(--surface-secondary)]"}`}><Icon className="h-4 w-4" /> {item.label}</button>;
})}
</nav>
{loading ? <div className="app-surface rounded-xl p-6 text-sm text-[var(--muted-foreground)]">Загрузка…</div> : (
<>
{tab === "products" ? <ProductsTable items={products} onCreate={() => openCreate("product")} onEdit={(item) => openEdit("product", item)} onDelete={(id) => void remove("product", id)} /> : null}
{tab === "sources" ? <SourcesTable items={sources} onCreate={() => openCreate("source")} onEdit={(item) => openEdit("source", item)} onDelete={(id) => void remove("source", id)} /> : null}
{tab === "bindings" ? <BindingsTable items={bindings} productMap={productMap} sourceMap={sourceMap} onCreate={() => openCreate("binding")} onEdit={(item) => openEdit("binding", item)} onDelete={(id) => void remove("binding", id)} /> : null}
{tab === "sales" ? <SalesTable items={sales} sourceMap={sourceMap} selectedSaleId={selectedSaleId} onSelect={setSelectedSaleId} onCreate={() => openCreate("sale")} onEdit={(item) => openEdit("sale", item)} onDelete={(id) => void remove("sale", id)} /> : null}
{tab === "sales" && selectedSale ? <SaleLinesTable sale={selectedSale} productMap={productMap} onCreate={() => openCreate("line")} onEdit={(item) => openEdit("line", item)} onDelete={(id) => void remove("line", id)} /> : null}
{tab === "costs" ? <CostsTable items={costs} productMap={productMap} sourceMap={sourceMap} onCreate={() => openCreate("cost")} onEdit={(item) => openEdit("cost", item)} onDelete={(id) => void remove("cost", id)} /> : null}
{tab === "stock" ? <StockTable items={stock} productMap={productMap} sourceMap={sourceMap} onCreate={() => openCreate("stock")} onEdit={(item) => openEdit("stock", item)} onDelete={(id) => void remove("stock", id)} /> : null}
</>
)}
</main>
<Modal isOpen={editor !== null} onClose={() => setEditor(null)} title={editorTitle(editor)} size="lg" footer={<div className="flex justify-end gap-2 pb-4"><button type="button" onClick={() => setEditor(null)} className="app-action-secondary rounded-lg px-4 py-2 text-sm">Отмена</button><button type="submit" form="commerce-editor-form" disabled={saving} className="app-action-primary rounded-lg px-4 py-2 text-sm disabled:opacity-50">{saving ? "Сохранение…" : "Сохранить"}</button></div>}>
{editor ? <form id="commerce-editor-form" onSubmit={(event) => { event.preventDefault(); void submitEditor(); }}><EditorFields kind={editor.kind} editorId={editor.id} form={form} setForm={setForm} products={products} sources={sources} sales={sales} /></form> : null}
</Modal>
</AppShell>
);
}
function editorTitle(editor: Editor) {
if (!editor) return "";
const labels: Record<EditorKind, string> = { product: "товар", source: "источник", binding: "привязку", sale: "продажу", line: "позицию продажи", cost: "расход", stock: "снимок остатка" };
return `${editor.id ? "Изменить" : "Добавить"} ${labels[editor.kind]}`;
}
function EditorFields({ kind, editorId, form, setForm, products, sources, sales }: { kind: EditorKind; editorId?: string | number; form: FormState; setForm: (value: FormState) => void; products: CommerceProduct[]; sources: SourceSystem[]; sales: CommerceSale[] }) {
const set = (name: string, value: string | boolean) => setForm({ ...form, [name]: value });
const sourceOptions = sources.map((source) => ({ value: String(source.id), label: `${source.name} (${source.code})` }));
const productOptions = products.filter((product) => kind !== "product" || product.id !== editorId).map((product) => ({ value: product.id, label: `${product.sku} — ${product.name}` }));
return <div className="grid gap-4 pb-2 sm:grid-cols-2">
{kind === "product" ? <>
<Field label="Внутренний SKU" required value={String(form.sku)} onChange={(value) => set("sku", value)} />
<Field label="Название" required value={String(form.name)} onChange={(value) => set("name", value)} />
<SelectField label="Тип" value={String(form.kind)} onChange={(value) => set("kind", value)} options={[{ value: "product", label: "Товар" }, { value: "service", label: "Услуга" }, { value: "bundle", label: "Комплект" }]} />
<SelectField label="Родительский товар" value={String(form.parent)} onChange={(value) => set("parent", value)} options={productOptions} emptyLabel="Без родителя" />
<Field label="Штрихкод" value={String(form.barcode)} onChange={(value) => set("barcode", value)} />
<CheckboxField label="Активен" checked={Boolean(form.is_active)} onChange={(value) => set("is_active", value)} />
<Field label="Описание" value={String(form.description)} onChange={(value) => set("description", value)} multiline className="sm:col-span-2" />
</> : null}
{kind === "source" ? <>
<Field label="Код" required value={String(form.code)} onChange={(value) => set("code", value)} placeholder="moysklad" />
<Field label="Название" required value={String(form.name)} onChange={(value) => set("name", value)} />
<CheckboxField label="Активен" checked={Boolean(form.is_active)} onChange={(value) => set("is_active", value)} />
</> : null}
{kind === "binding" ? <>
<SelectField label="Товар" required value={String(form.product)} onChange={(value) => set("product", value)} options={productOptions} />
<SelectField label="Источник" required value={String(form.source)} onChange={(value) => set("source", value)} options={sourceOptions} />
<Field label="Внешний ID" required value={String(form.external_id)} onChange={(value) => set("external_id", value)} />
<Field label="Внешний SKU" value={String(form.external_sku)} onChange={(value) => set("external_sku", value)} />
<CheckboxField label="Основная привязка" checked={Boolean(form.is_primary)} onChange={(value) => set("is_primary", value)} />
</> : null}
{kind === "sale" ? <>
<SelectField label="Источник" required value={String(form.source)} onChange={(value) => set("source", value)} options={sourceOptions} />
<Field label="Внешний ID" required value={String(form.external_id)} onChange={(value) => set("external_id", value)} />
<Field label="Номер" value={String(form.number)} onChange={(value) => set("number", value)} />
<SelectField label="Тип" value={String(form.event_type)} onChange={(value) => set("event_type", value)} options={[{ value: "sale", label: "Продажа" }, { value: "return", label: "Возврат" }]} />
<Field label="Канал" value={String(form.channel)} onChange={(value) => set("channel", value)} placeholder="site, ozon, avito" />
<Field label="Статус" value={String(form.status)} onChange={(value) => set("status", value)} />
<Field label="Валюта" required value={String(form.currency)} onChange={(value) => set("currency", value)} />
<Field label="Дата операции" required type="datetime-local" value={String(form.occurred_at)} onChange={(value) => set("occurred_at", value)} />
<CheckboxField label="Учитывать в аналитике" checked={Boolean(form.counts_as_sale)} onChange={(value) => set("counts_as_sale", value)} />
</> : null}
{kind === "line" ? <>
<SelectField label="Продажа" required value={String(form.sale)} onChange={(value) => set("sale", value)} options={sales.map((sale) => ({ value: sale.id, label: sale.number || sale.external_id }))} />
<SelectField label="Товар" required value={String(form.product)} onChange={(value) => set("product", value)} options={productOptions} />
<Field label="ID позиции" required value={String(form.external_id)} onChange={(value) => set("external_id", value)} />
<Field label="Название в источнике" value={String(form.name)} onChange={(value) => set("name", value)} />
<Field label="ID товара в источнике" value={String(form.external_product_id)} onChange={(value) => set("external_product_id", value)} />
<Field label="Внешний SKU" value={String(form.external_sku)} onChange={(value) => set("external_sku", value)} />
<Field label="Количество" required type="number" step="0.0001" value={String(form.quantity)} onChange={(value) => set("quantity", value)} />
<Field label="Сумма до скидки" required type="number" step="0.01" value={String(form.gross_amount)} onChange={(value) => set("gross_amount", value)} />
<Field label="Скидка" required type="number" step="0.01" value={String(form.discount_amount)} onChange={(value) => set("discount_amount", value)} />
<Field label="Себестоимость" required type="number" step="0.01" value={String(form.cost_amount)} onChange={(value) => set("cost_amount", value)} />
</> : null}
{kind === "cost" ? <>
<SelectField label="Источник" required value={String(form.source)} onChange={(value) => set("source", value)} options={sourceOptions} />
<SelectField label="Товар" required value={String(form.product)} onChange={(value) => set("product", value)} options={productOptions} />
<SelectField label="Тип расхода" required value={String(form.cost_type)} onChange={(value) => set("cost_type", value)} options={Object.entries(costTypeLabels).map(([value, label]) => ({ value, label }))} />
<Field label="Канал" value={String(form.channel)} onChange={(value) => set("channel", value)} />
<Field label="Сумма" required type="number" step="0.01" value={String(form.amount)} onChange={(value) => set("amount", value)} />
<Field label="Валюта" required value={String(form.currency)} onChange={(value) => set("currency", value)} />
<Field label="Дата расхода" required type="datetime-local" value={String(form.occurred_at)} onChange={(value) => set("occurred_at", value)} />
<Field label="Внешний ID" value={String(form.external_id)} onChange={(value) => set("external_id", value)} />
</> : null}
{kind === "stock" ? <>
<SelectField label="Источник" required value={String(form.source)} onChange={(value) => set("source", value)} options={sourceOptions} />
<SelectField label="Товар" required value={String(form.product)} onChange={(value) => set("product", value)} options={productOptions} />
<Field label="ID склада" required value={String(form.warehouse_external_id)} onChange={(value) => set("warehouse_external_id", value)} />
<Field label="Название склада" value={String(form.warehouse_name)} onChange={(value) => set("warehouse_name", value)} />
<Field label="Количество" required type="number" step="0.0001" value={String(form.quantity)} onChange={(value) => set("quantity", value)} />
<Field label="Резерв" required type="number" step="0.0001" value={String(form.reserved)} onChange={(value) => set("reserved", value)} />
<Field label="В пути" required type="number" step="0.0001" value={String(form.in_transit)} onChange={(value) => set("in_transit", value)} />
<Field label="Дата снимка" required type="datetime-local" value={String(form.captured_at)} onChange={(value) => set("captured_at", value)} />
</> : null}
</div>;
}
type TableActions<TId extends string | number, TItem> = { onCreate: () => void; onEdit: (item: TItem) => void; onDelete: (id: TId) => void };
function Section({ title, subtitle, onCreate, children }: { title: string; subtitle: string; onCreate: () => void; children: ReactNode }) {
return <section className="app-surface overflow-hidden rounded-xl"><header className="flex flex-wrap items-center justify-between gap-3 border-b border-[var(--border-subtle)] p-4"><div><h2 className="font-semibold">{title}</h2><p className="mt-1 text-xs text-[var(--muted-foreground)]">{subtitle}</p></div><button type="button" onClick={onCreate} className="app-action-primary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm"><Plus className="h-4 w-4" /> Добавить</button></header>{children}</section>;
}
function Actions({ onEdit, onDelete }: { onEdit: () => void; onDelete: () => void }) { return <div className="flex justify-end gap-1"><button type="button" onClick={onEdit} className="app-icon-button rounded-lg p-2" title="Изменить"><Pencil className="h-4 w-4" /></button><button type="button" onClick={onDelete} className="app-icon-button rounded-lg p-2 text-rose-600" title="Удалить"><Trash2 className="h-4 w-4" /></button></div>; }
function EmptyRow({ columns }: { columns: number }) { return <tr><td colSpan={columns} className="p-8 text-center text-sm text-[var(--muted-foreground)]">Записей пока нет.</td></tr>; }
function ProductsTable({ items, onCreate, onEdit, onDelete }: { items: CommerceProduct[] } & TableActions<string, CommerceProduct>) { return <Section title="Товары" subtitle="Единые внутренние SKU" onCreate={onCreate}><Table headers={["SKU", "Название", "Тип", "Привязки", ""]}>{items.length ? items.map((item) => <tr key={item.id}><Td>{item.sku}</Td><Td>{item.name}</Td><Td>{item.kind}</Td><Td>{item.bindings.length}</Td><Td><Actions onEdit={() => onEdit(item)} onDelete={() => onDelete(item.id)} /></Td></tr>) : <EmptyRow columns={5} />}</Table></Section>; }
function SourcesTable({ items, onCreate, onEdit, onDelete }: { items: SourceSystem[] } & TableActions<number, SourceSystem>) { return <Section title="Источники" subtitle="Сервисы, API и импортируемые отчёты" onCreate={onCreate}><Table headers={["Код", "Название", "Статус", ""]}>{items.length ? items.map((item) => <tr key={item.id}><Td>{item.code}</Td><Td>{item.name}</Td><Td>{item.is_active ? "Активен" : "Отключён"}</Td><Td><Actions onEdit={() => onEdit(item)} onDelete={() => onDelete(item.id)} /></Td></tr>) : <EmptyRow columns={4} />}</Table></Section>; }
function BindingsTable({ items, productMap, sourceMap, onCreate, onEdit, onDelete }: { items: ProductBinding[]; productMap: Map<string, CommerceProduct>; sourceMap: Map<number, SourceSystem> } & TableActions<string, ProductBinding>) { return <Section title="Внешние привязки" subtitle="Соответствия товаров и офферов разных систем" onCreate={onCreate}><Table headers={["Товар", "Источник", "Внешний ID", "Внешний SKU", ""]}>{items.length ? items.map((item) => <tr key={item.id}><Td>{productMap.get(item.product)?.sku ?? item.product}</Td><Td>{sourceMap.get(item.source)?.name ?? item.source_code}</Td><Td>{item.external_id}</Td><Td>{item.external_sku || "—"}</Td><Td><Actions onEdit={() => onEdit(item)} onDelete={() => onDelete(item.id)} /></Td></tr>) : <EmptyRow columns={5} />}</Table></Section>; }
function SalesTable({ items, sourceMap, selectedSaleId, onSelect, onCreate, onEdit, onDelete }: { items: CommerceSale[]; sourceMap: Map<number, SourceSystem>; selectedSaleId: string | null; onSelect: (id: string) => void } & TableActions<string, CommerceSale>) { return <Section title="Продажи и возвраты" subtitle="Выберите продажу, чтобы управлять её позициями" onCreate={onCreate}><Table headers={["Номер", "Источник", "Канал", "Статус", "Дата", "Позиции", ""]}>{items.length ? items.map((item) => <tr key={item.id} className={selectedSaleId === item.id ? "bg-[var(--surface-secondary)]" : ""}><Td><button type="button" onClick={() => onSelect(item.id)} className="font-medium hover:underline">{item.number || item.external_id}</button></Td><Td>{sourceMap.get(item.source)?.name ?? item.source_code}</Td><Td>{item.channel || "—"}</Td><Td>{item.status || item.event_type}</Td><Td>{displayDate(item.occurred_at)}</Td><Td>{item.lines.length}</Td><Td><Actions onEdit={() => onEdit(item)} onDelete={() => onDelete(item.id)} /></Td></tr>) : <EmptyRow columns={7} />}</Table></Section>; }
function SaleLinesTable({ sale, productMap, onCreate, onEdit, onDelete }: { sale: CommerceSale; productMap: Map<string, CommerceProduct> } & TableActions<string, CommerceSaleLine>) { return <Section title={`Позиции · ${sale.number || sale.external_id}`} subtitle="Товарные строки выбранной продажи" onCreate={onCreate}><Table headers={["Товар", "Внешний SKU", "Количество", "Выручка", "Себестоимость", ""]}>{sale.lines.length ? sale.lines.map((item) => <tr key={item.id}><Td>{item.product ? productMap.get(item.product)?.name ?? item.name : item.name || "Не сопоставлен"}</Td><Td>{item.external_sku || "—"}</Td><Td>{displayNumber(item.quantity, 4)}</Td><Td>{displayNumber(item.net_amount)}</Td><Td>{displayNumber(item.cost_amount)}</Td><Td><Actions onEdit={() => onEdit(item)} onDelete={() => onDelete(item.id)} /></Td></tr>) : <EmptyRow columns={6} />}</Table></Section>; }
function CostsTable({ items, productMap, sourceMap, onCreate, onEdit, onDelete }: { items: CommerceCost[]; productMap: Map<string, CommerceProduct>; sourceMap: Map<number, SourceSystem> } & TableActions<string, CommerceCost>) { return <Section title="Расходы" subtitle="Комиссии, логистика, реклама и прочие затраты" onCreate={onCreate}><Table headers={["Товар", "Источник", "Тип", "Канал", "Сумма", "Дата", ""]}>{items.length ? items.map((item) => <tr key={item.id}><Td>{item.product ? productMap.get(item.product)?.name ?? item.product : "По позиции продажи"}</Td><Td>{sourceMap.get(item.source)?.name ?? item.source_code}</Td><Td>{costTypeLabels[item.cost_type] ?? item.cost_type}</Td><Td>{item.channel || "—"}</Td><Td>{displayNumber(item.amount)} {item.currency}</Td><Td>{displayDate(item.occurred_at)}</Td><Td><Actions onEdit={() => onEdit(item)} onDelete={() => onDelete(item.id)} /></Td></tr>) : <EmptyRow columns={7} />}</Table></Section>; }
function StockTable({ items, productMap, sourceMap, onCreate, onEdit, onDelete }: { items: CommerceStockSnapshot[]; productMap: Map<string, CommerceProduct>; sourceMap: Map<number, SourceSystem> } & TableActions<string, CommerceStockSnapshot>) { return <Section title="Снимки остатков" subtitle="Наблюдения по складам и источникам" onCreate={onCreate}><Table headers={["Товар", "Источник", "Склад", "Количество", "Резерв", "Доступно", "Дата", ""]}>{items.length ? items.map((item) => <tr key={item.id}><Td>{productMap.get(item.product)?.name ?? item.product}</Td><Td>{sourceMap.get(item.source)?.name ?? item.source_code}</Td><Td>{item.warehouse_name || item.warehouse_external_id}</Td><Td>{displayNumber(item.quantity, 4)}</Td><Td>{displayNumber(item.reserved, 4)}</Td><Td>{displayNumber(item.available, 4)}</Td><Td>{displayDate(item.captured_at)}</Td><Td><Actions onEdit={() => onEdit(item)} onDelete={() => onDelete(item.id)} /></Td></tr>) : <EmptyRow columns={8} />}</Table></Section>; }
function Table({ headers, children }: { headers: string[]; children: ReactNode }) { return <div className="overflow-x-auto"><table className="w-full min-w-[760px] text-left text-sm"><thead className="bg-[var(--surface-secondary)] text-xs uppercase tracking-wide text-[var(--muted-foreground)]"><tr>{headers.map((header, index) => <th key={`${header}-${index}`} className="px-4 py-3 font-medium">{header}</th>)}</tr></thead><tbody className="divide-y divide-[var(--border-subtle)]">{children}</tbody></table></div>; }
function Td({ children }: { children: ReactNode }) { return <td className="px-4 py-3 align-middle">{children}</td>; }
function Field({ label, value, onChange, required = false, type = "text", step, placeholder, multiline = false, className = "" }: { label: string; value: string; onChange: (value: string) => void; required?: boolean; type?: string; step?: string; placeholder?: string; multiline?: boolean; className?: string }) { const controlClass = "mt-1 w-full rounded-lg border border-[var(--border-subtle)] bg-[var(--surface-primary)] px-3 py-2 text-sm outline-none focus:border-[var(--border-strong)]"; return <label className={className}><span className="text-xs font-medium text-[var(--muted-foreground)]">{label}{required ? " *" : ""}</span>{multiline ? <textarea required={required} value={value} onChange={(event) => onChange(event.target.value)} placeholder={placeholder} rows={4} className={controlClass} /> : <input required={required} type={type} step={step} value={value} onChange={(event) => onChange(event.target.value)} placeholder={placeholder} className={controlClass} />}</label>; }
function SelectField({ label, value, onChange, options, required = false, emptyLabel = "Выберите" }: { label: string; value: string; onChange: (value: string) => void; options: Array<{ value: string; label: string }>; required?: boolean; emptyLabel?: string }) { return <label><span className="text-xs font-medium text-[var(--muted-foreground)]">{label}{required ? " *" : ""}</span><select required={required} value={value} onChange={(event) => onChange(event.target.value)} className="mt-1 w-full rounded-lg border border-[var(--border-subtle)] bg-[var(--surface-primary)] px-3 py-2 text-sm outline-none"><option value="">{emptyLabel}</option>{options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label>; }
function CheckboxField({ label, checked, onChange }: { label: string; checked: boolean; onChange: (value: boolean) => void }) { return <label className="flex items-center gap-2 self-end rounded-lg border border-[var(--border-subtle)] px-3 py-2 text-sm"><input type="checkbox" checked={checked} onChange={(event) => onChange(event.target.checked)} /> {label}</label>; }

View File

@ -1,9 +1,10 @@
"use client";
import { AppShell } from "@/components/AppShell";
import { useUser } from "@/contexts/UserContext";
import { apiClient } from "@/lib/api";
import type { CommerceProduct } from "@/types/commerce";
import { ChevronRight, PackageSearch, Search } from "lucide-react";
import { ChevronRight, PackageSearch, Search, Settings2 } from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
@ -14,10 +15,20 @@ const kindLabels: Record<CommerceProduct["kind"], string> = {
};
export default function ProductsPage() {
const { user } = useUser();
const [products, setProducts] = useState<CommerceProduct[]>([]);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const canManage = Boolean(
user?.auth?.is_staff ||
user?.auth?.is_superuser ||
user?.auth?.permissions_by_app?.commerce?.some((permission) =>
permission.startsWith("add_") ||
permission.startsWith("change_") ||
permission.startsWith("delete_"),
),
);
const loadProducts = useCallback(async (query = "") => {
setLoading(true);
@ -48,16 +59,26 @@ export default function ProductsPage() {
return (
<AppShell>
<main className="mx-auto w-full max-w-7xl space-y-5 p-4 sm:p-6">
<header className="space-y-1">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-[var(--muted-foreground)]">
Коммерческое ядро
</p>
<h1 className="text-2xl font-semibold text-[var(--foreground)]">
Единый каталог товаров
</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Внутренние SKU и их соответствия во внешних системах.
</p>
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-1">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-[var(--muted-foreground)]">
Коммерческое ядро
</p>
<h1 className="text-2xl font-semibold text-[var(--foreground)]">
Единый каталог товаров
</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Внутренние SKU и их соответствия во внешних системах.
</p>
</div>
{canManage ? (
<Link
href="/products/manage"
className="app-action-primary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium"
>
<Settings2 className="h-4 w-4" /> Управление
</Link>
) : null}
</header>
<label className="app-surface flex items-center gap-3 rounded-xl px-4 py-3">

View File

@ -1,7 +1,20 @@
import type {
CommerceCost,
CommerceCostInput,
CommerceProduct,
CommerceProductInput,
CommerceSale,
CommerceSaleInput,
CommerceSaleLine,
CommerceSaleLineInput,
CommerceStockSnapshot,
CommerceStockSnapshotInput,
PaginatedCommerceResponse,
ProductBinding,
ProductBindingInput,
ProductMetrics,
SourceSystem,
SourceSystemInput,
} from "@/types/commerce";
import { buildQuery, type RequestFn } from "./utils";
@ -12,6 +25,7 @@ type ProductListParams = {
is_active?: boolean;
ordering?: string;
page?: number;
page_size?: number;
};
type ProductMetricsParams = {
@ -20,6 +34,11 @@ type ProductMetricsParams = {
};
export function createCommerceApi(request: RequestFn) {
const json = (method: "POST" | "PATCH", body: unknown) => ({
method,
body: JSON.stringify(body),
});
return {
getCommerceProducts: (
params?: ProductListParams,
@ -27,10 +46,105 @@ export function createCommerceApi(request: RequestFn) {
request(`/api/v1/commerce/products/${buildQuery(params)}`),
getCommerceProduct: (id: string): Promise<CommerceProduct> =>
request(`/api/v1/commerce/products/${id}/`),
createCommerceProduct: (data: CommerceProductInput): Promise<CommerceProduct> =>
request("/api/v1/commerce/products/", json("POST", data)),
updateCommerceProduct: (
id: string,
data: Partial<CommerceProductInput>,
): Promise<CommerceProduct> =>
request(`/api/v1/commerce/products/${id}/`, json("PATCH", data)),
deleteCommerceProduct: (id: string): Promise<void> =>
request(`/api/v1/commerce/products/${id}/`, { method: "DELETE" }),
getCommerceProductMetrics: (
id: string,
params?: ProductMetricsParams,
): Promise<ProductMetrics> =>
request(`/api/v1/commerce/products/${id}/analytics/${buildQuery(params)}`),
getCommerceSources: (): Promise<PaginatedCommerceResponse<SourceSystem>> =>
request("/api/v1/commerce/sources/?ordering=name&page_size=200"),
createCommerceSource: (data: SourceSystemInput): Promise<SourceSystem> =>
request("/api/v1/commerce/sources/", json("POST", data)),
updateCommerceSource: (
id: number,
data: Partial<SourceSystemInput>,
): Promise<SourceSystem> =>
request(`/api/v1/commerce/sources/${id}/`, json("PATCH", data)),
deleteCommerceSource: (id: number): Promise<void> =>
request(`/api/v1/commerce/sources/${id}/`, { method: "DELETE" }),
getCommerceBindings: (
params?: Record<string, string | number | boolean>,
): Promise<PaginatedCommerceResponse<ProductBinding>> =>
request(`/api/v1/commerce/product-bindings/${buildQuery(params)}`),
createCommerceBinding: (data: ProductBindingInput): Promise<ProductBinding> =>
request("/api/v1/commerce/product-bindings/", json("POST", data)),
updateCommerceBinding: (
id: string,
data: Partial<ProductBindingInput>,
): Promise<ProductBinding> =>
request(`/api/v1/commerce/product-bindings/${id}/`, json("PATCH", data)),
deleteCommerceBinding: (id: string): Promise<void> =>
request(`/api/v1/commerce/product-bindings/${id}/`, { method: "DELETE" }),
getCommerceSales: (
params?: Record<string, string | number | boolean>,
): Promise<PaginatedCommerceResponse<CommerceSale>> =>
request(`/api/v1/commerce/sales/${buildQuery(params)}`),
createCommerceSale: (data: CommerceSaleInput): Promise<CommerceSale> =>
request("/api/v1/commerce/sales/", json("POST", data)),
updateCommerceSale: (
id: string,
data: Partial<CommerceSaleInput>,
): Promise<CommerceSale> =>
request(`/api/v1/commerce/sales/${id}/`, json("PATCH", data)),
deleteCommerceSale: (id: string): Promise<void> =>
request(`/api/v1/commerce/sales/${id}/`, { method: "DELETE" }),
getCommerceSaleLines: (
params?: Record<string, string | number>,
): Promise<PaginatedCommerceResponse<CommerceSaleLine>> =>
request(`/api/v1/commerce/sale-lines/${buildQuery(params)}`),
createCommerceSaleLine: (
data: CommerceSaleLineInput,
): Promise<CommerceSaleLine> =>
request("/api/v1/commerce/sale-lines/", json("POST", data)),
updateCommerceSaleLine: (
id: string,
data: Partial<CommerceSaleLineInput>,
): Promise<CommerceSaleLine> =>
request(`/api/v1/commerce/sale-lines/${id}/`, json("PATCH", data)),
deleteCommerceSaleLine: (id: string): Promise<void> =>
request(`/api/v1/commerce/sale-lines/${id}/`, { method: "DELETE" }),
getCommerceCosts: (
params?: Record<string, string | number | boolean>,
): Promise<PaginatedCommerceResponse<CommerceCost>> =>
request(`/api/v1/commerce/costs/${buildQuery(params)}`),
createCommerceCost: (data: CommerceCostInput): Promise<CommerceCost> =>
request("/api/v1/commerce/costs/", json("POST", data)),
updateCommerceCost: (
id: string,
data: Partial<CommerceCostInput>,
): Promise<CommerceCost> =>
request(`/api/v1/commerce/costs/${id}/`, json("PATCH", data)),
deleteCommerceCost: (id: string): Promise<void> =>
request(`/api/v1/commerce/costs/${id}/`, { method: "DELETE" }),
getCommerceStockSnapshots: (
params?: Record<string, string | number | boolean>,
): Promise<PaginatedCommerceResponse<CommerceStockSnapshot>> =>
request(`/api/v1/commerce/stock-snapshots/${buildQuery(params)}`),
createCommerceStockSnapshot: (
data: CommerceStockSnapshotInput,
): Promise<CommerceStockSnapshot> =>
request("/api/v1/commerce/stock-snapshots/", json("POST", data)),
updateCommerceStockSnapshot: (
id: string,
data: Partial<CommerceStockSnapshotInput>,
): Promise<CommerceStockSnapshot> =>
request(`/api/v1/commerce/stock-snapshots/${id}/`, json("PATCH", data)),
deleteCommerceStockSnapshot: (id: string): Promise<void> =>
request(`/api/v1/commerce/stock-snapshots/${id}/`, { method: "DELETE" }),
};
}

View File

@ -17,6 +17,16 @@ export type ProductBinding = {
updated_at: string;
};
export type SourceSystem = {
id: number;
code: string;
name: string;
is_active: boolean;
metadata: Record<string, unknown>;
created_at: string;
updated_at: string;
};
export type CommerceProduct = {
id: string;
parent: string | null;
@ -32,6 +42,150 @@ export type CommerceProduct = {
updated_at: string;
};
export type CommerceSaleLine = {
id: string;
sale: string;
product: string | null;
external_id: string;
external_product_id: string;
external_sku: string;
name: string;
quantity: string;
gross_amount: string;
discount_amount: string;
net_amount: string;
cost_amount: string;
metadata: Record<string, unknown>;
};
export type CommerceSale = {
id: string;
source: number;
source_code: string;
source_record: string | null;
external_id: string;
number: string;
event_type: "sale" | "return";
channel: string;
status: string;
counts_as_sale: boolean;
currency: string;
occurred_at: string;
source_updated_at: string | null;
lines: CommerceSaleLine[];
created_at: string;
updated_at: string;
};
export type CommerceCost = {
id: string;
source: number;
source_code: string;
product: string | null;
sale_line: string | null;
external_id: string;
channel: string;
cost_type: string;
amount: string;
currency: string;
occurred_at: string;
metadata: Record<string, unknown>;
created_at: string;
};
export type CommerceStockSnapshot = {
id: string;
product: string;
source: number;
source_code: string;
warehouse_external_id: string;
warehouse_name: string;
quantity: string;
reserved: string;
available: string;
in_transit: string;
captured_at: string;
metadata: Record<string, unknown>;
created_at: string;
};
export type CommerceProductInput = {
parent?: string | null;
kind: CommerceProduct["kind"];
sku: string;
name: string;
barcode?: string;
description?: string;
is_active?: boolean;
};
export type SourceSystemInput = {
code: string;
name: string;
is_active?: boolean;
metadata?: Record<string, unknown>;
};
export type ProductBindingInput = {
product: string;
source: number;
external_id: string;
external_sku?: string;
is_primary?: boolean;
};
export type CommerceSaleInput = {
source: number;
external_id: string;
number?: string;
event_type?: CommerceSale["event_type"];
channel?: string;
status?: string;
counts_as_sale?: boolean;
currency?: string;
occurred_at: string;
source_updated_at?: string | null;
};
export type CommerceSaleLineInput = {
sale: string;
product?: string | null;
external_id: string;
external_product_id?: string;
external_sku?: string;
name?: string;
quantity?: string;
gross_amount?: string;
discount_amount?: string;
cost_amount?: string;
metadata?: Record<string, unknown>;
};
export type CommerceCostInput = {
source: number;
product?: string | null;
sale_line?: string | null;
external_id?: string;
channel?: string;
cost_type: string;
amount: string;
currency?: string;
occurred_at: string;
metadata?: Record<string, unknown>;
};
export type CommerceStockSnapshotInput = {
product: string;
source: number;
warehouse_external_id: string;
warehouse_name?: string;
quantity: string;
reserved?: string;
in_transit?: string;
captured_at: string;
metadata?: Record<string, unknown>;
};
export type ProductMonetaryMetrics = {
currency: string;
quantity: string;