diff --git a/src/app/(admin)/orders/[id]/page.tsx b/src/app/(admin)/orders/[id]/page.tsx index 8a4324b..1875f20 100644 --- a/src/app/(admin)/orders/[id]/page.tsx +++ b/src/app/(admin)/orders/[id]/page.tsx @@ -11,6 +11,7 @@ import { IncomeSection } from "./income-section"; import { ExpenseSection } from "./expense-section"; import { EventsSection } from "./events-section"; import { DocumentsSection } from "./documents-section"; +import { SignChecklistSection } from "./sign-checklist-section"; export default async function OrderDetailPage({ params }: { params: Promise<{ id: string }> }) { const session = await getCurrentSession(); @@ -97,6 +98,19 @@ export default async function OrderDetailPage({ params }: { params: Promise<{ id /> +
+ ({ + kind: r.kind, + present: r.present, + signedByUserId: r.signedByUserId, + signedAt: r.signedAt, + signedBy: r.signedBy ? { id: r.signedBy.id, name: r.signedBy.name } : null, + }))} + /> +
+
= { + contract: "Договор / Заявка-договор + Поручение", + upd: "УПД", + etrn: "ЭТрН", + forwarder_receipt: "Экспедиторская расписка", +}; + +interface ChecklistRow { + kind: DocKind; + present: boolean; + signedByUserId: string | null; + signedAt: string | Date | null; + signedBy: { id: string; name: string } | null; +} + +const STATUS_LABEL: Record<"red" | "yellow" | "green", string> = { + red: "Не собрано", + yellow: "Собрано, не подписано", + green: "Собрано и подписано", +}; +const STATUS_DOT: Record<"red" | "yellow" | "green", string> = { + red: "bg-danger", + yellow: "bg-warning", + green: "bg-success", +}; + +function computeStatus(rows: ChecklistRow[]): "red" | "yellow" | "green" { + if (rows.length === 0 || rows.some((r) => !r.present)) return "red"; + if (rows.some((r) => !r.signedByUserId)) return "yellow"; + return "green"; +} + +export function SignChecklistSection({ orderId, rows: initialRows }: { orderId: string; rows: ChecklistRow[] }) { + const router = useRouter(); + const [rows, setRows] = useState(initialRows); + const [loadingKind, setLoadingKind] = useState(null); + const [notice, setNotice] = useState(null); + + const status = computeStatus(rows); + + async function togglePresent(kind: DocKind, present: boolean) { + setLoadingKind(kind); + setRows((prev) => prev.map((r) => (r.kind === kind ? { ...r, present } : r))); + await fetch(`/api/orders/${orderId}/documents/${kind}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ present }), + }); + setLoadingKind(null); + router.refresh(); + } + + async function sign(kind: DocKind) { + setLoadingKind(kind); + setNotice(null); + const res = await fetch(`/api/orders/${orderId}/documents/${kind}/sign`, { method: "POST" }); + const data = await res.json().catch(() => null); + setLoadingKind(null); + if (data?.signed) { + setNotice(null); + } else if (data?.requested) { + setNotice("Нет прав на подпись этой суммы — запрос отправлен тем, кто может подписать."); + } + router.refresh(); + } + + return ( +
+
+ +

Документы по заявке — {STATUS_LABEL[status]}

+
+ +
+ {rows.map((row) => ( +
+ + +
+ {row.signedBy ? ( + + {/* eslint-disable-next-line @next/next/no-img-element -- маленькая приватная иконка-факсимиле */} + { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> + Подписал(а) {row.signedBy.name} + {row.signedAt ? `, ${new Date(row.signedAt).toLocaleDateString("ru-RU")}` : ""} + + ) : ( + + )} +
+
+ ))} +
+ + {notice &&

{notice}

} +
+ ); +} diff --git a/src/app/(admin)/orders/page.tsx b/src/app/(admin)/orders/page.tsx index 1141d6b..b950ada 100644 --- a/src/app/(admin)/orders/page.tsx +++ b/src/app/(admin)/orders/page.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { redirect } from "next/navigation"; import { getCurrentSession } from "@/lib/auth/session"; import { listOrders, computeMargin } from "@/lib/orders/service"; +import { getOrderDocStatus } from "@/lib/documents/checklist"; const STATUS_LABEL: Record = { new: "Новая", @@ -16,6 +17,7 @@ const STATUS_BADGE: Record = { closed: "badge-closed", }; const PAID_LABEL: Record = { unpaid: "Не оплачен", partial: "Частично", paid: "Оплачен" }; +const DOC_STATUS_DOT: Record<"red" | "yellow" | "green", string> = { red: "bg-danger", yellow: "bg-warning", green: "bg-success" }; export default async function OrdersPage() { const session = await getCurrentSession(); @@ -37,12 +39,14 @@ export default async function OrdersPage() { Менеджер Статус Оплата + Доки Маржа {orders.map((o) => { const margin = computeMargin(o); + const docStatus = getOrderDocStatus(o.documentChecklist); return ( @@ -59,13 +63,16 @@ export default async function OrdersPage() { {STATUS_LABEL[o.status]} {PAID_LABEL[o.paidStatus]} + + + {margin.marginWithVat.toLocaleString("ru-RU")} ₽ ); })} {orders.length === 0 && ( - + Пока нет заявок. Создайте их из запроса. diff --git a/src/app/(admin)/profile/facsimile-uploader.tsx b/src/app/(admin)/profile/facsimile-uploader.tsx new file mode 100644 index 0000000..626b549 --- /dev/null +++ b/src/app/(admin)/profile/facsimile-uploader.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { useRef, useState } from "react"; +import { useRouter } from "next/navigation"; + +export function FacsimileUploader({ userId, hasFacsimile }: { userId: string; hasFacsimile: boolean }) { + const router = useRouter(); + const inputRef = useRef(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + // Кэш-бастер для после перезаписи файла — меняется только после + // успешной загрузки, не на каждый рендер (Date.now() напрямую в JSX + // нарушает чистоту рендера). + const [version, setVersion] = useState(0); + + async function handleChange(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + setLoading(true); + setError(null); + + const form = new FormData(); + form.set("file", file); + const res = await fetch("/api/profile/facsimile", { method: "POST", body: form }); + + setLoading(false); + if (!res.ok) { + const data = await res.json().catch(() => null); + setError(data?.error ?? "Не удалось загрузить факсимиле"); + return; + } + setVersion((v) => v + 1); + router.refresh(); + } + + return ( +
+ {hasFacsimile ? ( + // eslint-disable-next-line @next/next/no-img-element -- маленькое приватное изображение по requireSession, next/image тут не даёт выгоды + Факсимиле + ) : ( +
+ Нет подписи +
+ )} +
+ + + {error &&

{error}

} +
+
+ ); +} diff --git a/src/app/(admin)/profile/page.tsx b/src/app/(admin)/profile/page.tsx index 64275c8..d26838e 100644 --- a/src/app/(admin)/profile/page.tsx +++ b/src/app/(admin)/profile/page.tsx @@ -1,6 +1,7 @@ import { redirect } from "next/navigation"; import { getCurrentSession } from "@/lib/auth/session"; import { AvatarUploader } from "./avatar-uploader"; +import { FacsimileUploader } from "./facsimile-uploader"; const ROLE_LABEL: Record = { admin: "Администратор", manager: "Менеджер", accountant: "Бухгалтер" }; @@ -18,6 +19,16 @@ export default async function ProfilePage() {
+
+

Факсимильная подпись

+ + {user.canSign && ( +

+ Право подписи: {user.signLimitRub == null ? "без ограничения по сумме" : `до ${user.signLimitRub.toLocaleString("ru-RU")} ₽`} +

+ )} +
+
diff --git a/src/app/(admin)/settings/users/page.tsx b/src/app/(admin)/settings/users/page.tsx index fb15392..8163ed5 100644 --- a/src/app/(admin)/settings/users/page.tsx +++ b/src/app/(admin)/settings/users/page.tsx @@ -2,6 +2,7 @@ import { redirect } from "next/navigation"; import { getCurrentSession } from "@/lib/auth/session"; import { listUsers } from "@/lib/users/service"; import { UsersToolbar } from "./toolbar"; +import { SignRightsCell } from "./sign-rights-cell"; const ROLE_LABEL: Record = { admin: "Администратор", @@ -32,6 +33,7 @@ export default async function UsersPage() { Email Роль № менеджера + Подпись @@ -41,6 +43,9 @@ export default async function UsersPage() { {u.email} {ROLE_LABEL[u.role]} {u.managerNumber ?? "—"} + + + ))} diff --git a/src/app/(admin)/settings/users/sign-rights-cell.tsx b/src/app/(admin)/settings/users/sign-rights-cell.tsx new file mode 100644 index 0000000..7baafac --- /dev/null +++ b/src/app/(admin)/settings/users/sign-rights-cell.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; + +export function SignRightsCell({ + userId, + canSign: initialCanSign, + signLimitRub: initialLimit, +}: { + userId: string; + canSign: boolean; + signLimitRub: number | null; +}) { + const router = useRouter(); + const [canSign, setCanSign] = useState(initialCanSign); + const [limit, setLimit] = useState(initialLimit == null ? "" : String(initialLimit)); + const [loading, setLoading] = useState(false); + + async function patch(body: { canSign?: boolean; signLimitRub?: number | null }) { + setLoading(true); + await fetch(`/api/users/${userId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + setLoading(false); + router.refresh(); + } + + return ( +
+ + {canSign && ( + setLimit(e.target.value)} + onBlur={() => patch({ signLimitRub: limit === "" ? null : Number(limit) })} + className="w-28 rounded-md border border-border bg-surface px-2 py-1 text-xs outline-none focus:border-accent" + /> + )} +
+ ); +} diff --git a/src/app/api/orders/[id]/documents/[kind]/route.ts b/src/app/api/orders/[id]/documents/[kind]/route.ts new file mode 100644 index 0000000..f678f2d --- /dev/null +++ b/src/app/api/orders/[id]/documents/[kind]/route.ts @@ -0,0 +1,32 @@ +export const runtime = "nodejs"; + +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { requireRole } from "@/lib/auth/require"; +import { getOrder } from "@/lib/orders/service"; +import { canAccessOrder } from "@/lib/orders/visibility"; +import { DOC_KINDS, setDocPresent, type DocKind } from "@/lib/documents/checklist"; + +const schema = z.object({ present: z.boolean() }); + +export async function PATCH(request: Request, { params }: { params: Promise<{ id: string; kind: string }> }) { + const { session, response } = await requireRole(["admin", "manager", "accountant"]); + if (!session) return response; + + const { id, kind } = await params; + if (!DOC_KINDS.includes(kind as DocKind)) { + return NextResponse.json({ error: "Неизвестный тип документа" }, { status: 400 }); + } + + const order = await getOrder(id); + if (!order) return NextResponse.json({ error: "Заявка не найдена" }, { status: 404 }); + if (!canAccessOrder(order, session.user)) return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + + const parsed = schema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Некорректные данные" }, { status: 400 }); + } + + await setDocPresent(id, kind as DocKind, parsed.data.present); + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/orders/[id]/documents/[kind]/sign/route.ts b/src/app/api/orders/[id]/documents/[kind]/sign/route.ts new file mode 100644 index 0000000..4d5db24 --- /dev/null +++ b/src/app/api/orders/[id]/documents/[kind]/sign/route.ts @@ -0,0 +1,24 @@ +export const runtime = "nodejs"; + +import { NextResponse } from "next/server"; +import { requireRole } from "@/lib/auth/require"; +import { getOrder } from "@/lib/orders/service"; +import { canAccessOrder } from "@/lib/orders/visibility"; +import { DOC_KINDS, signDocument, type DocKind } from "@/lib/documents/checklist"; + +export async function POST(_request: Request, { params }: { params: Promise<{ id: string; kind: string }> }) { + const { session, response } = await requireRole(["admin", "manager", "accountant"]); + if (!session) return response; + + const { id, kind } = await params; + if (!DOC_KINDS.includes(kind as DocKind)) { + return NextResponse.json({ error: "Неизвестный тип документа" }, { status: 400 }); + } + + const order = await getOrder(id); + if (!order) return NextResponse.json({ error: "Заявка не найдена" }, { status: 404 }); + if (!canAccessOrder(order, session.user)) return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + + const result = await signDocument(id, kind as DocKind, session.user.id); + return NextResponse.json(result); +} diff --git a/src/app/api/profile/facsimile/route.ts b/src/app/api/profile/facsimile/route.ts new file mode 100644 index 0000000..250be90 --- /dev/null +++ b/src/app/api/profile/facsimile/route.ts @@ -0,0 +1,35 @@ +export const runtime = "nodejs"; + +import { NextResponse } from "next/server"; +import { requireSession } from "@/lib/auth/require"; +import { saveFacsimile } from "@/lib/users/service"; + +const ALLOWED_EXT: Record = { + "image/png": "png", + "image/jpeg": "jpg", + "image/webp": "webp", + "image/gif": "gif", +}; +const MAX_BYTES = 5 * 1024 * 1024; + +export async function POST(request: Request) { + const { session, response } = await requireSession(); + if (!session) return response; + + const form = await request.formData().catch(() => null); + const file = form?.get("file"); + if (!(file instanceof File)) { + return NextResponse.json({ error: "Файл не найден" }, { status: 400 }); + } + if (file.size > MAX_BYTES) { + return NextResponse.json({ error: "Файл слишком большой (максимум 5 МБ)" }, { status: 400 }); + } + const extension = ALLOWED_EXT[file.type]; + if (!extension) { + return NextResponse.json({ error: "Поддерживаются только PNG, JPEG, WEBP, GIF" }, { status: 400 }); + } + + const content = Buffer.from(await file.arrayBuffer()); + await saveFacsimile(session.user.id, content, extension); + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/users/[id]/facsimile/route.ts b/src/app/api/users/[id]/facsimile/route.ts new file mode 100644 index 0000000..9aabc65 --- /dev/null +++ b/src/app/api/users/[id]/facsimile/route.ts @@ -0,0 +1,32 @@ +export const runtime = "nodejs"; + +import fs from "node:fs"; +import path from "node:path"; +import { NextResponse } from "next/server"; +import { requireSession } from "@/lib/auth/require"; +import { getUser } from "@/lib/users/service"; + +const MIME_BY_EXT: Record = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".webp": "image/webp", + ".gif": "image/gif", +}; + +export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) { + const { session, response } = await requireSession(); + if (!session) return response; + + const { id } = await params; + const user = await getUser(id); + if (!user?.facsimilePath || !fs.existsSync(user.facsimilePath)) { + return NextResponse.json({ error: "Нет факсимиле" }, { status: 404 }); + } + + const mime = MIME_BY_EXT[path.extname(user.facsimilePath).toLowerCase()] ?? "application/octet-stream"; + const file = fs.readFileSync(user.facsimilePath); + return new NextResponse(new Uint8Array(file), { + headers: { "Content-Type": mime, "Cache-Control": "private, max-age=300" }, + }); +} diff --git a/src/app/api/users/[id]/route.ts b/src/app/api/users/[id]/route.ts new file mode 100644 index 0000000..f217200 --- /dev/null +++ b/src/app/api/users/[id]/route.ts @@ -0,0 +1,28 @@ +export const runtime = "nodejs"; + +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { requireRole } from "@/lib/auth/require"; +import { updateSignRights } from "@/lib/users/service"; + +const schema = z.object({ + canSign: z.boolean().optional(), + signLimitRub: z.number().positive().nullable().optional(), +}); + +/** Пока только права подписи — общий PATCH профиля пользователя (имя/роль/пароль) не нужен, эти поля не редактируются через UI. */ +export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) { + const { session, response } = await requireRole(["admin"]); + if (!session) return response; + + const { id } = await params; + const parsed = schema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Некорректные данные" }, { status: 400 }); + } + + const row = await updateSignRights(id, parsed.data); + if (!row) return NextResponse.json({ error: "Пользователь не найден" }, { status: 404 }); + const { passwordHash: _passwordHash, ...safeRow } = row; + return NextResponse.json(safeRow); +} diff --git a/src/lib/db/migrations/0001_normal_captain_stacy.sql b/src/lib/db/migrations/0001_normal_captain_stacy.sql new file mode 100644 index 0000000..64cfd93 --- /dev/null +++ b/src/lib/db/migrations/0001_normal_captain_stacy.sql @@ -0,0 +1,18 @@ +CREATE TABLE `order_document_checklist` ( + `id` text PRIMARY KEY NOT NULL, + `order_id` text NOT NULL, + `kind` text NOT NULL, + `present` integer DEFAULT false NOT NULL, + `attachment_id` text, + `signed_by_user_id` text, + `signed_at` integer, + `created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL, + FOREIGN KEY (`order_id`) REFERENCES `orders`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`attachment_id`) REFERENCES `attachments`(`id`) ON UPDATE no action ON DELETE set null, + FOREIGN KEY (`signed_by_user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE UNIQUE INDEX `order_document_checklist_order_kind` ON `order_document_checklist` (`order_id`,`kind`);--> statement-breakpoint +ALTER TABLE `users` ADD `facsimile_path` text;--> statement-breakpoint +ALTER TABLE `users` ADD `can_sign` integer DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE `users` ADD `sign_limit_rub` real; \ No newline at end of file diff --git a/src/lib/db/migrations/meta/0001_snapshot.json b/src/lib/db/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..c43403e --- /dev/null +++ b/src/lib/db/migrations/meta/0001_snapshot.json @@ -0,0 +1,2720 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "5aeac874-5249-409e-86d0-620ccc521ace", + "prevId": "09975ec3-d63e-49b2-a08c-950ce840b1d6", + "tables": { + "attachments": { + "name": "attachments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "order_id": { + "name": "order_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'other'" + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stored_path": { + "name": "stored_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploaded_by_id": { + "name": "uploaded_by_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "attachments_order_id_orders_id_fk": { + "name": "attachments_order_id_orders_id_fk", + "tableFrom": "attachments", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "attachments_uploaded_by_id_users_id_fk": { + "name": "attachments_uploaded_by_id_users_id_fk", + "tableFrom": "attachments", + "tableTo": "users", + "columnsFrom": [ + "uploaded_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chat_attachments": { + "name": "chat_attachments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'file'" + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stored_path": { + "name": "stored_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "duration_seconds": { + "name": "duration_seconds", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "chat_attachments_message_id_chat_messages_id_fk": { + "name": "chat_attachments_message_id_chat_messages_id_fk", + "tableFrom": "chat_attachments", + "tableTo": "chat_messages", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chat_messages": { + "name": "chat_messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reply_to_message_id": { + "name": "reply_to_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "chat_messages_conversation_id_conversations_id_fk": { + "name": "chat_messages_conversation_id_conversations_id_fk", + "tableFrom": "chat_messages", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_messages_author_id_users_id_fk": { + "name": "chat_messages_author_id_users_id_fk", + "tableFrom": "chat_messages", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chat_reactions": { + "name": "chat_reactions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emoji": { + "name": "emoji", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "chat_reactions_unique": { + "name": "chat_reactions_unique", + "columns": [ + "message_id", + "user_id", + "emoji" + ], + "isUnique": true + } + }, + "foreignKeys": { + "chat_reactions_message_id_chat_messages_id_fk": { + "name": "chat_reactions_message_id_chat_messages_id_fk", + "tableFrom": "chat_reactions", + "tableTo": "chat_messages", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_reactions_user_id_users_id_fk": { + "name": "chat_reactions_user_id_users_id_fk", + "tableFrom": "chat_reactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "company_settings": { + "name": "company_settings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "inn": { + "name": "inn", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kpp": { + "name": "kpp", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ogrn": { + "name": "ogrn", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "legal_address": { + "name": "legal_address", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "postal_address": { + "name": "postal_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bank_name": { + "name": "bank_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bank_account": { + "name": "bank_account", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bank_bik": { + "name": "bank_bik", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bank_corr_account": { + "name": "bank_corr_account", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signatory_name": { + "name": "signatory_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signatory_position": { + "name": "signatory_position", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'генерального директора'" + }, + "signatory_basis": { + "name": "signatory_basis", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Устава'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "conversation_participants": { + "name": "conversation_participants", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_read_at": { + "name": "last_read_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "conversation_participants_unique": { + "name": "conversation_participants_unique", + "columns": [ + "conversation_id", + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "conversation_participants_conversation_id_conversations_id_fk": { + "name": "conversation_participants_conversation_id_conversations_id_fk", + "tableFrom": "conversation_participants", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversation_participants_user_id_users_id_fk": { + "name": "conversation_participants_user_id_users_id_fk", + "tableFrom": "conversation_participants", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "conversations": { + "name": "conversations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "is_group": { + "name": "is_group", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "conversations_created_by_id_users_id_fk": { + "name": "conversations_created_by_id_users_id_fk", + "tableFrom": "conversations", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "counterparties": { + "name": "counterparties", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inn": { + "name": "inn", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kpp": { + "name": "kpp", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ogrn": { + "name": "ogrn", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "legal_address": { + "name": "legal_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "postal_address": { + "name": "postal_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bank_name": { + "name": "bank_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bank_account": { + "name": "bank_account", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bank_bik": { + "name": "bank_bik", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bank_corr_account": { + "name": "bank_corr_account", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signatory_name": { + "name": "signatory_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signatory_position": { + "name": "signatory_position", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'генерального директора'" + }, + "signatory_basis": { + "name": "signatory_basis", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Устава'" + }, + "requisites": { + "name": "requisites", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contract_number": { + "name": "contract_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contract_date": { + "name": "contract_date", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contact_name": { + "name": "contact_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contact_phone": { + "name": "contact_phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contact_email": { + "name": "contact_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "edo_type": { + "name": "edo_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'email'" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "counterparty_contacts": { + "name": "counterparty_contacts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "counterparty_id": { + "name": "counterparty_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "counterparty_contacts_counterparty_id_counterparties_id_fk": { + "name": "counterparty_contacts_counterparty_id_counterparties_id_fk", + "tableFrom": "counterparty_contacts", + "tableTo": "counterparties", + "columnsFrom": [ + "counterparty_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "document_templates": { + "name": "document_templates", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stored_path": { + "name": "stored_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "email_inquiry_drafts": { + "name": "email_inquiry_drafts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "raw_body": { + "name": "raw_body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "extracted_cargo_description": { + "name": "extracted_cargo_description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "extracted_length_mm": { + "name": "extracted_length_mm", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "extracted_width_mm": { + "name": "extracted_width_mm", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "extracted_height_mm": { + "name": "extracted_height_mm", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "extracted_weight_kg": { + "name": "extracted_weight_kg", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "extracted_route_from": { + "name": "extracted_route_from", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "extracted_route_to": { + "name": "extracted_route_to", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "extracted_deadline": { + "name": "extracted_deadline", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "confidence": { + "name": "confidence", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'low'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "created_inquiry_id": { + "name": "created_inquiry_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inquiries": { + "name": "inquiries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "number": { + "name": "number", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "manager_id": { + "name": "manager_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cargo_description": { + "name": "cargo_description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "route_from": { + "name": "route_from", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "route_to": { + "name": "route_to", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "customs_place": { + "name": "customs_place", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "transport_mode": { + "name": "transport_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "transport_type": { + "name": "transport_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_rate": { + "name": "client_rate", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deadline": { + "name": "deadline", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'email'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'new'" + }, + "first_responded_at": { + "name": "first_responded_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "converted_order_id": { + "name": "converted_order_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "inquiries_number_unique": { + "name": "inquiries_number_unique", + "columns": [ + "number" + ], + "isUnique": true + } + }, + "foreignKeys": { + "inquiries_customer_id_counterparties_id_fk": { + "name": "inquiries_customer_id_counterparties_id_fk", + "tableFrom": "inquiries", + "tableTo": "counterparties", + "columnsFrom": [ + "customer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inquiries_manager_id_users_id_fk": { + "name": "inquiries_manager_id_users_id_fk", + "tableFrom": "inquiries", + "tableTo": "users", + "columnsFrom": [ + "manager_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inquiry_attachments": { + "name": "inquiry_attachments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "inquiry_id": { + "name": "inquiry_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stored_path": { + "name": "stored_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploaded_by_id": { + "name": "uploaded_by_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "inquiry_attachments_inquiry_id_inquiries_id_fk": { + "name": "inquiry_attachments_inquiry_id_inquiries_id_fk", + "tableFrom": "inquiry_attachments", + "tableTo": "inquiries", + "columnsFrom": [ + "inquiry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "inquiry_attachments_uploaded_by_id_users_id_fk": { + "name": "inquiry_attachments_uploaded_by_id_users_id_fk", + "tableFrom": "inquiry_attachments", + "tableTo": "users", + "columnsFrom": [ + "uploaded_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inquiry_cargo_items": { + "name": "inquiry_cargo_items", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "inquiry_id": { + "name": "inquiry_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "length_mm": { + "name": "length_mm", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "width_mm": { + "name": "width_mm", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "height_mm": { + "name": "height_mm", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "weight_kg": { + "name": "weight_kg", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "inquiry_cargo_items_inquiry_id_inquiries_id_fk": { + "name": "inquiry_cargo_items_inquiry_id_inquiries_id_fk", + "tableFrom": "inquiry_cargo_items", + "tableTo": "inquiries", + "columnsFrom": [ + "inquiry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inquiry_quotes": { + "name": "inquiry_quotes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "inquiry_id": { + "name": "inquiry_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "carrier_id": { + "name": "carrier_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "price": { + "name": "price", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'RUB'" + }, + "valid_until": { + "name": "valid_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "inquiry_quotes_inquiry_id_inquiries_id_fk": { + "name": "inquiry_quotes_inquiry_id_inquiries_id_fk", + "tableFrom": "inquiry_quotes", + "tableTo": "inquiries", + "columnsFrom": [ + "inquiry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "inquiry_quotes_carrier_id_counterparties_id_fk": { + "name": "inquiry_quotes_carrier_id_counterparties_id_fk", + "tableFrom": "inquiry_quotes", + "tableTo": "counterparties", + "columnsFrom": [ + "carrier_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "mailbox_config": { + "name": "mailbox_config", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "imap_host": { + "name": "imap_host", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "imap_port": { + "name": "imap_port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user": { + "name": "user", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_enc": { + "name": "password_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "allow_insecure_tls": { + "name": "allow_insecure_tls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "order_document_checklist": { + "name": "order_document_checklist", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "order_id": { + "name": "order_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "present": { + "name": "present", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "attachment_id": { + "name": "attachment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signed_by_user_id": { + "name": "signed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signed_at": { + "name": "signed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "order_document_checklist_order_kind": { + "name": "order_document_checklist_order_kind", + "columns": [ + "order_id", + "kind" + ], + "isUnique": true + } + }, + "foreignKeys": { + "order_document_checklist_order_id_orders_id_fk": { + "name": "order_document_checklist_order_id_orders_id_fk", + "tableFrom": "order_document_checklist", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "order_document_checklist_attachment_id_attachments_id_fk": { + "name": "order_document_checklist_attachment_id_attachments_id_fk", + "tableFrom": "order_document_checklist", + "tableTo": "attachments", + "columnsFrom": [ + "attachment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "order_document_checklist_signed_by_user_id_users_id_fk": { + "name": "order_document_checklist_signed_by_user_id_users_id_fk", + "tableFrom": "order_document_checklist", + "tableTo": "users", + "columnsFrom": [ + "signed_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "order_events": { + "name": "order_events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "order_id": { + "name": "order_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'comment'" + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "order_events_order_id_orders_id_fk": { + "name": "order_events_order_id_orders_id_fk", + "tableFrom": "order_events", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "order_events_author_id_users_id_fk": { + "name": "order_events_author_id_users_id_fk", + "tableFrom": "order_events", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "order_expense_lines": { + "name": "order_expense_lines", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "order_id": { + "name": "order_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "carrier_id": { + "name": "carrier_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sum": { + "name": "sum", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "vat_rate": { + "name": "vat_rate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 22 + }, + "invoice_number": { + "name": "invoice_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invoice_date": { + "name": "invoice_date", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "planned_pay_date": { + "name": "planned_pay_date", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actual_pay_date": { + "name": "actual_pay_date", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "paid_status": { + "name": "paid_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unpaid'" + }, + "paid_amount": { + "name": "paid_amount", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "order_expense_lines_order_id_orders_id_fk": { + "name": "order_expense_lines_order_id_orders_id_fk", + "tableFrom": "order_expense_lines", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "order_expense_lines_carrier_id_counterparties_id_fk": { + "name": "order_expense_lines_carrier_id_counterparties_id_fk", + "tableFrom": "order_expense_lines", + "tableTo": "counterparties", + "columnsFrom": [ + "carrier_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "order_income_lines": { + "name": "order_income_lines", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "order_id": { + "name": "order_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sum": { + "name": "sum", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "vat_rate": { + "name": "vat_rate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 22 + }, + "invoice_number": { + "name": "invoice_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invoice_date": { + "name": "invoice_date", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "paid_status": { + "name": "paid_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unpaid'" + }, + "paid_amount": { + "name": "paid_amount", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "docs_signed": { + "name": "docs_signed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "order_income_lines_order_id_orders_id_fk": { + "name": "order_income_lines_order_id_orders_id_fk", + "tableFrom": "order_income_lines", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "orders": { + "name": "orders", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "number": { + "name": "number", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inquiry_id": { + "name": "inquiry_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "manager_id": { + "name": "manager_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cargo_description": { + "name": "cargo_description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "route_from": { + "name": "route_from", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "route_to": { + "name": "route_to", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "customs_place": { + "name": "customs_place", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "transport_mode": { + "name": "transport_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "transport_type": { + "name": "transport_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "load_date": { + "name": "load_date", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "unload_plan_date": { + "name": "unload_plan_date", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "flight_number": { + "name": "flight_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "poa_driver_name": { + "name": "poa_driver_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "poa_driver_passport": { + "name": "poa_driver_passport", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "poa_vehicle_plate": { + "name": "poa_vehicle_plate", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "poa_vehicle_model": { + "name": "poa_vehicle_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "poa_valid_until": { + "name": "poa_valid_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'new'" + }, + "paid_status": { + "name": "paid_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unpaid'" + }, + "docs_received": { + "name": "docs_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "edo_uploaded": { + "name": "edo_uploaded", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "public_token": { + "name": "public_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "orders_number_unique": { + "name": "orders_number_unique", + "columns": [ + "number" + ], + "isUnique": true + }, + "orders_public_token_unique": { + "name": "orders_public_token_unique", + "columns": [ + "public_token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "orders_inquiry_id_inquiries_id_fk": { + "name": "orders_inquiry_id_inquiries_id_fk", + "tableFrom": "orders", + "tableTo": "inquiries", + "columnsFrom": [ + "inquiry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "orders_customer_id_counterparties_id_fk": { + "name": "orders_customer_id_counterparties_id_fk", + "tableFrom": "orders", + "tableTo": "counterparties", + "columnsFrom": [ + "customer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "orders_manager_id_users_id_fk": { + "name": "orders_manager_id_users_id_fk", + "tableFrom": "orders", + "tableTo": "users", + "columnsFrom": [ + "manager_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_subscriptions": { + "name": "push_subscriptions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "p256dh": { + "name": "p256dh", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth": { + "name": "auth", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "push_subscriptions_endpoint_unique": { + "name": "push_subscriptions_endpoint_unique", + "columns": [ + "endpoint" + ], + "isUnique": true + } + }, + "foreignKeys": { + "push_subscriptions_user_id_users_id_fk": { + "name": "push_subscriptions_user_id_users_id_fk", + "tableFrom": "push_subscriptions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "due_at": { + "name": "due_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reminded_at": { + "name": "reminded_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "tasks_assignee_id_users_id_fk": { + "name": "tasks_assignee_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tasks_creator_id_users_id_fk": { + "name": "tasks_creator_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tracking_updates": { + "name": "tracking_updates", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "order_id": { + "name": "order_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "planned_unload_date": { + "name": "planned_unload_date", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "extra": { + "name": "extra", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "tracking_updates_order_id_orders_id_fk": { + "name": "tracking_updates_order_id_orders_id_fk", + "tableFrom": "tracking_updates", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tracking_updates_created_by_id_users_id_fk": { + "name": "tracking_updates_created_by_id_users_id_fk", + "tableFrom": "tracking_updates", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "manager_number": { + "name": "manager_number", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_path": { + "name": "avatar_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "facsimile_path": { + "name": "facsimile_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "can_sign": { + "name": "can_sign", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sign_limit_rub": { + "name": "sign_limit_rub", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + }, + "users_manager_number_unique": { + "name": "users_manager_number_unique", + "columns": [ + "manager_number" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/src/lib/db/migrations/meta/_journal.json b/src/lib/db/migrations/meta/_journal.json index 8ce7f2b..22c88b9 100644 --- a/src/lib/db/migrations/meta/_journal.json +++ b/src/lib/db/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1788193891885, "tag": "0000_equal_elektra", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1788197632163, + "tag": "0001_normal_captain_stacy", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index bc7bee6..020f3ba 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -22,6 +22,14 @@ export const users = sqliteTable("users", { role: text("role", { enum: ["admin", "manager", "accountant"] }).notNull(), managerNumber: integer("manager_number").unique(), avatarPath: text("avatar_path"), + // Право факсимильной подписи документов по заявке (см. orderDocumentChecklist) + // — отдельно от role, т.к. это лимит по сумме сделки, а не по должности. + // signLimitRub: null при canSign=true значит "без ограничения" (ген./зам. + // директор, глав. бух); конкретную сумму для менеджеров задаёт админ в + // Настройки → Пользователи, не хардкодится. + facsimilePath: text("facsimile_path"), + canSign: integer("can_sign", { mode: "boolean" }).notNull().default(false), + signLimitRub: real("sign_limit_rub"), lastSeenAt: integer("last_seen_at", { mode: "timestamp_ms" }), ...timestamps, }); @@ -305,6 +313,32 @@ export const orderEvents = sqliteTable("order_events", { ...timestamps, }); +/** + * Чек-лист минимального набора документов по заявке (Договор/Заявка-договор+Поручение, + * УПД, ЭТрН, Экспедиторская расписка) — не путать с documentTemplates (шаблоны для + * генерации) и с orderIncomeLines.docsSigned (это про закрывающие по конкретному + * счёту клиенту, отдельная сущность). "contract" — один пункт на весь пакет + * договорных документов, а не отдельно на каждый вариант шаблона. + * present=false у любого пункта -> статус заявки "red"; все present, но не все + * подписаны -> "yellow"; все present и подписаны -> "green" (см. lib/documents/checklist.ts). + */ +export const orderDocumentChecklist = sqliteTable( + "order_document_checklist", + { + id: id(), + orderId: text("order_id") + .notNull() + .references(() => orders.id, { onDelete: "cascade" }), + kind: text("kind", { enum: ["contract", "upd", "etrn", "forwarder_receipt"] }).notNull(), + present: integer("present", { mode: "boolean" }).notNull().default(false), + attachmentId: text("attachment_id").references(() => attachments.id, { onDelete: "set null" }), + signedByUserId: text("signed_by_user_id").references(() => users.id), + signedAt: integer("signed_at", { mode: "timestamp_ms" }), + ...timestamps, + }, + (t) => [uniqueIndex("order_document_checklist_order_kind").on(t.orderId, t.kind)], +); + /** Загруженные .docx-шаблоны компании для генерации документов (docxtemplater). Раздельные kind на "наша компания = Экспедитор" / "наша компания = Заказчик" — это буквально два разных документа (роль компании определяет, чьи реквизиты куда подставляются), не одна форма с параметром. */ export const documentTemplates = sqliteTable("document_templates", { id: id(), @@ -480,6 +514,7 @@ export const usersRelations = relations(users, ({ many }) => ({ orders: many(orders), conversationParticipants: many(conversationParticipants), chatMessages: many(chatMessages), + signedDocuments: many(orderDocumentChecklist), })); export const pushSubscriptionsRelations = relations(pushSubscriptions, ({ one }) => ({ @@ -528,6 +563,13 @@ export const ordersRelations = relations(orders, ({ one, many }) => ({ trackingUpdates: many(trackingUpdates), attachments: many(attachments), events: many(orderEvents), + documentChecklist: many(orderDocumentChecklist), +})); + +export const orderDocumentChecklistRelations = relations(orderDocumentChecklist, ({ one }) => ({ + order: one(orders, { fields: [orderDocumentChecklist.orderId], references: [orders.id] }), + attachment: one(attachments, { fields: [orderDocumentChecklist.attachmentId], references: [attachments.id] }), + signedBy: one(users, { fields: [orderDocumentChecklist.signedByUserId], references: [users.id] }), })); export const orderIncomeLinesRelations = relations(orderIncomeLines, ({ one }) => ({ diff --git a/src/lib/documents/checklist.ts b/src/lib/documents/checklist.ts new file mode 100644 index 0000000..c3eff75 --- /dev/null +++ b/src/lib/documents/checklist.ts @@ -0,0 +1,117 @@ +import { eq, and, inArray } from "drizzle-orm"; +import { db } from "@/lib/db/client"; +import { orderDocumentChecklist, orders, users } from "@/lib/db/schema"; +import { sumWithVat, logSystemEvent } from "@/lib/orders/service"; +import { publishEvent } from "@/lib/events/bus"; + +export type DocKind = "contract" | "upd" | "etrn" | "forwarder_receipt"; + +export const DOC_KINDS: DocKind[] = ["contract", "upd", "etrn", "forwarder_receipt"]; + +export const DOC_KIND_LABELS: Record = { + contract: "Договор / Заявка-договор + Поручение", + upd: "УПД", + etrn: "ЭТрН", + forwarder_receipt: "Экспедиторская расписка", +}; + +/** Idempotent — вставляет недостающие пункты чек-листа. Основной путь — eager-seed в convertInquiryToOrder, это подстраховка на случай, если набор обязательных доков когда-то менялся после создания заявки. */ +export async function ensureChecklist(orderId: string): Promise { + const existing = await db.query.orderDocumentChecklist.findMany({ where: eq(orderDocumentChecklist.orderId, orderId) }); + const existingKinds = new Set(existing.map((r) => r.kind)); + const missing = DOC_KINDS.filter((k) => !existingKinds.has(k)); + if (missing.length === 0) return; + await db.insert(orderDocumentChecklist).values(missing.map((kind) => ({ orderId, kind }))); +} + +export type DocStatus = "red" | "yellow" | "green"; + +/** Красный — не собран хотя бы один документ; жёлтый — все собраны, но не все подписаны; зелёный — все собраны и подписаны. */ +export function getOrderDocStatus(checklist: { present: boolean; signedByUserId: string | null }[]): DocStatus { + if (checklist.length === 0 || checklist.some((c) => !c.present)) return "red"; + if (checklist.some((c) => !c.signedByUserId)) return "yellow"; + return "green"; +} + +/** Для списка заявок — один запрос на все заявки разом вместо N+1. */ +export async function getDocStatusByOrderId(orderIds: string[]): Promise> { + if (orderIds.length === 0) return new Map(); + const rows = await db.query.orderDocumentChecklist.findMany({ where: inArray(orderDocumentChecklist.orderId, orderIds) }); + const byOrder = new Map(); + for (const row of rows) { + const list = byOrder.get(row.orderId) ?? []; + list.push({ present: row.present, signedByUserId: row.signedByUserId }); + byOrder.set(row.orderId, list); + } + const result = new Map(); + for (const id of orderIds) { + result.set(id, getOrderDocStatus(byOrder.get(id) ?? [])); + } + return result; +} + +export async function setDocPresent(orderId: string, kind: DocKind, present: boolean): Promise { + await ensureChecklist(orderId); + await db + .update(orderDocumentChecklist) + .set({ present }) + .where(and(eq(orderDocumentChecklist.orderId, orderId), eq(orderDocumentChecklist.kind, kind))); +} + +function canUserSign(user: { canSign: boolean; signLimitRub: number | null }, orderSum: number): boolean { + return user.canSign && (user.signLimitRub == null || user.signLimitRub >= orderSum); +} + +async function getOrderWithSum(orderId: string) { + const order = await db.query.orders.findFirst({ where: eq(orders.id, orderId), with: { incomeLines: true } }); + if (!order) return null; + return { order, sum: sumWithVat(order.incomeLines) }; +} + +/** Все, кто вправе подписать заявку на данную сумму — получатели уведомления, когда инициатор сам подписать не может. */ +async function listEligibleSigners(orderSum: number, excludeUserId?: string) { + const rows = await db.query.users.findMany({ where: eq(users.canSign, true) }); + return rows.filter((u) => u.id !== excludeUserId && canUserSign(u, orderSum)); +} + +/** Возвращает {signed:true} если подписавший имел право; иначе документ НЕ подписывается — вместо этого уходит запрос всем, кто может подписать (push + системный комментарий на заявке), и возвращается {signed:false, requested:true}. */ +export async function signDocument( + orderId: string, + kind: DocKind, + signerId: string, +): Promise<{ signed: boolean; requested?: boolean }> { + await ensureChecklist(orderId); + + const [signer, loaded] = await Promise.all([ + db.query.users.findFirst({ where: eq(users.id, signerId) }), + getOrderWithSum(orderId), + ]); + if (!signer || !loaded) throw new Error("Заявка или пользователь не найдены"); + + if (canUserSign(signer, loaded.sum)) { + await db + .update(orderDocumentChecklist) + .set({ signedByUserId: signerId, signedAt: new Date() }) + .where(and(eq(orderDocumentChecklist.orderId, orderId), eq(orderDocumentChecklist.kind, kind))); + await logSystemEvent(orderId, `${signer.name} подписал(а) «${DOC_KIND_LABELS[kind]}»`); + return { signed: true }; + } + + await requestSignature(orderId, kind, signerId); + return { signed: false, requested: true }; +} + +async function requestSignature(orderId: string, kind: DocKind, requesterId: string): Promise { + const [requester, loaded] = await Promise.all([ + db.query.users.findFirst({ where: eq(users.id, requesterId) }), + getOrderWithSum(orderId), + ]); + if (!requester || !loaded) return; + + const eligible = await listEligibleSigners(loaded.sum, requesterId); + await logSystemEvent( + orderId, + `${requester.name} запросил(а) подпись «${DOC_KIND_LABELS[kind]}» — нет прав на сумму сделки ${loaded.sum.toLocaleString("ru-RU")} ₽`, + ); + publishEvent({ type: "order.signature_requested", orderId, kind, requesterId, actorId: requesterId, signerIds: eligible.map((u) => u.id) }); +} diff --git a/src/lib/events/bus.ts b/src/lib/events/bus.ts index 32b1fbd..7d5fea0 100644 --- a/src/lib/events/bus.ts +++ b/src/lib/events/bus.ts @@ -12,6 +12,7 @@ export type AppEvent = | { type: "order.created"; orderId: string; managerId: string } | { type: "tracking.updated"; orderId: string; actorId?: string } | { type: "order.event"; orderId: string; actorId?: string } + | { type: "order.signature_requested"; orderId: string; kind: string; requesterId: string; actorId: string; signerIds: string[] } | { type: "email_draft.created"; draftId: string } | { type: "chat.message"; conversationId: string; actorId: string } | { type: "chat.typing"; conversationId: string; userId: string; userName: string } diff --git a/src/lib/inquiries/service.ts b/src/lib/inquiries/service.ts index 638580f..22373d7 100644 --- a/src/lib/inquiries/service.ts +++ b/src/lib/inquiries/service.ts @@ -2,6 +2,7 @@ import { eq, desc } from "drizzle-orm"; import { db } from "@/lib/db/client"; import { inquiries, inquiryQuotes, inquiryCargoItems, orders } from "@/lib/db/schema"; import { formatInquiryNumber, formatOrderNumber, getManagerNumber, nextInquirySeq, nextOrderSeq } from "@/lib/orders/numbering"; +import { ensureChecklist } from "@/lib/documents/checklist"; import { publishEvent } from "@/lib/events/bus"; export interface CargoItemInput { @@ -147,6 +148,7 @@ export async function convertInquiryToOrder(inquiryId: string) { .returning(); await db.update(inquiries).set({ status: "converted", convertedOrderId: order.id }).where(eq(inquiries.id, inquiryId)); + await ensureChecklist(order.id); publishEvent({ type: "order.created", orderId: order.id, managerId: order.managerId }); return order; diff --git a/src/lib/orders/service.ts b/src/lib/orders/service.ts index d18ab90..bcb0a0b 100644 --- a/src/lib/orders/service.ts +++ b/src/lib/orders/service.ts @@ -11,7 +11,7 @@ export interface OrderVisibility { /** Менеджер видит только свои заявки; админ и бухгалтер — все (нужно для финансов/отчётов). */ export async function listOrders(visibility: OrderVisibility) { const rows = await db.query.orders.findMany({ - with: { customer: true, manager: true, incomeLines: true, expenseLines: true }, + with: { customer: true, manager: true, incomeLines: true, expenseLines: true, documentChecklist: true }, orderBy: desc(orders.createdAt), }); if (visibility.role === "manager") { @@ -31,13 +31,19 @@ export async function getOrder(id: string) { trackingUpdates: { orderBy: desc(trackingUpdates.createdAt) }, attachments: true, events: { with: { author: true }, orderBy: desc(orderEvents.createdAt) }, + documentChecklist: { with: { signedBy: true } }, }, }); } +/** Сумма строк с учётом НДС — используется и в марже, и в documents/checklist.ts для сравнения с лимитом подписи менеджера. */ +export function sumWithVat(lines: { sum: number; vatRate: number }[]): number { + return lines.reduce((acc, l) => acc + l.sum * (1 + l.vatRate / 100), 0); +} + export function computeMargin(order: { incomeLines: { sum: number; vatRate: number }[]; expenseLines: { sum: number; vatRate: number }[] }) { - const incomeTotal = order.incomeLines.reduce((acc, l) => acc + l.sum * (1 + l.vatRate / 100), 0); - const expenseTotal = order.expenseLines.reduce((acc, l) => acc + l.sum * (1 + l.vatRate / 100), 0); + const incomeTotal = sumWithVat(order.incomeLines); + const expenseTotal = sumWithVat(order.expenseLines); const incomeNoVat = order.incomeLines.reduce((acc, l) => acc + l.sum, 0); const expenseNoVat = order.expenseLines.reduce((acc, l) => acc + l.sum, 0); return { @@ -51,7 +57,7 @@ export function computeMargin(order: { incomeLines: { sum: number; vatRate: numb const STATUS_LABEL: Record = { new: "Новая", in_progress: "На исполнении", done: "Завершена", closed: "Закрыта" }; const PAID_LABEL: Record = { unpaid: "Не оплачен", partial: "Частично", paid: "Оплачен" }; -async function logSystemEvent(orderId: string, body: string) { +export async function logSystemEvent(orderId: string, body: string) { await db.insert(orderEvents).values({ orderId, kind: "system", body }); publishEvent({ type: "order.event", orderId }); } diff --git a/src/lib/push/notifier.ts b/src/lib/push/notifier.ts index 3d4c3cb..76fa4f0 100644 --- a/src/lib/push/notifier.ts +++ b/src/lib/push/notifier.ts @@ -1,5 +1,6 @@ import { subscribeEvents, type AppEvent } from "@/lib/events/bus"; import { getConversationParticipantIds, getUnreadChatCountForUser } from "@/lib/chat/service"; +import { DOC_KIND_LABELS, type DocKind } from "@/lib/documents/checklist"; import { sendPushToAllUsers, sendPushToUser, type PushPayload } from "./service"; /** @@ -76,6 +77,21 @@ export function ensurePushNotifierStarted(): void { void handleChatMessage(event); return; } + // Не через toPushPayload/sendPushToAllUsers: получатели — не "все кроме + // автора", а только те, у кого сумма сделки не превышает лимит подписи + // (уже посчитано в documents/checklist.ts, чтобы не дублировать эту + // логику здесь). + if (event.type === "order.signature_requested") { + const payload: PushPayload = { + title: `Нужна подпись: ${DOC_KIND_LABELS[event.kind as DocKind]}`, + body: "", + url: `/orders/${event.orderId}`, + }; + for (const userId of event.signerIds) { + void sendPushToUser(userId, payload); + } + return; + } // task.reminder is per-assignee, not broadcast — everything else goes to everyone. if (event.type === "task.reminder") { void sendPushToUser(event.assigneeId, { title: "Напоминание о задаче", body: event.title, url: "/tasks" }); diff --git a/src/lib/users/service.ts b/src/lib/users/service.ts index d1e1d50..c12fa1b 100644 --- a/src/lib/users/service.ts +++ b/src/lib/users/service.ts @@ -7,6 +7,7 @@ import { hashPassword } from "@/lib/auth/password"; const dataDir = process.env.DATA_DIR ?? path.join(process.cwd(), "data"); const avatarsDir = path.join(dataDir, "avatars"); +const facsimilesDir = path.join(dataDir, "facsimiles"); export interface CreateUserInput { email: string; @@ -58,6 +59,26 @@ export async function saveAvatar(userId: string, content: Buffer, extension: str return storedPath; } +/** Перезаписывает предыдущую факсимильную подпись — тот же принцип, что и saveAvatar. */ +export async function saveFacsimile(userId: string, content: Buffer, extension: string) { + fs.mkdirSync(facsimilesDir, { recursive: true }); + + const existing = await db.query.users.findFirst({ where: eq(users.id, userId) }); + if (existing?.facsimilePath && fs.existsSync(existing.facsimilePath)) { + fs.unlinkSync(existing.facsimilePath); + } + + const storedPath = path.join(facsimilesDir, `${userId}.${extension}`); + fs.writeFileSync(storedPath, content); + await db.update(users).set({ facsimilePath: storedPath }).where(eq(users.id, userId)); + return storedPath; +} + +export async function updateSignRights(userId: string, input: { canSign?: boolean; signLimitRub?: number | null }) { + const [row] = await db.update(users).set(input).where(eq(users.id, userId)).returning(); + return row; +} + export async function touchLastSeen(userId: string) { await db.update(users).set({ lastSeenAt: new Date() }).where(eq(users.id, userId)); }