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 { 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 { 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 { return new Promise(resolve => setTimeout(resolve, ms)); } async function withRetry(fn: () => Promise, maxRetries = 4, baseDelayMs = 1000): Promise { 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 { 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 { 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(); 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 || "неизвестная ошибка") }); } }); }