refactor(erp): integração direta com banco ERP — schema sar
Revoga ADR 0006 (BD-por-workspace separado). O SAR agora conecta ao banco PostgreSQL do ERP (módulo SIG) e usa o schema `sar` para tudo. PRISMA - Remove: Client, Product, Order, OrderItem, OrderStatusHistory, RepTarget, RepDiscountLimit, PushSubscription (modelos isolados) - Adiciona: Pedido, PedidoItem, HistoricoPedido, AlcadaDesconto, MetaRepresentante, PushSubscription (mapeados para sar.*) - IDs: id_cliente/cod_vendedor/id_empresa são INTEGER (ERP) - situa: Int (1=Pendente 2=Aprovado 3=Cancelado 4=Faturado) - JWT: workspace_id:string → id_empresa:number - URL: inclui ?schema=sar para Prisma rotear ao schema ERP SERVICES - ClientsService: $queryRawUnsafe contra sar.vw_clientes + sar.pedidos - CatalogService: $queryRawUnsafe contra sar.vw_produtos + sar.vw_estoque - OrdersService: Prisma models Pedido/PedidoItem/HistoricoPedido/AlcadaDesconto - DashboardService: MetaRepresentante + queries raw para inativos - NotificationsService: PushSubscription com codVendedor + idEmpresa CONTRATOS (api-interface) - client.contract: campos ERP (idCliente, nome, cgcpf, cod_vendedor…) - order.contract: PedidoSummary/PedidoDetail/CreatePedido + SITUA_LABEL - product.contract: ProdutoSummary/ProdutoDetail (vw_produtos) - auth.contract: workspaceId:string → idEmpresa:number WEB - Todos os cockpits e queries atualizados para os novos tipos Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -18,7 +18,7 @@ import {
|
||||
import type { TableColumnsType } from 'antd';
|
||||
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { Link, useNavigate, useSearch } from '@tanstack/react-router';
|
||||
import type { CreateOrder, CreateOrderItem, ProductSummary } from '@sar/api-interface';
|
||||
import type { CreatePedido, CreatePedidoItem, ProdutoSummary } from '@sar/api-interface';
|
||||
import { useClientDetail } from '../../lib/queries/clients';
|
||||
import { useCatalog } from '../../lib/queries/catalog';
|
||||
import { apiFetch } from '../../lib/api-client';
|
||||
@@ -26,7 +26,7 @@ import { apiFetch } from '../../lib/api-client';
|
||||
const { Title, Text } = Typography;
|
||||
const { Search } = Input;
|
||||
|
||||
type CartItem = CreateOrderItem & { key: string };
|
||||
type CartItem = CreatePedidoItem & { key: string };
|
||||
|
||||
function calcItemTotal(qty: number, price: number, disc: number): number {
|
||||
return Math.round(qty * price * (1 - disc / 100) * 100) / 100;
|
||||
@@ -46,7 +46,7 @@ function ProductStep({
|
||||
onDiscChange,
|
||||
}: {
|
||||
cart: CartItem[];
|
||||
onAdd: (p: ProductSummary) => void;
|
||||
onAdd: (p: ProdutoSummary) => void;
|
||||
onRemove: (key: string) => void;
|
||||
onQtyChange: (key: string, qty: number) => void;
|
||||
onDiscChange: (key: string, disc: number) => void;
|
||||
@@ -54,20 +54,20 @@ function ProductStep({
|
||||
const [q, setQ] = useState('');
|
||||
const { data, isLoading } = useCatalog({ q: q || undefined, limit: 20 });
|
||||
|
||||
const cartKeys = new Set(cart.map((c) => c.productCode));
|
||||
const cartKeys = new Set(cart.map((c) => String(c.idProduto)));
|
||||
|
||||
const catalogColumns: TableColumnsType<ProductSummary> = [
|
||||
{ title: 'Código', dataIndex: 'code', width: 100 },
|
||||
{ title: 'Produto', dataIndex: 'name', ellipsis: true },
|
||||
const catalogColumns: TableColumnsType<ProdutoSummary> = [
|
||||
{ title: 'Código', dataIndex: 'codigo', width: 100 },
|
||||
{ title: 'Produto', dataIndex: 'descricao', ellipsis: true },
|
||||
{
|
||||
title: 'Categoria',
|
||||
dataIndex: 'category',
|
||||
title: 'Grupo',
|
||||
dataIndex: 'grupo',
|
||||
width: 110,
|
||||
render: (v: string) => <Tag>{v}</Tag>,
|
||||
render: (v: string | null) => (v ? <Tag>{v}</Tag> : null),
|
||||
},
|
||||
{
|
||||
title: 'Preço',
|
||||
dataIndex: 'unitPrice',
|
||||
dataIndex: 'vlPreco1',
|
||||
width: 110,
|
||||
align: 'right',
|
||||
render: (v: string) => fmt(Number(v)),
|
||||
@@ -75,11 +75,11 @@ function ProductStep({
|
||||
{
|
||||
title: '',
|
||||
width: 80,
|
||||
render: (_: unknown, row: ProductSummary) => (
|
||||
render: (_: unknown, row: ProdutoSummary) => (
|
||||
<Button
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
disabled={cartKeys.has(row.code)}
|
||||
disabled={cartKeys.has(String(row.idErp))}
|
||||
onClick={() => onAdd(row)}
|
||||
>
|
||||
Add
|
||||
@@ -89,10 +89,10 @@ function ProductStep({
|
||||
];
|
||||
|
||||
const cartColumns: TableColumnsType<CartItem> = [
|
||||
{ title: 'Produto', dataIndex: 'productName', ellipsis: true },
|
||||
{ title: 'Produto', dataIndex: 'descProduto', ellipsis: true },
|
||||
{
|
||||
title: 'Qtd',
|
||||
dataIndex: 'quantity',
|
||||
dataIndex: 'qtd',
|
||||
width: 100,
|
||||
render: (v: number, row: CartItem) => (
|
||||
<InputNumber
|
||||
@@ -107,7 +107,7 @@ function ProductStep({
|
||||
},
|
||||
{
|
||||
title: 'Desc %',
|
||||
dataIndex: 'discountPct',
|
||||
dataIndex: 'descontoPerc',
|
||||
width: 100,
|
||||
render: (v: number, row: CartItem) => (
|
||||
<InputNumber
|
||||
@@ -126,7 +126,7 @@ function ProductStep({
|
||||
width: 120,
|
||||
align: 'right',
|
||||
render: (_: unknown, row: CartItem) =>
|
||||
fmt(calcItemTotal(row.quantity, row.unitPrice, row.discountPct)),
|
||||
fmt(calcItemTotal(row.qtd, row.precoUnitario, row.descontoPerc)),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
@@ -148,8 +148,8 @@ function ProductStep({
|
||||
if (!e.target.value) setQ('');
|
||||
}}
|
||||
/>
|
||||
<Table<ProductSummary>
|
||||
rowKey="id"
|
||||
<Table<ProdutoSummary>
|
||||
rowKey="idErp"
|
||||
columns={catalogColumns}
|
||||
dataSource={data?.data ?? []}
|
||||
loading={isLoading}
|
||||
@@ -175,20 +175,20 @@ function ProductStep({
|
||||
function ReviewStep({
|
||||
cart,
|
||||
globalDisc,
|
||||
notes,
|
||||
obs,
|
||||
creditLimit,
|
||||
onDiscChange,
|
||||
onNotesChange,
|
||||
onObsChange,
|
||||
}: {
|
||||
cart: CartItem[];
|
||||
globalDisc: number;
|
||||
notes: string;
|
||||
obs: string;
|
||||
creditLimit: string | null;
|
||||
onDiscChange: (v: number) => void;
|
||||
onNotesChange: (v: string) => void;
|
||||
onObsChange: (v: string) => void;
|
||||
}) {
|
||||
const itemsSubtotal = cart.reduce(
|
||||
(acc, it) => acc + calcItemTotal(it.quantity, it.unitPrice, it.discountPct),
|
||||
(acc, it) => acc + calcItemTotal(it.qtd, it.precoUnitario, it.descontoPerc),
|
||||
0,
|
||||
);
|
||||
const total = Math.round(itemsSubtotal * (1 - globalDisc / 100) * 100) / 100;
|
||||
@@ -228,8 +228,8 @@ function ReviewStep({
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
showCount
|
||||
value={notes}
|
||||
onChange={(e) => onNotesChange(e.target.value)}
|
||||
value={obs}
|
||||
onChange={(e) => onObsChange(e.target.value)}
|
||||
placeholder="Instruções de entrega, referência do comprador, etc."
|
||||
/>
|
||||
</Form.Item>
|
||||
@@ -242,16 +242,16 @@ function ReviewStep({
|
||||
function ConfirmStep({
|
||||
cart,
|
||||
globalDisc,
|
||||
notes,
|
||||
clientName,
|
||||
obs,
|
||||
clientNome,
|
||||
}: {
|
||||
cart: CartItem[];
|
||||
globalDisc: number;
|
||||
notes: string;
|
||||
clientName: string;
|
||||
obs: string;
|
||||
clientNome: string;
|
||||
}) {
|
||||
const itemsSubtotal = cart.reduce(
|
||||
(acc, it) => acc + calcItemTotal(it.quantity, it.unitPrice, it.discountPct),
|
||||
(acc, it) => acc + calcItemTotal(it.qtd, it.precoUnitario, it.descontoPerc),
|
||||
0,
|
||||
);
|
||||
const total = Math.round(itemsSubtotal * (1 - globalDisc / 100) * 100) / 100;
|
||||
@@ -259,7 +259,7 @@ function ConfirmStep({
|
||||
return (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
<Descriptions bordered size="small" column={1}>
|
||||
<Descriptions.Item label="Cliente">{clientName}</Descriptions.Item>
|
||||
<Descriptions.Item label="Cliente">{clientNome}</Descriptions.Item>
|
||||
<Descriptions.Item label="Produtos">{cart.length} item(ns)</Descriptions.Item>
|
||||
<Descriptions.Item label="Subtotal dos itens">{fmt(itemsSubtotal)}</Descriptions.Item>
|
||||
<Descriptions.Item label="Desconto global">{globalDisc}%</Descriptions.Item>
|
||||
@@ -268,11 +268,11 @@ function ConfirmStep({
|
||||
{fmt(total)}
|
||||
</Text>
|
||||
</Descriptions.Item>
|
||||
{notes && <Descriptions.Item label="Observações">{notes}</Descriptions.Item>}
|
||||
{obs && <Descriptions.Item label="Observações">{obs}</Descriptions.Item>}
|
||||
</Descriptions>
|
||||
<Alert
|
||||
type="info"
|
||||
message="O pedido será criado com status Orçamento ou Aguardando Aprovação, conforme a sua alçada de desconto."
|
||||
message="O pedido será criado como Pendente de Aprovação ou Aprovado conforme a sua alçada de desconto."
|
||||
showIcon
|
||||
/>
|
||||
</Space>
|
||||
@@ -288,61 +288,64 @@ export function NewOrderPage() {
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const { data: client, isLoading: clientLoading } = useClientDetail(clientId);
|
||||
const clientIdNum = clientId ? Number(clientId) : undefined;
|
||||
const { data: client, isLoading: clientLoading } = useClientDetail(clientIdNum);
|
||||
|
||||
const [step, setStep] = useState(0);
|
||||
const [cart, setCart] = useState<CartItem[]>([]);
|
||||
const [globalDisc, setGlobalDisc] = useState(0);
|
||||
const [notes, setNotes] = useState('');
|
||||
const [obs, setObs] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!clientId) throw new Error('clientId ausente');
|
||||
const body: CreateOrder = {
|
||||
clientId,
|
||||
discountPct: globalDisc,
|
||||
notes: notes || undefined,
|
||||
if (!clientIdNum) throw new Error('clientId ausente');
|
||||
const body: CreatePedido = {
|
||||
idCliente: clientIdNum,
|
||||
descontoPerc: globalDisc,
|
||||
obs: obs || undefined,
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
items: cart.map((it) => ({
|
||||
productCode: it.productCode,
|
||||
productName: it.productName,
|
||||
productCategory: it.productCategory,
|
||||
quantity: it.quantity,
|
||||
unitPrice: it.unitPrice,
|
||||
discountPct: it.discountPct,
|
||||
itens: cart.map((it, idx) => ({
|
||||
idProduto: it.idProduto,
|
||||
codProduto: it.codProduto,
|
||||
descProduto: it.descProduto,
|
||||
ordem: idx + 1,
|
||||
qtd: it.qtd,
|
||||
precoUnitario: it.precoUnitario,
|
||||
descontoPerc: it.descontoPerc,
|
||||
})),
|
||||
};
|
||||
return apiFetch('/orders', { method: 'POST', body });
|
||||
},
|
||||
onSuccess: (order: { id: string }) => {
|
||||
onSuccess: (pedido: { id: string }) => {
|
||||
void qc.invalidateQueries({ queryKey: ['orders'] });
|
||||
void qc.invalidateQueries({ queryKey: ['clients', clientId] });
|
||||
void navigate({ to: '/pedidos/$id', params: { id: order.id } });
|
||||
void qc.invalidateQueries({ queryKey: ['clients', clientIdNum] });
|
||||
void navigate({ to: '/pedidos/$id', params: { id: pedido.id } });
|
||||
},
|
||||
onError: (e: unknown) => setError(e instanceof Error ? e.message : 'Erro ao criar pedido'),
|
||||
});
|
||||
|
||||
const addToCart = (p: ProductSummary) => {
|
||||
const addToCart = (p: ProdutoSummary) => {
|
||||
setCart((prev) => [
|
||||
...prev,
|
||||
{
|
||||
key: p.code,
|
||||
productCode: p.code,
|
||||
productName: p.name,
|
||||
productCategory: p.category,
|
||||
quantity: 1,
|
||||
unitPrice: Number(p.unitPrice),
|
||||
discountPct: 0,
|
||||
key: String(p.idErp),
|
||||
idProduto: p.idErp,
|
||||
codProduto: p.codigo,
|
||||
descProduto: p.descricao,
|
||||
ordem: prev.length + 1,
|
||||
qtd: 1,
|
||||
precoUnitario: Number(p.vlPreco1),
|
||||
descontoPerc: 0,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const removeFromCart = (key: string) => setCart((prev) => prev.filter((it) => it.key !== key));
|
||||
const setQty = (key: string, qty: number) =>
|
||||
setCart((prev) => prev.map((it) => (it.key === key ? { ...it, quantity: qty } : it)));
|
||||
setCart((prev) => prev.map((it) => (it.key === key ? { ...it, qtd: qty } : it)));
|
||||
const setDisc = (key: string, disc: number) =>
|
||||
setCart((prev) => prev.map((it) => (it.key === key ? { ...it, discountPct: disc } : it)));
|
||||
setCart((prev) => prev.map((it) => (it.key === key ? { ...it, descontoPerc: disc } : it)));
|
||||
|
||||
if (!clientId)
|
||||
return <Alert type="error" message="Parâmetro clientId ausente." style={{ margin: 24 }} />;
|
||||
@@ -360,7 +363,7 @@ export function NewOrderPage() {
|
||||
<div style={{ padding: 24, maxWidth: 900 }}>
|
||||
<Space align="center" style={{ marginBottom: 16 }}>
|
||||
<Link to="/clientes/$id" params={{ id: clientId }}>
|
||||
← {client.tradeName ?? client.name}
|
||||
← {client.razao ?? client.nome}
|
||||
</Link>
|
||||
<Title level={3} style={{ margin: 0 }}>
|
||||
Novo Pedido
|
||||
@@ -382,18 +385,18 @@ export function NewOrderPage() {
|
||||
<ReviewStep
|
||||
cart={cart}
|
||||
globalDisc={globalDisc}
|
||||
notes={notes}
|
||||
creditLimit={client.creditLimit}
|
||||
obs={obs}
|
||||
creditLimit={client.limiteCreditoStr}
|
||||
onDiscChange={setGlobalDisc}
|
||||
onNotesChange={setNotes}
|
||||
onObsChange={setObs}
|
||||
/>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<ConfirmStep
|
||||
cart={cart}
|
||||
globalDisc={globalDisc}
|
||||
notes={notes}
|
||||
clientName={client.tradeName ?? client.name}
|
||||
obs={obs}
|
||||
clientNome={client.razao ?? client.nome}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user