create-devoplus-one-app 0.0.0-stage → 0.1.0-next.158
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -2
- package/dist/cli.mjs +17 -0
- package/package.json +35 -3
- package/templates/0001_init.sql.tpl +10 -0
- package/templates/AGENTS.md.tpl +286 -0
- package/templates/AGENT_GUIDE.md.tpl +36 -0
- package/templates/README.md.tpl +58 -0
- package/templates/_shared/AGENTS.writes.md.tpl +38 -0
- package/templates/_shared/README.tail.md.tpl +20 -0
- package/templates/_shared/files/src/app.test.ts.tpl +36 -0
- package/templates/_shared/files/src/index.ts.tpl +8 -0
- package/templates/_shared/files/src/lib/client.ts.tpl +213 -0
- package/templates/_shared/files/src/lib/files.ts.tpl +37 -0
- package/templates/_shared/files/src/lib/http.ts.tpl +101 -0
- package/templates/_shared/files/src/lib/mail.ts.tpl +26 -0
- package/templates/_shared/files/src/lib/model.ts.tpl +31 -0
- package/templates/_shared/files/src/lib/page.ts.tpl +71 -0
- package/templates/_shared/files/src/lib/record.ts.tpl +246 -0
- package/templates/_shared/files/src/lib/server.ts.tpl +132 -0
- package/templates/_shared/files/src/shell.test.ts.tpl +56 -0
- package/templates/agent.ts.tpl +23 -0
- package/templates/bakim-takvimi/AGENTS.template.md.tpl +40 -0
- package/templates/bakim-takvimi/README.md.tpl +45 -0
- package/templates/bakim-takvimi/files/AGENT_GUIDE.md.tpl +52 -0
- package/templates/bakim-takvimi/files/one.data.json +45 -0
- package/templates/bakim-takvimi/files/src/agent.ts.tpl +22 -0
- package/templates/bakim-takvimi/files/src/app.ts.tpl +259 -0
- package/templates/bakim-takvimi/files/src/jobs.ts.tpl +170 -0
- package/templates/bakim-takvimi/files/src/names.ts.tpl +5 -0
- package/templates/bakim-takvimi/files/src/queries.ts.tpl +71 -0
- package/templates/bakim-takvimi/files/src/schedule.test.ts.tpl +58 -0
- package/templates/bakim-takvimi/files/src/schedule.ts.tpl +106 -0
- package/templates/bakim-takvimi/files/src/shell.ts.tpl +299 -0
- package/templates/bakim-takvimi/manifest.json.tpl +64 -0
- package/templates/egitim-sertifika/AGENTS.template.md.tpl +41 -0
- package/templates/egitim-sertifika/README.md.tpl +46 -0
- package/templates/egitim-sertifika/files/AGENT_GUIDE.md.tpl +53 -0
- package/templates/egitim-sertifika/files/one.data.json +43 -0
- package/templates/egitim-sertifika/files/src/agent.ts.tpl +22 -0
- package/templates/egitim-sertifika/files/src/app.ts.tpl +312 -0
- package/templates/egitim-sertifika/files/src/certificate.test.ts.tpl +83 -0
- package/templates/egitim-sertifika/files/src/certificate.ts.tpl +163 -0
- package/templates/egitim-sertifika/files/src/jobs.ts.tpl +219 -0
- package/templates/egitim-sertifika/files/src/names.ts.tpl +5 -0
- package/templates/egitim-sertifika/files/src/queries.ts.tpl +115 -0
- package/templates/egitim-sertifika/files/src/shell.ts.tpl +345 -0
- package/templates/egitim-sertifika/manifest.json.tpl +57 -0
- package/templates/envanter/AGENTS.template.md.tpl +28 -0
- package/templates/envanter/README.md.tpl +40 -0
- package/templates/envanter/files/AGENT_GUIDE.md.tpl +47 -0
- package/templates/envanter/files/one.data.json +62 -0
- package/templates/envanter/files/src/agent.ts.tpl +20 -0
- package/templates/envanter/files/src/app.ts.tpl +204 -0
- package/templates/envanter/files/src/jobs.ts.tpl +30 -0
- package/templates/envanter/files/src/queries.ts.tpl +53 -0
- package/templates/envanter/files/src/shell.ts.tpl +265 -0
- package/templates/envanter/files/src/stock.test.ts.tpl +31 -0
- package/templates/envanter/files/src/stock.ts.tpl +33 -0
- package/templates/envanter/manifest.json.tpl +56 -0
- package/templates/hedef-takibi/AGENTS.template.md.tpl +37 -0
- package/templates/hedef-takibi/README.md.tpl +52 -0
- package/templates/hedef-takibi/files/AGENT_GUIDE.md.tpl +48 -0
- package/templates/hedef-takibi/files/one.data.json +76 -0
- package/templates/hedef-takibi/files/src/agent.ts.tpl +26 -0
- package/templates/hedef-takibi/files/src/app.ts.tpl +740 -0
- package/templates/hedef-takibi/files/src/progress.test.ts.tpl +53 -0
- package/templates/hedef-takibi/files/src/progress.ts.tpl +81 -0
- package/templates/hedef-takibi/files/src/queries.ts.tpl +83 -0
- package/templates/hedef-takibi/files/src/shell.ts.tpl +455 -0
- package/templates/hedef-takibi/manifest.json.tpl +38 -0
- package/templates/index.ts.tpl +262 -0
- package/templates/markdown.d.ts.tpl +6 -0
- package/templates/musteri-ziyaret/AGENTS.template.md.tpl +47 -0
- package/templates/musteri-ziyaret/README.md.tpl +48 -0
- package/templates/musteri-ziyaret/files/AGENT_GUIDE.md.tpl +44 -0
- package/templates/musteri-ziyaret/files/one.data.json +53 -0
- package/templates/musteri-ziyaret/files/src/agent.ts.tpl +21 -0
- package/templates/musteri-ziyaret/files/src/app.ts.tpl +340 -0
- package/templates/musteri-ziyaret/files/src/customers.ts.tpl +85 -0
- package/templates/musteri-ziyaret/files/src/queries.ts.tpl +67 -0
- package/templates/musteri-ziyaret/files/src/shell.ts.tpl +272 -0
- package/templates/musteri-ziyaret/files/src/visit.test.ts.tpl +65 -0
- package/templates/musteri-ziyaret/files/src/visit.ts.tpl +98 -0
- package/templates/musteri-ziyaret/manifest.json.tpl +37 -0
- package/templates/saha-denetimi/AGENTS.template.md.tpl +32 -0
- package/templates/saha-denetimi/README.md.tpl +44 -0
- package/templates/saha-denetimi/files/AGENT_GUIDE.md.tpl +55 -0
- package/templates/saha-denetimi/files/one.data.json +87 -0
- package/templates/saha-denetimi/files/src/agent.ts.tpl +41 -0
- package/templates/saha-denetimi/files/src/app.ts.tpl +444 -0
- package/templates/saha-denetimi/files/src/queries.ts.tpl +143 -0
- package/templates/saha-denetimi/files/src/score.test.ts.tpl +48 -0
- package/templates/saha-denetimi/files/src/score.ts.tpl +102 -0
- package/templates/saha-denetimi/files/src/shell.ts.tpl +585 -0
- package/templates/saha-denetimi/manifest.json.tpl +49 -0
- package/templates/shell.test.ts.tpl +30 -0
- package/templates/shell.ts.tpl +201 -0
- package/templates/uygunsuzluk-dof/AGENTS.template.md.tpl +40 -0
- package/templates/uygunsuzluk-dof/README.md.tpl +46 -0
- package/templates/uygunsuzluk-dof/files/AGENT_GUIDE.md.tpl +56 -0
- package/templates/uygunsuzluk-dof/files/one.data.json +99 -0
- package/templates/uygunsuzluk-dof/files/src/agent.ts.tpl +45 -0
- package/templates/uygunsuzluk-dof/files/src/app.ts.tpl +431 -0
- package/templates/uygunsuzluk-dof/files/src/capa.test.ts.tpl +53 -0
- package/templates/uygunsuzluk-dof/files/src/capa.ts.tpl +114 -0
- package/templates/uygunsuzluk-dof/files/src/jobs.ts.tpl +127 -0
- package/templates/uygunsuzluk-dof/files/src/queries.ts.tpl +102 -0
- package/templates/uygunsuzluk-dof/files/src/shell.ts.tpl +441 -0
- package/templates/uygunsuzluk-dof/manifest.json.tpl +56 -0
- package/templates/ziyaretci-kaydi/AGENTS.template.md.tpl +35 -0
- package/templates/ziyaretci-kaydi/README.md.tpl +46 -0
- package/templates/ziyaretci-kaydi/files/AGENT_GUIDE.md.tpl +39 -0
- package/templates/ziyaretci-kaydi/files/one.data.json +42 -0
- package/templates/ziyaretci-kaydi/files/src/agent.ts.tpl +13 -0
- package/templates/ziyaretci-kaydi/files/src/app.ts.tpl +293 -0
- package/templates/ziyaretci-kaydi/files/src/jobs.ts.tpl +49 -0
- package/templates/ziyaretci-kaydi/files/src/notice.ts.tpl +48 -0
- package/templates/ziyaretci-kaydi/files/src/queries.ts.tpl +45 -0
- package/templates/ziyaretci-kaydi/files/src/shell.ts.tpl +252 -0
- package/templates/ziyaretci-kaydi/files/src/visitor.test.ts.tpl +34 -0
- package/templates/ziyaretci-kaydi/files/src/visitor.ts.tpl +57 -0
- package/templates/ziyaretci-kaydi/manifest.json.tpl +38 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"entities": {
|
|
4
|
+
"checklists": {
|
|
5
|
+
"title": { "tr": "Kontrol listeleri", "en": "Checklists" },
|
|
6
|
+
"fields": {
|
|
7
|
+
"name": { "type": "text", "title": { "tr": "Ad", "en": "Name" }, "required": true, "unique": true, "maxLength": 120 },
|
|
8
|
+
"description": { "type": "longText", "title": { "tr": "Açıklama", "en": "Description" }, "maxLength": 2000 },
|
|
9
|
+
"active": { "type": "boolean", "title": { "tr": "Kullanımda", "en": "In use" }, "required": true, "default": true }
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"checklist_items": {
|
|
13
|
+
"title": { "tr": "Kontrol maddeleri", "en": "Checklist items" },
|
|
14
|
+
"fields": {
|
|
15
|
+
"checklist_id": { "type": "text", "title": { "tr": "Kontrol listesi", "en": "Checklist" }, "required": true, "maxLength": 36, "index": true },
|
|
16
|
+
"position": { "type": "number", "title": { "tr": "Sıra", "en": "Order" }, "required": true, "default": 1, "min": 1, "max": 999 },
|
|
17
|
+
"section": { "type": "text", "title": { "tr": "Bölüm", "en": "Section" }, "maxLength": 80 },
|
|
18
|
+
"title": { "type": "text", "title": { "tr": "Madde", "en": "Item" }, "required": true, "maxLength": 200 },
|
|
19
|
+
"weight": { "type": "number", "title": { "tr": "Ağırlık", "en": "Weight" }, "required": true, "default": 1, "min": 1, "max": 10 },
|
|
20
|
+
"critical": { "type": "boolean", "title": { "tr": "Kritik", "en": "Critical" }, "required": true, "default": false }
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"audits": {
|
|
24
|
+
"title": { "tr": "Denetimler", "en": "Audits" },
|
|
25
|
+
"fields": {
|
|
26
|
+
"checklist_id": { "type": "text", "title": { "tr": "Kontrol listesi (kimlik)", "en": "Checklist (id)" }, "required": true, "maxLength": 36, "index": true },
|
|
27
|
+
"checklist_name": { "type": "text", "title": { "tr": "Kontrol listesi", "en": "Checklist" }, "maxLength": 120 },
|
|
28
|
+
"location_id": { "type": "text", "title": { "tr": "İş yeri (kimlik)", "en": "Workplace (id)" }, "maxLength": 24, "index": true },
|
|
29
|
+
"location_code": { "type": "text", "title": { "tr": "Mağaza kodu", "en": "Store code" }, "maxLength": 40 },
|
|
30
|
+
"location_name": { "type": "text", "title": { "tr": "Denetlenen yer", "en": "Audited location" }, "required": true, "maxLength": 120, "index": true },
|
|
31
|
+
"auditor_name": { "type": "text", "title": { "tr": "Denetçi", "en": "Auditor" }, "maxLength": 120 },
|
|
32
|
+
"status": {
|
|
33
|
+
"type": "choice",
|
|
34
|
+
"title": { "tr": "Durum", "en": "Status" },
|
|
35
|
+
"required": true,
|
|
36
|
+
"default": "draft",
|
|
37
|
+
"index": true,
|
|
38
|
+
"choices": [
|
|
39
|
+
{ "value": "draft", "title": { "tr": "Taslak", "en": "Draft" } },
|
|
40
|
+
{ "value": "completed", "title": { "tr": "Tamamlandı", "en": "Completed" } }
|
|
41
|
+
]
|
|
42
|
+
},
|
|
43
|
+
"submitted_at": { "type": "datetime", "title": { "tr": "Tamamlanma", "en": "Completed at" }, "index": true },
|
|
44
|
+
"score": { "type": "number", "title": { "tr": "Puan", "en": "Score" }, "min": 0, "max": 100 },
|
|
45
|
+
"result": {
|
|
46
|
+
"type": "choice",
|
|
47
|
+
"title": { "tr": "Sonuç", "en": "Result" },
|
|
48
|
+
"index": true,
|
|
49
|
+
"choices": [
|
|
50
|
+
{ "value": "passed", "title": { "tr": "Başarılı", "en": "Passed" } },
|
|
51
|
+
{ "value": "needs_improvement", "title": { "tr": "Geliştirilmeli", "en": "Needs improvement" } },
|
|
52
|
+
{ "value": "failed", "title": { "tr": "Başarısız", "en": "Failed" } }
|
|
53
|
+
]
|
|
54
|
+
},
|
|
55
|
+
"critical_failed": { "type": "boolean", "title": { "tr": "Kritik madde uygun değil", "en": "Critical item failed" }, "required": true, "default": false },
|
|
56
|
+
"compliant_count": { "type": "number", "title": { "tr": "Uygun", "en": "Compliant" }, "required": true, "default": 0, "min": 0 },
|
|
57
|
+
"noncompliant_count": { "type": "number", "title": { "tr": "Uygun değil", "en": "Non-compliant" }, "required": true, "default": 0, "min": 0 },
|
|
58
|
+
"not_applicable_count": { "type": "number", "title": { "tr": "Uygulanamaz", "en": "Not applicable" }, "required": true, "default": 0, "min": 0 },
|
|
59
|
+
"general_note": { "type": "longText", "title": { "tr": "Genel not", "en": "General note" }, "maxLength": 2000 }
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
"audit_answers": {
|
|
63
|
+
"title": { "tr": "Denetim cevapları", "en": "Audit answers" },
|
|
64
|
+
"fields": {
|
|
65
|
+
"audit_id": { "type": "text", "title": { "tr": "Denetim", "en": "Audit" }, "required": true, "maxLength": 36, "index": true },
|
|
66
|
+
"item_id": { "type": "text", "title": { "tr": "Madde (kimlik)", "en": "Item (id)" }, "required": true, "maxLength": 36 },
|
|
67
|
+
"position": { "type": "number", "title": { "tr": "Sıra", "en": "Order" }, "required": true, "default": 1, "min": 1 },
|
|
68
|
+
"section": { "type": "text", "title": { "tr": "Bölüm", "en": "Section" }, "maxLength": 80 },
|
|
69
|
+
"title": { "type": "text", "title": { "tr": "Madde", "en": "Item" }, "required": true, "maxLength": 200 },
|
|
70
|
+
"weight": { "type": "number", "title": { "tr": "Ağırlık", "en": "Weight" }, "required": true, "default": 1, "min": 1, "max": 10 },
|
|
71
|
+
"critical": { "type": "boolean", "title": { "tr": "Kritik", "en": "Critical" }, "required": true, "default": false },
|
|
72
|
+
"result": {
|
|
73
|
+
"type": "choice",
|
|
74
|
+
"title": { "tr": "Sonuç", "en": "Result" },
|
|
75
|
+
"choices": [
|
|
76
|
+
{ "value": "compliant", "title": { "tr": "Uygun", "en": "Compliant" } },
|
|
77
|
+
{ "value": "noncompliant", "title": { "tr": "Uygun değil", "en": "Non-compliant" } },
|
|
78
|
+
{ "value": "not_applicable", "title": { "tr": "Uygulanamaz", "en": "Not applicable" } }
|
|
79
|
+
]
|
|
80
|
+
},
|
|
81
|
+
"note": { "type": "text", "title": { "tr": "Not", "en": "Note" }, "maxLength": 500 },
|
|
82
|
+
"photo": { "type": "file", "title": { "tr": "Fotoğraf", "en": "Photo" } },
|
|
83
|
+
"photo_name": { "type": "text", "title": { "tr": "Fotoğraf adı", "en": "Photo name" }, "maxLength": 200 }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Devoplus yapay zekâsının bu uygulamada çağırabileceği araçların İŞLEYİCİLERİ. Adı, açıklaması, girdi şeması,
|
|
2
|
+
// salt-okunurluğu ve izni `one.manifest.json` → `agent.tools`'tadır; rehber `AGENT_GUIDE.md`'dir. Yeni araç = üçü birlikte.
|
|
3
|
+
// Araçlar yalnız TAMAMLANMIŞ denetimleri okur ve yalnız yöneticiye açıktır (manifest `permission`). Denetim açan, cevap
|
|
4
|
+
// yazan ya da tamamlayan araç bilinçli olarak yoktur.
|
|
5
|
+
import { defineAgentTools } from '@devoplus/one-sdk'
|
|
6
|
+
import type { Env } from './app.ts'
|
|
7
|
+
import { failedItems, locationScores, recentAudits } from './queries.ts'
|
|
8
|
+
|
|
9
|
+
export const agentTools = defineAgentTools<Env>({
|
|
10
|
+
'recent-audit-results': {
|
|
11
|
+
handler: async ({ input, env }) => {
|
|
12
|
+
const limit = typeof input.limit === 'number' ? input.limit : 10
|
|
13
|
+
const audits = await recentAudits(env, limit, typeof input.location === 'string' ? input.location : undefined)
|
|
14
|
+
const failed = await failedItems(env, audits.map((a) => a.id))
|
|
15
|
+
return {
|
|
16
|
+
audits: audits.map((a) => ({
|
|
17
|
+
location: a.location_name,
|
|
18
|
+
checklist: a.checklist_name,
|
|
19
|
+
auditor: a.auditor_name,
|
|
20
|
+
completedAt: a.submitted_at,
|
|
21
|
+
score: a.score,
|
|
22
|
+
result: a.result,
|
|
23
|
+
criticalFailed: a.critical_failed,
|
|
24
|
+
failedItems: failed.get(a.id) ?? [],
|
|
25
|
+
})),
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
'location-scores': {
|
|
30
|
+
handler: async ({ env }) => ({
|
|
31
|
+
locations: (await locationScores(env, 50)).map((l) => ({
|
|
32
|
+
location: l.location,
|
|
33
|
+
audits: l.audits,
|
|
34
|
+
lastScore: l.last_score,
|
|
35
|
+
averageScore: l.average,
|
|
36
|
+
lastAuditAt: l.last_at,
|
|
37
|
+
criticalFailures: l.critical_failures,
|
|
38
|
+
})),
|
|
39
|
+
}),
|
|
40
|
+
},
|
|
41
|
+
})
|
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
// {{nameTr}} — saha denetimi (mağaza ve şube denetimi). Uçlar (hepsi oturum ister; kimlik platformdan gelir):
|
|
2
|
+
// GET /api/checklists[?…] · POST · PATCH/DELETE /api/checklists/{id} kontrol listeleri (yazma: yönetici)
|
|
3
|
+
// GET /api/checklist_items[?…] · POST · PATCH/DELETE /api/checklist_items/{id} maddeler (yazma: yönetici)
|
|
4
|
+
// GET /api/workplaces iş yerleri (organizasyon şemasından; okunamazsa boş + available:false)
|
|
5
|
+
// GET /api/audits[?…] denetimlerim (yalnız benim yaptıklarım) — hazır liste + Excel
|
|
6
|
+
// POST /api/audits { checklist_id, location_id? | location_name } — taslak açar
|
|
7
|
+
// GET /api/audits/{id}/answers denetim + maddeler (denetçi ya da yönetici)
|
|
8
|
+
// PUT /api/audits/{id}/answers { answers: [{ id, result?, note?, photo? }], general_note? } — yalnız taslak, yalnız denetçi
|
|
9
|
+
// POST /api/audits/{id}/submit tamamla: puan SUNUCUDA hesaplanır; sonra denetim değişmez
|
|
10
|
+
// DELETE /api/audits/{id} taslağı sil (tamamlanmış denetim silinmez)
|
|
11
|
+
// GET /api/audits/{id}/report.html yazdırılabilir denetim raporu
|
|
12
|
+
// GET /api/audits/{id}/answers/{answerId}/photo madde fotoğrafı
|
|
13
|
+
// GET /api/all/audits[?…] bütün denetimler (yönetici) — hazır liste + Excel + yazdırma
|
|
14
|
+
// GET /api/dashboard pano: son 30 gün özeti, yerlere göre puan, son denetimler (yönetici)
|
|
15
|
+
// POST /api/files fotoğraf yükleme (yalnız kendi ön ekinize)
|
|
16
|
+
// Puan, sonuç, sayılar ve durum istemciden ALINMAZ. Denetim başlarken kontrol listesinin maddeleri denetime KOPYALANIR:
|
|
17
|
+
// liste sonradan değişse de geçmiş denetimler ve puanları değişmez.
|
|
18
|
+
import {
|
|
19
|
+
DataValidationError,
|
|
20
|
+
OneError,
|
|
21
|
+
createCrudHandler,
|
|
22
|
+
foldSearchText,
|
|
23
|
+
getMyOrgContext,
|
|
24
|
+
getOrgSchema,
|
|
25
|
+
hasPermission,
|
|
26
|
+
localizedText,
|
|
27
|
+
logAudit,
|
|
28
|
+
logError,
|
|
29
|
+
renderPrintableReport,
|
|
30
|
+
reportResponse,
|
|
31
|
+
requirePermission,
|
|
32
|
+
requireUser,
|
|
33
|
+
type ExportColumn,
|
|
34
|
+
type OneContext,
|
|
35
|
+
type OneFilesBinding,
|
|
36
|
+
} from '@devoplus/one-sdk'
|
|
37
|
+
import manifest from '../one.manifest.json' with { type: 'json' }
|
|
38
|
+
import { agentTools } from './agent.ts'
|
|
39
|
+
import { dataModel } from './data/types.ts'
|
|
40
|
+
import { fileResponse, uploadFile } from './lib/files.ts'
|
|
41
|
+
import { fieldError, forward, matchId, nowIso, onlyFields, ownFileKey, readJsonObject } from './lib/http.ts'
|
|
42
|
+
import { choices } from './lib/model.ts'
|
|
43
|
+
import { applied, insertWhen, recordWrite, refuseActingSession, sql } from './lib/record.ts'
|
|
44
|
+
import { createApp, json, type AppDefinition, type BaseEnv, type RouteInput } from './lib/server.ts'
|
|
45
|
+
import { answersOf, findAudit, locationScores, recentAudits, summary, type AnswerRow, type AuditRow } from './queries.ts'
|
|
46
|
+
import { CHECKLIST_ITEMS_MAX, NOTE_MAX, WEIGHT_MAX, WEIGHT_MIN, computeScore, isAnswerResult, isValidWeight, submitProblems } from './score.ts'
|
|
47
|
+
import { renderShell, renderShellScript } from './shell.ts'
|
|
48
|
+
|
|
49
|
+
export interface Env extends BaseEnv {
|
|
50
|
+
/** Bu kurulumun dosya deposu (manifest services.files) — madde fotoğrafları. */
|
|
51
|
+
FILES: OneFilesBinding
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const APP_NAME = { tr: {{nameTrJs}}, en: {{nameEnJs}} }
|
|
55
|
+
/** Yönetici izni (manifest `permissions`): kontrol listelerini yönetir, bütün denetimleri ve panoyu görür. */
|
|
56
|
+
export const MANAGE_PERMISSION = '{{permissionPrefix}}Manage'
|
|
57
|
+
|
|
58
|
+
export const checklists = createCrudHandler({ model: dataModel, entity: 'checklists', records: 'shared', canWrite: MANAGE_PERMISSION })
|
|
59
|
+
export const checklistItems = createCrudHandler({ model: dataModel, entity: 'checklist_items', records: 'shared', canWrite: MANAGE_PERMISSION })
|
|
60
|
+
/** Denetimlerim: herkes yalnız KENDİ yaptığı denetimleri görür. */
|
|
61
|
+
export const myAudits = createCrudHandler({ model: dataModel, entity: 'audits', records: 'own' })
|
|
62
|
+
/** Bütün denetimler: yalnız yönetici; salt okunur. */
|
|
63
|
+
export const allAudits = createCrudHandler({ model: dataModel, entity: 'audits', records: 'shared', basePath: '/api/all', canRead: MANAGE_PERMISSION })
|
|
64
|
+
|
|
65
|
+
const ITEM_FIELDS = ['position', 'section', 'title', 'weight', 'critical'] as const
|
|
66
|
+
const GENERAL_NOTE_MAX = 2000
|
|
67
|
+
const LOCATION_MAX = 120
|
|
68
|
+
const ORG_ID_RE = /^[0-9a-f]{24}$/i
|
|
69
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
|
70
|
+
const PHOTO_RE = /^\/api\/audits\/([0-9a-f-]{36})\/answers\/([0-9a-f-]{36})\/photo$/
|
|
71
|
+
/** Satır sonu ve sekme dışındaki denetim karakterleri. */
|
|
72
|
+
const CONTROL_RE = /[\u0000-\u0008\u000b-\u001f\u007f]/
|
|
73
|
+
const RESULT_LABELS = Object.fromEntries(choices(dataModel, 'audit_answers', 'result').map((c) => [c.value, c.label]))
|
|
74
|
+
const AUDIT_RESULT_LABELS = Object.fromEntries(choices(dataModel, 'audits', 'result').map((c) => [c.value, c.label]))
|
|
75
|
+
|
|
76
|
+
async function myName(input: RouteInput<Env>): Promise<string | null> {
|
|
77
|
+
try {
|
|
78
|
+
return (await getMyOrgContext(input.api()))?.fullName ?? null
|
|
79
|
+
} catch (error) {
|
|
80
|
+
// Ad bir kolaylıktır: okunamazsa denetim yine açılır (denetçi adı boş kalır).
|
|
81
|
+
logError(input.ctx, 'kişi adı okunamadı', error)
|
|
82
|
+
return null
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Denetimi görebilen: denetçinin kendisi ya da yönetici. Göremeyen için "yok" — kimlik yoklanamaz. */
|
|
87
|
+
async function visibleAudit(env: Env, ctx: OneContext, id: string): Promise<{ audit: AuditRow; own: boolean }> {
|
|
88
|
+
const user = requireUser(ctx)
|
|
89
|
+
const audit = await findAudit(env, id)
|
|
90
|
+
const own = audit !== null && audit.created_by === user.sub
|
|
91
|
+
if (!audit || (!own && !hasPermission(ctx, MANAGE_PERMISSION))) throw new OneError(404, 'APP-E-404', 'Denetim bulunamadı.')
|
|
92
|
+
return { audit, own }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Değiştirme: yalnız denetçi ve yalnız taslak. */
|
|
96
|
+
async function editableAudit(env: Env, ctx: OneContext, id: string): Promise<AuditRow> {
|
|
97
|
+
const { audit, own } = await visibleAudit(env, ctx, id)
|
|
98
|
+
if (!own) throw new OneError(403, 'APP-E-403', 'Denetimi yalnız denetimi yapan kişi doldurabilir.')
|
|
99
|
+
if (audit.status !== 'draft') throw new OneError(409, 'APP-E-020', 'Tamamlanmış denetim değiştirilemez.')
|
|
100
|
+
return audit
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function text(raw: unknown, field: string, max: number, required: boolean, multiline = false): string | null {
|
|
104
|
+
if (raw === null || raw === undefined || raw === '') {
|
|
105
|
+
if (required) throw fieldError(field, 'Bu alan zorunlu.')
|
|
106
|
+
return null
|
|
107
|
+
}
|
|
108
|
+
if (typeof raw !== 'string') throw fieldError(field, 'Metin olmalı.')
|
|
109
|
+
const value = raw.trim()
|
|
110
|
+
if (value === '' && required) throw fieldError(field, 'Bu alan zorunlu.')
|
|
111
|
+
if ([...value].length > max) throw fieldError(field, `En çok ${max} karakter olabilir.`)
|
|
112
|
+
if (CONTROL_RE.test(value) || (!multiline && /[\r\n\t]/.test(value))) throw fieldError(field, 'Geçersiz karakter var.')
|
|
113
|
+
return value || null
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Denetlenen yer: organizasyon şemasındaki iş yeri (sunucuda doğrulanır) ya da elle yazılmış ad. */
|
|
117
|
+
async function resolveLocation(input: RouteInput<Env>, body: Record<string, unknown>): Promise<{ id: string | null; code: string | null; name: string }> {
|
|
118
|
+
const rawId = body.location_id
|
|
119
|
+
if (rawId !== undefined && rawId !== null && rawId !== '') {
|
|
120
|
+
if (typeof rawId !== 'string' || !ORG_ID_RE.test(rawId)) throw fieldError('location_id', 'Listeden bir iş yeri seçin.')
|
|
121
|
+
let workplaces
|
|
122
|
+
try {
|
|
123
|
+
workplaces = (await getOrgSchema(input.api())).workplaces
|
|
124
|
+
} catch (error) {
|
|
125
|
+
logError(input.ctx, 'organizasyon şeması okunamadı', error)
|
|
126
|
+
throw new OneError(503, 'APP-E-503', 'İş yeri listesi şu an okunamıyor; yerin adını elle yazın ya da biraz sonra yeniden deneyin.')
|
|
127
|
+
}
|
|
128
|
+
const wp = workplaces.find((w) => w.id.toLowerCase() === rawId.toLowerCase() && w.isActive !== false)
|
|
129
|
+
if (!wp) throw fieldError('location_id', 'İş yeri bulunamadı.')
|
|
130
|
+
const name = (localizedText(wp.workplaceName, 'tr') || wp.workplaceCode || '').slice(0, LOCATION_MAX)
|
|
131
|
+
return { id: wp.id.toLowerCase(), code: wp.storeCode ?? null, name: name || wp.id }
|
|
132
|
+
}
|
|
133
|
+
return { id: null, code: null, name: text(body.location_name, 'location_name', LOCATION_MAX, true)! }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function workplaces(input: RouteInput<Env>): Promise<Response> {
|
|
137
|
+
requireUser(input.ctx)
|
|
138
|
+
try {
|
|
139
|
+
const schema = await getOrgSchema(input.api())
|
|
140
|
+
const items = schema.workplaces
|
|
141
|
+
.filter((w) => w.isActive !== false)
|
|
142
|
+
.map((w) => ({ id: w.id.toLowerCase(), code: w.storeCode ?? null, label: { tr: localizedText(w.workplaceName, 'tr') || w.workplaceCode, en: localizedText(w.workplaceName, 'en') || w.workplaceCode } }))
|
|
143
|
+
.sort((a, b) => a.label.tr.localeCompare(b.label.tr, 'tr'))
|
|
144
|
+
return json({ available: true, items })
|
|
145
|
+
} catch (error) {
|
|
146
|
+
// Şema okunamazsa ekran serbest metne döner; denetim yine açılabilir.
|
|
147
|
+
logError(input.ctx, 'iş yerleri okunamadı', error)
|
|
148
|
+
return json({ available: false, items: [] })
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function itemWrite(input: RouteInput<Env>, id: string | null): Promise<Response> {
|
|
153
|
+
const { request, env, ctx, method } = input
|
|
154
|
+
requirePermission(ctx, MANAGE_PERMISSION)
|
|
155
|
+
if (id && method === 'DELETE') return (await checklistItems.handle(request, env.DB, ctx))!
|
|
156
|
+
const body = onlyFields(await readJsonObject(request), id ? ITEM_FIELDS : ['checklist_id', ...ITEM_FIELDS])
|
|
157
|
+
if ('weight' in body && !isValidWeight(body.weight)) throw fieldError('weight', `Ağırlık ${WEIGHT_MIN}-${WEIGHT_MAX} arası bir tam sayı olmalı.`)
|
|
158
|
+
if (id) return (await checklistItems.handle(forward(request, `${checklistItems.path}/${id}`, 'PATCH', body), env.DB, ctx))!
|
|
159
|
+
const user = requireUser(ctx)
|
|
160
|
+
refuseActingSession(ctx)
|
|
161
|
+
const listId = typeof body.checklist_id === 'string' ? body.checklist_id : ''
|
|
162
|
+
if (!UUID_RE.test(listId)) throw fieldError('checklist_id', 'Kontrol listesi bulunamadı.')
|
|
163
|
+
const write = recordWrite(dataModel, 'checklist_items', body, 'create')
|
|
164
|
+
const itemId = crypto.randomUUID()
|
|
165
|
+
// ⛔ "Liste var" ve "madde sayısı sınırın altında" koşulları eklemeyle AYNI ifadededir: önce sayıp sonra eklemek, aynı
|
|
166
|
+
// anda gelen iki ekleme isteğinin ikisinin de sınırın altında görüp sınırı aşmasına ya da silinmiş bir listeye madde
|
|
167
|
+
// eklenmesine izin verirdi.
|
|
168
|
+
const when = sql(
|
|
169
|
+
'EXISTS (SELECT 1 FROM "checklists" c WHERE c.id = ? AND c.deleted_at IS NULL) AND (SELECT COUNT(*) FROM "checklist_items" WHERE checklist_id = ? AND deleted_at IS NULL) < ?',
|
|
170
|
+
listId,
|
|
171
|
+
listId,
|
|
172
|
+
CHECKLIST_ITEMS_MAX,
|
|
173
|
+
)
|
|
174
|
+
if (!(await applied(write, insertWhen(env.DB, write, { id: itemId, actor: user.sub, now: nowIso(), when })))) {
|
|
175
|
+
const list = await env.DB.prepare('SELECT id FROM "checklists" WHERE id = ? AND deleted_at IS NULL').bind(listId).first<{ id: string }>()
|
|
176
|
+
if (!list) throw fieldError('checklist_id', 'Kontrol listesi bulunamadı.')
|
|
177
|
+
throw new OneError(409, 'APP-E-021', `Bir kontrol listesinde en çok ${CHECKLIST_ITEMS_MAX} madde olabilir.`)
|
|
178
|
+
}
|
|
179
|
+
const res = (await checklistItems.handle(forward(request, `${checklistItems.path}/${itemId}`, 'GET'), env.DB, ctx))!
|
|
180
|
+
if (!res.ok) return res
|
|
181
|
+
const out = json(await res.json(), 201)
|
|
182
|
+
out.headers.set('location', `${checklistItems.path}/${itemId}`)
|
|
183
|
+
return out
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function createAudit(input: RouteInput<Env>): Promise<Response> {
|
|
187
|
+
const { request, env, ctx } = input
|
|
188
|
+
const user = requireUser(ctx)
|
|
189
|
+
refuseActingSession(ctx)
|
|
190
|
+
// ⛔ Puan, durum ve sayılar burada GÖNDERİLEMEZ (alan listesi dışındaki her ad 400).
|
|
191
|
+
const body = onlyFields(await readJsonObject(request), ['checklist_id', 'location_id', 'location_name'])
|
|
192
|
+
const listId = typeof body.checklist_id === 'string' ? body.checklist_id : ''
|
|
193
|
+
const list = UUID_RE.test(listId)
|
|
194
|
+
? await env.DB.prepare('SELECT id, name, active FROM "checklists" WHERE id = ? AND deleted_at IS NULL').bind(listId).first<{ id: string; name: string; active: number }>()
|
|
195
|
+
: null
|
|
196
|
+
if (!list) throw fieldError('checklist_id', 'Kontrol listesi bulunamadı.')
|
|
197
|
+
if (list.active !== 1) throw new OneError(409, 'APP-E-021', 'Bu kontrol listesi kullanımda değil.')
|
|
198
|
+
const items = await env.DB.prepare(
|
|
199
|
+
'SELECT id, position, section, title, weight, critical FROM "checklist_items" WHERE checklist_id = ? AND deleted_at IS NULL ORDER BY position, created_at LIMIT ?',
|
|
200
|
+
)
|
|
201
|
+
.bind(listId, CHECKLIST_ITEMS_MAX + 1)
|
|
202
|
+
.all<{ id: string; position: number; section: string | null; title: string; weight: number; critical: number }>()
|
|
203
|
+
if (items.results.length === 0) throw new OneError(409, 'APP-E-022', 'Kontrol listesinde henüz madde yok.')
|
|
204
|
+
// ⛔ Maddeler KESİLMEZ: sınırı aşan liste (sınır sonradan düşürülmüş ya da veri elle eklenmiş) denetimi eksik maddeyle
|
|
205
|
+
// açıp puanı eksik kümeden hesaplatırdı. Bir fazla satır okunur; aşılmışsa denetim açılmaz ve nedeni söylenir.
|
|
206
|
+
if (items.results.length > CHECKLIST_ITEMS_MAX) {
|
|
207
|
+
const total = await env.DB.prepare('SELECT COUNT(*) AS n FROM "checklist_items" WHERE checklist_id = ? AND deleted_at IS NULL').bind(listId).first<{ n: number }>()
|
|
208
|
+
throw new OneError(409, 'APP-E-021', `Kontrol listesinde ${total?.n ?? items.results.length} madde var; bir denetim en çok ${CHECKLIST_ITEMS_MAX} madde taşır. Listeden madde çıkarın.`)
|
|
209
|
+
}
|
|
210
|
+
const location = await resolveLocation(input, body)
|
|
211
|
+
const auditor = await myName(input)
|
|
212
|
+
const now = nowIso()
|
|
213
|
+
const id = crypto.randomUUID()
|
|
214
|
+
const audit = recordWrite(
|
|
215
|
+
dataModel,
|
|
216
|
+
'audits',
|
|
217
|
+
{ checklist_id: listId, checklist_name: list.name, location_id: location.id, location_code: location.code, location_name: location.name, auditor_name: auditor ? [...auditor.trim()].slice(0, 120).join('') || null : null, status: 'draft' },
|
|
218
|
+
'create',
|
|
219
|
+
)
|
|
220
|
+
// Denetim ve maddelerinin kopyası TEK toplu yazımdır: ya hepsi yazılır ya hiçbiri. ⛔ Denetim yalnız liste HÂLÂ
|
|
221
|
+
// kullanımda, silinmemiş ve adı okuduğumuz adsa açılır (aynı ifadede); maddeler yalnız denetim açıldıysa eklenir.
|
|
222
|
+
// Madde kopyaları okunduğu andaki listedir: arada değişen bir madde, denetim o değişiklikten önce açılmış gibi kalır.
|
|
223
|
+
const statements = [
|
|
224
|
+
insertWhen(env.DB, audit, {
|
|
225
|
+
id,
|
|
226
|
+
actor: user.sub,
|
|
227
|
+
now,
|
|
228
|
+
when: sql('EXISTS (SELECT 1 FROM "checklists" c WHERE c.id = ? AND c.active = 1 AND c.deleted_at IS NULL AND c.name = ?)', listId, list.name),
|
|
229
|
+
}),
|
|
230
|
+
...items.results.map((item, index) => {
|
|
231
|
+
const answer = recordWrite(
|
|
232
|
+
dataModel,
|
|
233
|
+
'audit_answers',
|
|
234
|
+
{ audit_id: id, item_id: item.id, position: index + 1, section: item.section, title: item.title, weight: item.weight, critical: item.critical === 1 },
|
|
235
|
+
'create',
|
|
236
|
+
)
|
|
237
|
+
return insertWhen(env.DB, answer, { id: crypto.randomUUID(), actor: user.sub, now, when: sql('EXISTS (SELECT 1 FROM "audits" WHERE id = ?)', id) })
|
|
238
|
+
}),
|
|
239
|
+
]
|
|
240
|
+
const [opened] = await env.DB.batch(statements)
|
|
241
|
+
if (Number(opened?.meta.changes ?? 0) !== 1) throw new OneError(409, 'APP-E-012', 'Kontrol listesi bu arada değişti; sayfayı yenileyip yeniden deneyin.')
|
|
242
|
+
logAudit(ctx, 'audit.start', id, { items: items.results.length })
|
|
243
|
+
return json({ audit: await findAudit(env, id), answers: await answersOf(env, id) }, 201)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function answerView(a: AnswerRow) {
|
|
247
|
+
return { ...a, critical: a.critical === 1 }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function auditView(a: AuditRow) {
|
|
251
|
+
return { ...a, critical_failed: a.critical_failed === 1 }
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function readAnswers({ env, ctx }: RouteInput<Env>, id: string): Promise<Response> {
|
|
255
|
+
const { audit, own } = await visibleAudit(env, ctx, id)
|
|
256
|
+
return json({ audit: auditView(audit), answers: (await answersOf(env, id)).map(answerView), editable: own && audit.status === 'draft' })
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const fold = (value: string | null): string | null => (value === null ? null : foldSearchText(value))
|
|
260
|
+
|
|
261
|
+
async function saveAnswers(input: RouteInput<Env>, id: string): Promise<Response> {
|
|
262
|
+
const { request, env, ctx } = input
|
|
263
|
+
const user = requireUser(ctx)
|
|
264
|
+
refuseActingSession(ctx)
|
|
265
|
+
const body = onlyFields(await readJsonObject(request), ['answers', 'general_note'])
|
|
266
|
+
await editableAudit(env, ctx, id)
|
|
267
|
+
const existing = new Map((await answersOf(env, id)).map((a) => [a.id, a]))
|
|
268
|
+
const raw = body.answers ?? []
|
|
269
|
+
if (!Array.isArray(raw) || raw.length > CHECKLIST_ITEMS_MAX) throw fieldError('answers', 'Cevaplar bir liste olmalı.')
|
|
270
|
+
const now = nowIso()
|
|
271
|
+
const updates = raw.map((entry, index) => {
|
|
272
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) throw fieldError(`answers.${index}`, 'Cevap bir nesne olmalı.')
|
|
273
|
+
const answer = onlyFields(entry as Record<string, unknown>, ['id', 'result', 'note', 'photo'])
|
|
274
|
+
const target = typeof answer.id === 'string' ? existing.get(answer.id) : undefined
|
|
275
|
+
if (!target) throw fieldError(`answers.${index}.id`, 'Madde bu denetimde yok.')
|
|
276
|
+
const sets: string[] = []
|
|
277
|
+
const values: (string | number | null)[] = []
|
|
278
|
+
if ('result' in answer) {
|
|
279
|
+
const result = answer.result === '' || answer.result === null ? null : answer.result
|
|
280
|
+
if (result !== null && !isAnswerResult(result)) throw fieldError(`answers.${index}.result`, 'Uygun, uygun değil ya da uygulanamaz seçin.')
|
|
281
|
+
sets.push('result = ?')
|
|
282
|
+
values.push(result)
|
|
283
|
+
}
|
|
284
|
+
if ('note' in answer) {
|
|
285
|
+
const note = text(answer.note, `answers.${index}.note`, NOTE_MAX, false, true)
|
|
286
|
+
sets.push('note = ?', 'note_fold = ?')
|
|
287
|
+
values.push(note, fold(note))
|
|
288
|
+
}
|
|
289
|
+
if ('photo' in answer) {
|
|
290
|
+
const key = ownFileKey(answer.photo, user.sub, `answers.${index}.photo`)
|
|
291
|
+
const name = key && typeof answer.photo === 'object' && answer.photo !== null && typeof (answer.photo as { name?: unknown }).name === 'string' ? (answer.photo as { name: string }).name.slice(0, 200) : null
|
|
292
|
+
// Ad gönderilmediyse satırdaki ad KORUNUR (aynı ifadede; okunmuş bir değer geri yazılmaz). Fotoğraf silinince ad da silinir.
|
|
293
|
+
if (key) sets.push('photo = ?', 'photo_name = coalesce(?, photo_name)', 'photo_name_fold = coalesce(?, photo_name_fold)')
|
|
294
|
+
else sets.push('photo = ?', 'photo_name = ?', 'photo_name_fold = ?')
|
|
295
|
+
values.push(key, name, fold(name))
|
|
296
|
+
}
|
|
297
|
+
// ⛔ Madde yalnız denetim HÂLÂ bu kişinin taslağıysa güncellenir (aynı toplu yazımda tamamlanan denetim değişmez).
|
|
298
|
+
return env.DB.prepare(
|
|
299
|
+
`UPDATE "audit_answers" SET ${[...sets, 'updated_at = ?', 'updated_by = ?'].join(', ')} WHERE id = ? AND audit_id = ? AND deleted_at IS NULL ` +
|
|
300
|
+
`AND EXISTS (SELECT 1 FROM "audits" WHERE id = ? AND created_by = ? AND status = 'draft' AND deleted_at IS NULL)`,
|
|
301
|
+
).bind(...values, now, user.sub, target.id, id, id, user.sub)
|
|
302
|
+
})
|
|
303
|
+
// Genel not yalnız GÖNDERİLDİYSE yazılır: başka bir sekmede kaydedilmiş notu okunmuş eski değerle ezmez.
|
|
304
|
+
const noteSet = 'general_note' in body ? text(body.general_note, 'general_note', GENERAL_NOTE_MAX, false, true) : undefined
|
|
305
|
+
// Denetimin `updated_at`'i her kayıtta ilerler: tamamlama, okuduğu cevaplar bu arada değiştiyse durur (409).
|
|
306
|
+
const touch = env.DB.prepare(
|
|
307
|
+
`UPDATE "audits" SET ${noteSet === undefined ? '' : 'general_note = ?, general_note_fold = ?, '}updated_at = ?, updated_by = ? WHERE id = ? AND created_by = ? AND status = 'draft' AND deleted_at IS NULL`,
|
|
308
|
+
).bind(...(noteSet === undefined ? [] : [noteSet, fold(noteSet)]), now, user.sub, id, user.sub)
|
|
309
|
+
const [touched] = await env.DB.batch([touch, ...updates])
|
|
310
|
+
if (Number(touched?.meta.changes ?? 0) !== 1) throw new OneError(409, 'APP-E-020', 'Tamamlanmış denetim değiştirilemez.')
|
|
311
|
+
return readAnswers(input, id)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function submit(input: RouteInput<Env>, id: string): Promise<Response> {
|
|
315
|
+
const { request, env, ctx } = input
|
|
316
|
+
const user = requireUser(ctx)
|
|
317
|
+
refuseActingSession(ctx)
|
|
318
|
+
// Gövde boş olmalıdır: puan, sonuç ya da sayı GÖNDERİLEMEZ (400).
|
|
319
|
+
if (request.body !== null) onlyFields(await readJsonObject(request), [])
|
|
320
|
+
const audit = await editableAudit(env, ctx, id)
|
|
321
|
+
const answers = await answersOf(env, id)
|
|
322
|
+
const scored = answers.map((a) => ({ weight: a.weight, critical: a.critical === 1, result: a.result, note: a.note }))
|
|
323
|
+
const problems = submitProblems(scored)
|
|
324
|
+
if (problems.length > 0) throw new DataValidationError(400, 'APP-E-400', 'Denetim tamamlanamadı: eksik maddeler var.', problems.map((message) => ({ field: 'answers', message })))
|
|
325
|
+
const s = computeScore(scored)
|
|
326
|
+
const now = nowIso()
|
|
327
|
+
const res = await env.DB.prepare(
|
|
328
|
+
"UPDATE \"audits\" SET status = 'completed', submitted_at = ?, score = ?, result = ?, critical_failed = ?, compliant_count = ?, noncompliant_count = ?, not_applicable_count = ?, updated_at = ?, updated_by = ? " +
|
|
329
|
+
"WHERE id = ? AND created_by = ? AND status = 'draft' AND updated_at = ? AND deleted_at IS NULL",
|
|
330
|
+
)
|
|
331
|
+
.bind(now, s.score, s.result, s.criticalFailed ? 1 : 0, s.compliant, s.noncompliant, s.notApplicable, now, user.sub, id, user.sub, audit.updated_at)
|
|
332
|
+
.run()
|
|
333
|
+
if (Number(res.meta.changes ?? 0) !== 1) throw new OneError(409, 'APP-E-012', 'Denetim bu arada değişti; sayfayı yenileyip yeniden deneyin.')
|
|
334
|
+
logAudit(ctx, 'audit.submit', id, { score: s.score, result: s.result })
|
|
335
|
+
const after = await findAudit(env, id)
|
|
336
|
+
return json(after ? auditView(after) : { id })
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function deleteDraft({ env, ctx }: RouteInput<Env>, id: string): Promise<Response> {
|
|
340
|
+
const user = requireUser(ctx)
|
|
341
|
+
refuseActingSession(ctx)
|
|
342
|
+
await editableAudit(env, ctx, id)
|
|
343
|
+
const now = nowIso()
|
|
344
|
+
const [audit] = await env.DB.batch([
|
|
345
|
+
env.DB.prepare("UPDATE \"audits\" SET deleted_at = ?, updated_at = ?, updated_by = ? WHERE id = ? AND created_by = ? AND status = 'draft' AND deleted_at IS NULL").bind(now, now, user.sub, id, user.sub),
|
|
346
|
+
env.DB.prepare(
|
|
347
|
+
"UPDATE \"audit_answers\" SET deleted_at = ? WHERE audit_id = ? AND deleted_at IS NULL AND EXISTS (SELECT 1 FROM \"audits\" WHERE id = ? AND deleted_at = ?)",
|
|
348
|
+
).bind(now, id, id, now),
|
|
349
|
+
])
|
|
350
|
+
if (Number(audit?.meta.changes ?? 0) !== 1) throw new OneError(409, 'APP-E-020', 'Tamamlanmış denetim silinemez.')
|
|
351
|
+
logAudit(ctx, 'audit.delete', id)
|
|
352
|
+
return new Response(null, { status: 204 })
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const trDate = (iso: string): string => new Intl.DateTimeFormat('tr-TR', { timeZone: 'Europe/Istanbul', day: '2-digit', month: '2-digit', year: 'numeric' }).format(new Date(iso))
|
|
356
|
+
|
|
357
|
+
async function report({ env, ctx, url }: RouteInput<Env>, id: string): Promise<Response> {
|
|
358
|
+
const { audit } = await visibleAudit(env, ctx, id)
|
|
359
|
+
const lang = url.searchParams.get('_lang') === 'en' ? 'en' : 'tr'
|
|
360
|
+
const answers = await answersOf(env, id)
|
|
361
|
+
const en = lang === 'en'
|
|
362
|
+
const columns: ExportColumn[] = [
|
|
363
|
+
{ key: 'position', title: { tr: 'Sıra', en: 'No.' }, type: 'number', width: 6 },
|
|
364
|
+
{ key: 'section', title: { tr: 'Bölüm', en: 'Section' } },
|
|
365
|
+
{ key: 'title', title: { tr: 'Madde', en: 'Item' }, width: 40 },
|
|
366
|
+
{ key: 'weight', title: { tr: 'Ağırlık', en: 'Weight' }, type: 'number', width: 8 },
|
|
367
|
+
{ key: 'critical', title: { tr: 'Kritik', en: 'Critical' }, type: 'boolean', width: 8 },
|
|
368
|
+
{ key: 'result', title: { tr: 'Sonuç', en: 'Result' }, labels: RESULT_LABELS },
|
|
369
|
+
{ key: 'note', title: { tr: 'Not', en: 'Note' }, width: 40 },
|
|
370
|
+
]
|
|
371
|
+
const rows = answers.map((a) => ({ position: a.position, section: a.section ?? '', title: a.title, weight: a.weight, critical: a.critical === 1, result: a.result ?? '', note: a.note ?? '' }))
|
|
372
|
+
const resultLabel = audit.result ? (AUDIT_RESULT_LABELS[audit.result]?.[lang] ?? audit.result) : ''
|
|
373
|
+
const subtitle = [
|
|
374
|
+
audit.checklist_name ?? '',
|
|
375
|
+
`${en ? 'Auditor' : 'Denetçi'}: ${audit.auditor_name ?? '-'}`,
|
|
376
|
+
audit.submitted_at ? `${en ? 'Date' : 'Tarih'}: ${trDate(audit.submitted_at)}` : en ? 'Draft (not completed)' : 'Taslak (tamamlanmadı)',
|
|
377
|
+
audit.score !== null ? `${en ? 'Score' : 'Puan'}: ${audit.score} - ${resultLabel}${audit.critical_failed === 1 ? (en ? ' (critical item failed)' : ' (kritik madde uygun değil)') : ''}` : '',
|
|
378
|
+
]
|
|
379
|
+
.filter(Boolean)
|
|
380
|
+
.join(' - ')
|
|
381
|
+
const title = `${en ? 'Audit report' : 'Denetim raporu'}: ${audit.location_name}`
|
|
382
|
+
return reportResponse(renderPrintableReport({ title, subtitle, columns, rows, lang, orientation: 'landscape' }))
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async function photo({ env, ctx }: RouteInput<Env>, auditId: string, answerId: string): Promise<Response> {
|
|
386
|
+
await visibleAudit(env, ctx, auditId)
|
|
387
|
+
const row = await env.DB.prepare('SELECT photo FROM "audit_answers" WHERE id = ? AND audit_id = ? AND deleted_at IS NULL').bind(answerId, auditId).first<{ photo: string | null }>()
|
|
388
|
+
if (!row?.photo) throw new OneError(404, 'APP-E-404', 'Bu maddede fotoğraf yok.')
|
|
389
|
+
return fileResponse(env.FILES, row.photo)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async function dashboard({ env, ctx }: RouteInput<Env>): Promise<Response> {
|
|
393
|
+
requirePermission(ctx, MANAGE_PERMISSION)
|
|
394
|
+
const since = new Date(Date.now() - 30 * 86_400_000).toISOString()
|
|
395
|
+
const [totals, locations, recent] = await Promise.all([summary(env, since), locationScores(env, 12), recentAudits(env, 20)])
|
|
396
|
+
return json({ summary: totals, locations, recent })
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async function routes(input: RouteInput<Env>): Promise<Response | null> {
|
|
400
|
+
const { url, method, env, ctx } = input
|
|
401
|
+
const path = url.pathname
|
|
402
|
+
if (path === '/api/files' && method === 'POST') return uploadFile(input.request, env.FILES, ctx)
|
|
403
|
+
if (path === '/api/workplaces' && method === 'GET') return workplaces(input)
|
|
404
|
+
if (path === '/api/dashboard' && method === 'GET') return dashboard(input)
|
|
405
|
+
if (path === allAudits.path || path.startsWith(`${allAudits.path}/`)) {
|
|
406
|
+
if (method !== 'GET' && method !== 'HEAD') throw new OneError(405, 'APP-E-405', 'Bu liste yalnız okunur.')
|
|
407
|
+
return allAudits.handle(input.request, env.DB, ctx)
|
|
408
|
+
}
|
|
409
|
+
if (path === checklistItems.path && method === 'POST') return itemWrite(input, null)
|
|
410
|
+
const itemId = matchId(path, checklistItems.path)
|
|
411
|
+
if (itemId && (method === 'PATCH' || method === 'DELETE')) return itemWrite(input, itemId)
|
|
412
|
+
if (path === checklistItems.path || path.startsWith(`${checklistItems.path}/`)) return checklistItems.handle(input.request, env.DB, ctx)
|
|
413
|
+
if (path === checklists.path || path.startsWith(`${checklists.path}/`)) return checklists.handle(input.request, env.DB, ctx)
|
|
414
|
+
|
|
415
|
+
const photoMatch = PHOTO_RE.exec(path)
|
|
416
|
+
if (photoMatch && method === 'GET') return photo(input, photoMatch[1]!, photoMatch[2]!)
|
|
417
|
+
if (path === myAudits.path && method === 'POST') return createAudit(input)
|
|
418
|
+
let id = matchId(path, myAudits.path, 'answers')
|
|
419
|
+
if (id && method === 'GET') return readAnswers(input, id)
|
|
420
|
+
if (id && method === 'PUT') return saveAnswers(input, id)
|
|
421
|
+
id = matchId(path, myAudits.path, 'submit')
|
|
422
|
+
if (id && method === 'POST') return submit(input, id)
|
|
423
|
+
id = matchId(path, myAudits.path, 'report.html')
|
|
424
|
+
if (id && method === 'GET') return report(input, id)
|
|
425
|
+
id = matchId(path, myAudits.path)
|
|
426
|
+
if (id && method === 'DELETE') return deleteDraft(input, id)
|
|
427
|
+
if (path === myAudits.path || path.startsWith(`${myAudits.path}/`)) {
|
|
428
|
+
if (method !== 'GET' && method !== 'HEAD') throw new OneError(405, 'APP-E-405', 'Denetim bu adresten değiştirilemez; cevaplar için denetimin cevap ucunu kullanın.')
|
|
429
|
+
return myAudits.handle(input.request, env.DB, ctx)
|
|
430
|
+
}
|
|
431
|
+
return null
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export const definition: Omit<AppDefinition<Env>, 'guide'> = {
|
|
435
|
+
manifest,
|
|
436
|
+
tools: agentTools,
|
|
437
|
+
page: renderShell,
|
|
438
|
+
script: renderShellScript,
|
|
439
|
+
routes,
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export function buildApp(guide: string) {
|
|
443
|
+
return createApp<Env>({ ...definition, guide })
|
|
444
|
+
}
|