203 lines
6.1 KiB
TypeScript
203 lines
6.1 KiB
TypeScript
import { and, asc, eq, inArray, isNull } from 'drizzle-orm';
|
|
import { crmQuotations, memberships, users } from '@/db/schema';
|
|
import { db } from '@/lib/db';
|
|
import { AuthError } from '@/lib/auth/session';
|
|
import {
|
|
getApprovalRequest,
|
|
listApprovalRequests
|
|
} from '@/features/foundation/approval/server/service';
|
|
import type {
|
|
ApprovalDetailRecord,
|
|
ApprovalTimelineItem
|
|
} from '@/features/foundation/approval/types';
|
|
import {
|
|
getSignaturePositionLookup,
|
|
resolveSignaturePositionLabel
|
|
} from '@/features/crm/quotations/document/signature-position';
|
|
import type {
|
|
SignatureBlock,
|
|
SignatureParty
|
|
} from '@/features/crm/quotations/document/signature.types';
|
|
|
|
const EMPTY_SIGNATURE_PARTY: SignatureParty = {
|
|
name: '-',
|
|
position: '-',
|
|
actedAt: null,
|
|
userId: null,
|
|
roleCode: null
|
|
};
|
|
|
|
const APPROVED_BY_ROLE_CODES = new Set(['sales_manager', 'department_manager']);
|
|
|
|
type ApprovalActionWithRole = ApprovalTimelineItem & {
|
|
roleCode: string | null;
|
|
};
|
|
|
|
function toSignatureParty(input?: Partial<SignatureParty> | null): SignatureParty {
|
|
return {
|
|
name: input?.name?.trim() || '-',
|
|
position: input?.position?.trim() || '-',
|
|
actedAt: input?.actedAt ?? null,
|
|
userId: input?.userId ?? null,
|
|
roleCode: input?.roleCode ?? null
|
|
};
|
|
}
|
|
|
|
function mapApprovalActions(detail: ApprovalDetailRecord | null): ApprovalActionWithRole[] {
|
|
if (!detail) {
|
|
return [];
|
|
}
|
|
|
|
return detail.timeline
|
|
.filter((item) => item.action === 'approve')
|
|
.map((item) => ({
|
|
...item,
|
|
roleCode: detail.steps.find((step) => step.stepNumber === item.stepNumber)?.roleCode ?? null
|
|
}));
|
|
}
|
|
|
|
function resolveApprovedByAction(actions: ApprovalActionWithRole[], finalRoleCode: string | null) {
|
|
const candidates = actions.filter(
|
|
(item) =>
|
|
item.roleCode !== finalRoleCode &&
|
|
item.roleCode !== null &&
|
|
APPROVED_BY_ROLE_CODES.has(item.roleCode)
|
|
);
|
|
|
|
return candidates.at(-1) ?? null;
|
|
}
|
|
|
|
function resolveAuthorizedByAction(
|
|
actions: ApprovalActionWithRole[],
|
|
finalRoleCode: string | null
|
|
) {
|
|
if (!finalRoleCode) {
|
|
return null;
|
|
}
|
|
|
|
return actions.filter((item) => item.roleCode === finalRoleCode).at(-1) ?? null;
|
|
}
|
|
|
|
export async function resolveQuotationSignatures(
|
|
quotationId: string,
|
|
organizationId: string
|
|
): Promise<SignatureBlock> {
|
|
const [quotation] = await db
|
|
.select({
|
|
id: crmQuotations.id,
|
|
organizationId: crmQuotations.organizationId,
|
|
salesmanId: crmQuotations.salesmanId,
|
|
createdBy: crmQuotations.createdBy,
|
|
createdAt: crmQuotations.createdAt
|
|
})
|
|
.from(crmQuotations)
|
|
.where(
|
|
and(
|
|
eq(crmQuotations.id, quotationId),
|
|
eq(crmQuotations.organizationId, organizationId),
|
|
isNull(crmQuotations.deletedAt)
|
|
)
|
|
)
|
|
.limit(1);
|
|
|
|
if (!quotation) {
|
|
throw new AuthError('Quotation not found', 404);
|
|
}
|
|
|
|
const approvalRequests = await listApprovalRequests(organizationId, {
|
|
entityType: 'quotation',
|
|
entityId: quotationId,
|
|
limit: 20
|
|
});
|
|
const approvalRequest =
|
|
approvalRequests.items.find((item) => item.status === 'approved') ??
|
|
approvalRequests.items[0] ??
|
|
null;
|
|
const approvalDetail = approvalRequest
|
|
? await getApprovalRequest(approvalRequest.id, organizationId)
|
|
: null;
|
|
const finalStep = approvalDetail?.steps.at(-1) ?? null;
|
|
const approvalActions = mapApprovalActions(approvalDetail);
|
|
const approvedByAction = resolveApprovedByAction(approvalActions, finalStep?.roleCode ?? null);
|
|
const authorizedByAction = resolveAuthorizedByAction(
|
|
approvalActions,
|
|
finalStep?.roleCode ?? null
|
|
);
|
|
const preparedByUserId = quotation.salesmanId ?? quotation.createdBy;
|
|
const userIds = [
|
|
preparedByUserId,
|
|
approvedByAction?.actedBy ?? null,
|
|
authorizedByAction?.actedBy ?? null
|
|
].filter((value): value is string => Boolean(value));
|
|
const uniqueUserIds = [...new Set(userIds)];
|
|
const [userRows, membershipRows, positionLookup] = await Promise.all([
|
|
uniqueUserIds.length
|
|
? db
|
|
.select({ id: users.id, name: users.name })
|
|
.from(users)
|
|
.where(inArray(users.id, uniqueUserIds))
|
|
: Promise.resolve([]),
|
|
uniqueUserIds.length
|
|
? db
|
|
.select({
|
|
userId: memberships.userId,
|
|
businessRole: memberships.businessRole
|
|
})
|
|
.from(memberships)
|
|
.where(
|
|
and(
|
|
eq(memberships.organizationId, organizationId),
|
|
inArray(memberships.userId, uniqueUserIds)
|
|
)
|
|
)
|
|
: Promise.resolve([]),
|
|
getSignaturePositionLookup(organizationId)
|
|
]);
|
|
const userMap = new Map(userRows.map((row) => [row.id, row.name]));
|
|
const membershipMap = new Map(membershipRows.map((row) => [row.userId, row.businessRole]));
|
|
|
|
const createPartyFromUser = (args: {
|
|
userId: string | null | undefined;
|
|
actedAt?: string | null;
|
|
roleCode?: string | null;
|
|
fallbackName?: string | null;
|
|
}) => {
|
|
if (!args.userId && !args.fallbackName) {
|
|
return toSignatureParty(EMPTY_SIGNATURE_PARTY);
|
|
}
|
|
|
|
const businessRole =
|
|
(args.userId ? membershipMap.get(args.userId) : null) ?? args.roleCode ?? null;
|
|
|
|
return toSignatureParty({
|
|
name:
|
|
(args.userId ? userMap.get(args.userId) : null) ??
|
|
args.fallbackName?.trim() ??
|
|
EMPTY_SIGNATURE_PARTY.name,
|
|
position: resolveSignaturePositionLabel(businessRole, positionLookup),
|
|
actedAt: args.actedAt ?? null,
|
|
userId: args.userId ?? null,
|
|
roleCode: businessRole
|
|
});
|
|
};
|
|
|
|
return {
|
|
preparedBy: createPartyFromUser({
|
|
userId: preparedByUserId,
|
|
actedAt: quotation.createdAt.toISOString()
|
|
}),
|
|
approvedBy: createPartyFromUser({
|
|
userId: approvedByAction?.actedBy,
|
|
actedAt: approvedByAction?.actedAt ?? null,
|
|
roleCode: approvedByAction?.roleCode ?? null,
|
|
fallbackName: approvedByAction?.actorName ?? null
|
|
}),
|
|
authorizedBy: createPartyFromUser({
|
|
userId: authorizedByAction?.actedBy,
|
|
actedAt: authorizedByAction?.actedAt ?? null,
|
|
roleCode: authorizedByAction?.roleCode ?? finalStep?.roleCode ?? null,
|
|
fallbackName: authorizedByAction?.actorName ?? null
|
|
})
|
|
};
|
|
}
|