Files
iistwin/server/finance/google-sheets.ts

495 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { type Express } from "express";
import { google } from "googleapis";
import { readFileSync, writeFileSync, existsSync, unlinkSync, mkdirSync } from "fs";
import { resolve, dirname } from "path";
import { query, getPoolClient } from "./db-client";
import { authenticateToken, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware";
// =====================
// Google OAuth + участки + синхронизация зарплат (перенесено из Data-Insight2)
// =====================
const TOKENS_PATH = resolve(process.cwd(), "data", "google-tokens.json");
const SCOPES = [
"https://www.googleapis.com/auth/spreadsheets.readonly",
"https://www.googleapis.com/auth/userinfo.email",
];
function getRedirectUri(): string {
return process.env.FINANCE_GOOGLE_REDIRECT_URI || "https://iistwin.ru/api/finance/google/callback";
}
function createOAuth2Client() {
return new google.auth.OAuth2(
process.env.GOOGLE_CLIENT_ID,
process.env.GOOGLE_CLIENT_SECRET,
getRedirectUri()
);
}
interface StoredTokens {
access_token: string;
refresh_token?: string;
expiry_date?: number;
token_type?: string;
scope?: string;
email?: string;
}
function loadTokens(): StoredTokens | null {
try {
if (existsSync(TOKENS_PATH)) {
return JSON.parse(readFileSync(TOKENS_PATH, "utf-8"));
}
} catch {}
return null;
}
function saveTokens(tokens: StoredTokens): void {
const dir = dirname(TOKENS_PATH);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
writeFileSync(TOKENS_PATH, JSON.stringify(tokens, null, 2), "utf-8");
}
function clearTokens(): void {
try {
if (existsSync(TOKENS_PATH)) unlinkSync(TOKENS_PATH);
} catch {}
}
export function getAuthUrl(): string {
const client = createOAuth2Client();
return client.generateAuthUrl({
access_type: "offline",
scope: SCOPES,
prompt: "consent",
});
}
async function handleCallback(code: string): Promise<{ email?: string }> {
const client = createOAuth2Client();
const { tokens } = await client.getToken(code);
client.setCredentials(tokens);
let email: string | undefined;
try {
const oauth2 = google.oauth2({ version: "v2", auth: client });
const userInfo = await oauth2.userinfo.get();
email = userInfo.data.email || undefined;
} catch {}
saveTokens({
access_token: tokens.access_token!,
refresh_token: tokens.refresh_token || undefined,
expiry_date: tokens.expiry_date || undefined,
token_type: tokens.token_type || undefined,
scope: tokens.scope || undefined,
email,
});
return { email };
}
export function getAuthStatus(): { loggedIn: boolean; email?: string } {
const tokens = loadTokens();
if (!tokens?.access_token) return { loggedIn: false };
return { loggedIn: true, email: tokens.email };
}
export async function getAuthenticatedClient() {
const tokens = loadTokens();
if (!tokens?.access_token) throw new Error("Не авторизован в Google");
const client = createOAuth2Client();
client.setCredentials({
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expiry_date: tokens.expiry_date,
});
const needsRefresh = tokens.expiry_date
? tokens.expiry_date < Date.now() + 60000
: false;
if (needsRefresh && tokens.refresh_token) {
try {
const { credentials } = await client.refreshAccessToken();
client.setCredentials(credentials);
saveTokens({
...tokens,
access_token: credentials.access_token!,
expiry_date: credentials.expiry_date || undefined,
});
} catch (err) {
clearTokens();
throw new Error("Токен Google истёк. Пожалуйста, войдите заново.");
}
}
client.on("tokens", (newTokens: any) => {
const current = loadTokens();
if (current && newTokens.access_token) {
saveTokens({
...current,
access_token: newTokens.access_token,
expiry_date: newTokens.expiry_date || current.expiry_date,
refresh_token: newTokens.refresh_token || current.refresh_token,
});
}
});
return client;
}
async function getSheetNames(spreadsheetId: string): Promise<string[]> {
const auth = await getAuthenticatedClient();
const sheets = google.sheets({ version: "v4", auth });
const res = await sheets.spreadsheets.get({ spreadsheetId, fields: "sheets.properties.title" });
return (res.data.sheets || []).map((s: any) => s.properties?.title || "").filter(Boolean);
}
async function getSheetData(spreadsheetId: string, range: string): Promise<any[][]> {
const auth = await getAuthenticatedClient();
const sheets = google.sheets({ version: "v4", auth });
const res = await sheets.spreadsheets.values.get({ spreadsheetId, range });
return res.data.values || [];
}
// =====================
// Участки (data/salary-areas.json)
// =====================
const SALARY_AREAS_PATH = resolve(process.cwd(), "data", "salary-areas.json");
interface SalaryArea {
id: string;
name: string;
spreadsheetId: string;
fioCol: string;
bazaCol: string;
kVydacheCol: string;
}
function colLetterToIndex(col: string): number {
const c = col.toUpperCase();
let idx = 0;
for (let i = 0; i < c.length; i++) {
idx = idx * 26 + (c.charCodeAt(i) - 64);
}
return idx - 1;
}
function maxColLetter(...cols: string[]): string {
let max = cols[0];
for (const c of cols) {
if (colLetterToIndex(c) > colLetterToIndex(max)) max = c;
}
return max.toUpperCase();
}
function loadAreas(): SalaryArea[] {
try {
if (existsSync(SALARY_AREAS_PATH)) {
const raw = JSON.parse(readFileSync(SALARY_AREAS_PATH, "utf-8"));
return raw.map((a: any) => ({
fioCol: "B",
bazaCol: "F",
kVydacheCol: "J",
...a,
}));
}
} catch {}
return [];
}
function saveAreas(areas: any[]): void {
writeFileSync(SALARY_AREAS_PATH, JSON.stringify(areas, null, 2), "utf-8");
}
function extractSpreadsheetId(url: string): string | null {
const m = url.match(/\/spreadsheets\/d\/([a-zA-Z0-9_-]+)/);
return m ? m[1] : null;
}
const COL_PATTERN = /^[A-Za-z]{1,2}$/;
// =====================
// Загрузка данных зарплат из Google Sheets
// =====================
const MONTH_PATTERN = /^(\d{1,2})\.(\d{2})$/;
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function withRetry<T>(fn: () => Promise<T>, maxRetries = 4, baseDelayMs = 1000): Promise<T> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (err: any) {
const msg = String(err?.message || "");
const status = err?.statusCode || err?.code || err?.response?.status;
const isQuota = msg.includes("Quota exceeded") || msg.includes("quota") || status === 429 || msg.includes("429") || msg.includes("RATE_LIMIT") || msg.includes("rate limit");
if (!isQuota || attempt === maxRetries) throw err;
const delayMs = baseDelayMs * Math.pow(2, attempt);
console.log(`[opneof] retry ${attempt + 1}/${maxRetries} after ${delayMs}ms — ${msg.slice(0, 120)}`);
await sleep(delayMs);
}
}
throw new Error("withRetry: unreachable");
}
async function fetchNeofFromSheets(targetAreas: SalaryArea[]): Promise<any[]> {
const results: any[] = [];
for (const area of targetAreas) {
try {
const sheetNames = await withRetry(() => getSheetNames(area.spreadsheetId));
const monthSheets = sheetNames
.filter(name => MONTH_PATTERN.test(name))
.sort((a, b) => {
const [, am, ay] = a.match(MONTH_PATTERN)!;
const [, bm, by] = b.match(MONTH_PATTERN)!;
return (Number(ay) * 12 + Number(am)) - (Number(by) * 12 + Number(bm));
});
const months: { month: string; employees: any[] }[] = [];
for (const sheetName of monthSheets) {
const fioIdx = colLetterToIndex(area.fioCol || "B");
const bazaIdx = colLetterToIndex(area.bazaCol || "F");
const kVydacheIdx = colLetterToIndex(area.kVydacheCol || "J");
const lastCol = maxColLetter(area.fioCol || "B", area.bazaCol || "F", area.kVydacheCol || "J");
const rows = await withRetry(() => getSheetData(area.spreadsheetId, `'${sheetName}'!A4:${lastCol}500`));
const employees = rows
.filter(row => row && row[fioIdx] && String(row[fioIdx]).trim())
.map(row => {
const rawFio = String(row[fioIdx] || "");
const fio = rawFio.replace(/[0-9]/g, "").replace(/[^\p{L}\s\-().]/gu, "").replace(/\s+/g, " ").trim();
if (!fio) return null;
const baza = Number(String(row[bazaIdx] || "0").replace(/\s/g, "").replace(",", ".")) || 0;
const kVydache = Number(String(row[kVydacheIdx] || "0").replace(/\s/g, "").replace(",", ".")) || 0;
return { fio, baza, kVydache };
})
.filter(Boolean);
months.push({ month: sheetName, employees });
await sleep(200);
}
results.push({ id: area.id, name: area.name, months: months.filter(m => m.employees.length > 0) });
} catch (err: any) {
console.error(`Error fetching area ${area.name}:`, err?.message);
results.push({ id: area.id, name: area.name, months: [], error: err?.message || "Ошибка загрузки" });
}
await sleep(600);
}
return results;
}
// Таблица opneof создаётся в salary-routes.ts (ensureTables)
async function ensureOpneof(): Promise<boolean> {
try {
const r = await query(`SELECT to_regclass('opneof') as reg`);
return !!r.rows[0]?.reg;
} catch {
return false;
}
}
let syncInProgress = false;
// =====================
// Routes
// =====================
export function registerGoogleSheetsRoutes(app: Express) {
// --- Google OAuth ---
app.get("/api/finance/google/status", authenticateToken, requirePermission('finance.manage'), (_req: AuthenticatedRequest, res) => {
res.json({ success: true, ...getAuthStatus() });
});
app.get("/api/finance/google/auth-url", authenticateToken, requirePermission('finance.manage'), (_req: AuthenticatedRequest, res) => {
res.json({ success: true, url: getAuthUrl() });
});
app.get("/api/finance/google/callback", async (req, res) => {
try {
const code = req.query.code as string;
if (!code) { res.redirect("/salary?google_auth=error"); return; }
await handleCallback(code);
res.redirect("/salary?google_auth=success");
} catch (err) {
console.error("[google callback]", err);
res.redirect("/salary?google_auth=error");
}
});
app.post("/api/finance/google/logout", authenticateToken, requirePermission('finance.manage'), (_req: AuthenticatedRequest, res) => {
clearTokens();
res.json({ success: true });
});
// --- Участки ---
app.get("/api/salary/areas", authenticateToken, requirePermission('finance.manage'), (_req: AuthenticatedRequest, res) => {
res.json({ success: true, areas: loadAreas() });
});
app.post("/api/salary/areas", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => {
try {
const { name, spreadsheetUrl, fioCol = "B", bazaCol = "F", kVydacheCol = "J" } = req.body || {};
if (!name || !spreadsheetUrl) return res.status(400).json({ success: false, error: "Укажите название участка и ссылку на таблицу" });
const spreadsheetId = extractSpreadsheetId(spreadsheetUrl);
if (!spreadsheetId) return res.status(400).json({ success: false, error: "Неверная ссылка на Google Таблицу" });
if (!COL_PATTERN.test(fioCol) || !COL_PATTERN.test(bazaCol) || !COL_PATTERN.test(kVydacheCol)) {
return res.status(400).json({ success: false, error: "Столбцы должны быть буквами (A-Z)" });
}
const areas = loadAreas();
const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
const area = { id, name, spreadsheetId, fioCol: fioCol.toUpperCase(), bazaCol: bazaCol.toUpperCase(), kVydacheCol: kVydacheCol.toUpperCase() };
areas.push(area);
saveAreas(areas);
res.json({ success: true, area });
} catch (err) {
console.error(err);
res.status(500).json({ success: false, error: "Ошибка при добавлении участка" });
}
});
app.patch("/api/salary/areas/:id", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => {
try {
const areas = loadAreas();
const area = areas.find(a => a.id === req.params.id);
if (!area) return res.status(404).json({ success: false, error: "Участок не найден" });
const { fioCol, bazaCol, kVydacheCol } = req.body || {};
if (fioCol !== undefined) {
if (!COL_PATTERN.test(fioCol)) return res.status(400).json({ success: false, error: "Столбец ФИО должен быть буквой (A-Z)" });
area.fioCol = fioCol.toUpperCase();
}
if (bazaCol !== undefined) {
if (!COL_PATTERN.test(bazaCol)) return res.status(400).json({ success: false, error: "Столбец База должен быть буквой (A-Z)" });
area.bazaCol = bazaCol.toUpperCase();
}
if (kVydacheCol !== undefined) {
if (!COL_PATTERN.test(kVydacheCol)) return res.status(400).json({ success: false, error: "Столбец К выдаче должен быть буквой (A-Z)" });
area.kVydacheCol = kVydacheCol.toUpperCase();
}
saveAreas(areas);
res.json({ success: true, area });
} catch (err) {
console.error(err);
res.status(500).json({ success: false, error: "Ошибка при обновлении участка" });
}
});
app.delete("/api/salary/areas/:id", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => {
try {
let areas = loadAreas();
areas = areas.filter(a => a.id !== req.params.id);
saveAreas(areas);
res.json({ success: true });
} catch (err) {
console.error(err);
res.status(500).json({ success: false, error: "Ошибка при удалении участка" });
}
});
// --- Синхронизация зарплат ---
app.post("/api/salary/sync", authenticateToken, requirePermission('finance.manage'), async (_req: AuthenticatedRequest, res) => {
try {
if (syncInProgress) return res.status(409).json({ success: false, error: "Синхронизация уже выполняется" });
syncInProgress = true;
const status = getAuthStatus();
if (!status.loggedIn) { syncInProgress = false; return res.status(401).json({ success: false, error: "Не авторизован в Google", authRequired: true }); }
if (!(await ensureOpneof())) { syncInProgress = false; return res.status(503).json({ success: false, error: "Таблица opneof не готова" }); }
const areas = loadAreas();
if (areas.length === 0) { syncInProgress = false; return res.json({ success: true, added: 0, updated: 0, deleted: 0, total: 0 }); }
const sheetsData = await fetchNeofFromSheets(areas);
const failedAreas = sheetsData.filter(a => a.error);
if (failedAreas.length === sheetsData.length) {
syncInProgress = false;
const firstErr = failedAreas[0]?.error || "Неизвестная ошибка";
if (firstErr.includes("авторизован") || firstErr.includes("Токен") || firstErr.includes("401")) {
return res.status(401).json({ success: false, error: "Ошибка авторизации Google: " + firstErr, authRequired: true });
}
return res.status(500).json({ success: false, error: "Все участки вернули ошибку: " + firstErr });
}
const now = new Date().toISOString();
const client = await getPoolClient();
try {
await client.query("BEGIN");
let added = 0, updated = 0, deleted = 0;
const errors: string[] = failedAreas.map(a => `${a.name}: ${a.error}`);
const sheetsKeys = new Set<string>();
for (const areaData of sheetsData) {
if (areaData.error) continue;
for (const monthData of areaData.months) {
for (const emp of monthData.employees) {
sheetsKeys.add(`${areaData.id}||${monthData.month}||${emp.fio}`);
const r = await client.query(
`INSERT INTO opneof (area_id, area_name, month, fio, baza, k_vydache, synced_at, deleted_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, NULL)
ON CONFLICT (area_id, month, fio) DO UPDATE
SET baza = EXCLUDED.baza, k_vydache = EXCLUDED.k_vydache,
area_name = EXCLUDED.area_name, synced_at = EXCLUDED.synced_at, deleted_at = NULL
WHERE opneof.baza IS DISTINCT FROM EXCLUDED.baza
OR opneof.k_vydache IS DISTINCT FROM EXCLUDED.k_vydache
OR opneof.area_name IS DISTINCT FROM EXCLUDED.area_name
OR opneof.deleted_at IS NOT NULL
RETURNING (xmax = 0) AS inserted`,
[areaData.id, areaData.name, monthData.month, emp.fio, emp.baza, emp.kVydache, now]
);
if (r.rows.length === 0) continue;
if (r.rows[0]?.inserted) added++; else updated++;
}
}
}
for (const areaData of sheetsData) {
if (areaData.error) continue;
const existingRows = await client.query(
"SELECT id, area_id, month, fio FROM opneof WHERE area_id = $1 AND deleted_at IS NULL",
[areaData.id]
);
for (const row of existingRows.rows) {
if (!sheetsKeys.has(`${row.area_id}||${row.month}||${row.fio}`)) {
await client.query("UPDATE opneof SET deleted_at = $1, synced_at = $1 WHERE id = $2", [now, row.id]);
deleted++;
}
}
}
await client.query("COMMIT");
client.release();
const totalRes = await query("SELECT COUNT(*) as cnt FROM opneof WHERE deleted_at IS NULL");
const total = Number(totalRes.rows[0]?.cnt || 0);
console.log(`[opneof sync] added=${added} updated=${updated} deleted=${deleted} total=${total} errors=${errors.length}`);
syncInProgress = false;
res.json({ success: true, added, updated, deleted, total, errors: errors.length > 0 ? errors : undefined });
} catch (txErr) {
await client.query("ROLLBACK").catch(() => {});
client.release();
throw txErr;
}
} catch (err: any) {
syncInProgress = false;
if (err?.message?.includes("авторизован") || err?.message?.includes("Токен")) {
return res.status(401).json({ success: false, error: err.message, authRequired: true });
}
console.error("[salary sync] Error:", err);
res.status(500).json({ success: false, error: "Ошибка синхронизации: " + (err?.message || "неизвестная ошибка") });
}
});
}