- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
370 lines
12 KiB
TypeScript
370 lines
12 KiB
TypeScript
import { query } from "./db-client";
|
||
import { getTableConfig } from "./table-config";
|
||
import { storage } from "../storage";
|
||
import { sql } from "drizzle-orm";
|
||
import { db } from "../db";
|
||
import { formatFieldValueForTitle, buildTableRowMap } from "../routes/shared";
|
||
import { findCustomerByInn } from "./sync-utils";
|
||
|
||
const LEGAL_ENTITY_FORM_ID = 7;
|
||
const CUSTOMER_FORM_ID = 8;
|
||
const OMEGA_PROM_NAME = "ОМЕГАПРОМ";
|
||
|
||
export interface ParsedContract {
|
||
raw: string;
|
||
number: string;
|
||
date: string; // YYYY-MM-DD
|
||
inn: string;
|
||
}
|
||
|
||
export interface ContractSyncItem {
|
||
raw: string;
|
||
number: string;
|
||
date: string;
|
||
inn: string;
|
||
customerTaskId?: number;
|
||
}
|
||
|
||
export interface SyncApplyContract {
|
||
raw: string;
|
||
number: string;
|
||
date: string;
|
||
inn: string;
|
||
customerTaskId: number;
|
||
}
|
||
|
||
function parseDate(dateStr: string): string | null {
|
||
const match = dateStr.match(/^(\d{2})\.(\d{2})\.(\d{2,4})$/);
|
||
if (!match) return null;
|
||
const [, day, month, yearStr] = match;
|
||
let year = parseInt(yearStr, 10);
|
||
if (yearStr.length === 2) {
|
||
year = year >= 50 ? 1900 + year : 2000 + year;
|
||
}
|
||
return `${year}-${month}-${day}`;
|
||
}
|
||
|
||
export function parseContractString(raw: string): { number: string; date: string } | null {
|
||
const trimmed = raw.trim();
|
||
if (!trimmed) return null;
|
||
|
||
// Extract number: everything after № (with optional space) up to " от "
|
||
const numberMatch = trimmed.match(/№\s*([^\s].*?)\s+от\s/);
|
||
if (!numberMatch) return null;
|
||
const number = numberMatch[1].trim();
|
||
|
||
// Extract date: DD.MM.YY or DD.MM.YYYY
|
||
const dateMatch = trimmed.match(/(\d{2})\.(\d{2})\.(\d{2,4})/);
|
||
if (!dateMatch) return null;
|
||
const date = parseDate(`${dateMatch[1]}.${dateMatch[2]}.${dateMatch[3]}`);
|
||
if (!date) return null;
|
||
|
||
return { number, date };
|
||
}
|
||
|
||
export async function getUniqueFinanceContracts(): Promise<{
|
||
contracts: ParsedContract[];
|
||
skipped: number;
|
||
}> {
|
||
const config = getTableConfig();
|
||
const result = await query(
|
||
`SELECT DISTINCT "dogovorzakazchika" as raw, "innzakazchika" as inn FROM "${config.incomeTable}" WHERE "dogovorzakazchika" IS NOT NULL AND "dogovorzakazchika" <> ''`
|
||
);
|
||
|
||
const contracts: ParsedContract[] = [];
|
||
let skipped = 0;
|
||
const seen = new Set<string>();
|
||
|
||
for (const row of result.rows) {
|
||
const raw = String(row.raw || "").trim();
|
||
const parsed = parseContractString(raw);
|
||
if (!parsed) {
|
||
skipped++;
|
||
continue;
|
||
}
|
||
|
||
const inn = row.inn ? String(row.inn).trim() : "";
|
||
if (!inn || inn === "0") {
|
||
skipped++;
|
||
continue;
|
||
}
|
||
|
||
const key = `${parsed.number}:${parsed.date}:${inn}`;
|
||
if (seen.has(key)) continue;
|
||
seen.add(key);
|
||
|
||
contracts.push({ raw, number: parsed.number, date: parsed.date, inn });
|
||
}
|
||
|
||
contracts.sort((a, b) => a.raw.localeCompare(b.raw));
|
||
return { contracts, skipped };
|
||
}
|
||
|
||
export async function findOmegaPromTaskId(organizationId: number): Promise<number | null> {
|
||
const result = await db.execute(sql`
|
||
SELECT id FROM tasks
|
||
WHERE form_id = ${LEGAL_ENTITY_FORM_ID}
|
||
AND organization_id = ${organizationId}
|
||
AND title ILIKE ${"%" + OMEGA_PROM_NAME + "%"}
|
||
ORDER BY id
|
||
LIMIT 1
|
||
`);
|
||
const rows = result.rows as Array<{ id: number }>;
|
||
return rows[0]?.id ?? null;
|
||
}
|
||
|
||
export async function findExistingContracts(
|
||
formId: number,
|
||
organizationId: number
|
||
): Promise<Set<string>> {
|
||
const formFields = await storage.getFormFields(formId, organizationId);
|
||
const contractField = formFields.find((f) => f.type === "contract-number");
|
||
const customerField = formFields.find((f) => f.type === "task" && f.linkedFormId === CUSTOMER_FORM_ID);
|
||
|
||
if (!contractField || !customerField) {
|
||
return new Set();
|
||
}
|
||
|
||
const result = await db.execute(sql`
|
||
SELECT t.id
|
||
FROM tasks t
|
||
JOIN task_field_values v1 ON t.id = v1.task_id AND t.form_id = v1.form_id
|
||
JOIN task_field_values v2 ON t.id = v2.task_id AND t.form_id = v2.form_id
|
||
WHERE t.form_id = ${formId}
|
||
AND t.organization_id = ${organizationId}
|
||
AND v1.field_id = ${contractField.id}
|
||
AND v1.value IS NOT NULL
|
||
AND v2.field_id = ${customerField.id}
|
||
AND v2.value IS NOT NULL
|
||
`);
|
||
|
||
const existing = new Set<string>();
|
||
for (const row of result.rows as Array<{ id: number }>) {
|
||
// Load field values for this task
|
||
const values = await storage.getTaskFieldValues(row.id, organizationId);
|
||
const contractValue = values.find((v) => v.fieldId === contractField.id)?.value;
|
||
const customerValue = values.find((v) => v.fieldId === customerField.id)?.value;
|
||
if (contractValue && customerValue) {
|
||
try {
|
||
const parsed = typeof contractValue === "string" ? JSON.parse(contractValue) : contractValue;
|
||
const number = parsed?.value || "";
|
||
const customerId = String(customerValue).trim();
|
||
if (number && customerId) {
|
||
existing.add(`${number}:${customerId}`);
|
||
}
|
||
} catch {
|
||
// ignore parse errors
|
||
}
|
||
}
|
||
}
|
||
return existing;
|
||
}
|
||
|
||
export async function previewContractSync(
|
||
formId: number,
|
||
organizationId: number
|
||
): Promise<{
|
||
auto: ContractSyncItem[];
|
||
ambiguous: Array<ContractSyncItem & { reason: string }>;
|
||
existing: ContractSyncItem[];
|
||
skipped: number;
|
||
}> {
|
||
const { contracts, skipped } = await getUniqueFinanceContracts();
|
||
const omegaPromTaskId = await findOmegaPromTaskId(organizationId);
|
||
const existingContracts = await findExistingContracts(formId, organizationId);
|
||
|
||
const auto: ContractSyncItem[] = [];
|
||
const ambiguous: Array<ContractSyncItem & { reason: string }> = [];
|
||
const existing: ContractSyncItem[] = [];
|
||
|
||
for (const contract of contracts) {
|
||
const customerTaskId = await findCustomerByInn(contract.inn, organizationId);
|
||
const existingKey = `${contract.number}:${customerTaskId || ""}`;
|
||
|
||
if (existingContracts.has(existingKey)) {
|
||
existing.push(contract);
|
||
continue;
|
||
}
|
||
|
||
if (!omegaPromTaskId) {
|
||
ambiguous.push({ ...contract, reason: "Не найдено юр.лицо ОМЕГАПРОМ" });
|
||
continue;
|
||
}
|
||
|
||
if (!customerTaskId) {
|
||
ambiguous.push({ ...contract, reason: `Не найден заказчик по ИНН ${contract.inn}` });
|
||
continue;
|
||
}
|
||
|
||
auto.push({ ...contract, customerTaskId });
|
||
}
|
||
|
||
return { auto, ambiguous, existing, skipped };
|
||
}
|
||
|
||
export async function createContractTask(
|
||
formId: number,
|
||
organizationId: number,
|
||
createdBy: number,
|
||
contract: SyncApplyContract,
|
||
omegaPromTaskId: number
|
||
): Promise<{ taskId: number; title: string }> {
|
||
const formFields = await storage.getFormFields(formId, organizationId);
|
||
|
||
const contractField = formFields.find((f) => f.type === "contract-number");
|
||
const dateField = formFields.find((f) => f.type === "date");
|
||
const customerField = formFields.find((f) => f.type === "task" && f.linkedFormId === CUSTOMER_FORM_ID);
|
||
|
||
if (!contractField) {
|
||
throw new Error("В форме не найдено поле типа contract-number");
|
||
}
|
||
if (!dateField) {
|
||
throw new Error("В форме не найдено поле типа date");
|
||
}
|
||
if (!customerField) {
|
||
throw new Error("В форме не найдено поле типа task, связанное с формой Заказчик");
|
||
}
|
||
|
||
const form = await storage.getForm(formId, organizationId);
|
||
if (!form) {
|
||
throw new Error("Форма не найдена");
|
||
}
|
||
|
||
let statuses = await storage.getFormStatuses(formId, organizationId);
|
||
if (statuses.length === 0) {
|
||
const defaultStatus = await storage.createFormStatus({
|
||
formId,
|
||
organizationId,
|
||
name: "Новый",
|
||
color: "#e5e7eb",
|
||
isInitial: true,
|
||
isFinal: false,
|
||
position: 0,
|
||
} as any);
|
||
statuses = [defaultStatus];
|
||
}
|
||
const initialStatus = statuses[0];
|
||
|
||
// Build title from template or fallback
|
||
const title = form.titleTemplate
|
||
? contract.number
|
||
: `Договор №${contract.number}`;
|
||
|
||
// Extract parentTaskId from contract-number field
|
||
const contractValue = JSON.stringify({
|
||
mode: "theirs",
|
||
value: contract.number,
|
||
legalEntityTaskId: omegaPromTaskId,
|
||
});
|
||
|
||
const task = await storage.createTask({
|
||
title,
|
||
formId,
|
||
organizationId,
|
||
createdBy,
|
||
currentStatusId: initialStatus.id,
|
||
description: null,
|
||
assignedTo: null,
|
||
dueDate: null,
|
||
completedAt: null,
|
||
parentTaskId: omegaPromTaskId,
|
||
} as any);
|
||
|
||
// Insert field values
|
||
const fieldValues = [
|
||
{ fieldId: contractField.id, value: contractValue },
|
||
{ fieldId: dateField.id, value: contract.date },
|
||
{ fieldId: customerField.id, value: String(contract.customerTaskId) },
|
||
];
|
||
|
||
for (const { fieldId, value } of fieldValues) {
|
||
await storage.createTaskFieldValue({
|
||
taskId: task.id,
|
||
fieldId,
|
||
formId,
|
||
value,
|
||
});
|
||
}
|
||
|
||
// Create task relation for customer field if needed
|
||
if (customerField.taskRelationType) {
|
||
if (customerField.taskRelationType === "parent") {
|
||
await storage.upsertTaskRelation({
|
||
parentTaskId: contract.customerTaskId,
|
||
childTaskId: task.id,
|
||
fieldId: customerField.id,
|
||
organizationId,
|
||
});
|
||
} else if (customerField.taskRelationType === "child") {
|
||
await storage.upsertTaskRelation({
|
||
parentTaskId: task.id,
|
||
childTaskId: contract.customerTaskId,
|
||
fieldId: customerField.id,
|
||
organizationId,
|
||
});
|
||
}
|
||
}
|
||
|
||
// Recalculate title if template exists
|
||
if (form.titleTemplate) {
|
||
const [allFormFields, savedValues, titleStatuses, titleUsers, titleRoles] = await Promise.all([
|
||
storage.getFormFields(formId, organizationId),
|
||
storage.getTaskFieldValues(task.id, organizationId),
|
||
storage.getFormStatuses(formId, organizationId),
|
||
storage.getUsersByOrganization(organizationId),
|
||
storage.getRoles(organizationId),
|
||
]);
|
||
const fieldCodeMap = new Map(allFormFields.map((f) => [f.code, f]));
|
||
const valueByFieldId = new Map(savedValues.map((v) => [v.fieldId, v.value]));
|
||
const usersMap = new Map(titleUsers.map((u) => [u.id, u]));
|
||
const rolesMap = new Map(titleRoles.map((r) => [r.id, r]));
|
||
|
||
const { buildSystemFieldValues } = await import("../routes/shared");
|
||
const sysValues = await buildSystemFieldValues(task, organizationId, { statuses: titleStatuses, usersMap });
|
||
const tableRowMap = await buildTableRowMap(allFormFields, organizationId);
|
||
const computedTitle = form.titleTemplate.replace(/\{\{([^}]+)\}\}/g, (_, code: string) => {
|
||
if (sysValues.has(code)) return sysValues.get(code)!;
|
||
const fld = fieldCodeMap.get(code);
|
||
if (!fld) return "";
|
||
const val = valueByFieldId.get(fld.id);
|
||
return val === null || val === undefined ? "" : formatFieldValueForTitle(val, fld.type, { usersMap, rolesMap, tableRowMap }, fld.options);
|
||
});
|
||
await storage.updateTask(task.id, organizationId, { title: computedTitle });
|
||
}
|
||
|
||
return { taskId: task.id, title };
|
||
}
|
||
|
||
export async function applyContractSync(
|
||
formId: number,
|
||
organizationId: number,
|
||
createdBy: number,
|
||
contracts: SyncApplyContract[]
|
||
): Promise<{ created: number; skipped: number; errors: Array<{ raw: string; error: string }> }> {
|
||
const omegaPromTaskId = await findOmegaPromTaskId(organizationId);
|
||
if (!omegaPromTaskId) {
|
||
throw new Error("Не найдено юр.лицо ОМЕГАПРОМ");
|
||
}
|
||
|
||
const existingContracts = await findExistingContracts(formId, organizationId);
|
||
let created = 0;
|
||
let skipped = 0;
|
||
const errors: Array<{ raw: string; error: string }> = [];
|
||
|
||
for (const contract of contracts) {
|
||
try {
|
||
const key = `${contract.number}:${contract.customerTaskId}`;
|
||
if (existingContracts.has(key)) {
|
||
skipped++;
|
||
continue;
|
||
}
|
||
await createContractTask(formId, organizationId, createdBy, contract, omegaPromTaskId);
|
||
created++;
|
||
} catch (err: any) {
|
||
errors.push({ raw: contract.raw, error: err.message || "Ошибка создания" });
|
||
}
|
||
}
|
||
|
||
return { created, skipped, errors };
|
||
}
|