fix: resolve SF field type mismatches, date format conversions, and gender mapping

- MNP_Application__c: change Zod type from z.string() to z.coerce.boolean()
  to match Salesforce Checkbox field type
- Date fields (mnpExpiry, portingDateOfBirth, scheduledAt): convert
  YYYY-MM-DD back to YYYYMMDD when reading from SF for Freebit API
- Gender mapping: handle full SF picklist values (Male/Female/Corporate)
  not just legacy M/F codes, for both Freebit activation and PA05-05
- MNP reservation number: add max(10) per SF Text(10) field limit
- Porting name fields: add max(255) to match SF Text(255) limits

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Temuulen Ankhbayar 2026-03-07 11:32:31 +09:00
parent 9ae3d5e9c7
commit 1ef2c5e125
5 changed files with 47 additions and 23 deletions

View File

@ -3,6 +3,12 @@ import { Logger } from "nestjs-pino";
import type { SalesforceOrderRecord } from "@customer-portal/domain/orders/providers";
import type { ContactIdentityData } from "./sim-fulfillment.service.js";
/** Strip dashes from SF date (YYYY-MM-DD → YYYYMMDD) for Freebit API */
function sfDateToYYYYMMDD(date: string): string {
const stripped = date.replace(/-/g, "");
return /^\d{8}$/.test(stripped) ? stripped : date;
}
/**
* Configuration fields extracted from checkout payload and Salesforce order records.
* Used during fulfillment for SIM activation, MNP porting, and address data.
@ -81,7 +87,7 @@ export class FulfillmentContextMapper {
config["activationType"] = sfOrder.Activation_Type__c;
}
if (!config["scheduledAt"] && sfOrder.Activation_Scheduled_At__c) {
config["scheduledAt"] = sfOrder.Activation_Scheduled_At__c;
config["scheduledAt"] = sfDateToYYYYMMDD(sfOrder.Activation_Scheduled_At__c);
}
if (!config["mnpPhone"] && sfOrder.MNP_Phone_Number__c) {
config["mnpPhone"] = sfOrder.MNP_Phone_Number__c;
@ -94,7 +100,7 @@ export class FulfillmentContextMapper {
config["mnpNumber"] = sfOrder.MNP_Reservation_Number__c;
}
if (!config["mnpExpiry"] && sfOrder.MNP_Expiry_Date__c) {
config["mnpExpiry"] = sfOrder.MNP_Expiry_Date__c;
config["mnpExpiry"] = sfDateToYYYYMMDD(sfOrder.MNP_Expiry_Date__c);
}
if (!config["mvnoAccountNumber"] && sfOrder.MVNO_Account_Number__c) {
config["mvnoAccountNumber"] = sfOrder.MVNO_Account_Number__c;
@ -115,7 +121,7 @@ export class FulfillmentContextMapper {
config["portingGender"] = sfOrder.Porting_Gender__c;
}
if (!config["portingDateOfBirth"] && sfOrder.Porting_DateOfBirth__c) {
config["portingDateOfBirth"] = sfOrder.Porting_DateOfBirth__c;
config["portingDateOfBirth"] = sfDateToYYYYMMDD(sfOrder.Porting_DateOfBirth__c);
}
}
@ -207,8 +213,8 @@ export class FulfillmentContextMapper {
return undefined;
}
// Validate gender (must be M or F)
const gender = genderRaw === "M" || genderRaw === "F" ? genderRaw : undefined;
// Map gender to M/F for PA05-05 (accepts picklist values from SF)
const gender = this.mapGenderToCode(genderRaw);
if (!gender) {
this.logger.debug("Invalid or missing gender for contact identity", { genderRaw });
return undefined;
@ -231,6 +237,18 @@ export class FulfillmentContextMapper {
};
}
/**
* Map Salesforce gender picklist value to single-letter code.
* SF picklist: "Male", "Female", "Corporate/Other" (or legacy "M", "F")
*/
private mapGenderToCode(gender?: string | null): "M" | "F" | undefined {
if (!gender) return undefined;
const normalized = gender.trim().toLowerCase();
if (normalized === "male" || normalized === "m") return "M";
if (normalized === "female" || normalized === "f") return "F";
return undefined;
}
/**
* Format birthday from various formats to YYYYMMDD
*/

