init: M1 scaffolding + M2 organization/clients/services CRUD
- monorepo (npm workspaces): apps/api (Fastify+Prisma+TS), apps/web (Vite+React+TS), packages/shared (zod schemas) - SSO via auth.queo.ru: jose+JWKS plugin, requireDocPermission(viewer|user|admin) - DEV_BYPASS_AUTH for local development (hard-checked off in production) - M2: organization upsert, clients CRUD with search, services catalog with soft-delete - BigInt -> Number serializer for Prisma money columns - Embedded Postgres + npm run dev:demo for one-command local boot - Docker compose for queoserver: postgres + api + web (nginx as ingress proxying /api -> api:3030) - First migration 0_init committed (prisma migrate diff) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@doc-manager/shared",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./blocks": "./src/blocks/schema.ts",
|
||||
"./tochka": "./src/tochka/dto.ts",
|
||||
"./auth": "./src/auth/types.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// Контракт JWT-payload, выдаваемого auth.queo.ru.
|
||||
// Источник: C:\project\Auth_server\backend\src\services\tokens.js
|
||||
export const PermissionRole = z.enum(['viewer', 'user', 'admin']);
|
||||
export type PermissionRole = z.infer<typeof PermissionRole>;
|
||||
|
||||
export const AuthPayload = z.object({
|
||||
sub: z.string().uuid(),
|
||||
email: z.string().email(),
|
||||
groups: z.array(z.string()).default([]),
|
||||
permissions: z.record(PermissionRole).default({}),
|
||||
isSuperuser: z.boolean().default(false),
|
||||
iat: z.number().optional(),
|
||||
exp: z.number().optional(),
|
||||
iss: z.literal('https://auth.queo.ru').optional(),
|
||||
aud: z.union([z.literal('queo.ru'), z.array(z.string())]).optional(),
|
||||
});
|
||||
export type AuthPayload = z.infer<typeof AuthPayload>;
|
||||
|
||||
export const DOC_MANAGER_RESOURCE = 'doc_manager' as const;
|
||||
|
||||
// Иерархия ролей: admin ⊃ user ⊃ viewer
|
||||
export function hasDocPermission(payload: AuthPayload, required: PermissionRole): boolean {
|
||||
if (payload.isSuperuser) return true;
|
||||
const role = payload.permissions[DOC_MANAGER_RESOURCE];
|
||||
if (!role) return false;
|
||||
const order: PermissionRole[] = ['viewer', 'user', 'admin'];
|
||||
return order.indexOf(role) >= order.indexOf(required);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// TipTap-совместимое JSON-дерево rich-текста.
|
||||
// Не валидируем глубоко — TipTap отдаёт собственный формат, нам важно только сохранить и доставить.
|
||||
export const RichTextSchema: z.ZodType<unknown> = z.object({
|
||||
type: z.string(),
|
||||
content: z.array(z.lazy((): z.ZodType<unknown> => RichTextSchema)).optional(),
|
||||
text: z.string().optional(),
|
||||
marks: z.array(z.object({ type: z.string(), attrs: z.record(z.unknown()).optional() })).optional(),
|
||||
attrs: z.record(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
export type RichText = z.infer<typeof RichTextSchema>;
|
||||
|
||||
// VAT-ставки актуальные на 2026-04 (включая 5% и 7% для УСН-плательщиков).
|
||||
// Имена совпадают с TS-клиентом Prisma (в БД хранится как '0','5',... через @map).
|
||||
// При отправке в Tochka API используется отдельный маппер (см. packages/shared/src/tochka/dto.ts).
|
||||
export const VatRate = z.enum(['none', 'vat_0', 'vat_5', 'vat_7', 'vat_10', 'vat_20']);
|
||||
export type VatRate = z.infer<typeof VatRate>;
|
||||
|
||||
export const VAT_LABEL: Record<VatRate, string> = {
|
||||
none: 'Без НДС',
|
||||
vat_0: '0%',
|
||||
vat_5: '5%',
|
||||
vat_7: '7%',
|
||||
vat_10: '10%',
|
||||
vat_20: '20%',
|
||||
};
|
||||
|
||||
export const VAT_PERCENT: Record<VatRate, number> = {
|
||||
none: 0,
|
||||
vat_0: 0,
|
||||
vat_5: 5,
|
||||
vat_7: 7,
|
||||
vat_10: 10,
|
||||
vat_20: 20,
|
||||
};
|
||||
|
||||
const blockBase = z.object({ id: z.string().min(1) });
|
||||
|
||||
export const HeadingBlock = blockBase.extend({
|
||||
type: z.literal('heading'),
|
||||
level: z.union([z.literal(1), z.literal(2), z.literal(3)]),
|
||||
text: RichTextSchema,
|
||||
});
|
||||
|
||||
export const ParagraphBlock = blockBase.extend({
|
||||
type: z.literal('paragraph'),
|
||||
text: RichTextSchema,
|
||||
});
|
||||
|
||||
export const PartyBind = z.discriminatedUnion('kind', [
|
||||
z.object({ kind: z.literal('client'), clientId: z.string().uuid().optional() }),
|
||||
z.object({ kind: z.literal('self') }),
|
||||
]);
|
||||
|
||||
export const PartyBlock = blockBase.extend({
|
||||
type: z.literal('party'),
|
||||
role: z.enum(['executor', 'customer']),
|
||||
bind: PartyBind,
|
||||
});
|
||||
|
||||
export const ServicesTableBlock = blockBase.extend({
|
||||
type: z.literal('services_table'),
|
||||
columns: z.array(z.enum(['name', 'qty', 'unit', 'price', 'vat', 'sum'])).min(1),
|
||||
// строки услуг живут в таблице document_lines; здесь только ссылки на строки этого документа
|
||||
lines: z.array(z.object({ lineId: z.string().uuid() })),
|
||||
});
|
||||
|
||||
export const TotalsBlock = blockBase.extend({
|
||||
type: z.literal('totals'),
|
||||
showVat: z.boolean(),
|
||||
showInWords: z.boolean(),
|
||||
});
|
||||
|
||||
export const TermsBlock = blockBase.extend({
|
||||
type: z.literal('terms'),
|
||||
text: RichTextSchema,
|
||||
});
|
||||
|
||||
export const SignaturesBlock = blockBase.extend({
|
||||
type: z.literal('signatures'),
|
||||
sides: z.array(z.enum(['executor', 'customer'])).min(1),
|
||||
});
|
||||
|
||||
export const CustomTextBlock = blockBase.extend({
|
||||
type: z.literal('custom_text'),
|
||||
text: RichTextSchema,
|
||||
});
|
||||
|
||||
export const PageBreakBlock = blockBase.extend({
|
||||
type: z.literal('page_break'),
|
||||
});
|
||||
|
||||
export const Block = z.discriminatedUnion('type', [
|
||||
HeadingBlock,
|
||||
ParagraphBlock,
|
||||
PartyBlock,
|
||||
ServicesTableBlock,
|
||||
TotalsBlock,
|
||||
TermsBlock,
|
||||
SignaturesBlock,
|
||||
CustomTextBlock,
|
||||
PageBreakBlock,
|
||||
]);
|
||||
export type Block = z.infer<typeof Block>;
|
||||
|
||||
export const DocBody = z.object({
|
||||
version: z.literal(1),
|
||||
blocks: z.array(Block),
|
||||
vars: z.record(z.unknown()).default({}),
|
||||
});
|
||||
export type DocBody = z.infer<typeof DocBody>;
|
||||
|
||||
export const emptyDocBody = (): DocBody => ({ version: 1, blocks: [], vars: {} });
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './blocks/schema.js';
|
||||
export * from './tochka/dto.js';
|
||||
export * from './auth/types.js';
|
||||
@@ -0,0 +1,74 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const TochkaEnv = z.enum(['sandbox', 'prod']);
|
||||
export type TochkaEnv = z.infer<typeof TochkaEnv>;
|
||||
|
||||
export const TochkaBaseUrl: Record<TochkaEnv, string> = {
|
||||
sandbox: 'https://enter.tochka.com/sandbox/v2',
|
||||
prod: 'https://enter.tochka.com/uapi',
|
||||
};
|
||||
|
||||
// Минимальный набор полей для M4. Расширим, когда возьмёмся за акты/УПД.
|
||||
export const TochkaSecondSide = z.object({
|
||||
inn: z.string().min(10).max(12),
|
||||
kpp: z.string().length(9).optional(),
|
||||
legalAddress: z.string().optional(),
|
||||
contactName: z.string().optional(),
|
||||
contactEmail: z.string().email().optional(),
|
||||
contactPhone: z.string().optional(),
|
||||
});
|
||||
export type TochkaSecondSide = z.infer<typeof TochkaSecondSide>;
|
||||
|
||||
export const TochkaInvoiceLine = z.object({
|
||||
name: z.string(),
|
||||
measure: z.string(), // ед. измерения, напр. "шт", "час"
|
||||
amount: z.number().positive(), // количество
|
||||
price: z.number().nonnegative(), // цена за единицу в рублях (Точка ждёт число, не копейки)
|
||||
vatRate: z.enum(['none', '0', '5', '7', '10', '20']),
|
||||
});
|
||||
export type TochkaInvoiceLine = z.infer<typeof TochkaInvoiceLine>;
|
||||
|
||||
export const TochkaCreateInvoiceRequest = z.object({
|
||||
Data: z.object({
|
||||
accountId: z.string(),
|
||||
customerCode: z.string(),
|
||||
documentNumber: z.string().optional(),
|
||||
documentDate: z.string().optional(), // YYYY-MM-DD
|
||||
secondSide: TochkaSecondSide,
|
||||
items: z.array(TochkaInvoiceLine).min(1),
|
||||
paymentExpiryDate: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
export type TochkaCreateInvoiceRequest = z.infer<typeof TochkaCreateInvoiceRequest>;
|
||||
|
||||
export const TochkaCreateInvoiceResponse = z.object({
|
||||
Data: z.object({
|
||||
documentId: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
// Webhook payloads (минимум, что мы используем для статуса оплаты).
|
||||
export const TochkaWebhookEvent = z.object({
|
||||
webhookType: z.enum([
|
||||
'incomingPayment',
|
||||
'incomingSbpPayment',
|
||||
'outgoingPayment',
|
||||
'incomingSbpB2BPayment',
|
||||
]),
|
||||
paymentId: z.string(),
|
||||
amount: z.number().optional(),
|
||||
payerInn: z.string().optional(),
|
||||
payerName: z.string().optional(),
|
||||
purpose: z.string().optional(),
|
||||
paidAt: z.string().optional(),
|
||||
});
|
||||
export type TochkaWebhookEvent = z.infer<typeof TochkaWebhookEvent>;
|
||||
|
||||
export const TochkaPaymentStatus = z.enum([
|
||||
'awaiting_payment',
|
||||
'partially_paid',
|
||||
'paid',
|
||||
'overdue',
|
||||
'cancelled',
|
||||
]);
|
||||
export type TochkaPaymentStatus = z.infer<typeof TochkaPaymentStatus>;
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"declaration": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user