uat-doc-perfix
This commit is contained in:
@@ -158,19 +158,28 @@ export const documentSequences = pgTable(
|
||||
id: text('id').primaryKey(),
|
||||
organizationId: text('organization_id').notNull(),
|
||||
branchId: text('branch_id').default('').notNull(),
|
||||
productType: text('product_type').default('all').notNull(),
|
||||
documentType: text('document_type').notNull(),
|
||||
prefix: text('prefix').notNull(),
|
||||
period: text('period').notNull(),
|
||||
currentNumber: integer('current_number').default(0).notNull(),
|
||||
paddingLength: integer('padding_length').default(3).notNull(),
|
||||
format: text('format').default('{prefix}{period}-{running}').notNull(),
|
||||
resetPolicy: text('reset_policy').default('period').notNull(),
|
||||
isActive: boolean('is_active').default(true).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
|
||||
},
|
||||
(table) => ({
|
||||
organizationDocumentPeriodBranchIdx: uniqueIndex(
|
||||
'document_sequences_org_doc_period_branch_idx'
|
||||
).on(table.organizationId, table.documentType, table.period, table.branchId)
|
||||
organizationBranchProductDocumentPeriodIdx: uniqueIndex(
|
||||
'document_sequences_org_branch_product_doc_period_idx'
|
||||
).on(
|
||||
table.organizationId,
|
||||
table.branchId,
|
||||
table.productType,
|
||||
table.documentType,
|
||||
table.period
|
||||
)
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -3,12 +3,19 @@ import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import postgres from "postgres";
|
||||
import {
|
||||
formatDocumentSequencePeriod,
|
||||
normalizeDocumentSequenceProductType,
|
||||
resolveDocumentSequenceOrganizationCode,
|
||||
resolveDocumentSequenceProductTypeCode,
|
||||
} from "../../features/foundation/document-sequence/config.ts";
|
||||
|
||||
type SqlClient = ReturnType<typeof postgres>;
|
||||
|
||||
type OrganizationRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
createdBy: string;
|
||||
};
|
||||
|
||||
@@ -891,16 +898,40 @@ function buildDocumentCode(prefix: string, number: number) {
|
||||
return `${prefix}${PERIOD}-${String(number).padStart(3, "0")}`;
|
||||
}
|
||||
|
||||
function buildQuotationCode(productTypeCode: string, number: number) {
|
||||
const prefixByProductType: Record<string, string> = {
|
||||
crane: "CR",
|
||||
dockdoor: "DK",
|
||||
solarcell: "SOL",
|
||||
service: "SV",
|
||||
spare_part: "SP",
|
||||
};
|
||||
function buildQuotationCode(input: {
|
||||
organizationSlug: string;
|
||||
productTypeCode: string;
|
||||
number: number;
|
||||
quotationDate?: Date;
|
||||
}) {
|
||||
const normalizedProductType = normalizeDocumentSequenceProductType(
|
||||
input.productTypeCode
|
||||
);
|
||||
const productTypePrefix =
|
||||
resolveDocumentSequenceProductTypeCode(normalizedProductType);
|
||||
const organizationCode = resolveDocumentSequenceOrganizationCode(
|
||||
input.organizationSlug
|
||||
);
|
||||
|
||||
return buildDocumentCode(prefixByProductType[productTypeCode] ?? "QT", number);
|
||||
if (!productTypePrefix) {
|
||||
throw new Error(
|
||||
`Missing quotation product type sequence code for ${normalizedProductType}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!organizationCode) {
|
||||
throw new Error(
|
||||
`Missing quotation organization sequence code for ${input.organizationSlug}`
|
||||
);
|
||||
}
|
||||
|
||||
const period = formatDocumentSequencePeriod(
|
||||
input.quotationDate ?? new Date("2026-06-25T00:00:00.000Z")
|
||||
);
|
||||
|
||||
return `${productTypePrefix}${organizationCode}${period}-${String(
|
||||
input.number
|
||||
).padStart(3, "0")}`;
|
||||
}
|
||||
|
||||
function pickSalesUser(
|
||||
@@ -919,6 +950,17 @@ function seededNote(scope: string) {
|
||||
return `[${SEED_TAG}:${scope}]`;
|
||||
}
|
||||
|
||||
function createQuotationSequenceAllocator(start = 1101) {
|
||||
const counters = new Map<string, number>();
|
||||
|
||||
return (productTypeCode: string) => {
|
||||
const normalized = normalizeDocumentSequenceProductType(productTypeCode);
|
||||
const nextNumber = counters.get(normalized) ?? start;
|
||||
counters.set(normalized, nextNumber + 1);
|
||||
return nextNumber;
|
||||
};
|
||||
}
|
||||
|
||||
function daysAgo(days: number, hour = 9) {
|
||||
return new Date(Date.UTC(2026, 5, 25 - days, hour, 0, 0));
|
||||
}
|
||||
@@ -979,7 +1021,7 @@ function getOptionalOptionId(
|
||||
|
||||
async function loadOrganizations(sql: SqlClient): Promise<OrganizationRow[]> {
|
||||
return sql<OrganizationRow[]>`
|
||||
select id, name, created_by as "createdBy"
|
||||
select id, name, slug, created_by as "createdBy"
|
||||
from organizations
|
||||
order by name asc
|
||||
`;
|
||||
@@ -2036,6 +2078,7 @@ function buildLeadAndOpportunityData(
|
||||
}
|
||||
|
||||
function buildQuotationData(
|
||||
organization: OrganizationRow,
|
||||
optionMap: Map<string, OptionRow>,
|
||||
storyOpportunityMap: Map<string, SeedOpportunity>,
|
||||
storyCustomerMap: Map<string, SeedCustomer>,
|
||||
@@ -2051,7 +2094,7 @@ function buildQuotationData(
|
||||
const approvalRequests: SeedApprovalRequest[] = [];
|
||||
const approvalActions: SeedApprovalAction[] = [];
|
||||
|
||||
let quotationSequence = 1101;
|
||||
const nextQuotationSequence = createQuotationSequenceAllocator(1101);
|
||||
|
||||
const acceptedStories = new Set([
|
||||
"website-warehouse-crane-win",
|
||||
@@ -2124,10 +2167,16 @@ function buildQuotationData(
|
||||
const taxRate = 7;
|
||||
const taxAmount = Math.round((subtotal - discount) * (taxRate / 100));
|
||||
const totalAmount = subtotal - discount + taxAmount;
|
||||
const quotationNumber = nextQuotationSequence(story.productTypeCode);
|
||||
|
||||
quotations.push({
|
||||
id: quotationId,
|
||||
code: buildQuotationCode(story.productTypeCode, quotationSequence++),
|
||||
code: buildQuotationCode({
|
||||
organizationSlug: organization.slug,
|
||||
productTypeCode: story.productTypeCode,
|
||||
number: quotationNumber,
|
||||
quotationDate,
|
||||
}),
|
||||
opportunityId: opportunity.id,
|
||||
customerId: customer.id,
|
||||
contactId: contact?.id ?? null,
|
||||
@@ -2458,28 +2507,35 @@ function buildQuotationData(
|
||||
quotations.length,
|
||||
);
|
||||
const subtotal = 980000 + (quotations.length % 8) * 240000;
|
||||
const discount = quotations.length % 3 === 0 ? 25000 : 0;
|
||||
const taxRate = 7;
|
||||
const taxAmount = Math.round((subtotal - discount) * 0.07);
|
||||
const totalAmount = subtotal - discount + taxAmount;
|
||||
const quotationId = crypto.randomUUID();
|
||||
quotations.push({
|
||||
id: quotationId,
|
||||
code: buildQuotationCode(
|
||||
choose(["crane", "dockdoor", "solarcell"], quotations.length),
|
||||
quotationSequence++
|
||||
),
|
||||
opportunityId: null,
|
||||
customerId: customer.id,
|
||||
contactId: contact?.id ?? null,
|
||||
branchId: customer.branchId,
|
||||
const discount = quotations.length % 3 === 0 ? 25000 : 0;
|
||||
const taxRate = 7;
|
||||
const taxAmount = Math.round((subtotal - discount) * 0.07);
|
||||
const totalAmount = subtotal - discount + taxAmount;
|
||||
const quotationId = crypto.randomUUID();
|
||||
const supplementalProductTypeCode = choose(
|
||||
["crane", "dockdoor", "solarcell"],
|
||||
quotations.length
|
||||
);
|
||||
const quotationNumber = nextQuotationSequence(supplementalProductTypeCode);
|
||||
quotations.push({
|
||||
id: quotationId,
|
||||
code: buildQuotationCode({
|
||||
organizationSlug: organization.slug,
|
||||
productTypeCode: supplementalProductTypeCode,
|
||||
number: quotationNumber,
|
||||
quotationDate,
|
||||
validUntil: addDays(quotationDate, 30),
|
||||
quotationTypeId: getOptionId(
|
||||
optionMap,
|
||||
"crm_quotation_type",
|
||||
choose(["crane", "dockdoor", "solarcell"], quotations.length),
|
||||
),
|
||||
}),
|
||||
opportunityId: null,
|
||||
customerId: customer.id,
|
||||
contactId: contact?.id ?? null,
|
||||
branchId: customer.branchId,
|
||||
quotationDate,
|
||||
validUntil: addDays(quotationDate, 30),
|
||||
quotationTypeId: getOptionId(
|
||||
optionMap,
|
||||
"crm_quotation_type",
|
||||
supplementalProductTypeCode,
|
||||
),
|
||||
projectName: `${customer.name} Supplemental Quotation ${quotations.length + 1}`,
|
||||
projectLocation: `${customer.district}, ${customer.province}`,
|
||||
attention: contact?.name ?? null,
|
||||
@@ -2866,12 +2922,13 @@ async function bumpDocumentSequences(
|
||||
sql: SqlClient,
|
||||
organizationId: string,
|
||||
branches: BranchRow[],
|
||||
optionMap: Map<string, OptionRow>,
|
||||
quotations: SeedQuotation[],
|
||||
) {
|
||||
const documentTypes = [
|
||||
["customer", 850],
|
||||
["crm_lead", 980],
|
||||
["crm_opportunity", 1050],
|
||||
["quotation", 1160],
|
||||
] as const;
|
||||
|
||||
for (const branch of branches) {
|
||||
@@ -2881,11 +2938,46 @@ async function bumpDocumentSequences(
|
||||
set current_number = greatest(current_number, ${currentNumber}), updated_at = now()
|
||||
where organization_id = ${organizationId}
|
||||
and branch_id = ${branch.id}
|
||||
and product_type = ${"all"}
|
||||
and document_type = ${documentType}
|
||||
and period = ${PERIOD}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
const optionById = new Map(
|
||||
[...optionMap.values()].map((option) => [option.id, option])
|
||||
);
|
||||
const quotationMaxByScope = new Map<string, number>();
|
||||
|
||||
for (const quotation of quotations) {
|
||||
const option = optionById.get(quotation.quotationTypeId);
|
||||
const productTypeCode = normalizeDocumentSequenceProductType(option?.code ?? "");
|
||||
const branchId = quotation.branchId ?? "";
|
||||
const runningNumber = Number(quotation.code.split("-").at(-1) ?? "0");
|
||||
|
||||
if (!productTypeCode || Number.isNaN(runningNumber)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const scopeKey = `${branchId}:${productTypeCode}`;
|
||||
const current = quotationMaxByScope.get(scopeKey) ?? 0;
|
||||
quotationMaxByScope.set(scopeKey, Math.max(current, runningNumber));
|
||||
}
|
||||
|
||||
for (const [scopeKey, currentNumber] of quotationMaxByScope) {
|
||||
const [branchId, productTypeCode] = scopeKey.split(":");
|
||||
|
||||
await sql`
|
||||
update document_sequences
|
||||
set current_number = greatest(current_number, ${currentNumber}), updated_at = now()
|
||||
where organization_id = ${organizationId}
|
||||
and branch_id = ${branchId}
|
||||
and product_type = ${productTypeCode}
|
||||
and document_type = ${"quotation"}
|
||||
and period = ${PERIOD}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
async function seedOrganization(sql: SqlClient, organization: OrganizationRow) {
|
||||
@@ -2916,6 +3008,7 @@ async function seedOrganization(sql: SqlClient, organization: OrganizationRow) {
|
||||
users,
|
||||
);
|
||||
const quotationData = buildQuotationData(
|
||||
organization,
|
||||
optionMap,
|
||||
leadData.storyOpportunityMap,
|
||||
storyCustomerMap,
|
||||
@@ -2944,7 +3037,13 @@ async function seedOrganization(sql: SqlClient, organization: OrganizationRow) {
|
||||
quotationData.approvalActions,
|
||||
);
|
||||
|
||||
await bumpDocumentSequences(sql, organization.id, branches);
|
||||
await bumpDocumentSequences(
|
||||
sql,
|
||||
organization.id,
|
||||
branches,
|
||||
optionMap,
|
||||
quotationData.quotations
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[seed:crm-uat] ${organization.name}: ${customers.length} customers, ${leadData.leads.length} leads, ${leadData.opportunities.length} opportunities, ${quotationData.quotations.length} quotations`,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user