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) => (
+
+
+ togglePresent(row.kind, e.target.checked)}
+ />
+ {DOC_KIND_LABELS[row.kind]}
+
+
+
+ {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")}` : ""}
+
+ ) : (
+
sign(row.kind)}
+ disabled={!row.present || loadingKind === row.kind}
+ className="btn btn-ghost"
+ >
+ Подписать
+
+ )}
+
+
+ ))}
+
+
+ {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 тут не даёт выгоды
+
+ ) : (
+
+ Нет подписи
+
+ )}
+
+
inputRef.current?.click()} disabled={loading} className="btn btn-ghost">
+ {loading ? "Загружаем…" : "Загрузить факсимиле"}
+
+
+ {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 (
+
+
+ {
+ setCanSign(e.target.checked);
+ void patch({ canSign: e.target.checked });
+ }}
+ />
+ Может подписывать
+
+ {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));
}