View File

@ -84,12 +84,15 @@ interface MnpConfig {
}
/**
* Map Salesforce gender code to Freebit gender code.
* Salesforce: 'M' (Male), 'F' (Female)
* Freebit: 'M' (Male), 'W' (Weiblich/Female), 'C' (Corporation)
* Map Salesforce gender value to Freebit gender code.
* Salesforce picklist: "Male", "Female", "Corporate/Other" (or legacy "M", "F")
* Freebit codes: "M" (Male), "W" (Weiblich/Female), "C" (Corporation)
*/
function mapGenderToFreebit(gender: string): string {
if (gender === "F") return "W";
const normalized = gender.trim().toLowerCase();
if (normalized === "female" || normalized === "f") return "W";
if (normalized === "male" || normalized === "m") return "M";
if (normalized.startsWith("corporate") || normalized === "c") return "C";
return gender;
}

View File

@ -96,7 +96,7 @@ export const salesforceOrderRecordSchema = z.object({
Assign_Physical_SIM__c: z.string().nullable().optional(), // Lookup to SIM_Inventory__c
// MNP (Mobile Number Portability) fields
MNP_Application__c: z.string().nullable().optional(),
MNP_Application__c: z.coerce.boolean().nullable().optional(),
MNP_Reservation_Number__c: z.string().nullable().optional(),
MNP_Expiry_Date__c: z.string().nullable().optional(),
MNP_Phone_Number__c: z.string().nullable().optional(),

View File

@ -136,14 +136,14 @@ export const orderConfigurationsSchema = z.object({
.regex(/^\d{32}$/, "EID must be exactly 32 digits")
.optional(),
isMnp: z.string().optional(),
mnpNumber: z.string().optional(),
mnpNumber: z.string().max(10, "MNP reservation number must be 10 digits or fewer").optional(),
mnpExpiry: z.string().optional(),
mnpPhone: z.string().max(11, "MNP phone number must be 11 digits or fewer").optional(),
mvnoAccountNumber: z.string().optional(),
portingLastName: z.string().optional(),
portingFirstName: z.string().optional(),
portingLastNameKatakana: z.string().optional(),
portingFirstNameKatakana: z.string().optional(),
mvnoAccountNumber: z.string().max(255).optional(),
portingLastName: z.string().max(255).optional(),
portingFirstName: z.string().max(255).optional(),
portingLastNameKatakana: z.string().max(255).optional(),
portingFirstNameKatakana: z.string().max(255).optional(),
portingGender: z.enum(PORTING_GENDER_VALUES).optional(),
portingDateOfBirth: z.string().optional(),
address: orderConfigurationsAddressSchema.optional(),
@ -172,14 +172,14 @@ export const orderSelectionsSchema = z
.regex(/^\d{32}$/, "EID must be exactly 32 digits")
.optional(),
isMnp: z.string().optional(),
mnpNumber: z.string().optional(),
mnpNumber: z.string().max(10, "MNP reservation number must be 10 digits or fewer").optional(),
mnpExpiry: z.string().optional(),
mnpPhone: z.string().max(11, "MNP phone number must be 11 digits or fewer").optional(),
mvnoAccountNumber: z.string().optional(),
portingLastName: z.string().optional(),
portingFirstName: z.string().optional(),
portingLastNameKatakana: z.string().optional(),
portingFirstNameKatakana: z.string().optional(),
mvnoAccountNumber: z.string().max(255).optional(),
portingLastName: z.string().max(255).optional(),
portingFirstName: z.string().max(255).optional(),
portingLastNameKatakana: z.string().max(255).optional(),
portingFirstNameKatakana: z.string().max(255).optional(),
portingGender: z.enum(PORTING_GENDER_VALUES).optional(),
portingDateOfBirth: z.string().optional(),
address: z

View File

@ -399,7 +399,10 @@ export const simChangePlanFullRequestSchema = z.object({
});
const simMnpFormSchema = z.object({
reservationNumber: z.string().min(1, "Reservation number is required"),
reservationNumber: z
.string()
.min(1, "Reservation number is required")
.max(10, "Reservation number must be 10 digits or fewer"),
expiryDate: z.string().regex(/^\d{8}$/, "Expiry date must be in YYYYMMDD format"),
phoneNumber: z
.string()