task-d.5.7

This commit is contained in:
phaichayon
2026-06-25 15:41:59 +07:00
parent 65dd0e2e56
commit 69279fc341
9 changed files with 1111 additions and 29 deletions

View File

@@ -351,6 +351,23 @@ export function OpportunityDetail({
value={String(projectPartiesData.items.length)}
/>
<FieldItem label='Record Type' value={workspace === 'opportunity' ? 'Opportunity' : 'Lead'} />
{relatedQuotations.length ? (
<div className='space-y-2'>
{relatedQuotations.slice(0, 3).map((quotation) => (
<div key={quotation.id} className='rounded-lg border p-3 text-sm'>
<div className='flex items-center justify-between gap-3'>
<Link href={`/dashboard/crm/quotations/${quotation.id}`} className='font-medium hover:underline'>
{quotation.code}
</Link>
<Badge variant='outline'>Rev {quotation.revision}</Badge>
</div>
<div className='text-muted-foreground mt-1'>
{quotation.status} · {quotation.totalAmount.toLocaleString()} {quotation.currency}
</div>
</div>
))}
</div>
) : null}
</CardContent>
</Card>
</div>
@@ -358,4 +375,3 @@ export function OpportunityDetail({
</div>
);
}

View File

@@ -0,0 +1,244 @@
import { and, eq, inArray, isNull } from 'drizzle-orm';
import { crmOpportunities, crmQuotations } from '@/db/schema';
import { auditAction } from '@/features/foundation/audit-log/service';
import { db } from '@/lib/db';
const QUOTATION_OPEN_CODES = new Set(['draft', 'revised', 'pending_approval', 'approved', 'sent_to_customer', 'sent']);
const QUOTATION_NEGOTIATION_CODES = new Set(['approved', 'sent_to_customer', 'sent']);
async function getOpportunity(opportunityId: string, organizationId: string) {
const [opportunity] = await db
.select()
.from(crmOpportunities)
.where(
and(
eq(crmOpportunities.id, opportunityId),
eq(crmOpportunities.organizationId, organizationId),
isNull(crmOpportunities.deletedAt)
)
)
.limit(1);
return opportunity ?? null;
}
async function getActiveQuotationCodes(opportunityId: string, organizationId: string) {
const rows = await db
.select({
status: crmQuotations.status,
acceptedAt: crmQuotations.acceptedAt,
rejectedAt: crmQuotations.rejectedAt
})
.from(crmQuotations)
.where(
and(
eq(crmQuotations.organizationId, organizationId),
eq(crmQuotations.opportunityId, opportunityId),
isNull(crmQuotations.deletedAt)
)
);
return rows;
}
async function updateOpportunityStage(
opportunityId: string,
organizationId: string,
userId: string,
nextStage: string,
action: string,
quotationId: string,
reason: string
) {
const opportunity = await getOpportunity(opportunityId, organizationId);
if (!opportunity || (opportunity.outcomeStatus ?? 'open') !== 'open' || opportunity.status === nextStage) {
return;
}
const [updated] = await db
.update(crmOpportunities)
.set({
status: nextStage,
pipelineStage: 'opportunity',
updatedAt: new Date(),
updatedBy: userId
})
.where(eq(crmOpportunities.id, opportunityId))
.returning();
await auditAction({
organizationId,
branchId: updated.branchId,
userId,
entityType: 'crm_opportunity',
entityId: opportunityId,
action,
afterData: {
quotationId,
previousOpportunityStage: opportunity.status,
newOpportunityStage: updated.status,
previousOutcomeStatus: opportunity.outcomeStatus ?? 'open',
newOutcomeStatus: updated.outcomeStatus,
reason,
actedBy: userId
}
});
}
export async function syncOpportunityFromQuotationCreated(args: {
organizationId: string;
userId: string;
opportunityId: string;
quotationId: string;
}) {
await updateOpportunityStage(
args.opportunityId,
args.organizationId,
args.userId,
'quotation_created',
'sync_opportunity_from_quotation_created',
args.quotationId,
'quotation_created'
);
}
export async function syncOpportunityFromQuotationApproval(args: {
organizationId: string;
userId: string;
opportunityId: string;
quotationId: string;
quotationStatusCode: string | null;
}) {
if (!args.quotationStatusCode) {
return;
}
const nextStage = QUOTATION_NEGOTIATION_CODES.has(args.quotationStatusCode)
? 'negotiation'
: 'quotation_created';
const action = QUOTATION_NEGOTIATION_CODES.has(args.quotationStatusCode)
? args.quotationStatusCode === 'approved'
? 'sync_opportunity_from_quotation_approved'
: 'sync_opportunity_from_quotation_sent'
: 'sync_opportunity_from_quotation_created';
await updateOpportunityStage(
args.opportunityId,
args.organizationId,
args.userId,
nextStage,
action,
args.quotationId,
args.quotationStatusCode
);
}
export async function syncOpportunityFromQuotationWon(args: {
organizationId: string;
userId: string;
opportunityId: string;
quotationId: string;
reason?: string | null;
}) {
const opportunity = await getOpportunity(args.opportunityId, args.organizationId);
if (!opportunity) {
return;
}
const now = new Date();
const [updated] = await db
.update(crmOpportunities)
.set({
status: 'closed',
outcomeStatus: 'won',
pipelineStage: 'closed_won',
closedAt: now,
closedWonAt: now,
closedLostAt: null,
closedByUserId: args.userId,
updatedAt: now,
updatedBy: args.userId
})
.where(eq(crmOpportunities.id, args.opportunityId))
.returning();
await auditAction({
organizationId: args.organizationId,
branchId: updated.branchId,
userId: args.userId,
entityType: 'crm_opportunity',
entityId: args.opportunityId,
action: 'sync_opportunity_from_quotation_won',
afterData: {
quotationId: args.quotationId,
previousOpportunityStage: opportunity.status,
newOpportunityStage: updated.status,
previousOutcomeStatus: opportunity.outcomeStatus ?? 'open',
newOutcomeStatus: updated.outcomeStatus,
reason: args.reason ?? 'quotation_won',
actedBy: args.userId
}
});
}
export async function syncOpportunityFromQuotationLost(args: {
organizationId: string;
userId: string;
opportunityId: string;
quotationId: string;
reason: string;
}) {
const opportunity = await getOpportunity(args.opportunityId, args.organizationId);
if (!opportunity) {
return;
}
const rows = await getActiveQuotationCodes(args.opportunityId, args.organizationId);
const otherOpenExists = rows.some((row) => {
if (row.acceptedAt || row.rejectedAt) {
return false;
}
return QUOTATION_OPEN_CODES.has(row.status);
});
if (otherOpenExists) {
return;
}
const now = new Date();
const [updated] = await db
.update(crmOpportunities)
.set({
status: 'closed',
outcomeStatus: 'lost',
pipelineStage: 'closed_lost',
closedAt: now,
closedWonAt: null,
closedLostAt: now,
closedByUserId: args.userId,
updatedAt: now,
updatedBy: args.userId
})
.where(eq(crmOpportunities.id, args.opportunityId))
.returning();
await auditAction({
organizationId: args.organizationId,
branchId: updated.branchId,
userId: args.userId,
entityType: 'crm_opportunity',
entityId: args.opportunityId,
action: 'sync_opportunity_from_quotation_lost',
afterData: {
quotationId: args.quotationId,
previousOpportunityStage: opportunity.status,
newOpportunityStage: updated.status,
previousOutcomeStatus: opportunity.outcomeStatus ?? 'open',
newOutcomeStatus: updated.outcomeStatus,
reason: args.reason,
actedBy: args.userId
}
});
}

View File

@@ -234,8 +234,15 @@ export interface QuotationRelationItem {
id: string;
code: string;
status: string;
revision: number;
quotationType: string;
totalAmount: number;
currency: string;
validUntil: string | null;
approvedAt: string | null;
acceptedAt: string | null;
rejectedAt: string | null;
rejectionReason: string | null;
updatedAt: string;
}
@@ -420,4 +427,3 @@ export interface MutationSuccessResponse {
success: boolean;
message: string;
}

View File

@@ -0,0 +1,97 @@
import { and, eq, isNull } from 'drizzle-orm';
import { crmOpportunities, crmQuotations } from '@/db/schema';
import { auditAction } from '@/features/foundation/audit-log/service';
import { db } from '@/lib/db';
import { AuthError } from '@/lib/auth/session';
export async function assertOpportunityLinkIntegrity(args: {
organizationId: string;
opportunityId: string | null | undefined;
customerId: string;
productType?: string | null;
}) {
if (!args.opportunityId) {
return null;
}
const [opportunity] = await db
.select()
.from(crmOpportunities)
.where(
and(
eq(crmOpportunities.id, args.opportunityId),
eq(crmOpportunities.organizationId, args.organizationId),
isNull(crmOpportunities.deletedAt)
)
)
.limit(1);
if (!opportunity) {
throw new AuthError('Opportunity not found', 404);
}
if ((opportunity.outcomeStatus ?? 'open') !== 'open') {
throw new AuthError('Closed opportunity cannot create quotation', 400);
}
if (opportunity.customerId !== args.customerId) {
throw new AuthError('Quotation customer must match linked opportunity customer', 400);
}
if (args.productType && opportunity.productType !== args.productType) {
throw new AuthError('Quotation product type must match linked opportunity product type', 400);
}
return opportunity;
}
export async function assertRevisionOpportunityLinkIntegrity(args: {
organizationId: string;
quotationId: string;
}) {
const [quotation] = await db
.select()
.from(crmQuotations)
.where(
and(
eq(crmQuotations.id, args.quotationId),
eq(crmQuotations.organizationId, args.organizationId),
isNull(crmQuotations.deletedAt)
)
)
.limit(1);
if (!quotation) {
throw new AuthError('Quotation not found', 404);
}
return quotation.opportunityId ?? null;
}
export async function auditQuotationOpportunityLink(args: {
organizationId: string;
userId: string;
quotationId: string;
opportunityId: string | null;
previousOpportunityId?: string | null;
reason: string;
}) {
if (!args.opportunityId && !args.previousOpportunityId) {
return;
}
await auditAction({
organizationId: args.organizationId,
userId: args.userId,
entityType: 'crm_quotation',
entityId: args.quotationId,
action: 'sync_quotation_opportunity_link',
afterData: {
quotationId: args.quotationId,
opportunityId: args.opportunityId,
previousOpportunityId: args.previousOpportunityId ?? null,
reason: args.reason,
actedBy: args.userId
}
});
}

View File

@@ -26,6 +26,15 @@ import {
import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service';
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
import { syncOpportunityPipelineStageFromQuotationStatus } from '@/features/crm/opportunities/server/service';
import {
syncOpportunityFromQuotationApproval,
syncOpportunityFromQuotationCreated
} from '@/features/crm/opportunities/server/quotation-sync.service';
import {
assertOpportunityLinkIntegrity,
assertRevisionOpportunityLinkIntegrity,
auditQuotationOpportunityLink
} from './opportunity-link.service';
import type {
QuotationActivityRecord,
QuotationAttachmentMutationPayload,
@@ -1297,12 +1306,11 @@ export async function createQuotation(
payload.status
);
const opportunity = payload.opportunityId
? await assertOpportunityBelongsToOrganization(payload.opportunityId, organizationId)
: null;
if (opportunity) {
assertOpportunityOpenForQuotation(opportunity);
}
const opportunity = await assertOpportunityLinkIntegrity({
organizationId,
opportunityId: payload.opportunityId,
customerId: payload.customerId
});
const documentCode = await generateNextDocumentCode({
organizationId,
documentType: 'quotation',
@@ -1362,6 +1370,19 @@ export async function createQuotation(
);
if (created.opportunityId) {
await auditQuotationOpportunityLink({
organizationId,
userId,
quotationId: created.id,
opportunityId: created.opportunityId,
reason: 'quotation_created'
});
await syncOpportunityFromQuotationCreated({
organizationId,
userId,
opportunityId: created.opportunityId,
quotationId: created.id
});
await syncOpportunityPipelineStageFromQuotationStatus(
created.opportunityId,
organizationId,
@@ -1387,9 +1408,11 @@ export async function updateQuotation(
payload.status
);
const existing = await assertQuotationBelongsToOrganization(id, organizationId, accessContext);
const opportunity = payload.opportunityId
? await assertOpportunityBelongsToOrganization(payload.opportunityId, organizationId)
: null;
const opportunity = await assertOpportunityLinkIntegrity({
organizationId,
opportunityId: payload.opportunityId,
customerId: payload.customerId
});
const updated = await db.transaction(async (tx) => {
const [row] = await tx
@@ -1435,11 +1458,26 @@ export async function updateQuotation(
);
if (row.opportunityId) {
await auditQuotationOpportunityLink({
organizationId,
userId,
quotationId: row.id,
opportunityId: row.opportunityId,
previousOpportunityId: existing.opportunityId,
reason: 'quotation_updated'
});
await syncOpportunityPipelineStageFromQuotationStatus(
row.opportunityId,
organizationId,
quotationStatusCode
);
await syncOpportunityFromQuotationApproval({
organizationId,
userId,
opportunityId: row.opportunityId,
quotationId: row.id,
quotationStatusCode
});
}
return row;
@@ -2125,6 +2163,7 @@ export async function createQuotationRevision(
revisionRemark?: string
) {
const parent = await assertQuotationBelongsToOrganization(quotationId, organizationId);
await assertRevisionOpportunityLinkIntegrity({ organizationId, quotationId });
const statusCode = await resolveOptionCodeById(
organizationId,
QUOTATION_OPTION_CATEGORIES.status,
@@ -2273,6 +2312,23 @@ export async function createQuotationRevision(
}
}
if (created.opportunityId) {
await auditQuotationOpportunityLink({
organizationId,
userId,
quotationId: created.id,
opportunityId: created.opportunityId,
previousOpportunityId: parent.opportunityId,
reason: 'quotation_revision_created'
});
await syncOpportunityFromQuotationCreated({
organizationId,
userId,
opportunityId: created.opportunityId,
quotationId: created.id
});
}
return created;
});
}
@@ -2292,14 +2348,21 @@ export async function listQuotationRevisions(id: string, organizationId: string)
)
.orderBy(asc(crmQuotations.revision), asc(crmQuotations.createdAt));
return rows.map<QuotationRelationItem>((row) => ({
id: row.id,
code: row.code,
status: row.status,
quotationType: row.quotationType,
totalAmount: row.totalAmount,
updatedAt: row.updatedAt.toISOString()
}));
return rows.map<QuotationRelationItem>((row) => ({
id: row.id,
code: row.code,
status: row.status,
revision: row.revision,
quotationType: row.quotationType,
totalAmount: row.totalAmount,
currency: row.currency,
validUntil: row.validUntil?.toISOString() ?? null,
approvedAt: row.approvedAt?.toISOString() ?? null,
acceptedAt: row.acceptedAt?.toISOString() ?? null,
rejectedAt: row.rejectedAt?.toISOString() ?? null,
rejectionReason: row.rejectionReason,
updatedAt: row.updatedAt.toISOString()
}));
}
export async function listOpportunityQuotationRelations(
@@ -2318,14 +2381,21 @@ export async function listOpportunityQuotationRelations(
)
.orderBy(desc(crmQuotations.updatedAt));
return rows.map((row) => ({
id: row.id,
code: row.code,
status: row.status,
quotationType: row.quotationType,
totalAmount: row.totalAmount,
updatedAt: row.updatedAt.toISOString()
}));
return rows.map((row) => ({
id: row.id,
code: row.code,
status: row.status,
revision: row.revision,
quotationType: row.quotationType,
totalAmount: row.totalAmount,
currency: row.currency,
validUntil: row.validUntil?.toISOString() ?? null,
approvedAt: row.approvedAt?.toISOString() ?? null,
acceptedAt: row.acceptedAt?.toISOString() ?? null,
rejectedAt: row.rejectedAt?.toISOString() ?? null,
rejectionReason: row.rejectionReason,
updatedAt: row.updatedAt.toISOString()
}));
}
export async function listCustomerQuotationRelations(
@@ -2351,8 +2421,15 @@ export async function listCustomerQuotationRelations(
id: row.id,
code: row.code,
status: row.status,
revision: row.revision,
quotationType: row.quotationType,
totalAmount: row.totalAmount,
currency: row.currency,
validUntil: row.validUntil?.toISOString() ?? null,
approvedAt: row.approvedAt?.toISOString() ?? null,
acceptedAt: row.acceptedAt?.toISOString() ?? null,
rejectedAt: row.rejectedAt?.toISOString() ?? null,
rejectionReason: row.rejectionReason,
updatedAt: row.updatedAt.toISOString()
}));
}