daemon-idc/app/Forms/CommonForm.php
2026-02-09 16:48:11 +09:00

508 lines
16 KiB
PHP

<?php
namespace App\Forms;
use RuntimeException;
/**
* CommonForm
* - 모든 Form의 공통 베이스
* - 핵심 개선점:
* 1) FK/숫자 필드 미입력('')을 NULL로 정규화 ('' -> null)
* 2) 전역 null -> '' 변환 제거 (FK/숫자/날짜 타입 깨짐 방지)
* 3) validate()에서 dynamicRules 누적 버그 수정 (마지막 규칙만 남는 문제 해결)
* 4) "필드 존재 보장"으로 임의 '' 삽입 제거 (미입력 필드가 FK/숫자 규칙을 깨는 문제 방지)
* 5) role.* 같은 배열 원소 규칙을 위해 부모 배열 보정 로직 유지/강화
*/
abstract class CommonForm
{
private $_validation = null;
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 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 -> '' 같은 변환을 절대 하지 않습니다.
* - 이유: FK/숫자/날짜 필드가 ''로 변하면 validation/DB에서 문제가 발생함.
*/
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 캐스팅 (선택)
*
* 주의:
* - "빈값을 0으로 취급" 같은 정책이 있다면 여기에서 조정해야 함.
*/
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) 핵심: 배열 원소의 null/'' 제거 + 문자열화(Trim이 null 받지 않도록)
$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)"로 취급할 필드를 수집
* - getFormRule()에서 permit_empty|numeric 로 정의되는 필드를 공통 처리하기 위함
*
* 구현 전략:
* - formRules에서 rule 문자열에 'numeric'가 포함된 필드를 모음
* - wildcard(role.*) 제외
*/
protected function collectNumericFieldsFromRules(array $formRules): array
{
$numericFields = [];
foreach ($formRules as $field => $rule) {
$fieldName = (string) $field;
if (str_contains($fieldName, '.*')) {
continue;
}
// getValidationRule 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
* --------------------------------------------------------------------- */
/**
* 데이터를 검증하고 유효하지 않을 경우 예외를 발생시킵니다.
* 2025 CI4 표준: 규칙 배열 내에 label을 포함하여 한글 메시지 출력을 보장합니다.
*/
final public function validate(array &$formDatas): void
{
log_message('debug', '>>> CommonForm::validate CALLED: ' . static::class);
if ($this->_validation === null) {
throw new RuntimeException(static::class . '->' . __FUNCTION__ . "에서 오류발생: Validation 서비스가 초기화되지 않았습니다.");
}
try {
// 0) 데이터 구조 정리 (null 변환 X)
$formDatas = $this->sanitizeFormDatas($formDatas);
// 1) 필드 라벨/규칙
$formFields = $this->getFormFields();
$formRules = $this->getFormRules();
if (empty($formRules)) {
throw new RuntimeException(static::class . '->' . __FUNCTION__ . "에서 오류발생: 지정된 Form RULE이 없습니다.");
}
// 2) wildcard(role.*) 부모 배열 보정
$this->ensureParentArrayForWildcardRules($formDatas, $formRules);
// 3) numeric(FK 포함) 필드: '' -> null, 숫자 문자열 -> int
// (규칙 기반 자동 수집)
$numericFields = $this->collectNumericFieldsFromRules($formRules);
$formDatas = $this->normalizeNumericEmptyToNull($formDatas, $numericFields);
// 4) dynamicRules 누적 구성 (버그 수정: 루프마다 초기화 금지)
$dynamicRules = [];
foreach ($formRules as $field => $rule) {
try {
// 필드명/규칙 추출(확장 포인트)
[$fieldName, $ruleStr] = $this->getValidationRule((string) $field, (string) $rule);
// label 결정
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,
];
// ❌ 존재 보장으로 '' 삽입하지 않음
// - required는 CI4가 "키 없음"도 실패 처리 가능(일반적으로)
// - permit_empty는 키 없어도 통과 (강제로 '' 만들면 FK/숫자 문제 발생)
} catch (\Throwable $e) {
throw new RuntimeException("유효성 검사 규칙 준비 중 오류 발생 (필드: {$field}): " . $e->getMessage());
}
}
$this->_validation->setRules($dynamicRules);
try {
if (!$this->_validation->run($formDatas)) {
$errors = $this->_validation->getErrors();
throw new RuntimeException(implode("\n", $errors));
}
} catch (\TypeError $e) {
throw new RuntimeException("검증 도중 타입 오류 발생: " . $e->getMessage());
}
} catch (\Throwable $e) {
if ($e instanceof RuntimeException) {
throw $e;
}
throw new RuntimeException("유효성 검사 중 시스템 오류 발생: " . $e->getMessage());
}
}
/* ---------------------------------------------------------------------
* Overridable hooks
* --------------------------------------------------------------------- */
// 사용자 정의 hook: 필드/룰 커스터마이즈
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;
}
/**
* Form rule 정의
* - permit_empty|numeric 인 FK들이 여기서 정의되면,
* validate()에서 자동으로 ''->null 정규화 대상에 포함됩니다.
*/
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;
}
}