feat: multi-organization support + bank accounts

- Renamed singular /api/organization → CRUD /api/organizations
- New BankAccount table with CRUD under /api/organizations/:orgId/bank-accounts
- TochkaCredential gets optional bankAccountId for future per-account bank API config
- Active organization stored in cookie dm_org, /api/active-organization GET/POST
- activeOrgPlugin resolves req._orgId from cookie (or first non-archived as fallback)
- Migration 1_multiorg: BankAccount table + data backfill from legacy Organization.bank* fields
- Web: new /companies list + /companies/:id with tabs (Реквизиты, Банки и счета, Интеграции stub)
- Web: OrgSwitcher dropdown in header (active org + management link)
- Removed nav "Реквизиты", "Банк" — replaced by "Компании"
- Per-field error highlighting wired up on new forms

Existing organization data backfills cleanly: legacy bank* fields stay readable, but new
BankAccount becomes the source of truth going forward.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
admin
2026-05-01 11:08:26 +03:00
parent b28c0463b3
commit 524789facc
15 changed files with 909 additions and 200 deletions
@@ -0,0 +1,114 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { prisma } from '../../db.js';
import { optionalRegex, optionalText } from '../../lib/zod-utils.js';
const BankAccountUpsert = z.object({
name: z.string().min(1).max(200),
bankName: optionalText(500),
bankBik: optionalRegex(/^\d{9}$/),
accountNumber: optionalRegex(/^\d{20}$/),
corrAccount: optionalRegex(/^\d{20}$/),
currency: z.string().length(3).default('RUB'),
isPrimary: z.boolean().default(false),
});
export async function bankAccountsRoutes(app: FastifyInstance) {
// ---- список под organization ----
app.get(
'/api/organizations/:orgId/bank-accounts',
{ preHandler: app.requireDocPermission('viewer') },
async (req, reply) => {
const { orgId } = req.params as { orgId: string };
const org = await prisma.organization.findFirst({ where: { id: orgId, archivedAt: null } });
if (!org) {
reply.code(404).send({ error: 'organization_not_found' });
return;
}
const items = await prisma.bankAccount.findMany({
where: { organizationId: orgId, archivedAt: null },
orderBy: [{ isPrimary: 'desc' }, { createdAt: 'asc' }],
});
return { items };
},
);
// ---- create ----
app.post(
'/api/organizations/:orgId/bank-accounts',
{ preHandler: app.requireDocPermission('user') },
async (req, reply) => {
const { orgId } = req.params as { orgId: string };
const parsed = BankAccountUpsert.safeParse(req.body);
if (!parsed.success) {
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
return;
}
const org = await prisma.organization.findFirst({ where: { id: orgId, archivedAt: null } });
if (!org) {
reply.code(404).send({ error: 'organization_not_found' });
return;
}
// Только один primary на организацию
const created = await prisma.$transaction(async (tx) => {
if (parsed.data.isPrimary) {
await tx.bankAccount.updateMany({
where: { organizationId: orgId, isPrimary: true },
data: { isPrimary: false },
});
}
return tx.bankAccount.create({ data: { ...parsed.data, organizationId: orgId } });
});
reply.code(201).send(created);
},
);
// ---- update ----
app.put(
'/api/organizations/:orgId/bank-accounts/:id',
{ preHandler: app.requireDocPermission('user') },
async (req, reply) => {
const { orgId, id } = req.params as { orgId: string; id: string };
const parsed = BankAccountUpsert.safeParse(req.body);
if (!parsed.success) {
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
return;
}
const existing = await prisma.bankAccount.findFirst({
where: { id, organizationId: orgId, archivedAt: null },
});
if (!existing) {
reply.code(404).send({ error: 'not_found' });
return;
}
const updated = await prisma.$transaction(async (tx) => {
if (parsed.data.isPrimary) {
await tx.bankAccount.updateMany({
where: { organizationId: orgId, isPrimary: true, id: { not: id } },
data: { isPrimary: false },
});
}
return tx.bankAccount.update({ where: { id }, data: parsed.data });
});
return updated;
},
);
// ---- archive ----
app.delete(
'/api/organizations/:orgId/bank-accounts/:id',
{ preHandler: app.requireDocPermission('user') },
async (req, reply) => {
const { orgId, id } = req.params as { orgId: string; id: string };
const existing = await prisma.bankAccount.findFirst({
where: { id, organizationId: orgId, archivedAt: null },
});
if (!existing) {
reply.code(404).send({ error: 'not_found' });
return;
}
await prisma.bankAccount.update({ where: { id }, data: { archivedAt: new Date() } });
reply.code(204).send();
},
);
}
+87 -29
View File
@@ -1,54 +1,112 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { prisma } from '../../db.js';
import { getOrganizationId } from '../../lib/org.js';
import { ACTIVE_ORG_COOKIE, resolveOrganizationId, setActiveOrgCookie } from '../../lib/org.js';
import { optionalRegex, optionalText } from '../../lib/zod-utils.js';
const OrgUpdate = z.object({
const OrgUpsert = z.object({
name: z.string().min(1).max(500),
shortName: optionalText(100),
inn: z.string().regex(/^\d{10}$|^\d{12}$/),
kpp: optionalRegex(/^\d{9}$/),
ogrn: optionalRegex(/^\d{13}$|^\d{15}$/),
legalAddress: optionalText(1000),
bankName: optionalText(500),
bankBik: optionalRegex(/^\d{9}$/),
bankAccount: optionalRegex(/^\d{20}$/),
signatoryName: optionalText(500),
signatoryPosition: optionalText(500),
});
const SetActive = z.object({ id: z.string().uuid() });
export async function organizationsRoutes(app: FastifyInstance) {
app.get(
'/api/organization',
// ---- список ----
app.get('/api/organizations', { preHandler: app.requireDocPermission('viewer') }, async () => {
const items = await prisma.organization.findMany({
where: { archivedAt: null },
orderBy: { createdAt: 'asc' },
});
return { items };
});
// ---- активная (id + объект) ----
app.get('/api/active-organization', { preHandler: app.requireDocPermission('viewer') }, async (req) => {
const id = await resolveOrganizationId(req);
const org = await prisma.organization.findUnique({ where: { id } });
return { id, organization: org };
});
app.post(
'/api/active-organization',
{ preHandler: app.requireDocPermission('viewer') },
async (req, reply) => {
const id = getOrganizationId(req);
const org = await prisma.organization.findUnique({ where: { id } });
if (!org) {
reply.code(404).send({ error: 'organization_not_found' });
return;
}
return org;
},
);
app.put(
'/api/organization',
{ preHandler: app.requireDocPermission('admin') },
async (req, reply) => {
const id = getOrganizationId(req);
const parsed = OrgUpdate.safeParse(req.body);
const parsed = SetActive.safeParse(req.body);
if (!parsed.success) {
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
return;
}
// upsert чтобы первое сохранение из UI создавало строку, если её ещё нет (вместо seed-only)
const org = await prisma.organization.upsert({
where: { id },
update: parsed.data,
create: { id, ...parsed.data },
const org = await prisma.organization.findFirst({
where: { id: parsed.data.id, archivedAt: null },
});
return org;
if (!org) {
reply.code(404).send({ error: 'not_found' });
return;
}
setActiveOrgCookie(reply, org.id);
return { id: org.id, organization: org };
},
);
// ---- одна организация ----
app.get('/api/organizations/:id', { preHandler: app.requireDocPermission('viewer') }, async (req, reply) => {
const { id } = req.params as { id: string };
const org = await prisma.organization.findFirst({ where: { id, archivedAt: null } });
if (!org) {
reply.code(404).send({ error: 'not_found' });
return;
}
return org;
});
// ---- create ----
app.post('/api/organizations', { preHandler: app.requireDocPermission('admin') }, async (req, reply) => {
const parsed = OrgUpsert.safeParse(req.body);
if (!parsed.success) {
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
return;
}
const created = await prisma.organization.create({ data: parsed.data });
// если активной не было — сделать новую активной
if (!req.cookies?.[ACTIVE_ORG_COOKIE]) {
setActiveOrgCookie(reply, created.id);
}
reply.code(201).send(created);
});
// ---- update ----
app.put('/api/organizations/:id', { preHandler: app.requireDocPermission('admin') }, async (req, reply) => {
const { id } = req.params as { id: string };
const parsed = OrgUpsert.safeParse(req.body);
if (!parsed.success) {
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
return;
}
const existing = await prisma.organization.findFirst({ where: { id, archivedAt: null } });
if (!existing) {
reply.code(404).send({ error: 'not_found' });
return;
}
return prisma.organization.update({ where: { id }, data: parsed.data });
});
// ---- archive ----
app.delete('/api/organizations/:id', { preHandler: app.requireDocPermission('admin') }, async (req, reply) => {
const { id } = req.params as { id: string };
const existing = await prisma.organization.findFirst({ where: { id, archivedAt: null } });
if (!existing) {
reply.code(404).send({ error: 'not_found' });
return;
}
// Не удаляем физически — есть FK от документов, клиентов, услуг; делаем soft-delete.
await prisma.organization.update({ where: { id }, data: { archivedAt: new Date() } });
reply.code(204).send();
});
}