feat(profile): add counterparty profile and enforce it for order creation

This commit is contained in:
Ruslan Bakiev
2026-04-02 16:46:36 +07:00
parent 3a9b922a07
commit 3ee14d508c
4 changed files with 176 additions and 0 deletions

View File

@@ -0,0 +1,29 @@
-- CreateTable
CREATE TABLE "CounterpartyProfile" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"companyName" TEXT NOT NULL,
"companyFullName" TEXT NOT NULL,
"inn" TEXT NOT NULL,
"kpp" TEXT,
"ogrn" TEXT,
"legalAddress" TEXT NOT NULL,
"bankName" TEXT NOT NULL,
"bik" TEXT NOT NULL,
"correspondentAccount" TEXT NOT NULL,
"checkingAccount" TEXT NOT NULL,
"signerFullName" TEXT NOT NULL,
"signerPosition" TEXT NOT NULL,
"signerBasis" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CounterpartyProfile_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "CounterpartyProfile_userId_key" ON "CounterpartyProfile"("userId");
-- AddForeignKey
ALTER TABLE "CounterpartyProfile" ADD CONSTRAINT "CounterpartyProfile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -61,6 +61,7 @@ model User {
role UserRole
companyId String?
company Company? @relation(fields: [companyId], references: [id])
counterpartyProfile CounterpartyProfile?
registrationRequests RegistrationRequest[] @relation("RegistrationRequester")
reviewedRequests RegistrationRequest[] @relation("RegistrationReviewer")
sentInvitations Invitation[] @relation("InvitationManager")
@@ -78,6 +79,27 @@ model User {
updatedAt DateTime @updatedAt
}
model CounterpartyProfile {
id String @id @default(cuid())
userId String @unique
user User @relation(fields: [userId], references: [id])
companyName String
companyFullName String
inn String
kpp String?
ogrn String?
legalAddress String
bankName String
bik String
correspondentAccount String
checkingAccount String
signerFullName String
signerPosition String
signerBasis String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model RegistrationRequest {
id String @id @default(cuid())
companyName String

View File

@@ -66,6 +66,62 @@ function buildDefaultFullName(email) {
.join(' ');
}
function normalizeText(value) {
return String(value ?? '').trim();
}
function normalizeOptionalText(value) {
const normalized = normalizeText(value);
return normalized ? normalized : null;
}
function isCounterpartyProfileComplete(profile) {
if (!profile) {
return false;
}
return Boolean(
normalizeText(profile.companyName) &&
normalizeText(profile.companyFullName) &&
normalizeText(profile.inn) &&
normalizeText(profile.legalAddress) &&
normalizeText(profile.bankName) &&
normalizeText(profile.bik) &&
normalizeText(profile.correspondentAccount) &&
normalizeText(profile.checkingAccount) &&
normalizeText(profile.signerFullName) &&
normalizeText(profile.signerPosition) &&
normalizeText(profile.signerBasis),
);
}
function toCounterpartyProfileInputData(input) {
return {
companyName: normalizeText(input.companyName),
companyFullName: normalizeText(input.companyFullName),
inn: normalizeText(input.inn),
kpp: normalizeOptionalText(input.kpp),
ogrn: normalizeOptionalText(input.ogrn),
legalAddress: normalizeText(input.legalAddress),
bankName: normalizeText(input.bankName),
bik: normalizeText(input.bik),
correspondentAccount: normalizeText(input.correspondentAccount),
checkingAccount: normalizeText(input.checkingAccount),
signerFullName: normalizeText(input.signerFullName),
signerPosition: normalizeText(input.signerPosition),
signerBasis: normalizeText(input.signerBasis),
};
}
async function requireCompletedCounterpartyProfile(context, userId) {
const profile = await context.prisma.counterpartyProfile.findUnique({
where: { userId },
});
if (!isCounterpartyProfileComplete(profile)) {
throw new Error('Counterparty profile is incomplete. Fill profile before placing an order.');
}
}
function formatOrderStatusMessage(order, status, note) {
const suffix = note ? `\nКомментарий: ${note}` : '';
return `Заказ ${order.code} изменил статус: ${status}.${suffix}`;
@@ -154,12 +210,22 @@ async function collectNotificationHistory(context, userId, channel, limit) {
export const resolvers = {
DateTime: dateTimeScalar,
JSON: jsonScalar,
CounterpartyProfile: {
isComplete: (profile) => isCounterpartyProfileComplete(profile),
},
Query: {
healthcheck: () => 'ok',
me: (_, __, context) => context.user,
myCounterpartyProfile: async (_, __, context) => {
const user = requireUser(context);
return context.prisma.counterpartyProfile.findUnique({
where: { userId: user.id },
});
},
myMessengerConnections: async (_, __, context) => {
const user = requireUser(context);
return context.prisma.messengerConnection.findMany({
@@ -443,6 +509,24 @@ export const resolvers = {
return user;
},
upsertMyCounterpartyProfile: async (_, { input }, context) => {
const user = requireUser(context);
const payload = toCounterpartyProfileInputData(input);
if (!isCounterpartyProfileComplete(payload)) {
throw new Error('Counterparty profile is incomplete. Fill all required fields.');
}
return context.prisma.counterpartyProfile.upsert({
where: { userId: user.id },
update: payload,
create: {
userId: user.id,
...payload,
},
});
},
connectMessenger: (_, { input }, context) => {
const user = requireUser(context);
return context.prisma.messengerConnection.upsert({
@@ -499,6 +583,7 @@ export const resolvers = {
submitReadyOrder: async (_, { input }, context) => {
const customer = requireRole(context, 'CLIENT');
await requireCompletedCounterpartyProfile(context, customer.id);
if (!input.items.length) {
throw new Error('Order must contain at least one item.');
}
@@ -548,6 +633,7 @@ export const resolvers = {
submitCalculationOrder: async (_, { input }, context) => {
const customer = requireRole(context, 'CLIENT');
await requireCompletedCounterpartyProfile(context, customer.id);
const order = await context.prisma.order.create({
data: {
code: orderCode(),

View File

@@ -65,6 +65,27 @@ type User {
company: Company
}
type CounterpartyProfile {
id: ID!
userId: ID!
companyName: String!
companyFullName: String!
inn: String!
kpp: String
ogrn: String
legalAddress: String!
bankName: String!
bik: String!
correspondentAccount: String!
checkingAccount: String!
signerFullName: String!
signerPosition: String!
signerBasis: String!
isComplete: Boolean!
createdAt: DateTime!
updatedAt: DateTime!
}
type AuthCodeRequestResult {
challengeToken: String!
channel: LoginChannel!
@@ -222,6 +243,7 @@ type ReferralStats {
type Query {
healthcheck: String!
me: User
myCounterpartyProfile: CounterpartyProfile
myMessengerConnections: [MessengerConnection!]!
myNotificationHistory(channel: MessengerType!, limit: Int = 50): [NotificationHistoryItem!]!
managerNotificationHistory(userId: ID!, channel: MessengerType!, limit: Int = 50): [NotificationHistoryItem!]!
@@ -272,6 +294,22 @@ input ConnectMessengerInput {
channelId: String!
}
input UpsertMyCounterpartyProfileInput {
companyName: String!
companyFullName: String!
inn: String!
kpp: String
ogrn: String
legalAddress: String!
bankName: String!
bik: String!
correspondentAccount: String!
checkingAccount: String!
signerFullName: String!
signerPosition: String!
signerBasis: String!
}
input ReadyOrderItemInput {
productId: ID!
quantity: Float!
@@ -329,6 +367,7 @@ type Mutation {
createInvitation(input: CreateInvitationInput!): Invitation!
acceptInvitation(input: AcceptInvitationInput!): User!
connectMessenger(input: ConnectMessengerInput!): MessengerConnection!
upsertMyCounterpartyProfile(input: UpsertMyCounterpartyProfileInput!): CounterpartyProfile!
sendTestMessengerMessage(type: MessengerType!, channelId: String, message: String): MessengerDispatchResult!
submitReadyOrder(input: SubmitReadyOrderInput!): Order!