import { __, sprintf } from '@wordpress/i18n'; import type { Order, OrderItem, RuleSets } from '../../types'; import type { OrderItemProductIdentifiers } from '../../types/order'; import { mapRuleSet, type SectionData } from './requirementRules'; /** The ruleset the identifier rules live in. */ const PARCEL_CONTENT_RULESET = 'parcelContent'; export type ProductIdentifierKey = | 'merchantProductId' | 'manufacturerProductId' | 'standardProductId'; /** The three identifiers, in display order: the API rule key, the order item field and the column width. */ export const PRODUCT_IDENTIFIERS: ReadonlyArray< { key: ProductIdentifierKey; field: keyof OrderItemProductIdentifiers; maxLength: number; } > = [ { key: 'merchantProductId', field: 'merchant_product_id', maxLength: 64 }, { key: 'manufacturerProductId', field: 'manufacturer_product_id', maxLength: 32 }, { key: 'standardProductId', field: 'standard_product_id', maxLength: 32 }, ]; export function getIdentifierLabel( key: ProductIdentifierKey ): string { switch ( key ) { case 'merchantProductId': return __( 'Item SKU', 'parcel2go-shipping' ); case 'manufacturerProductId': return __( 'Manufacturer ID', 'parcel2go-shipping' ); case 'standardProductId': return __( 'Barcode', 'parcel2go-shipping' ); } } export function getIdentifierHelp( key: ProductIdentifierKey ): string { switch ( key ) { case 'merchantProductId': return __( 'Your own code for this product, such as its SKU.', 'parcel2go-shipping' ); case 'manufacturerProductId': return __( 'The model or part number from the manufacturer or your supplier.', 'parcel2go-shipping' ); case 'standardProductId': return __( 'A GTIN, EAN or UPC. Leave it empty if the product has no barcode.', 'parcel2go-shipping' ); } } export type ProductIdentifierProblem = 'required' | 'placeholder' | 'forbidden-chars'; // Compared after stripping case and punctuation. The literals `M-PID` / `NS-PID` are the // exemption values for handmade goods, so they are not placeholders. const PLACEHOLDERS = new Set( [ 'na', 'none', 'nil', 'null', 'no', 'nocode', 'nocodeavailable', 'notapplicable', 'unknown', 'notknown', 'tbc', 'tba', ] ); // These break DPD's delimited `MMI1:...|IMI1:...|SMI1:...` string. const FORBIDDEN_CHARS = /[|:]/; export function isPlaceholderIdentifier( value: string ): boolean { // Keep letters and digits of any script, so a SKU like 商品 is not read as punctuation. const collapsed = value.toLowerCase().replace( /[^\p{L}\p{N}]/gu, '' ); return collapsed === '' || PLACEHOLDERS.has( collapsed ); } /** The first problem with one identifier, or undefined. Placeholders are only policed when required. */ export function validateProductIdentifier( value: string | undefined | null, required: boolean ): ProductIdentifierProblem | undefined { const trimmed = value?.trim() ?? ''; if ( trimmed === '' ) { return required ? 'required' : undefined; } if ( FORBIDDEN_CHARS.test( trimmed ) ) { return 'forbidden-chars'; } if ( required && isPlaceholderIdentifier( trimmed ) ) { return 'placeholder'; } return undefined; } export function productIdentifierProblemMessage( label: string, problem: ProductIdentifierProblem ): string { switch ( problem ) { case 'required': /* translators: %s: the name of a field, such as "Item SKU". */ return sprintf( __( '%s is required', 'parcel2go-shipping' ), label ); case 'placeholder': return __( 'Enter a real code. Placeholders such as none or n/a are not accepted.', 'parcel2go-shipping' ); case 'forbidden-chars': return __( 'Remove the | and : characters.', 'parcel2go-shipping' ); } } export interface ProductIdentifierFieldState { key: ProductIdentifierKey; label: string; value: string; required: boolean; maxLength: number; /** The rule's position, so fields render in the order the API sends them. */ order: number; problem?: ProductIdentifierProblem; } export interface ProductIdentifierLine { itemIndex: number; name: string; fields: ProductIdentifierFieldState[]; } export interface ProductIdentifierLines { /** Only items with at least one field to show. */ lines: ProductIdentifierLine[]; /** A visible field is empty when required, or holds a value the form rejects. */ blocking: boolean; } /** * The order items that show identifier fields. The order is one parcel, so every item shares * `parcelValue` (minor units). A field shows when its rule is present, the group is required and * the parcel is under `maxParcelValue`; a held code in a hidden field is still sent. */ export function deriveProductIdentifierLines( { items, ruleSets, parcelValue, ruleData, }: { items: OrderItem[]; ruleSets: RuleSets | null | undefined; parcelValue: number; /** What the rules' `requiredIf` read (see `shipmentRuleData`); defaults to a sale of `parcelValue`. */ ruleData?: SectionData; } ): ProductIdentifierLines { // This app always sends the export reason "Sale". const mapped = mapRuleSet( ruleSets?.[ PARCEL_CONTENT_RULESET ], ruleData ?? { customs: { exportReason: 'Sale' }, parcels: { value: parcelValue }, } ); const rules = mapped?.rules ?? []; const groupRequired = rules.some( ( rule ) => PRODUCT_IDENTIFIERS.some( ( id ) => id.key === rule.key ) && rule.required ); const lines: ProductIdentifierLine[] = []; items.forEach( ( item, itemIndex ) => { const fields: ProductIdentifierFieldState[] = []; for ( const id of PRODUCT_IDENTIFIERS ) { const order = rules.findIndex( ( rule ) => rule.key === id.key ); if ( order === -1 ) { continue; } const rule = rules[ order ]; const value = item.product_identifiers?.[ id.field ] ?? ''; const cap = rule.metadata?.maxParcelValue; const overCap = typeof cap === 'number' && parcelValue > cap; if ( ! groupRequired || overCap ) { continue; } fields.push( { key: id.key, label: getIdentifierLabel( id.key ), value, required: rule.required, maxLength: id.maxLength, order, problem: validateProductIdentifier( value, rule.required ), } ); } if ( fields.length > 0 ) { fields.sort( ( a, b ) => a.order - b.order ); lines.push( { itemIndex, name: item.name, fields } ); } } ); return { lines, blocking: lines.some( ( line ) => line.fields.some( ( f ) => f.problem ) ), }; } /** * The order with one identifier set on one item. The value is trimmed; a blank one means none. */ export function setItemIdentifier( order: Order, itemIndex: number, key: ProductIdentifierKey, value: string ): Order { const item = order.items?.[ itemIndex ]; const id = PRODUCT_IDENTIFIERS.find( ( i ) => i.key === key ); if ( ! item || ! id ) { return order; } return { ...order, items: order.items.map( ( it, i ) => i === itemIndex ? { ...it, product_identifiers: { ...it.product_identifiers, [ id.field ]: value.trim(), }, } : it ), }; } /** The label for an identifier's API field name (any case), or null if it is not one. */ export function getIdentifierLabelForField( field: string ): string | null { const id = PRODUCT_IDENTIFIERS.find( ( i ) => i.key.toLowerCase() === field.toLowerCase() ); return id ? getIdentifierLabel( id.key ) : null; }