From a254b98e6828864f08d45b388e1be613fc8e0c09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ersin=20KO=C3=87?= Date: Fri, 11 Sep 2026 19:32:56 +0300 Subject: [PATCH 1/2] fix(dashboard): trend grafigi ay sonunda 7-11 aya dusuyordu Kok neden: strtotime('-N month') gun tasmasini kirmaz. Ayin 29-31'inde hedef ayda o gun yoksa sonuc bir SONRAKI aya tasiyor; ayni 'Y-m' anahtari iki kez uretiliyor ve api/dashboard_charts.php'deki 12 aylik trend penceresi 7-11 aylik map'e cokuyordu. Kayip aylarin riskleri trend graginde hic sayilmiyor, SQL penceresinin alt siniri (:since) da bir ay geriden geliyordu. Cozum: ay aritmetigi ayin 1'ine sabitlenen recent_months() yardimcisi (includes/functions.php) eklendi; uc nokta bu yardimciyi kullaniyor. 1. gunden cikarilan ay asla tasamaz. Kanit: .temp_files altindaki proof-script oncesinde 11 prob gununden 9'unda FAIL (buckets=7, since kayik), sonrasi PASS. Dayanikli regresyon testi tools/trend_months_test.php olarak eklendi (22 kontrol: ay sonu gunleri, artik yil, yil siniri, count sinirlari, uc nokta baglantisi; DB gerektirmez, php tools/trend_months_test.php ile calisir). --- api/dashboard_charts.php | 10 +-- includes/functions.php | 30 ++++++++ tools/trend_months_test.php | 133 ++++++++++++++++++++++++++++++++++++ 3 files changed, 168 insertions(+), 5 deletions(-) create mode 100644 tools/trend_months_test.php diff --git a/api/dashboard_charts.php b/api/dashboard_charts.php index 6a09946..a5e8b71 100644 --- a/api/dashboard_charts.php +++ b/api/dashboard_charts.php @@ -37,11 +37,11 @@ /* tek eksende gosterilebilir). */ /* ------------------------------------------------------------------ */ -$months = []; -for ($i = 11; $i >= 0; $i--) { - $months[date('Y-m', strtotime("-{$i} month"))] = ['opened' => 0, 'closed' => 0]; -} -$since = date('Y-m-01', strtotime('-11 month')); +// Takvim ayi penceresi ayin 1'ine sabitlenerek kurulur (bkz. recent_months): +// strtotime('-N month') ay sonu gunlerinde bir sonraki aya tasiyordu. +$monthKeys = recent_months(12); +$months = array_fill_keys($monthKeys, ['opened' => 0, 'closed' => 0]); +$since = $monthKeys[0] . '-01'; $rows = $pdo->prepare( "SELECT DATE_FORMAT(created_at, '%Y-%m') AS ay, COUNT(*) AS adet diff --git a/includes/functions.php b/includes/functions.php index 92f9613..b0a854b 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -216,6 +216,36 @@ function days_until(?string $date): ?int return (int)floor(($t - strtotime(date('Y-m-d'))) / 86400); } +/** + * Son $count takvim ayinin 'Y-m' anahtarlarini eskiden yeniye sirali dondurur. + * Pencerenin alt siniri (SQL :since degeri) ilk anahtarin '-01' ekidir. + * + * NEDEN AYRI BIR FONKSIYON: strtotime('-N month') gun tasmasini kirmaz; + * ayin 29-31'inde hedef ayda o gun yoksa sonuc bir SONRAKI aya taser. + * Dashboard trend penceresi (api/dashboard_charts.php) bu yuzden ay sonu + * isteklerinde 12 yerine 7-11 ay uretiyor, kayip aylarin riskleri hic + * sayilmiyordu. Burada ay aritmetigi her zaman ayin 1'ine sabitlenir; + * 1. gunden cikarilan ay asla tasmaz. + * + * @param int $count Kac ay dondurecek (1 = yalnizca bulunulan ay). + * @param int|null $baseTs Pencerenin bittigi an (null = simdi). + * @return list 'YYYY-MM' anahtarlari, eskiden yeniye. + */ +function recent_months(int $count, ?int $baseTs = null): array +{ + if ($count < 1) { + return []; + } + $anchor = (new DateTimeImmutable(date('Y-m-d H:i:s', $baseTs ?? time()))) + ->modify('first day of this month midnight'); + + $out = []; + for ($i = $count - 1; $i >= 0; $i--) { + $out[] = $anchor->modify("-{$i} months")->format('Y-m'); + } + return $out; +} + function str_limit(?string $text, int $limit = 80): string { $text = trim((string)$text); diff --git a/tools/trend_months_test.php b/tools/trend_months_test.php new file mode 100644 index 0000000..38aad6f --- /dev/null +++ b/tools/trend_months_test.php @@ -0,0 +1,133 @@ += 0; $k--) { + $out[] = date('Y-m', mktime(1, 1, 1, $m - $k, 1, $y)); + } + return $out; +} + +echo "\n=============== Trend Month Window Regression Test ==============="; + +/* ------------------------------------------------------------------ */ +section('1. Ay sonu gunlerinde pencere (regresyon)'); +/* ------------------------------------------------------------------ */ + +// Hatali davranisin kanitlandigi gunler: 29-31 (hedef ay kisa ise tasma). +$probes = [ + '2026-05-31', '2026-03-31', '2026-08-31', '2025-12-31', + '2026-01-31', '2026-01-30', '2024-02-29', '2026-02-28', + '2026-10-31', '2026-04-30', '2025-11-30', '2026-12-31', +]; + +foreach ($probes as $d) { + $bts = strtotime($d . ' 12:00:00'); + $got = recent_months(12, $bts); + check( + "12 ay: {$d}", + $got === expected_months($bts, 12) && count(array_unique($got)) === 12, + count($got) . ' ay' + ); +} + +/* ------------------------------------------------------------------ */ +section('2. Sinir ve ikincil dallar'); +/* ------------------------------------------------------------------ */ + +$now = time(); +check('bugun (varsayilan taban): 12 ay', + recent_months(12) === expected_months($now, 12)); +check('taban null acik gecilirse ayni sonuc', + recent_months(12, null) === recent_months(12)); +check('count=1 yalnizca bulunulan ay', + recent_months(1, $now) === [date('Y-m', $now)]); +check('count=0 bos liste', recent_months(0, $now) === []); +check('count negatif bos liste', recent_months(-3, $now) === []); +check('count=13 yil sinirini asar', + recent_months(13, strtotime('2026-01-15 12:00:00')) + === expected_months(strtotime('2026-01-15 12:00:00'), 13)); +check('anahtarlar eskiden yeniye sirali', + recent_months(12, $now) === array_values(array_sort(recent_months(12, $now)))); + +/* SQL :since degeri: en eski ayin 1'i (api/dashboard_charts.php tuketimi) */ +$keys = recent_months(12, strtotime('2026-05-31 12:00:00')); +check('since = en eski ayin 1\'i (2026-05-31 taban)', + $keys[0] . '-01' === '2025-06-01', $keys[0] . '-01'); + +/* ------------------------------------------------------------------ */ +section('3. Uc nokta baglantisi'); +/* ------------------------------------------------------------------ */ + +$src = (string)file_get_contents(__DIR__ . '/../api/dashboard_charts.php'); +check('api/dashboard_charts.php recent_months() kullaniyor', + str_contains($src, 'recent_months(12)')); +check('bozuk strtotime("-N month") ifadesi kaldirildi', + !str_contains($src, 'strtotime("-{')); + +/* ------------------------------------------------------------------ */ + +echo "\n" . str_repeat('-', 72) . "\n"; +printf("Sonuc: %d OK, %d FAIL\n", $PASS, $FAIL); +if ($FAIL > 0) { + echo "TREND MONTH WINDOW TEST: FAIL\n"; + exit(1); +} +echo "TREND MONTH WINDOW TEST: PASS\n"; +exit(0); + +/** Kucukten buyuge siralar (strcmp) — 'Y-m' anahtarlari icin yeterli. */ +function array_sort(array $a): array +{ + usort($a, static fn(string $x, string $y) => strcmp($x, $y)); + return $a; +} From 22cf1927425c44820b6623f179e1866d2c87af5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ersin=20KO=C3=87?= Date: Fri, 11 Sep 2026 19:52:07 +0300 Subject: [PATCH 2/2] fix(assessments): review degerlendirmesi inherent skorunu residualin altina indirebiliyordu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kok neden: "residual skor inherent skoru asamaz" invarianti risk olusturma/duzenleme formlarinda (risks/_validate.php) ve 'residual' tipi degerlendirmede (assessments/_validate.php) dogrulaniyordu, ancak 'review' tipi icin koruma yoktu. Review, likelihood/impact (ve uretilen inherent_score) kolonlarini degistirdigi icin mevcut residual skorun ALTINA dusen bir inherent kabul ediliyordu: ornek 5x5=25 inherent, 4x4=16 residual olan riskte 2x3=6 review kabul edilir, tabloda residual_score(16) > inherent_score(6) kalirdi. Etki: tum "etkin skor" hesaplari (COALESCE(residual_score, inherent_score) - risks/index.php filtreleri, reports/_reports.php, api/dashboard_charts.php, reports/executive_summary.php) riskin kendisinden buyuk deger gosterir; register ve matris celisir. Cozum: assessment_collect_input() icinde review tarafi icin tek bir dogrulama dalı eklendi - riskte residual varsa yeni skor residual skordan kucuk olamaz; esit/uzerinde/residualsiz review'ler etkilenmez. Kanit: round-owned proof-script GERCEK dogrulayiciyi stub db() koza semaiyle calistirdi: oncesinde FAIL (hata donmuyordu), sonrasi PASS (8/8 kontrol). Dayanikli regresyon testi tools/assessment_validation_test.php eklendi (11 kontrol, DB gerektirmez, uretim dogrulayicisini gercekten calistirir). Not: bu dal main uzerinden acilmistir; PR #1/#2'ye bagimliligi yoktur. --- assessments/_validate.php | 15 +++ tools/assessment_validation_test.php | 190 +++++++++++++++++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 tools/assessment_validation_test.php diff --git a/assessments/_validate.php b/assessments/_validate.php index 32cb36f..6bfa585 100644 --- a/assessments/_validate.php +++ b/assessments/_validate.php @@ -89,6 +89,21 @@ function assessment_collect_input(): array } } + /* Bir 'review', inherent skoru mevcut residual skorun ALTINA + indiremez: kalıcı tabloda residual > inherent kalır ve tüm etkin + skor hesapları (COALESCE(residual, inherent)) riskin kendisinden + büyük çıkar. Önce residual güncellenmelidir. */ + if ($type === 'review' && $risk !== null && $likelihood !== null && $impact !== null + && $risk['residual_likelihood'] !== null && $risk['residual_impact'] !== null + && ($likelihood * $impact) < ((int)$risk['residual_likelihood'] * (int)$risk['residual_impact'])) { + $errors['impact'] = sprintf( + 'Inherent skor (%d) mevcut residual skordan (%d) küçük olamaz: kontroller riski artırmaz. ' + . 'Residual skor da düşmeli; önce residual değerlendirmesini güncelleyin.', + $likelihood * $impact, + (int)$risk['residual_likelihood'] * (int)$risk['residual_impact'] + ); + } + $notes = input('notes'); if ($notes !== null && mb_strlen($notes) > 2000) { $errors['notes'] = 'Not en fazla 2000 karakter olabilir.'; diff --git a/tools/assessment_validation_test.php b/tools/assessment_validation_test.php new file mode 100644 index 0000000..f783e05 --- /dev/null +++ b/tools/assessment_validation_test.php @@ -0,0 +1,190 @@ + assessment_collect_input) + * calistirilir; db() yalnizca gercek MySQL'in verecegi risk satirini + * saglayan minik bir koza (seam) ile ikame edilir. + * + * REGRESYON ARKAPLANI: 'review' turu degerlendirme bir zamanlar inherent + * skoru mevcut residual skorun ALTINA indirebiliyordu. Kalici tabloda + * residual > inherent kaliyor ve tum etkin skor hesaplari + * (COALESCE(residual, inherent)) riskin kendisinden buyuk cikiyordu. + * Bu invariant risks/_validate.php ve residual tarafiyla korunur; + * review tarafi da ayni sekilde korunmalidir. + */ + +if (PHP_SAPI !== 'cli') { + http_response_code(403); + exit('CLI only.'); +} + +define('RISKOPS_BOOTSTRAPPED', true); + +require_once __DIR__ . '/../includes/functions.php'; +require_once __DIR__ . '/../assessments/_validate.php'; + +/* ---- Minimal db() koza: gercek MySQL'in verecegi risk satiri --------- */ + +final class AssessmentTestStmt +{ + public function __construct(private array|false $row) {} + public function execute(?array $params = null): bool { return true; } + public function fetch(): array|false { return $this->row; } +} + +final class AssessmentTestDb +{ + public function __construct(private array|false $row) {} + public function prepare(string $sql): AssessmentTestStmt + { + return new AssessmentTestStmt($this->row); + } +} + +/** Risk fiksturu: inherent 5x5=25, residual 4x4=16 (gecerli durum). */ +function assessment_test_risk(): array +{ + return [ + 'id' => 7, 'risk_code' => 'RISK-2026-0001', 'title' => 'Test riski', + 'status' => 'Open', + 'likelihood' => 5, 'impact' => 5, 'inherent_score' => 25, + 'residual_likelihood' => 4, 'residual_impact' => 4, + ]; +} + +/** GERCEK uretim dogrulayicisini verilen POST ile calistirir. */ +function assessment_test_collect(array $post, array|false|null $riskRow = null): array +{ + $_POST = $post; + $_GET = []; + global $assessmentTestRiskRow; + $assessmentTestRiskRow = $riskRow ?? assessment_test_risk(); + return assessment_collect_input(); +} + +function db(): AssessmentTestDb +{ + global $assessmentTestRiskRow; + return new AssessmentTestDb($assessmentTestRiskRow ?? assessment_test_risk()); +} + +$PASS = 0; +$FAIL = 0; + +function check(string $label, bool $ok, string $detail = ''): void +{ + global $PASS, $FAIL; + if ($ok) { + $PASS++; + printf(" [ OK ] %-56s %s\n", $label, $detail); + } else { + $FAIL++; + printf(" [FAIL] %-56s %s\n", $label, $detail); + } +} + +function section(string $title): void +{ + echo "\n" . str_repeat('-', 72) . "\n " . $title . "\n" . str_repeat('-', 72) . "\n"; +} + +echo "\n============ Assessment Validation Regression Test ============="; + +/* ------------------------------------------------------------------ */ +section('1) Review inherent skorunu residualin altina indiremez'); +/* ------------------------------------------------------------------ */ + +[, $e] = assessment_test_collect([ + 'risk_id' => '7', 'assessment_type' => 'review', + 'likelihood' => '2', 'impact' => '3', +]); +check('review 2x3=6 vs residual 16 reddedilir', $e !== [], + $e === [] ? 'hata donmedi; residual > inherent kalici olurdu' : 'reddedildi'); + +[, $e] = assessment_test_collect([ + 'risk_id' => '7', 'assessment_type' => 'review', + 'likelihood' => '4', 'impact' => '4', +]); +check('review residuala esit (16 == 16) kabul edilir', $e === []); + +[, $e] = assessment_test_collect([ + 'risk_id' => '7', 'assessment_type' => 'review', + 'likelihood' => '5', 'impact' => '5', +]); +check('review residualin uzerinde (25) kabul edilir', $e === []); + +[, $e] = assessment_test_collect([ + 'risk_id' => '7', 'assessment_type' => 'review', + 'likelihood' => '1', 'impact' => '1', +], array_merge(assessment_test_risk(), [ + 'residual_likelihood' => null, 'residual_impact' => null, +])); +check('residuali olmayan riskte 1x1 review kabul edilir', $e === []); + +/* ------------------------------------------------------------------ */ +section('2) Mevcut korumalar yerinde kalmali'); +/* ------------------------------------------------------------------ */ + +$smallRisk = array_merge(assessment_test_risk(), [ + 'likelihood' => 3, 'impact' => 3, 'inherent_score' => 9, + 'residual_likelihood' => 2, 'residual_impact' => 2, +]); + +[, $e] = assessment_test_collect([ + 'risk_id' => '7', 'assessment_type' => 'residual', + 'likelihood' => '4', 'impact' => '4', +], $smallRisk); +check('residual 16 > inherent 9 reddedilir', $e !== []); + +[, $e] = assessment_test_collect([ + 'risk_id' => '7', 'assessment_type' => 'residual', + 'likelihood' => '3', 'impact' => '3', +], $smallRisk); +check('residual == inherent kabul edilir (sinir)', $e === []); + +[, $e] = assessment_test_collect([ + 'risk_id' => '7', 'assessment_type' => 'initial', + 'likelihood' => '1', 'impact' => '1', +]); +check("tip 'initial' elle girilemez", $e !== []); + +[, $e] = assessment_test_collect([ + 'risk_id' => '7', 'assessment_type' => 'hacked', + 'likelihood' => '1', 'impact' => '1', +]); +check('gecersiz tip reddedilir', $e !== []); + +[, $e] = assessment_test_collect([ + 'risk_id' => '7', 'assessment_type' => 'review', + 'likelihood' => '7', 'impact' => '1', +]); +check('olasilik 1-5 disi reddedilir', $e !== []); + +[, $e] = assessment_test_collect([ + 'risk_id' => '7', 'assessment_type' => 'review', + 'likelihood' => '2', 'impact' => '3', + 'assessed_at' => date('Y-m-d', strtotime('+1 day')), +]); +check('gelecek tarihli assessed_at reddedilir', isset($e['assessed_at'])); + +[, $e] = assessment_test_collect([ + 'risk_id' => '999', 'assessment_type' => 'review', + 'likelihood' => '2', 'impact' => '3', +], false); +check('bulunmayan risk reddedilir', $e !== []); + +/* ------------------------------------------------------------------ */ + +echo "\n" . str_repeat('-', 72) . "\n"; +printf("Sonuc: %d OK, %d FAIL\n", $PASS, $FAIL); +if ($FAIL > 0) { + echo "ASSESSMENT VALIDATION TEST: FAIL\n"; + exit(1); +} +echo "ASSESSMENT VALIDATION TEST: PASS\n"; +exit(0);