null) * 2) 전역 null -> '' 변환 제거 (FK/숫자/날짜 타입 깨짐 방지) * 3) validate()에서 dynamicRules 누적 버그 수정 (마지막 규칙만 남는 문제 해결) * 4) "필드 존재 보장"으로 임의 '' 삽입 제거 (미입력 필드가 FK/숫자 규칙을 깨는 문제 방지) * 5) role.* 같은 배열 원소 규칙을 위해 부모 배열 보정 로직 유지/강화 * * ✅ 추가: * - validate() 실패 시 RuntimeException(implode) 대신 * FormValidationException(errors 배열)을 throw하여 * Controller에서 AJAX(422 JSON errors) 응답이 가능하게 함 */ abstract class CommonForm { private array $_attributes = []; private array $_formFields = []; private array $_formRules = []; private array $_formFilters = []; private array $_indexFilters = []; private array $_batchjobFilters = []; private array $_formOptions = []; private array $_actionButtons = ['view' => ICONS['SEARCH'], 'delete' => ICONS['DELETE']]; private array $_batchjobButtons = ['batchjob' => '일괄처리', 'batchjob_delete' => '일괄삭제']; protected $validation = null; protected function __construct() { $this->validation = service('validation'); } public function action_init_process(string $action, array &$formDatas = []): void { $actionButtons = ['view' => ICONS['SEARCH'], 'delete' => ICONS['DELETE']]; $batchjobButtons = []; $this->setActionButtons($actionButtons); $this->setBatchjobButtons($batchjobButtons); } final public function setAttributes(array $attributes): void { $this->_attributes = $attributes; } final public function getAttribute(string $key): string { if (!array_key_exists($key, $this->_attributes)) { throw new RuntimeException(static::class . '->' . __FUNCTION__ . "에서 오류발생: {$key}에 해당하는 속성이 정의되지 않았습니다."); } return $this->_attributes[$key]; } final public function setFormFields(array $fields): void { foreach ($fields as $field) { $this->_formFields[$field] = $this->getFormFieldLabel($field); } } // $fields 매치된것만 반환, []->전체 final public function getFormFields(array $fields = []): array { if (empty($fields)) { return $this->_formFields; } return array_intersect_key($this->_formFields, array_flip($fields)); } public function setFormRules(string $action, array $fields, $formRules = []): void { foreach ($fields as $field) { $formRules = $this->getFormRule($action, $field, $formRules); } $this->_formRules = $formRules; } final public function getFormRules(array $fields = []): array { if (empty($fields)) { return $this->_formRules; } return array_intersect_key($this->_formRules, array_flip($fields)); } final public function setFormOptions(string $action, array $fields, array $formDatas = [], $formOptions = []): void { foreach ($fields as $field) { $formOptions[$field] = $formOptions[$field] ?? $this->getFormOption($action, $field, $formDatas); } $this->_formOptions = $formOptions; } // $fields 매치된것만 반환, []->전체 final public function getFormOptions(array $fields = []): array { if (empty($fields)) { return $this->_formOptions; } return array_intersect_key($this->_formOptions, array_flip($fields)); } final public function setFormFilters(array $fields): void { $this->_formFilters = $fields; } final public function getFormFilters(): array { return $this->_formFilters; } final public function setIndexFilters(array $fields): void { $this->_indexFilters = $fields; } final public function getIndexFilters(): array { return $this->_indexFilters; } final public function setBatchjobFilters(array $fields): void { $this->_batchjobFilters = $fields; } final public function getBatchjobFilters(): array { return $this->_batchjobFilters; } final public function setActionButtons(array $buttons): array { return $this->_actionButtons = $buttons; } final public function getActionButtons(): array { return $this->_actionButtons; } final public function setBatchjobButtons(array $buttons): array { return $this->_batchjobButtons = $buttons; } final public function getBatchjobButtons(): array { return $this->_batchjobButtons; } /* --------------------------------------------------------------------- * Normalize / Sanitize * --------------------------------------------------------------------- */ /** * 1) 깊은 배열 구조 정리(배열은 유지) * - 여기서는 null -> '' 같은 변환을 절대 하지 않습니다. */ protected function sanitizeFormDatas($data, string $path = '') { if (!is_array($data)) { return $data; } foreach ($data as $k => $v) { if (is_array($v)) { $data[$k] = $this->sanitizeFormDatas($v, ($path !== '' ? "{$path}.{$k}" : (string) $k)); } } return $data; } /** * 2) 숫자/FK 필드 정규화 * - '' -> null * - 숫자 문자열 -> int */ protected function normalizeNumericEmptyToNull(array $data, array $numericFields): array { foreach ($numericFields as $f) { if (!array_key_exists($f, $data)) { continue; } if ($data[$f] === '') { $data[$f] = null; continue; } if (is_string($data[$f]) && ctype_digit($data[$f])) { $data[$f] = (int) $data[$f]; } } return $data; } /** * 3) role.* 같은 배열 원소 규칙이 있을 때, 부모 배열 존재/타입 보정 */ protected function ensureParentArrayForWildcardRules(array &$formDatas, array $formRules): void { foreach ($formRules as $fieldKey => $ruleDef) { $fieldName = (string) $fieldKey; if (!str_contains($fieldName, '.*')) { continue; } $parent = str_replace('.*', '', $fieldName); // 1) 부모가 없거나 ''/null 이면 빈 배열 if (!array_key_exists($parent, $formDatas) || $formDatas[$parent] === '' || $formDatas[$parent] === null) { $formDatas[$parent] = []; continue; } // 2) 문자열이면 CSV로 분해 (혹시 JSON 문자열이면 JSON 우선) if (is_string($formDatas[$parent])) { $raw = trim($formDatas[$parent]); if ($raw === '') { $formDatas[$parent] = []; } else { $decoded = json_decode($raw, true); if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { $formDatas[$parent] = $decoded; } else { $formDatas[$parent] = explode(DEFAULTS["DELIMITER_COMMA"], $raw); } } } // 3) 배열이 아니면 강제 빈 배열 if (!is_array($formDatas[$parent])) { $formDatas[$parent] = []; } // 4) 배열 원소 정리 $clean = array_map( fn($v) => is_scalar($v) ? trim((string) $v) : '', $formDatas[$parent] ); $clean = array_values(array_filter($clean, fn($v) => $v !== '')); $formDatas[$parent] = $clean; } } /** * 4) 검증 rule에 따라 numeric(FK 포함) 필드 수집 */ protected function collectNumericFieldsFromRules(array $formRules): array { $numericFields = []; foreach ($formRules as $field => $rule) { $fieldName = (string) $field; if (str_contains($fieldName, '.*')) { continue; } // hook 적용 [$fieldName, $ruleStr] = $this->getValidationRule($fieldName, (string) $rule); if (is_string($ruleStr) && str_contains($ruleStr, 'numeric')) { $numericFields[] = $fieldName; } } return array_values(array_unique($numericFields)); } /* --------------------------------------------------------------------- * Validation * --------------------------------------------------------------------- */ /** * 데이터를 검증하고 유효하지 않을 경우 예외를 발생시킵니다. * ✅ 변경점: * - 실패 시 FormValidationException(errors 배열)을 throw * (AJAX에서 422로 내려보내기 위함) */ final public function validate(array &$formDatas): void { log_message('debug', '>>> CommonForm::validate CALLED: ' . static::class); try { $formDatas = $this->sanitizeFormDatas($formDatas); $formFields = $this->getFormFields(); $formRules = $this->getFormRules(); if (empty($formRules)) { throw new RuntimeException(static::class . '->' . __FUNCTION__ . "에서 오류발생: 지정된 Form RULE이 없습니다."); } $this->ensureParentArrayForWildcardRules($formDatas, $formRules); $numericFields = $this->collectNumericFieldsFromRules($formRules); $formDatas = $this->normalizeNumericEmptyToNull($formDatas, $numericFields); $dynamicRules = []; foreach ($formRules as $field => $rule) { [$fieldName, $ruleStr] = $this->getValidationRule((string) $field, (string) $rule); if (isset($formFields[$fieldName])) { $label = $formFields[$fieldName]; } elseif (str_contains($fieldName, '.*')) { $parentField = str_replace('.*', '', $fieldName); $label = ($formFields[$parentField] ?? $fieldName) . ' 항목'; } else { $label = $fieldName; } $dynamicRules[$fieldName] = [ 'label' => $label, 'rules' => $ruleStr, ]; } $this->validation->setRules($dynamicRules); if (!$this->validation->run($formDatas)) { throw new FormValidationException($this->validation->getErrors()); } } catch (FormValidationException $e) { throw $e; // ✅ 필드별 errors 유지 } catch (\TypeError $e) { throw new RuntimeException('검증 도중 타입 오류 발생: ' . $e->getMessage()); } catch (\Throwable $e) { throw new RuntimeException('유효성 검사 중 시스템 오류 발생: ' . $e->getMessage()); } } /* --------------------------------------------------------------------- * Overridable hooks * --------------------------------------------------------------------- */ protected function getValidationRule(string $field, string $rule): array { return [$field, $rule]; } public function getFormFieldLabel(string $field, ?string $label = null): string { switch ($field) { default: $label = $label ?? lang("{$this->getAttribute('class_path')}.label.{$field}"); break; } return $label; } public function getFormRule(string $action, string $field, array $formRules): array { switch ($field) { case $this->getAttribute('pk_field'): if (!$this->getAttribute('useAutoIncrement')) { $formRules[$field] = sprintf( "required|regex_match[/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/]%s", in_array($action, ["create"]) ? "|is_unique[{$this->getAttribute('table')}.{$field}]" : "" ); } else { $formRules[$field] = "required|numeric"; } break; case $this->getAttribute('title_field'): $formRules[$field] = sprintf( "required|trim|string%s", in_array($action, ["create", "create_form"]) ? "|is_unique[{$this->getAttribute('table')}.{$field}]" : "" ); break; case "code": $formRules[$field] = sprintf( "required|regex_match[/^[a-zA-Z0-9가-힣\-\_]+$/]|min_length[4]%s", in_array($action, ["create"]) ? "|is_unique[{$this->getAttribute('table')}.{$field}]" : "" ); break; case "user_uid": $formRules[$field] = "required|numeric"; break; case "status": $formRules[$field] = "required|trim|string"; break; case 'picture': $formRules[$field] = "is_image[{$field}]|mime_in[{$field},image/jpg,image/jpeg,image/gif,image/png,image/webp]|max_size[{$field},300]|max_dims[{$field},2048,768]"; break; case "updated_at": case "created_at": case "deleted_at": $formRules[$field] = "permit_empty|trim|valid_date"; break; default: $formRules[$field] = "permit_empty|trim|string"; break; } return $formRules; } /* --------------------------------------------------------------------- * Options * --------------------------------------------------------------------- */ protected function getFormOption_process($service, string $action, string $field, array $formDatas = []): array { $entities = []; switch ($field) { default: if (in_array($action, ['create_form', 'modify_form', 'alternative_create_form'])) { if (array_key_exists($field, $formDatas)) { $where = sprintf("status = '%s' OR %s='%s'", STATUS['AVAILABLE'], $this->getAttribute('pk_field'), $formDatas[$field]); } else { $where = sprintf("status = '%s'", STATUS['AVAILABLE']); } $entities = $service->getEntities([$where => null]); } else { $entities = $service->getEntities(); } break; } return $entities; } public function getFormOption(string $action, string $field, array $formDatas = [], array $options = ['options' => [], 'atttributes' => []]): array { $tempOptions = ['' => lang("{$this->getAttribute('class_path')}.label.{$field}") . " 선택"]; switch ($field) { case 'user_uid': foreach ($this->getFormOption_process(service('userservice'), $action, $field, $formDatas) as $entity) { $tempOptions[$entity->getPK()] = $entity->getTitle(); } $options['options'] = $tempOptions; break; case 'clientinfo_uid': foreach ($this->getFormOption_process(service('customer_clientservice'), $action, $field, $formDatas) as $entity) { $tempOptions[$entity->getPK()] = $entity->getCustomTitle(); } $options['options'] = $tempOptions; break; default: $optionDatas = lang($this->getAttribute('class_path') . "." . strtoupper($field)); if (!is_array($optionDatas)) { throw new RuntimeException(static::class . '->' . __FUNCTION__ . "에서 오류발생:{$field}가 배열값이 아닙니다."); } foreach ($optionDatas as $key => $label) { $tempOptions[$key] = $label; } $options['options'] = $tempOptions; break; } return $options; } }