309 lines
10 KiB
TypeScript
309 lines
10 KiB
TypeScript
import axios from "axios";
|
||
|
||
import { buildCommanderHeaders, resolveCommanderBaseUrl } from "./commanderHttp";
|
||
|
||
export type BancoPontosEstaAtivoFiltro = "all" | "true" | "false";
|
||
|
||
export type BancoPontosSaldoItem = {
|
||
parceiroId: string;
|
||
nome: string;
|
||
codinome: string | null;
|
||
estaAtivo: boolean;
|
||
logoUrl: string | null;
|
||
/** URL pública da foto (Commander pode enviar junto com `logoUrl`). */
|
||
fotoUrl?: string | null;
|
||
saldo: number;
|
||
};
|
||
|
||
export type BancoPontosExtratoItem = {
|
||
id: string;
|
||
parceiroId: string;
|
||
fechamentoId: string | null;
|
||
tipo: "credito" | "debito";
|
||
quantidade: number;
|
||
descricao: string;
|
||
criadoEm: string;
|
||
/** Mês da competência do fechamento (1–12), se a linha estiver vinculada a um fechamento. */
|
||
competenciaMes: number | null;
|
||
/** Ano da competência do fechamento, se houver. */
|
||
competenciaAno: number | null;
|
||
};
|
||
|
||
export type BancoPontosParceiroExtrato = {
|
||
parceiroId: string;
|
||
nome: string;
|
||
codinome: string | null;
|
||
logoUrl: string | null;
|
||
fotoUrl?: string | null;
|
||
saldo: number;
|
||
};
|
||
|
||
type MetaResponse = {
|
||
total: number;
|
||
paginaAtual: number;
|
||
totalPaginas: number;
|
||
};
|
||
|
||
export type ListarBancoPontosSaldosResponse = {
|
||
data: BancoPontosSaldoItem[];
|
||
meta: MetaResponse;
|
||
};
|
||
|
||
export type ListarBancoPontosExtratoResponse = {
|
||
parceiro: BancoPontosParceiroExtrato;
|
||
data: BancoPontosExtratoItem[];
|
||
meta: MetaResponse;
|
||
};
|
||
|
||
type ApiErrorShape = {
|
||
error?: {
|
||
message?: string;
|
||
};
|
||
};
|
||
|
||
function pickLogoUrl(row: { logoUrl?: unknown; logo_url?: unknown }): string | null {
|
||
const v = row.logoUrl ?? row.logo_url;
|
||
if (v == null) return null;
|
||
const s = String(v).trim();
|
||
return s.length > 0 ? s : null;
|
||
}
|
||
|
||
function pickSaldo(row: { saldo?: unknown; saldo_atual?: unknown }): number {
|
||
const v = row.saldo ?? row.saldo_atual;
|
||
if (v == null || v === "") return 0;
|
||
const n = Number(v);
|
||
return Number.isFinite(n) ? n : 0;
|
||
}
|
||
|
||
function pickFotoUrl(raw: { fotoUrl?: unknown; foto_url?: unknown }): string | null {
|
||
const v = raw.fotoUrl ?? raw.foto_url;
|
||
if (v == null) return null;
|
||
const s = String(v).trim();
|
||
return s.length > 0 ? s : null;
|
||
}
|
||
|
||
function normalizeSaldoItem(raw: Record<string, unknown>): BancoPontosSaldoItem {
|
||
const logo = pickLogoUrl(raw);
|
||
const foto = pickFotoUrl(raw) ?? logo;
|
||
return {
|
||
parceiroId: String(raw.parceiroId ?? raw.parceiro_id ?? ""),
|
||
nome: String(raw.nome ?? ""),
|
||
codinome: raw.codinome != null ? String(raw.codinome) : null,
|
||
estaAtivo: Boolean(raw.estaAtivo ?? raw.esta_ativo),
|
||
logoUrl: foto,
|
||
fotoUrl: foto,
|
||
saldo: pickSaldo(raw),
|
||
};
|
||
}
|
||
|
||
function normalizeParceiroExtrato(raw: Record<string, unknown>): BancoPontosParceiroExtrato {
|
||
const logo = pickLogoUrl(raw);
|
||
const foto = pickFotoUrl(raw) ?? logo;
|
||
return {
|
||
parceiroId: String(raw.parceiroId ?? raw.parceiro_id ?? ""),
|
||
nome: String(raw.nome ?? ""),
|
||
codinome: raw.codinome != null ? String(raw.codinome) : null,
|
||
logoUrl: foto,
|
||
fotoUrl: foto,
|
||
saldo: pickSaldo(raw),
|
||
};
|
||
}
|
||
|
||
function pickNullableInt(v: unknown): number | null {
|
||
if (v == null || v === "") return null;
|
||
const n = Number(v);
|
||
return Number.isFinite(n) ? Math.trunc(n) : null;
|
||
}
|
||
|
||
export function normalizeBancoPontosExtratoItem(raw: Record<string, unknown>): BancoPontosExtratoItem {
|
||
const fechamentoRaw = raw.fechamentoId ?? raw.fechamento_id;
|
||
return {
|
||
id: String(raw.id ?? ""),
|
||
parceiroId: String(raw.parceiroId ?? raw.parceiro_id ?? ""),
|
||
fechamentoId: fechamentoRaw != null && String(fechamentoRaw).trim() !== "" ? String(fechamentoRaw) : null,
|
||
tipo: raw.tipo === "debito" ? "debito" : "credito",
|
||
quantidade: Number(raw.quantidade ?? 0),
|
||
descricao: String(raw.descricao ?? ""),
|
||
criadoEm: String(raw.criadoEm ?? raw.criado_em ?? ""),
|
||
competenciaMes: pickNullableInt(raw.competenciaMes ?? raw.competencia_mes),
|
||
competenciaAno: pickNullableInt(raw.competenciaAno ?? raw.competencia_ano),
|
||
};
|
||
}
|
||
|
||
export type ListarBancoPontosSaldosParams = {
|
||
estaAtivo?: BancoPontosEstaAtivoFiltro;
|
||
busca?: string;
|
||
page: number;
|
||
perPage: number;
|
||
};
|
||
|
||
export type ListarBancoPontosExtratoParams = {
|
||
page: number;
|
||
perPage: number;
|
||
};
|
||
|
||
export type ExportarBancoPontosExtratoResponse = {
|
||
buffer: ArrayBuffer;
|
||
filename: string | null;
|
||
};
|
||
|
||
class FechamentoBancoPontosService {
|
||
private extractFilenameFromContentDisposition(value: string | undefined): string | null {
|
||
if (!value) return null;
|
||
const utf8Match = value.match(/filename\*=UTF-8''([^;]+)/i);
|
||
if (utf8Match?.[1]) {
|
||
try {
|
||
return decodeURIComponent(utf8Match[1]);
|
||
} catch {
|
||
return utf8Match[1];
|
||
}
|
||
}
|
||
const regularMatch = value.match(/filename="?([^";]+)"?/i);
|
||
return regularMatch?.[1] ?? null;
|
||
}
|
||
|
||
private messageFromResponseData(data: unknown): string | null {
|
||
if (data == null) return null;
|
||
if (typeof data === "object" && !(data instanceof ArrayBuffer) && !ArrayBuffer.isView(data)) {
|
||
const msg = (data as ApiErrorShape).error?.message;
|
||
return typeof msg === "string" && msg.trim() ? msg : null;
|
||
}
|
||
if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) {
|
||
const buf = data instanceof ArrayBuffer ? new Uint8Array(data) : new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
||
if (buf.byteLength === 0) return null;
|
||
try {
|
||
const text = new TextDecoder().decode(buf);
|
||
const trimmed = text.trim();
|
||
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
||
const json = JSON.parse(trimmed) as ApiErrorShape;
|
||
if (typeof json.error?.message === "string" && json.error.message.trim()) {
|
||
return json.error.message;
|
||
}
|
||
}
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
private handleAxiosError(error: unknown, fallback: string): never {
|
||
if (axios.isAxiosError(error)) {
|
||
if (!error.response) {
|
||
throw new Error(
|
||
"Não foi possível conectar na API do Commander. Verifique VITE_API_BASE_URL_COMMANDER e se o backend está acessível.",
|
||
);
|
||
}
|
||
const status = error.response.status;
|
||
const fromBody =
|
||
this.messageFromResponseData(error.response.data) ??
|
||
(status === 404
|
||
? "Recurso não encontrado. Confirme se o backend está atualizado (rota de exportação)."
|
||
: null);
|
||
const message = (fromBody ?? error.message ?? fallback) as string;
|
||
throw new Error(message);
|
||
}
|
||
throw new Error(fallback);
|
||
}
|
||
|
||
async listarSaldos(params: ListarBancoPontosSaldosParams): Promise<ListarBancoPontosSaldosResponse> {
|
||
try {
|
||
const headers = await buildCommanderHeaders();
|
||
const baseUrl = resolveCommanderBaseUrl();
|
||
const response = await axios.get<ListarBancoPontosSaldosResponse>(`${baseUrl}/banco-pontos`, {
|
||
headers,
|
||
params: {
|
||
estaAtivo: params.estaAtivo ?? "all",
|
||
busca: params.busca?.trim() || undefined,
|
||
page: params.page,
|
||
perPage: params.perPage,
|
||
},
|
||
});
|
||
const body = response.data;
|
||
return {
|
||
...body,
|
||
data: (body.data ?? []).map((row) => normalizeSaldoItem(row as unknown as Record<string, unknown>)),
|
||
};
|
||
} catch (error) {
|
||
this.handleAxiosError(error, "Erro ao listar saldos do banco de pontos.");
|
||
}
|
||
}
|
||
|
||
async listarExtrato(
|
||
parceiroId: string,
|
||
params: ListarBancoPontosExtratoParams,
|
||
): Promise<ListarBancoPontosExtratoResponse> {
|
||
try {
|
||
const headers = await buildCommanderHeaders();
|
||
const baseUrl = resolveCommanderBaseUrl();
|
||
const response = await axios.get<ListarBancoPontosExtratoResponse>(
|
||
`${baseUrl}/banco-pontos/${parceiroId}/extrato`,
|
||
{
|
||
headers,
|
||
params: {
|
||
page: params.page,
|
||
perPage: params.perPage,
|
||
},
|
||
},
|
||
);
|
||
const body = response.data;
|
||
const parceiroRaw = body.parceiro as unknown;
|
||
const parceiroNorm =
|
||
parceiroRaw != null && typeof parceiroRaw === "object"
|
||
? normalizeParceiroExtrato(parceiroRaw as Record<string, unknown>)
|
||
: body.parceiro;
|
||
return {
|
||
...body,
|
||
parceiro: parceiroNorm,
|
||
data: (body.data ?? []).map((row) => normalizeBancoPontosExtratoItem(row as unknown as Record<string, unknown>)),
|
||
};
|
||
} catch (error) {
|
||
this.handleAxiosError(error, "Erro ao carregar extrato do banco de pontos.");
|
||
}
|
||
}
|
||
|
||
async exportarExtrato(parceiroId: string): Promise<ExportarBancoPontosExtratoResponse> {
|
||
try {
|
||
const headers = await buildCommanderHeaders();
|
||
const baseUrl = resolveCommanderBaseUrl();
|
||
const response = await axios.get<ArrayBuffer>(
|
||
`${baseUrl}/banco-pontos/${parceiroId}/extrato/exportar`,
|
||
{
|
||
headers,
|
||
responseType: "arraybuffer",
|
||
},
|
||
);
|
||
const buf = response.data;
|
||
if (buf.byteLength < 4) {
|
||
throw new Error("Resposta vazia ao exportar planilha.");
|
||
}
|
||
const bytes = new Uint8Array(buf);
|
||
const headText = new TextDecoder().decode(bytes.subarray(0, Math.min(256, bytes.length))).trim();
|
||
if (headText.startsWith("{")) {
|
||
let json: ApiErrorShape;
|
||
try {
|
||
json = JSON.parse(new TextDecoder().decode(bytes)) as ApiErrorShape;
|
||
} catch {
|
||
throw new Error("A API retornou uma resposta inválida ao exportar.");
|
||
}
|
||
const msg = json.error?.message?.trim();
|
||
throw new Error(msg || "Falha ao exportar extrato (resposta JSON). Atualize o Commander.");
|
||
}
|
||
if (bytes[0] !== 0x50 || bytes[1] !== 0x4b) {
|
||
throw new Error("A API não retornou um arquivo XLSX válido (assinatura inválida).");
|
||
}
|
||
return {
|
||
buffer: buf,
|
||
filename: this.extractFilenameFromContentDisposition(response.headers["content-disposition"]),
|
||
};
|
||
} catch (error) {
|
||
if (axios.isAxiosError(error)) {
|
||
this.handleAxiosError(error, "Erro ao exportar extrato do banco de pontos.");
|
||
}
|
||
throw error instanceof Error ? error : new Error("Erro ao exportar extrato do banco de pontos.");
|
||
}
|
||
}
|
||
}
|
||
|
||
export const fechamentoBancoPontosService = new FechamentoBancoPontosService();
|