uat-doc-perfix

This commit is contained in:
phaichayon
2026-06-30 14:48:34 +07:00
parent 9698228c51
commit 47eac3badc
28 changed files with 2518 additions and 996 deletions

View File

@@ -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
)
})
);

View File

@@ -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