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:
@@ -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();
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user