Add document-signature authority + traffic-light order status (Phase 1)

Per-user configurable signing rights (canSign + optional ruble limit) with
a facsimile-signature image upload; a per-order checklist of the 4 minimum
documents (Договор/Заявка-договор+Поручение, УПД, ЭТрН, Экспедиторская
расписка) with a red/yellow/green status derived from present+signed
state. Signing checks the order's income-line sum against the signer's
limit; if they're not authorized, the request is redirected to everyone
who can sign (push notification + order comment) instead of silently
failing. Phase 2 (the 3-funnel stage/field redesign from the flowchart
screenshots, plus subcontractor mailing) is separate follow-up work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gh2UXUQUVBWroWEnn1FLFG
This commit is contained in:
ogrechkoandClaude Sonnet 5 committed 2026-08-31 17:46:19 +00:00
1 parent 579528edf7
commit e9e05d859d
22 files changed
+3385 -5

No files matched your search

+14
View File
@@ -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
/>
</div>
<div className="mb-5">
<SignChecklistSection
orderId={order.id}
rows={order.documentChecklist.map((r) => ({
kind: r.kind,
present: r.present,
signedByUserId: r.signedByUserId,
signedAt: r.signedAt,
signedBy: r.signedBy ? { id: r.signedBy.id, name: r.signedBy.name } : null,
}))}
/>
</div>
<div className="mb-5">
<DocumentsSection
orderId={order.id}
@@ -0,0 +1,127 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export type DocKind = "contract" | "upd" | "etrn" | "forwarder_receipt";
const DOC_KIND_LABELS: Record<DocKind, string> = {
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<DocKind | null>(null);
const [notice, setNotice] = useState<string | null>(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 (
<div className="card p-4">
<div className="mb-3 flex items-center gap-2">
<span className={`h-2.5 w-2.5 rounded-full ${STATUS_DOT[status]}`} />
<h2 className="text-sm font-semibold text-text-muted">Документы по заявке — {STATUS_LABEL[status]}</h2>
</div>
<div className="flex flex-col gap-2">
{rows.map((row) => (
<div key={row.kind} className="flex flex-wrap items-center gap-3 rounded-md border border-border px-3 py-2 text-sm">
<label className="flex items-center gap-1.5">
<input
type="checkbox"
checked={row.present}
disabled={loadingKind === row.kind}
onChange={(e) => togglePresent(row.kind, e.target.checked)}
/>
<span>{DOC_KIND_LABELS[row.kind]}</span>
</label>
<div className="ml-auto flex items-center gap-2">
{row.signedBy ? (
<span className="flex items-center gap-2 text-xs text-text-muted">
{/* eslint-disable-next-line @next/next/no-img-element -- маленькая приватная иконка-факсимиле */}
<img
src={`/api/users/${row.signedBy.id}/facsimile`}
alt=""
className="h-6 w-12 rounded border border-border object-contain bg-surface"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
Подписал(а) {row.signedBy.name}
{row.signedAt ? `, ${new Date(row.signedAt).toLocaleDateString("ru-RU")}` : ""}
</span>
) : (
<button
type="button"
onClick={() => sign(row.kind)}
disabled={!row.present || loadingKind === row.kind}
className="btn btn-ghost"
>
Подписать
</button>
)}
</div>
</div>
))}
</div>
{notice && <p className="mt-3 text-sm text-warning-soft-text">{notice}</p>}
</div>
);
}
+8 -1
View File
@@ -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<string, string> = {
new: "Новая",
@@ -16,6 +17,7 @@ const STATUS_BADGE: Record<string, string> = {
closed: "badge-closed",
};
const PAID_LABEL: Record<string, string> = { 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() {
<th className="px-4 py-3 font-medium">Менеджер</th>
<th className="px-4 py-3 font-medium">Статус</th>
<th className="px-4 py-3 font-medium">Оплата</th>
<th className="px-4 py-3 font-medium">Доки</th>
<th className="px-4 py-3 font-medium text-right">Маржа</th>
</tr>
</thead>
<tbody>
{orders.map((o) => {
const margin = computeMargin(o);
const docStatus = getOrderDocStatus(o.documentChecklist);
return (
<tr key={o.id} className="border-b border-border last:border-0 hover:bg-surface-hover">
<td className="px-4 py-3">
@@ -59,13 +63,16 @@ export default async function OrdersPage() {
<span className={`badge ${STATUS_BADGE[o.status]}`}>{STATUS_LABEL[o.status]}</span>
</td>
<td className="px-4 py-3 text-text-muted">{PAID_LABEL[o.paidStatus]}</td>
<td className="px-4 py-3">
<span className={`inline-block h-2.5 w-2.5 rounded-full ${DOC_STATUS_DOT[docStatus]}`} />
</td>
<td className="px-4 py-3 text-right font-medium">{margin.marginWithVat.toLocaleString("ru-RU")} ₽</td>
</tr>
);
})}
{orders.length === 0 && (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-text-faint">
<td colSpan={8} className="px-4 py-8 text-center text-text-faint">
Пока нет заявок. Создайте их из запроса.
</td>
</tr>
@@ -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<HTMLInputElement>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Кэш-бастер для <img> после перезаписи файла — меняется только после
// успешной загрузки, не на каждый рендер (Date.now() напрямую в JSX
// нарушает чистоту рендера).
const [version, setVersion] = useState(0);
async function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
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 (
<div className="flex items-center gap-4">
{hasFacsimile ? (
// eslint-disable-next-line @next/next/no-img-element -- маленькое приватное изображение по requireSession, next/image тут не даёт выгоды
<img src={`/api/users/${userId}/facsimile?v=${version}`} alt="Факсимиле" className="h-14 w-28 rounded-md border border-border object-contain bg-surface" />
) : (
<div className="flex h-14 w-28 items-center justify-center rounded-md border border-dashed border-border text-xs text-text-faint">
Нет подписи
</div>
)}
<div>
<button type="button" onClick={() => inputRef.current?.click()} disabled={loading} className="btn btn-ghost">
{loading ? "Загружаем…" : "Загрузить факсимиле"}
</button>
<input ref={inputRef} type="file" accept="image/png,image/jpeg,image/webp,image/gif" hidden onChange={handleChange} />
{error && <p className="mt-2 text-sm text-danger-soft-text">{error}</p>}
</div>
</div>
);
}
+11
View File
@@ -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<string, string> = { admin: "Администратор", manager: "Менеджер", accountant: "Бухгалтер" };
@@ -18,6 +19,16 @@ export default async function ProfilePage() {
<AvatarUploader userId={user.id} name={user.name} hasAvatar={Boolean(user.avatarPath)} />
</div>
<div className="card mb-5 p-4">
<h2 className="mb-3 text-sm font-semibold text-text-muted">Факсимильная подпись</h2>
<FacsimileUploader userId={user.id} hasFacsimile={Boolean(user.facsimilePath)} />
{user.canSign && (
<p className="mt-3 text-xs text-text-muted">
Право подписи: {user.signLimitRub == null ? "без ограничения по сумме" : `до ${user.signLimitRub.toLocaleString("ru-RU")} ₽`}
</p>
)}
</div>
<div className="card p-4">
<dl className="flex flex-col gap-2 text-sm">
<div className="flex justify-between">
+5
View File
@@ -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<string, string> = {
admin: "Администратор",
@@ -32,6 +33,7 @@ export default async function UsersPage() {
<th className="px-4 py-3 font-medium">Email</th>
<th className="px-4 py-3 font-medium">Роль</th>
<th className="px-4 py-3 font-medium">№ менеджера</th>
<th className="px-4 py-3 font-medium">Подпись</th>
</tr>
</thead>
<tbody>
@@ -41,6 +43,9 @@ export default async function UsersPage() {
<td className="px-4 py-3 text-text-muted">{u.email}</td>
<td className="px-4 py-3 text-text-muted">{ROLE_LABEL[u.role]}</td>
<td className="px-4 py-3 text-text-muted">{u.managerNumber ?? "—"}</td>
<td className="px-4 py-3">
<SignRightsCell userId={u.id} canSign={u.canSign} signLimitRub={u.signLimitRub} />
</td>
</tr>
))}
</tbody>
@@ -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 (
<div className="flex items-center gap-2">
<label className="flex items-center gap-1.5">
<input
type="checkbox"
checked={canSign}
disabled={loading}
onChange={(e) => {
setCanSign(e.target.checked);
void patch({ canSign: e.target.checked });
}}
/>
<span className="text-text-muted">Может подписывать</span>
</label>
{canSign && (
<input
type="number"
min={0}
step={1000}
placeholder="без лимита"
value={limit}
disabled={loading}
onChange={(e) => 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"
/>
)}
</div>
);
}
@@ -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 });
}
@@ -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);
}
+35
View File
@@ -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<string, string> = {
"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 });
}
+32
View File
@@ -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<string, string> = {
".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" },
});
}
+28
View File
@@ -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);
}
@@ -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;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -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
}
]
}
+42
View File
@@ -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 }) => ({
+117
View File
@@ -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<DocKind, string> = {
contract: "Договор / Заявка-договор + Поручение",
upd: "УПД",
etrn: "ЭТрН",
forwarder_receipt: "Экспедиторская расписка",
};
/** Idempotent — вставляет недостающие пункты чек-листа. Основной путь — eager-seed в convertInquiryToOrder, это подстраховка на случай, если набор обязательных доков когда-то менялся после создания заявки. */
export async function ensureChecklist(orderId: string): Promise<void> {
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<Map<string, DocStatus>> {
if (orderIds.length === 0) return new Map();
const rows = await db.query.orderDocumentChecklist.findMany({ where: inArray(orderDocumentChecklist.orderId, orderIds) });
const byOrder = new Map<string, { present: boolean; signedByUserId: string | null }[]>();
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<string, DocStatus>();
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<void> {
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<void> {
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) });
}
+1
View File
@@ -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 }
+2
View File
@@ -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;
+10 -4
View File
@@ -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<string, string> = { new: "Новая", in_progress: "На исполнении", done: "Завершена", closed: "Закрыта" };
const PAID_LABEL: Record<string, string> = { 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 });
}
+16
View File
@@ -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" });
+21
View File
@@ -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));
}