mm, ...]. Lines without a valid mm value are skipped. */ function blinds_admin_parse_fabrics(string $raw): array { $out = []; foreach (preg_split("/\r\n|\n|\r/", $raw) as $line) { $line = trim($line); if ($line === '') { continue; } $parts = explode('|', $line, 2); $name = sanitize_text_field(trim($parts[0])); $mm = isset($parts[1]) ? absint(trim($parts[1])) : 0; if ($name !== '' && $mm > 0) { $out[$name] = $mm; } } return $out; } /** * Build the config array to save from POST data, starting from the * current config so keys this page does not edit (price_matrix, * width_bands, drop_bands, pricing_mode, synthetic_test_rules_source, * and per-option fields such as requires / max_width_mm / max_drop_mm) * are preserved untouched. */ function blinds_admin_build_config_from_post(array $current): array { $config = $current; $config['supplier_label'] = blinds_admin_post_text('blinds_supplier_label'); $currency = strtoupper(blinds_admin_post_text('blinds_currency')); if ($currency !== '') { $config['currency'] = substr($currency, 0, 3); } // Data label: only ever changed by an explicit admin edit of this // field. It is NEVER auto-changed by saving, restoring, or loading. // Default 'SYNTHETIC' until the admin replaces the defaults with // real supplier values and explicitly sets this label themselves. $label = blinds_admin_post_text('blinds_data_label'); if ($label !== '') { $config['data_label'] = $label; } elseif (!isset($config['data_label'])) { $config['data_label'] = 'SYNTHETIC'; } $config['deductions'] = [ 'recess' => [ 'width_mm' => blinds_admin_post_int('blinds_deduction_recess_width'), 'drop_mm' => blinds_admin_post_int('blinds_deduction_recess_drop'), ], 'exact' => [ 'width_mm' => blinds_admin_post_int('blinds_deduction_exact_width'), 'drop_mm' => blinds_admin_post_int('blinds_deduction_exact_drop'), ], ]; $config['corner_deduction'] = [ 'width_mm' => blinds_admin_post_int('blinds_corner_width'), 'drop_mm' => blinds_admin_post_int('blinds_corner_drop'), 'drop_over_mm' => blinds_admin_post_int('blinds_corner_drop_over'), ]; $config['mechanism_allowance_mm'] = blinds_admin_post_int('blinds_mechanism_allowance'); $config['limits'] = [ 'min_overall_width_mm' => blinds_admin_post_int('blinds_limit_min_width'), 'max_overall_width_mm' => blinds_admin_post_int('blinds_limit_max_width'), 'min_overall_drop_mm' => blinds_admin_post_int('blinds_limit_min_drop'), 'max_overall_drop_mm' => blinds_admin_post_int('blinds_limit_max_drop'), ]; $config['roll_width_mm'] = blinds_admin_post_int('blinds_roll_width_default'); $fabrics = blinds_admin_parse_fabrics(blinds_admin_post_textarea('blinds_fabrics')); if ($fabrics !== []) { $config['roll_widths_by_fabric'] = $fabrics; } $config['outside_mount_additions'] = [ 'width_mm' => blinds_admin_post_int('blinds_outside_width'), 'drop_mm' => blinds_admin_post_int('blinds_outside_drop'), ]; $config['recess_depth_min_mm'] = blinds_admin_post_int('blinds_recess_depth_min'); $config['tolerance_mm'] = [ 'width_mm' => blinds_admin_post_int('blinds_tolerance_width'), 'drop_mm' => blinds_admin_post_int('blinds_tolerance_drop'), ]; $config['min_charge_minor'] = blinds_admin_post_int('blinds_min_charge_minor'); // Options: edit existing options in place (code is the key and is // never changed from the form), preserving unedited per-option // fields. One optional new option row is added only when its code // is non-empty and not already present. $postedOptions = isset($_POST['blinds_options']) && is_array($_POST['blinds_options']) ? wp_unslash($_POST['blinds_options']) : []; $existing = []; foreach (($current['options'] ?? []) as $opt) { if (isset($opt['code'])) { $existing[(string) $opt['code']] = $opt; } } $saved = []; foreach ($postedOptions as $row) { if (!is_array($row)) { continue; } $code = isset($row['code']) ? sanitize_text_field((string) $row['code']) : ''; if ($code === '' || !isset($existing[$code])) { continue; } $opt = $existing[$code]; if (isset($row['label'])) { $opt['label'] = sanitize_text_field((string) $row['label']); } if (isset($row['shape'])) { $shape = sanitize_text_field((string) $row['shape']); if (in_array($shape, ['flat_per_blind', 'flat_per_order', 'per_m2', 'percent', 'width_banded', 'quote_only'], true)) { $opt['shape'] = $shape; } } if (isset($row['amount_minor'])) { // Money stays integer minor units. A per-m2 rate that needs // decimals must be expressed in minor units per m2 here; // floatval is used only to normalise the input before it // is cast back to an integer — no fractional money is stored. $opt['amount_minor'] = (int) floatval($row['amount_minor']); if ($opt['amount_minor'] < 0) { $opt['amount_minor'] = 0; } } if (isset($row['workshop_part'])) { $opt['workshop_part'] = sanitize_text_field((string) $row['workshop_part']); } $saved[] = $opt; unset($existing[$code]); } // Keep any existing option not present in the posted rows. foreach ($existing as $opt) { $saved[] = $opt; } $newCode = blinds_admin_post_text('blinds_new_option_code'); if ($newCode !== '') { $codes = array_column($saved, 'code'); if (!in_array($newCode, $codes, true)) { $newShape = blinds_admin_post_text('blinds_new_option_shape'); if (!in_array($newShape, ['flat_per_blind', 'flat_per_order', 'per_m2', 'percent', 'width_banded', 'quote_only'], true)) { $newShape = 'flat_per_blind'; } $saved[] = [ 'code' => $newCode, 'label' => blinds_admin_post_text('blinds_new_option_label'), 'shape' => $newShape, 'amount_minor' => blinds_admin_post_int('blinds_new_option_amount'), 'workshop_part' => blinds_admin_post_text('blinds_new_option_part'), ]; } } $config['options'] = $saved; // Version stamp: incremented integer, author, timestamp. $config['config_version'] = (int) ($current['config_version'] ?? 0) + 1; $config['updated_by'] = function_exists('get_current_user_id') ? get_current_user_id() : 0; $config['updated_at'] = function_exists('current_time') ? current_time('mysql') : gmdate('Y-m-d H:i:s'); return $config; } /** Render the settings page. Handles save and restore before output. */ function blinds_admin_render_settings_page(): void { if (!current_user_can('manage_options')) { return; } $notice = ''; $config = blinds_get_supplier_config(); if (!empty($_POST['blinds_supplier_save'])) { $nonce = isset($_POST['blinds_supplier_config_nonce']) ? sanitize_text_field(wp_unslash($_POST['blinds_supplier_config_nonce'])) : ''; if (!wp_verify_nonce($nonce, 'blinds_supplier_config_save')) { $notice = 'Settings not saved: security check failed.'; } else { $saved = blinds_admin_build_config_from_post($config); update_option('blinds_supplier_config', $saved); $config = $saved; $notice = 'Supplier configuration saved (version ' . (int) $saved['config_version'] . ').'; } } elseif (!empty($_POST['blinds_supplier_restore'])) { $nonce = isset($_POST['blinds_supplier_restore_nonce']) ? sanitize_text_field(wp_unslash($_POST['blinds_supplier_restore_nonce'])) : ''; if (!wp_verify_nonce($nonce, 'blinds_supplier_config_restore')) { $notice = 'Defaults not restored: security check failed.'; } else { $defaults = blinds_admin_load_json_defaults(); $defaults['config_version'] = (int) ($config['config_version'] ?? 0) + 1; $defaults['updated_by'] = function_exists('get_current_user_id') ? get_current_user_id() : 0; $defaults['updated_at'] = function_exists('current_time') ? current_time('mysql') : gmdate('Y-m-d H:i:s'); update_option('blinds_supplier_config', $defaults); $config = $defaults; $notice = 'Synthetic defaults restored. Data label is SYNTHETIC until you replace these values with real supplier values and explicitly change the label.'; } } $deductions = $config['deductions'] ?? []; $limits = $config['limits'] ?? []; $corner = $config['corner_deduction'] ?? []; $tolerance = $config['tolerance_mm'] ?? []; $outside = $config['outside_mount_additions'] ?? []; $fabrics = $config['roll_widths_by_fabric'] ?? []; $fabricLines = ''; foreach ($fabrics as $name => $mm) { $fabricLines .= $name . '|' . (int) $mm . "\n"; } $shapes = ['flat_per_blind', 'flat_per_order', 'per_m2', 'percent', 'width_banded', 'quote_only']; echo '
' . esc_html($notice) . '
Data label: ' . esc_html((string) ($config['data_label'] ?? 'SYNTHETIC')) . '. '; echo 'These are SYNTHETIC defaults until you replace them with real supplier values. '; echo 'Change the data label below ONLY when you have done so — it is never changed automatically. '; echo 'Money is stored in integer minor units; all measurements are in millimetres. '; echo 'Price matrix and width/drop bands are edited through the CSV importer, not on this page, and are preserved unchanged when you save here.
'; if (!empty($config['config_version'])) { echo 'Config version: ' . esc_html((string) $config['config_version']) . '. Last updated: ' . esc_html((string) ($config['updated_at'] ?? '')) . '.
'; } echo ''; echo '