Финансы: дебиторка (vw_debt_active), расчёты НДС/налога на прибыль, аудит целостности с автопланировщиком, синхронизация зарплат из Google Sheets, миграция настроек Data-Insight2
This commit is contained in:
494
server/finance/google-sheets.ts
Normal file
494
server/finance/google-sheets.ts
Normal file
@@ -0,0 +1,494 @@
|
||||
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 || "неизвестная ошибка") });
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user