create-devoplus-one-app 0.0.0-stage → 0.1.0-next.163

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.
Files changed (122) hide show
  1. package/README.md +62 -2
  2. package/dist/cli.mjs +17 -0
  3. package/package.json +35 -3
  4. package/templates/0001_init.sql.tpl +10 -0
  5. package/templates/AGENTS.md.tpl +290 -0
  6. package/templates/AGENT_GUIDE.md.tpl +36 -0
  7. package/templates/README.md.tpl +58 -0
  8. package/templates/_shared/AGENTS.writes.md.tpl +38 -0
  9. package/templates/_shared/README.tail.md.tpl +20 -0
  10. package/templates/_shared/files/src/app.test.ts.tpl +36 -0
  11. package/templates/_shared/files/src/index.ts.tpl +8 -0
  12. package/templates/_shared/files/src/lib/client.ts.tpl +213 -0
  13. package/templates/_shared/files/src/lib/files.ts.tpl +37 -0
  14. package/templates/_shared/files/src/lib/http.ts.tpl +101 -0
  15. package/templates/_shared/files/src/lib/mail.ts.tpl +26 -0
  16. package/templates/_shared/files/src/lib/model.ts.tpl +31 -0
  17. package/templates/_shared/files/src/lib/page.ts.tpl +71 -0
  18. package/templates/_shared/files/src/lib/record.ts.tpl +246 -0
  19. package/templates/_shared/files/src/lib/server.ts.tpl +132 -0
  20. package/templates/_shared/files/src/shell.test.ts.tpl +56 -0
  21. package/templates/agent.ts.tpl +23 -0
  22. package/templates/bakim-takvimi/AGENTS.template.md.tpl +40 -0
  23. package/templates/bakim-takvimi/README.md.tpl +45 -0
  24. package/templates/bakim-takvimi/files/AGENT_GUIDE.md.tpl +52 -0
  25. package/templates/bakim-takvimi/files/one.data.json +45 -0
  26. package/templates/bakim-takvimi/files/src/agent.ts.tpl +22 -0
  27. package/templates/bakim-takvimi/files/src/app.ts.tpl +259 -0
  28. package/templates/bakim-takvimi/files/src/jobs.ts.tpl +170 -0
  29. package/templates/bakim-takvimi/files/src/names.ts.tpl +5 -0
  30. package/templates/bakim-takvimi/files/src/queries.ts.tpl +71 -0
  31. package/templates/bakim-takvimi/files/src/schedule.test.ts.tpl +58 -0
  32. package/templates/bakim-takvimi/files/src/schedule.ts.tpl +106 -0
  33. package/templates/bakim-takvimi/files/src/shell.ts.tpl +299 -0
  34. package/templates/bakim-takvimi/manifest.json.tpl +64 -0
  35. package/templates/egitim-sertifika/AGENTS.template.md.tpl +41 -0
  36. package/templates/egitim-sertifika/README.md.tpl +46 -0
  37. package/templates/egitim-sertifika/files/AGENT_GUIDE.md.tpl +53 -0
  38. package/templates/egitim-sertifika/files/one.data.json +43 -0
  39. package/templates/egitim-sertifika/files/src/agent.ts.tpl +22 -0
  40. package/templates/egitim-sertifika/files/src/app.ts.tpl +312 -0
  41. package/templates/egitim-sertifika/files/src/certificate.test.ts.tpl +83 -0
  42. package/templates/egitim-sertifika/files/src/certificate.ts.tpl +163 -0
  43. package/templates/egitim-sertifika/files/src/jobs.ts.tpl +219 -0
  44. package/templates/egitim-sertifika/files/src/names.ts.tpl +5 -0
  45. package/templates/egitim-sertifika/files/src/queries.ts.tpl +115 -0
  46. package/templates/egitim-sertifika/files/src/shell.ts.tpl +345 -0
  47. package/templates/egitim-sertifika/manifest.json.tpl +57 -0
  48. package/templates/envanter/AGENTS.template.md.tpl +28 -0
  49. package/templates/envanter/README.md.tpl +40 -0
  50. package/templates/envanter/files/AGENT_GUIDE.md.tpl +47 -0
  51. package/templates/envanter/files/one.data.json +62 -0
  52. package/templates/envanter/files/src/agent.ts.tpl +20 -0
  53. package/templates/envanter/files/src/app.ts.tpl +204 -0
  54. package/templates/envanter/files/src/jobs.ts.tpl +30 -0
  55. package/templates/envanter/files/src/queries.ts.tpl +53 -0
  56. package/templates/envanter/files/src/shell.ts.tpl +265 -0
  57. package/templates/envanter/files/src/stock.test.ts.tpl +31 -0
  58. package/templates/envanter/files/src/stock.ts.tpl +33 -0
  59. package/templates/envanter/manifest.json.tpl +56 -0
  60. package/templates/hedef-takibi/AGENTS.template.md.tpl +37 -0
  61. package/templates/hedef-takibi/README.md.tpl +52 -0
  62. package/templates/hedef-takibi/files/AGENT_GUIDE.md.tpl +48 -0
  63. package/templates/hedef-takibi/files/one.data.json +76 -0
  64. package/templates/hedef-takibi/files/src/agent.ts.tpl +26 -0
  65. package/templates/hedef-takibi/files/src/app.ts.tpl +740 -0
  66. package/templates/hedef-takibi/files/src/progress.test.ts.tpl +53 -0
  67. package/templates/hedef-takibi/files/src/progress.ts.tpl +81 -0
  68. package/templates/hedef-takibi/files/src/queries.ts.tpl +83 -0
  69. package/templates/hedef-takibi/files/src/shell.ts.tpl +455 -0
  70. package/templates/hedef-takibi/manifest.json.tpl +38 -0
  71. package/templates/index.ts.tpl +262 -0
  72. package/templates/markdown.d.ts.tpl +6 -0
  73. package/templates/musteri-ziyaret/AGENTS.template.md.tpl +47 -0
  74. package/templates/musteri-ziyaret/README.md.tpl +48 -0
  75. package/templates/musteri-ziyaret/files/AGENT_GUIDE.md.tpl +44 -0
  76. package/templates/musteri-ziyaret/files/one.data.json +53 -0
  77. package/templates/musteri-ziyaret/files/src/agent.ts.tpl +21 -0
  78. package/templates/musteri-ziyaret/files/src/app.ts.tpl +340 -0
  79. package/templates/musteri-ziyaret/files/src/customers.ts.tpl +85 -0
  80. package/templates/musteri-ziyaret/files/src/queries.ts.tpl +67 -0
  81. package/templates/musteri-ziyaret/files/src/shell.ts.tpl +272 -0
  82. package/templates/musteri-ziyaret/files/src/visit.test.ts.tpl +65 -0
  83. package/templates/musteri-ziyaret/files/src/visit.ts.tpl +98 -0
  84. package/templates/musteri-ziyaret/manifest.json.tpl +37 -0
  85. package/templates/saha-denetimi/AGENTS.template.md.tpl +32 -0
  86. package/templates/saha-denetimi/README.md.tpl +44 -0
  87. package/templates/saha-denetimi/files/AGENT_GUIDE.md.tpl +55 -0
  88. package/templates/saha-denetimi/files/one.data.json +87 -0
  89. package/templates/saha-denetimi/files/src/agent.ts.tpl +41 -0
  90. package/templates/saha-denetimi/files/src/app.ts.tpl +444 -0
  91. package/templates/saha-denetimi/files/src/queries.ts.tpl +143 -0
  92. package/templates/saha-denetimi/files/src/score.test.ts.tpl +48 -0
  93. package/templates/saha-denetimi/files/src/score.ts.tpl +102 -0
  94. package/templates/saha-denetimi/files/src/shell.ts.tpl +585 -0
  95. package/templates/saha-denetimi/manifest.json.tpl +49 -0
  96. package/templates/shell.test.ts.tpl +30 -0
  97. package/templates/shell.ts.tpl +201 -0
  98. package/templates/uygunsuzluk-dof/AGENTS.template.md.tpl +40 -0
  99. package/templates/uygunsuzluk-dof/README.md.tpl +46 -0
  100. package/templates/uygunsuzluk-dof/files/AGENT_GUIDE.md.tpl +56 -0
  101. package/templates/uygunsuzluk-dof/files/one.data.json +99 -0
  102. package/templates/uygunsuzluk-dof/files/src/agent.ts.tpl +45 -0
  103. package/templates/uygunsuzluk-dof/files/src/app.ts.tpl +431 -0
  104. package/templates/uygunsuzluk-dof/files/src/capa.test.ts.tpl +53 -0
  105. package/templates/uygunsuzluk-dof/files/src/capa.ts.tpl +114 -0
  106. package/templates/uygunsuzluk-dof/files/src/jobs.ts.tpl +127 -0
  107. package/templates/uygunsuzluk-dof/files/src/queries.ts.tpl +102 -0
  108. package/templates/uygunsuzluk-dof/files/src/shell.ts.tpl +441 -0
  109. package/templates/uygunsuzluk-dof/manifest.json.tpl +56 -0
  110. package/templates/ziyaretci-kaydi/AGENTS.template.md.tpl +35 -0
  111. package/templates/ziyaretci-kaydi/README.md.tpl +46 -0
  112. package/templates/ziyaretci-kaydi/files/AGENT_GUIDE.md.tpl +39 -0
  113. package/templates/ziyaretci-kaydi/files/one.data.json +42 -0
  114. package/templates/ziyaretci-kaydi/files/src/agent.ts.tpl +13 -0
  115. package/templates/ziyaretci-kaydi/files/src/app.ts.tpl +293 -0
  116. package/templates/ziyaretci-kaydi/files/src/jobs.ts.tpl +49 -0
  117. package/templates/ziyaretci-kaydi/files/src/notice.ts.tpl +48 -0
  118. package/templates/ziyaretci-kaydi/files/src/queries.ts.tpl +45 -0
  119. package/templates/ziyaretci-kaydi/files/src/shell.ts.tpl +252 -0
  120. package/templates/ziyaretci-kaydi/files/src/visitor.test.ts.tpl +34 -0
  121. package/templates/ziyaretci-kaydi/files/src/visitor.ts.tpl +57 -0
  122. package/templates/ziyaretci-kaydi/manifest.json.tpl +38 -0
@@ -0,0 +1,52 @@
1
+ # {{nameTr}} — Devoplus yapay zekâsı için rehber
2
+
3
+ Bu dosyayı **Devoplus yapay zekâsı** okur (uygulama adresinde `/.well-known/one-agent.md`). Bir aracı eklerken,
4
+ değiştirirken ya da kaldırırken bu dosyayı AYNI değişiklikte güncelleyin; yayın kapısı dört başlığı ve her aracın adını
5
+ arar.
6
+
7
+ ## Uygulama ne yapar
8
+
9
+ {{nameTr}}, kurumdaki cihaz ve ekipmanların periyodik bakımını izleyen bir bakım takvimidir. Her ekipmanın bir bakım
10
+ aralığı (gün) ve son bakım tarihi vardır; sıradaki bakım tarihi bunlardan hesaplanır. Bakım yapıldığında ekipmanın
11
+ sorumlusu ya da bakım sorumlusu kaydı girer ve sıradaki tarih ilerler. Sıradaki bakımı 7 gün içinde olan ya da geçmiş
12
+ ekipmanların sorumlusuna her sabah bir kez e-posta gider. Kullanıcı "bu hafta hangi bakımlar var", "geciken bakım var
13
+ mı", "kompresörün son bakımı ne zaman yapıldı" gibi şeyler sorduğunda bu uygulamaya başvurun. Bakım kaydetmek için araç
14
+ yoktur.
15
+
16
+ ## Veri
17
+
18
+ - **Ekipman** (`equipment`): ekipman kodu (tekil), ad, tür, konum, sorumlu kişinin adı, bakım aralığı (gün), son bakım
19
+ tarihi, sıradaki bakım tarihi, kullanımda mı.
20
+ - **Bakım kaydı** (`maintenance_records`): ekipman, bakım tarihi, yapılan iş, isteğe bağlı fotoğraf ya da belge,
21
+ kaydeden kişi, kayıtla birlikte hesaplanan sonraki bakım tarihi. Kayıt değiştirilmez.
22
+ - **Günlük hatırlatma kontrolü** (`reminder_runs`): her sabah 08:00'de (Türkiye saati) kaç bakımın hatırlatılacağı ve
23
+ kaç e-posta gittiği.
24
+ - Durumlar: `overdue` (gecikti — sıradaki tarih bugünden önce), `due-soon` (7 gün içinde, bugün dahil), `scheduled`
25
+ (planlı). Tarihler `YYYY-AA-GG` biçimindedir; kullanıcıya `gg.aa.yyyy` olarak söyleyin.
26
+
27
+ ## Araçlar
28
+
29
+ ### `upcoming-maintenance` — yaklaşan bakımlar (salt okunur)
30
+
31
+ - **Ne zaman:** kullanıcı yaklaşan, bu haftaki ya da geciken bakımları sorduğunda.
32
+ - **Girdi:** `days` — bugünden itibaren kaç gün (0-90, varsayılan 14; geciken bakımlar her zaman dahildir);
33
+ `limit` (1-50, varsayılan 20).
34
+ - **Çıktı:** `{ "today": "2026-10-20", "until": "2026-11-03", "count": 4, "items": [{ "code", "name", "location",
35
+ "responsible_name", "last_maintenance_date", "next_due_date", "days_left", "status" }] }` — en acil olan önce;
36
+ `count` tam sayıdır, `items` en çok `limit` kadar. `days_left` eksiyse bakım o kadar gün gecikmiştir.
37
+
38
+ ### `search-equipment` — ekipman ara (salt okunur)
39
+
40
+ - **Ne zaman:** kullanıcı belirli bir ekipmanın son ya da sıradaki bakımını, sorumlusunu ya da yerini sorduğunda.
41
+ - **Girdi:** `query` — ekipman kodunda, adında ya da konumunda geçen ifade; `limit` (1-50, varsayılan 10).
42
+ - **Çıktı:** `{ "items": [{ "code", "name", "location", "responsible_name", "last_maintenance_date",
43
+ "next_due_date", "days_left", "status" }] }`.
44
+ - **Dikkat:** büyük/küçük harf eşleşmesi yalnız Latin harflerde yok sayılır; Türkçe harfli bir aramada sonuç çıkmazsa
45
+ ifadeyi küçük harfle yeniden deneyin.
46
+
47
+ ## Örnek istekler
48
+
49
+ - "Bu hafta hangi bakımlar var?" → `upcoming-maintenance` (`days: 7`)
50
+ - "Geciken bakım var mı?" → `upcoming-maintenance` (`days: 0`) → `status: "overdue"` olanlar
51
+ - "Kompresörün son bakımı ne zaman yapıldı?" → `search-equipment` (`query: "kompresör"`) → `last_maintenance_date`
52
+ - "Klimanın bakımını yaptım, kaydet." → araç yok; kullanıcıyı uygulamanın Bakım kayıtları sekmesine yönlendirin.
@@ -0,0 +1,45 @@
1
+ {
2
+ "version": 1,
3
+ "entities": {
4
+ "equipment": {
5
+ "title": { "tr": "Ekipmanlar", "en": "Equipment" },
6
+ "fields": {
7
+ "code": { "type": "text", "title": { "tr": "Ekipman kodu", "en": "Equipment code" }, "required": true, "unique": true, "maxLength": 40 },
8
+ "name": { "type": "text", "title": { "tr": "Ad", "en": "Name" }, "required": true, "maxLength": 120 },
9
+ "category": { "type": "text", "title": { "tr": "Tür", "en": "Type" }, "maxLength": 60, "index": true },
10
+ "location": { "type": "text", "title": { "tr": "Konum", "en": "Location" }, "maxLength": 120, "index": true },
11
+ "responsible_org_id": { "type": "text", "title": { "tr": "Sorumlu (kimlik)", "en": "Responsible (id)" }, "maxLength": 24, "index": true },
12
+ "responsible_name": { "type": "text", "title": { "tr": "Sorumlu", "en": "Responsible" }, "maxLength": 120 },
13
+ "period_days": { "type": "number", "title": { "tr": "Bakım aralığı (gün)", "en": "Interval (days)" }, "required": true, "default": 30, "min": 1, "max": 3650 },
14
+ "last_maintenance_date": { "type": "date", "title": { "tr": "Son bakım", "en": "Last maintenance" }, "required": true },
15
+ "next_due_date": { "type": "date", "title": { "tr": "Sıradaki bakım", "en": "Next due" }, "index": true },
16
+ "reminder_sent_for": { "type": "date", "title": { "tr": "Hatırlatılan bakım tarihi", "en": "Reminded due date" } },
17
+ "reminder_failed_on": { "type": "date", "title": { "tr": "Hatırlatmanın ulaşmadığı son gün", "en": "Last undelivered reminder" } },
18
+ "active": { "type": "boolean", "title": { "tr": "Kullanımda", "en": "In use" }, "required": true, "default": true }
19
+ }
20
+ },
21
+ "maintenance_records": {
22
+ "title": { "tr": "Bakım kayıtları", "en": "Maintenance records" },
23
+ "fields": {
24
+ "equipment_id": { "type": "text", "title": { "tr": "Ekipman", "en": "Equipment" }, "required": true, "maxLength": 36, "index": true },
25
+ "equipment_name": { "type": "text", "title": { "tr": "Ekipman adı", "en": "Equipment name" }, "maxLength": 120 },
26
+ "performed_on": { "type": "date", "title": { "tr": "Bakım tarihi", "en": "Maintenance date" }, "required": true, "index": true },
27
+ "note": { "type": "longText", "title": { "tr": "Yapılan iş", "en": "Work done" }, "maxLength": 2000 },
28
+ "attachment": { "type": "file", "title": { "tr": "Fotoğraf ya da belge", "en": "Photo or document" } },
29
+ "attachment_name": { "type": "text", "title": { "tr": "Ek dosya adı", "en": "Attachment name" }, "maxLength": 200 },
30
+ "performer_name": { "type": "text", "title": { "tr": "Kaydeden", "en": "Recorded by" }, "maxLength": 120 },
31
+ "next_due_date": { "type": "date", "title": { "tr": "Sonraki bakım", "en": "Next due" } }
32
+ }
33
+ },
34
+ "reminder_runs": {
35
+ "title": { "tr": "Günlük hatırlatma kontrolleri", "en": "Daily reminder checks" },
36
+ "fields": {
37
+ "run_key": { "type": "text", "title": { "tr": "Kontrol günü", "en": "Check day" }, "required": true, "unique": true, "maxLength": 20 },
38
+ "due_count": { "type": "number", "title": { "tr": "Hatırlatılacak bakım", "en": "Maintenance to remind" }, "required": true, "default": 0, "min": 0 },
39
+ "sent_count": { "type": "number", "title": { "tr": "Gönderilen e-posta", "en": "E-mails sent" }, "required": true, "default": 0, "min": 0 },
40
+ "waiting_count": { "type": "number", "title": { "tr": "Gönderilemeyen e-posta", "en": "E-mails not sent" }, "required": true, "default": 0, "min": 0 },
41
+ "consent_needed": { "type": "boolean", "title": { "tr": "E-posta onayı bekleniyor", "en": "E-mail approval pending" }, "required": true, "default": false }
42
+ }
43
+ }
44
+ }
45
+ }
@@ -0,0 +1,22 @@
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 uygulamanın KENDİ veritabanını okur. Bakım KAYDEDEN araç bilinçli olarak yoktur.
4
+ import { defineAgentTools } from '@devoplus/one-sdk'
5
+ import type { Env } from './app.ts'
6
+ import { searchEquipment, upcoming } from './queries.ts'
7
+ import { todayKey } from './schedule.ts'
8
+
9
+ export const agentTools = defineAgentTools<Env>({
10
+ 'upcoming-maintenance': {
11
+ handler: async ({ input, env }) => {
12
+ const days = typeof input.days === 'number' ? input.days : 14
13
+ const limit = typeof input.limit === 'number' ? input.limit : 20
14
+ return upcoming(env, todayKey(new Date()), days, limit)
15
+ },
16
+ },
17
+ 'search-equipment': {
18
+ handler: async ({ input, env }) => ({
19
+ items: await searchEquipment(env, todayKey(new Date()), String(input.query), typeof input.limit === 'number' ? input.limit : 10),
20
+ }),
21
+ },
22
+ })
@@ -0,0 +1,259 @@
1
+ // {{nameTr}} — bakım takvimi. Uçlar (hepsi oturum ister; kimlik platformdan gelir):
2
+ // GET /api/equipment[?…] ekipmanlar (herkes) — sıradaki bakıma göre sıralı; hazır liste + Excel
3
+ // POST /api/equipment · PATCH/DELETE /api/equipment/{id} ekipman tanımı (bakım sorumlusu izni)
4
+ // ⛔ sıradaki bakım tarihi istemciden ALINMAZ; son bakım yalnız kayıtla değişir
5
+ // POST /api/equipment/{id}/maintenance { performed_on, note?, attachment? } — bakım kaydı (bakım sorumlusu izni
6
+ // ya da ekipmanın sorumlusu): kayıt + son/sıradaki tarih TEK toplu yazım
7
+ // GET /api/overdue/equipment[?…] geciken bakımlar (herkes) — süzgeci SUNUCU koyar; hazır liste + Excel
8
+ // GET /api/maintenance_records[?…] bakım kayıtları (herkes) — değiştirilemez ve silinemez
9
+ // GET /api/maintenance_records/{id}/attachment kaydın fotoğrafı ya da belgesi
10
+ // POST /api/files dosya yükleme (yalnız kendi ön ekinize)
11
+ // GET /api/summary geciken / yaklaşan sayısı, son hatırlatma kontrolü
12
+ import {
13
+ OneError,
14
+ createCrudHandler,
15
+ foldSearchText,
16
+ getMyOrgContext,
17
+ hasPermission,
18
+ logAudit,
19
+ logError,
20
+ requirePermission,
21
+ requireUser,
22
+ type MyOrgContext,
23
+ type OneFilesBinding,
24
+ } from '@devoplus/one-sdk'
25
+ import manifest from '../one.manifest.json' with { type: 'json' }
26
+ import { agentTools } from './agent.ts'
27
+ import { dataModel } from './data/types.ts'
28
+ import { jobs } from './jobs.ts'
29
+ import { fileResponse, uploadFile } from './lib/files.ts'
30
+ import { fieldError, forward, matchId, nowIso, onlyFields, ownFileKey, personValue, readJsonObject, withQuery } from './lib/http.ts'
31
+ import { refuseActingSession } from './lib/record.ts'
32
+ import { createApp, json, type AppDefinition, type BaseEnv, type RouteInput } from './lib/server.ts'
33
+ import { MANAGE_PERMISSION } from './names.ts'
34
+ import { NEXT_DUE_SQL, counts, lastRun } from './queries.ts'
35
+ import { REMIND_DAYS, addDays, checkMaintenanceDate, checkPeriod, nextDueDate, todayKey } from './schedule.ts'
36
+ import { renderShell, renderShellScript } from './shell.ts'
37
+
38
+ export { APP_NAME, MANAGE_PERMISSION } from './names.ts'
39
+
40
+ export interface Env extends BaseEnv {
41
+ /** Bu kurulumun dosya deposu (manifest services.files): bakım fotoğrafları ve belgeleri. */
42
+ FILES: OneFilesBinding
43
+ }
44
+
45
+ export const equipment = createCrudHandler({ model: dataModel, entity: 'equipment', records: 'shared', canWrite: MANAGE_PERMISSION })
46
+ /** Gecikenler: aynı kayıtlar, süzgeci sunucu koyar (salt okunur). */
47
+ export const overdue = createCrudHandler({ model: dataModel, entity: 'equipment', records: 'shared', basePath: '/api/overdue' })
48
+ export const records = createCrudHandler({ model: dataModel, entity: 'maintenance_records', records: 'shared' })
49
+
50
+ const CREATE_FIELDS = ['code', 'name', 'category', 'location', 'responsible', 'period_days', 'last_maintenance_date', 'active']
51
+ /** Güncellemede son bakım tarihi YOKTUR: o yalnız bakım kaydıyla değişir. */
52
+ const UPDATE_FIELDS = ['code', 'name', 'category', 'location', 'responsible', 'period_days', 'active']
53
+ const NOTE_MAX = 2000
54
+ const CONTROL_RE = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/
55
+
56
+ const today = (): string => todayKey(new Date())
57
+
58
+ async function myOrg(input: RouteInput<Env>): Promise<MyOrgContext | null> {
59
+ try {
60
+ return await getMyOrgContext(input.api())
61
+ } catch (error) {
62
+ logError(input.ctx, 'kişi bilgisi okunamadı', error)
63
+ return null
64
+ }
65
+ }
66
+
67
+ /** Kişi seçicinin değeri iki kolona ayrılır. */
68
+ function withResponsible(body: Record<string, unknown>): Record<string, unknown> {
69
+ const { responsible, ...rest } = body
70
+ if (!('responsible' in body)) return rest
71
+ const person = personValue(responsible, 'responsible')
72
+ return { ...rest, responsible_org_id: person?.id ?? null, responsible_name: person?.name ?? null }
73
+ }
74
+
75
+ async function createEquipment(input: RouteInput<Env>): Promise<Response> {
76
+ const { request, env, ctx } = input
77
+ requirePermission(ctx, MANAGE_PERMISSION)
78
+ const body = onlyFields(await readJsonObject(request), CREATE_FIELDS)
79
+ const period = checkPeriod(body.period_days)
80
+ if (!period.ok) throw fieldError('period_days', period.message)
81
+ const last = checkMaintenanceDate(body.last_maintenance_date, today())
82
+ if (!last.ok) throw fieldError('last_maintenance_date', last.message)
83
+ // ⛔ Sıradaki bakım SUNUCUDA hesaplanır; gövdede gelirse yukarıdaki alan denetimi zaten reddeder (400).
84
+ const record = { ...withResponsible(body), period_days: period.value, last_maintenance_date: last.value, next_due_date: nextDueDate(last.value, period.value) }
85
+ return (await equipment.handle(forward(request, equipment.path, 'POST', record), env.DB, ctx))!
86
+ }
87
+
88
+ async function updateEquipment(input: RouteInput<Env>, id: string): Promise<Response> {
89
+ const { request, env, ctx, method } = input
90
+ const user = requirePermission(ctx, MANAGE_PERMISSION)
91
+ if (method === 'DELETE') return (await equipment.handle(request, env.DB, ctx))!
92
+ const body = onlyFields(await readJsonObject(request), UPDATE_FIELDS)
93
+ if ('period_days' in body) {
94
+ const period = checkPeriod(body.period_days)
95
+ if (!period.ok) throw fieldError('period_days', period.message)
96
+ body.period_days = period.value
97
+ }
98
+ const res = (await equipment.handle(forward(request, `${equipment.path}/${id}`, 'PATCH', withResponsible(body)), env.DB, ctx))!
99
+ if (!res.ok) return res
100
+ // Aralık değiştiyse sıradaki tarih son bakımdan yeniden hesaplanır (kural tek SQL ifadesindedir; iş de onarır).
101
+ await env.DB.prepare(`UPDATE "equipment" SET next_due_date = ${NEXT_DUE_SQL}, updated_by = ? WHERE id = ? AND next_due_date IS NOT ${NEXT_DUE_SQL}`).bind(user.sub, id).run()
102
+ const saved = (await res.json()) as Record<string, unknown>
103
+ const fresh = await env.DB.prepare('SELECT next_due_date FROM "equipment" WHERE id = ?').bind(id).first<{ next_due_date: string | null }>()
104
+ return json({ ...saved, next_due_date: fresh?.next_due_date ?? null })
105
+ }
106
+
107
+ async function recordMaintenance(input: RouteInput<Env>, id: string): Promise<Response> {
108
+ const { request, env, ctx } = input
109
+ const user = requireUser(ctx)
110
+ refuseActingSession(ctx)
111
+ const body = onlyFields(await readJsonObject(request), ['performed_on', 'note', 'attachment'])
112
+ const item = await env.DB.prepare('SELECT id, name, active, responsible_org_id FROM "equipment" WHERE id = ? AND deleted_at IS NULL')
113
+ .bind(id)
114
+ .first<{ id: string; name: string; active: number; responsible_org_id: string | null }>()
115
+ if (!item) throw new OneError(404, 'APP-E-404', 'Ekipman bulunamadı.')
116
+ // Bakımı bakım sorumlusu (izin) ya da ekipmanın kendi sorumlusu kaydeder. Kişi bilgisi okunamazsa kapı KAPALI kalır.
117
+ const me = await myOrg(input)
118
+ const manager = hasPermission(ctx, MANAGE_PERMISSION)
119
+ const isResponsible = me !== null && item.responsible_org_id !== null && me.orgUserId.toLowerCase() === item.responsible_org_id.toLowerCase()
120
+ if (!manager && !isResponsible)
121
+ throw new OneError(403, 'APP-E-403', 'Bu ekipmanın bakımını yalnız sorumlusu ya da bakım sorumlusu izni olan kişi kaydedebilir.')
122
+ if (item.active !== 1) throw new OneError(409, 'APP-E-021', 'Kullanımda olmayan ekipmana bakım kaydı girilmez.')
123
+ const date = checkMaintenanceDate(body.performed_on, today())
124
+ if (!date.ok) throw fieldError('performed_on', date.message)
125
+ const note = typeof body.note === 'string' ? body.note.trim() : ''
126
+ if ([...note].length > NOTE_MAX || CONTROL_RE.test(note)) throw fieldError('note', `Açıklama en çok ${NOTE_MAX} karakter olmalı.`)
127
+ const attachment = ownFileKey(body.attachment, user.sub, 'attachment')
128
+ const attachmentName =
129
+ attachment && typeof body.attachment === 'object' && body.attachment !== null && typeof (body.attachment as { name?: unknown }).name === 'string'
130
+ ? (body.attachment as { name: string }).name.slice(0, 200)
131
+ : null
132
+ const now = nowIso()
133
+ const recordId = crypto.randomUUID()
134
+ const performer = me?.fullName?.trim() ? [...me.fullName.trim()].slice(0, 120).join('') : null
135
+ // ⛔ Bakım kaydı ve ekipmanın tarihleri TEK toplu yazımdır (ikisi birlikte yazılır ya da hiçbiri):
136
+ // 1. Kayıt yalnız ekipman kullanımdaysa ve yazan kişi HÂLÂ yetkiliyse eklenir: bakım sorumlusu izni olmayan kişi için
137
+ // ekipmanın sorumlusu olması aynı ifadededir (denetimden sonra sorumlu değişirse kayıt yazılmaz). Sonraki bakım
138
+ // tarihi aynı ifadede, veritabanındaki aralıkla hesaplanır.
139
+ // 2. Ekipmanın son ve sıradaki bakım tarihi yalnız BU kayıt eklendiyse değişir. Geriye dönük (eski tarihli) kayıt
140
+ // son bakım tarihini geri götürmez: `max(son bakım, kayıt tarihi)`.
141
+ const nextFromRecord = `date(max(e.last_maintenance_date, ?), '+' || CAST(e.period_days AS INTEGER) || ' days')`
142
+ const responsibleOnly = manager ? '' : ' AND lower(e.responsible_org_id) = ?'
143
+ const fold = (text: string | null) => (text === null ? null : foldSearchText(text))
144
+ const insert = env.DB.prepare(
145
+ 'INSERT INTO "maintenance_records" (id, created_at, created_by, updated_at, updated_by, equipment_id, equipment_name, equipment_name_fold, performed_on, note, note_fold, ' +
146
+ 'attachment, attachment_name, attachment_name_fold, performer_name, performer_name_fold, next_due_date) ' +
147
+ `SELECT ?, ?, ?, ?, ?, e.id, e.name, e.name_fold, ?, ?, ?, ?, ?, ?, ?, ?, ${nextFromRecord} FROM "equipment" e WHERE e.id = ? AND e.active = 1 AND e.deleted_at IS NULL${responsibleOnly}`,
148
+ ).bind(
149
+ recordId,
150
+ now,
151
+ user.sub,
152
+ now,
153
+ user.sub,
154
+ date.value,
155
+ note || null,
156
+ fold(note || null),
157
+ attachment,
158
+ attachmentName,
159
+ fold(attachmentName),
160
+ performer,
161
+ fold(performer),
162
+ date.value,
163
+ id,
164
+ ...(manager ? [] : [me!.orgUserId.toLowerCase()]),
165
+ )
166
+ const update = env.DB.prepare(
167
+ `UPDATE "equipment" SET last_maintenance_date = max(last_maintenance_date, ?), next_due_date = date(max(last_maintenance_date, ?), '+' || CAST(period_days AS INTEGER) || ' days'), ` +
168
+ 'updated_at = ?, updated_by = ? WHERE id = ? AND EXISTS (SELECT 1 FROM "maintenance_records" WHERE id = ?)',
169
+ ).bind(date.value, date.value, now, user.sub, id, recordId)
170
+ const [inserted, changed] = await env.DB.batch([insert, update])
171
+ if (Number(inserted?.meta.changes ?? 0) !== 1 || Number(changed?.meta.changes ?? 0) !== 1) {
172
+ const latest = await env.DB.prepare('SELECT responsible_org_id FROM "equipment" WHERE id = ? AND deleted_at IS NULL').bind(id).first<{ responsible_org_id: string | null }>()
173
+ if (!latest) throw new OneError(404, 'APP-E-404', 'Ekipman bulunamadı.')
174
+ if (!manager && (latest.responsible_org_id ?? '').toLowerCase() !== me!.orgUserId.toLowerCase())
175
+ throw new OneError(403, 'APP-E-403', 'Bu ekipmanın bakımını yalnız sorumlusu ya da bakım sorumlusu izni olan kişi kaydedebilir.')
176
+ throw new OneError(409, 'APP-E-021', 'Ekipman bu arada değişti; sayfayı yenileyip yeniden deneyin.')
177
+ }
178
+ logAudit(ctx, 'maintenance.record', id, { performedOn: date.value })
179
+ const after = await env.DB.prepare('SELECT last_maintenance_date, next_due_date FROM "equipment" WHERE id = ?').bind(id).first<{ last_maintenance_date: string; next_due_date: string }>()
180
+ return json({ id: recordId, equipment: { id, ...after } }, 201)
181
+ }
182
+
183
+ /** Liste varsayılan olarak sıradaki bakıma göre sıralanır (istemci başka bir sıralama isterse o geçerlidir). */
184
+ function sortedByDue(input: RouteInput<Env>): Request {
185
+ const params = input.url.searchParams
186
+ if (params.has('sort') || params.has('_sort')) return input.request
187
+ return withQuery(input.request, input.url.pathname, (p) => p.set('_sort', 'next_due_date'))
188
+ }
189
+
190
+ /** Gecikenler: istemcinin gönderdiği tarih ve durum süzgeci YOK SAYILIR; sunucu "dünden eski ve kullanımda" koyar. */
191
+ async function overdueList(input: RouteInput<Env>): Promise<Response | null> {
192
+ const { env, ctx, url } = input
193
+ const rest = url.pathname.slice(overdue.path.length)
194
+ if (rest !== '' && rest !== '/export.xlsx' && rest !== '/export.csv' && rest !== '/report.html') throw new OneError(404, 'APP-E-404', 'Bulunamadı.')
195
+ if (input.method !== 'GET' && input.method !== 'HEAD') throw new OneError(405, 'APP-E-405', 'Geciken bakımlar yalnız okunur.')
196
+ requireUser(ctx)
197
+ const scoped = withQuery(input.request, url.pathname, (p) => {
198
+ for (const key of ['next_due_date', 'next_due_date.from', 'next_due_date.to', 'next_due_dateFrom', 'next_due_dateTo', 'active']) p.delete(key)
199
+ p.set('next_due_date.to', addDays(today(), -1))
200
+ p.set('active', 'true')
201
+ if (!p.has('sort') && !p.has('_sort')) p.set('_sort', 'next_due_date')
202
+ })
203
+ return overdue.handle(scoped, env.DB, ctx)
204
+ }
205
+
206
+ async function attachmentOf(input: RouteInput<Env>, id: string): Promise<Response> {
207
+ requireUser(input.ctx)
208
+ const row = await input.env.DB.prepare('SELECT attachment FROM "maintenance_records" WHERE id = ? AND deleted_at IS NULL').bind(id).first<{ attachment: string | null }>()
209
+ if (!row?.attachment) throw new OneError(404, 'APP-E-404', 'Bu kayıtta dosya yok.')
210
+ return fileResponse(input.env.FILES, row.attachment)
211
+ }
212
+
213
+ async function summary({ env, ctx }: RouteInput<Env>): Promise<Response> {
214
+ requireUser(ctx)
215
+ const day = today()
216
+ const [c, run] = await Promise.all([counts(env, day, REMIND_DAYS), lastRun(env)])
217
+ return json({ today: day, remindDays: REMIND_DAYS, ...c, lastRun: run ? { ...run, consent_needed: run.consent_needed === 1 } : null })
218
+ }
219
+
220
+ async function routes(input: RouteInput<Env>): Promise<Response | null> {
221
+ const { url, method, env, ctx } = input
222
+ const path = url.pathname
223
+ if (path === '/api/summary' && method === 'GET') return summary(input)
224
+ if (path === '/api/files' && method === 'POST') return uploadFile(input.request, env.FILES, ctx)
225
+ if (path === overdue.path || path.startsWith(`${overdue.path}/`)) return overdueList(input)
226
+
227
+ const maintenanceId = matchId(path, equipment.path, 'maintenance')
228
+ if (maintenanceId && method === 'POST') return recordMaintenance(input, maintenanceId)
229
+ if (path === equipment.path && method === 'POST') return createEquipment(input)
230
+ const equipmentId = matchId(path, equipment.path)
231
+ if (equipmentId && (method === 'PATCH' || method === 'PUT' || method === 'DELETE')) {
232
+ if (method === 'PUT') throw new OneError(405, 'APP-E-405', 'Ekipman PATCH ile güncellenir.')
233
+ return updateEquipment(input, equipmentId)
234
+ }
235
+ if (path === equipment.path || path === `${equipment.path}/export.xlsx` || path === `${equipment.path}/export.csv` || path === `${equipment.path}/report.html`)
236
+ return equipment.handle(sortedByDue(input), env.DB, ctx)
237
+ if (path.startsWith(`${equipment.path}/`)) return equipment.handle(input.request, env.DB, ctx)
238
+
239
+ const attachmentId = matchId(path, records.path, 'attachment')
240
+ if (attachmentId && method === 'GET') return attachmentOf(input, attachmentId)
241
+ if (path === records.path || path.startsWith(`${records.path}/`)) {
242
+ if (method !== 'GET' && method !== 'HEAD') throw new OneError(405, 'APP-E-405', 'Bakım kaydı değiştirilemez ve silinemez; düzeltme için yeni bir kayıt girin.')
243
+ return records.handle(input.request, env.DB, ctx)
244
+ }
245
+ return null
246
+ }
247
+
248
+ export const definition: Omit<AppDefinition<Env>, 'guide'> = {
249
+ manifest,
250
+ tools: agentTools,
251
+ jobs,
252
+ page: renderShell,
253
+ script: renderShellScript,
254
+ routes,
255
+ }
256
+
257
+ export function buildApp(guide: string) {
258
+ return createApp<Env>({ ...definition, guide })
259
+ }
@@ -0,0 +1,170 @@
1
+ // Zamanlanmış görev (manifest `jobs`). Her sabah 08:00 (İstanbul): sıradaki bakımı `REMIND_DAYS` gün içinde olan ya da
2
+ // geçmiş (geciken) ekipmanların sorumlusuna e-postayla hatırlatır — kişi başına TEK e-posta.
3
+ //
4
+ // ⛔ Zamanlanmış iş KULLANICISIZDIR: e-posta uygulamanın kendi kimliğiyle gider ve yalnız manifest `jobs.services`
5
+ // beyanı + kurulumda portal yöneticisinin onayıyla açılır. Onay yoksa iş e-posta göndermez, sonucu "onay bekleniyor"
6
+ // diye kaydeder ve damgayı geri alır (onay verilince ertesi sabah gider). Organizasyon şeması işte KULLANILAMAZ:
7
+ // alıcı, ekipman kaydındaki sorumlunun kişi kimliğidir (`responsible_org_id`, kayıt yazılırken sunucuda doğrulanır).
8
+ // ⛔ Aynı bakım her gün yeniden hatırlatılmaz: gönderimden ÖNCE koşullu damga konur (`reminder_sent_for` = hatırlatılan
9
+ // bakım tarihi). Damgayı yalnız bir koşum koyabilir (eşzamanlı iki koşum aynı e-postayı göndermez); gönderilemezse geri
10
+ // alınır. Bakım kaydedilince sıradaki tarih değişir ve damga kendiliğinden eskir: bir sonraki bakım yine hatırlatılır.
11
+ // ⛔ Ulaşmayan hatırlatma sıranın SONUNA geçer (`reminder_failed_on`): hiç ulaşamayan bir sorumlu (ör. ayrılmış bir
12
+ // çalışan) en acil bakıma sahip olduğu için her sabah ilk sıraları alıp öteki sorumluları hatırlatmasız bırakmaz.
13
+ // ⚠️ Platform bir istekte en çok 10 dış çağrıya izin verir: bir koşumda en çok `MAILS_PER_RUN` kişiye yazılır; kalanlar
14
+ // damgalanmadığı için ertesi sabah gider.
15
+ import { BackendClient, defineJobs, logError, notify, systemNotice, type OneContext } from '@devoplus/one-sdk'
16
+ import type { Env } from './app.ts'
17
+ import { APP_NAME } from './names.ts'
18
+ import { NEXT_DUE_SQL } from './queries.ts'
19
+ import { REMIND_DAYS, REMINDER_ITEMS_PER_MAIL, addDays, groupByResponsible, todayKey, type DueRow } from './schedule.ts'
20
+
21
+ /** Sistemin yazdığı kayıtlarda `created_by` değeri (kişi kimliği değildir). */
22
+ export const SYSTEM_ACTOR = 'system:daily-maintenance-reminders'
23
+ /** Bir koşumda en çok kaç kişiye e-posta gider. */
24
+ export const MAILS_PER_RUN = 5
25
+ const MAIL_SCOPE = 'mail.execute'
26
+
27
+ export type JobMailOutcome = 'sent' | 'consent-needed' | 'failed'
28
+
29
+ /** İşte e-posta: servis beyanı/onayı yoksa `consent-needed` (gönderim denenmez ya da sunucu 403 döner). */
30
+ export async function sendJobMail(ctx: OneContext, api: BackendClient, message: ReturnType<typeof systemNotice>, toOrgUserIds: string[]): Promise<JobMailOutcome> {
31
+ if (!(ctx.jobServices ?? []).includes(MAIL_SCOPE)) return 'consent-needed'
32
+ try {
33
+ const mail = (await notify(api, message, { toOrgUserIds, channels: ['mail'] })).mail
34
+ if (mail && 'error' in mail) {
35
+ if (mail.error.status === 403) return 'consent-needed'
36
+ logError(ctx, 'bakım hatırlatması gönderilemedi', mail.error)
37
+ return 'failed'
38
+ }
39
+ // Sunucu isteği kabul edip HİÇBİR alıcıyı kuyruğa almadıysa (pasif kişi, adres yok, sınır…) ileti kimseye gitmedi:
40
+ // "gönderildi" sayılmaz, damga geri alınır ve sonraki koşumda yeniden denenir.
41
+ if (!mail || mail.queued === 0) {
42
+ logError(ctx, 'bakım hatırlatması hiçbir alıcıya ulaşmadı', mail?.skipped ?? [])
43
+ return 'failed'
44
+ }
45
+ return 'sent'
46
+ } catch (error) {
47
+ logError(ctx, 'bakım hatırlatması gönderilemedi', error)
48
+ return 'failed'
49
+ }
50
+ }
51
+
52
+ function formatDay(day: string): string {
53
+ return `${day.slice(8, 10)}.${day.slice(5, 7)}.${day.slice(0, 4)}`
54
+ }
55
+
56
+ function reminderMessage(items: readonly DueRow[], today: string, more = 0) {
57
+ const lines = items.map((i) => `- ${i.code} ${i.name}${i.location ? ` (${i.location})` : ''}: ${formatDay(i.next_due_date)}${i.next_due_date < today ? ' — GECİKTİ' : ''}`)
58
+ // E-postaya sığmayan ekipmanlar SÖYLENİR: damgalanmadıkları için sonraki sabahlarda ayrıca hatırlatılırlar.
59
+ if (more > 0) lines.push(`- ve ${more} ekipman daha (sonraki sabahlarda ayrıca hatırlatılır)`)
60
+ return systemNotice({
61
+ appName: APP_NAME.tr,
62
+ title: items.length === 1 ? `Bakım zamanı: ${items[0]!.name}` : `${items.length} ekipmanın bakım zamanı geldi`,
63
+ message: `Sorumlusu olduğunuz ekipmanların sıradaki bakım tarihleri:\n${lines.join('\n')}\n\nBakımı yaptıktan sonra uygulamanın Bakım kayıtları sekmesinden kaydedin.`,
64
+ })
65
+ }
66
+
67
+ export const jobs = defineJobs<Env>({
68
+ schedules: {
69
+ 'daily-maintenance-reminders': async (job, env, ctx) => {
70
+ const at = job.scheduledTime ? new Date(job.scheduledTime) : new Date()
71
+ const today = todayKey(at)
72
+ const now = at.toISOString()
73
+ // Kendini onarma: sıradaki tarih her zaman son bakım + aralıktır (yarım kalmış bir güncellemeden sonra da).
74
+ await env.DB.prepare(`UPDATE "equipment" SET next_due_date = ${NEXT_DUE_SQL} WHERE deleted_at IS NULL AND next_due_date IS NOT ${NEXT_DUE_SQL}`).run()
75
+ const dueUntil = addDays(today, REMIND_DAYS)
76
+ const pending =
77
+ 'active = 1 AND deleted_at IS NULL AND responsible_org_id IS NOT NULL AND next_due_date <= ? ' +
78
+ 'AND (reminder_sent_for IS NULL OR reminder_sent_for <> next_due_date)'
79
+ const dueCount = Number((await env.DB.prepare(`SELECT COUNT(*) AS n FROM "equipment" WHERE ${pending}`).bind(dueUntil).first<{ n: number }>())?.n ?? 0)
80
+ // Alıcılar ÖNCE seçilir (en acil bakımı olan MAILS_PER_RUN kişi), kalemler SONRA kişi başına sınırlanır: satırlar
81
+ // tek bir sınırla okunsaydı çok sayıda ekipmanı olan bir sorumlu bütün sınırı doldurur, öteki sorumlular günlerce
82
+ // hatırlatma almazdı. Hatırlatması ulaşmamış sorumlular sıranın sonundadır (en eski başarısız deneme önce).
83
+ const recipients = await env.DB.prepare(
84
+ `SELECT lower(responsible_org_id) AS responsible, MIN(next_due_date) AS first_due, MAX(coalesce(reminder_failed_on, '')) AS failed FROM "equipment" WHERE ${pending} ` +
85
+ 'GROUP BY lower(responsible_org_id) ORDER BY failed, first_due, responsible LIMIT ?',
86
+ )
87
+ .bind(dueUntil, MAILS_PER_RUN)
88
+ .all<{ responsible: string }>()
89
+ const perRecipient = recipients.results.length
90
+ ? await env.DB.batch<DueRow>(
91
+ recipients.results.map((r) =>
92
+ env.DB.prepare(
93
+ 'SELECT id, code, name, location, next_due_date, responsible_org_id, reminder_sent_for FROM "equipment" ' +
94
+ `WHERE ${pending} AND lower(responsible_org_id) = ? ORDER BY next_due_date, code LIMIT ?`,
95
+ ).bind(dueUntil, r.responsible, REMINDER_ITEMS_PER_MAIL),
96
+ ),
97
+ )
98
+ : []
99
+ // Sorumlunun bekleyen ekipman SAYISI ayrı sayılır: e-postaya en çok REMINDER_ITEMS_PER_MAIL kalem sığar, kalanı söylenir.
100
+ const recipientTotals = recipients.results.length
101
+ ? await env.DB.batch<{ n: number }>(
102
+ recipients.results.map((r) => env.DB.prepare(`SELECT COUNT(*) AS n FROM "equipment" WHERE ${pending} AND lower(responsible_org_id) = ?`).bind(dueUntil, r.responsible)),
103
+ )
104
+ : []
105
+ const totals = new Map(recipients.results.map((r, n) => [r.responsible, Number(recipientTotals[n]?.results[0]?.n ?? 0)]))
106
+ const groups = groupByResponsible(perRecipient.flatMap((r) => r.results), MAILS_PER_RUN)
107
+ const chosen = groups.flatMap((g) => g.items)
108
+ // ⛔ Damga TOPLU ve koşullu konur ve satırı seçen koşulun TAMAMINI yeniden ister: yalnız bu koşum hâlâ hatırlatılmamış
109
+ // bulduğu satırı damgalar; okumadan sonra bakım kaydedilir, ekipman kullanımdan kalkar ya da sorumlusu değişirse satır
110
+ // damgalanmaz ve eski sorumluya giden e-postada yer almaz (yeni sorumlunun hatırlatması da bastırılmaz).
111
+ const stamps = chosen.length
112
+ ? await env.DB.batch(
113
+ chosen.map((i) =>
114
+ env.DB.prepare(
115
+ 'UPDATE "equipment" SET reminder_sent_for = next_due_date WHERE id = ? AND next_due_date = ? AND active = 1 AND deleted_at IS NULL ' +
116
+ 'AND lower(responsible_org_id) = ? AND (reminder_sent_for IS NULL OR reminder_sent_for <> next_due_date)',
117
+ ).bind(i.id, i.next_due_date, (i.responsible_org_id ?? '').toLowerCase()),
118
+ ),
119
+ )
120
+ : []
121
+ const claimed = new Set(chosen.filter((_, n) => Number(stamps[n]?.meta.changes ?? 0) === 1).map((i) => i.id))
122
+ const api = new BackendClient({ baseUrl: env.API_BASE, authorization: null })
123
+ /** Geri alınacak damgalar; `failed` = ileti sorumluya ULAŞMADI (sıranın sonuna geçer). */
124
+ const rollback: Array<{ item: DueRow; failed: boolean }> = []
125
+ /** İletisi ulaşan kalemler: önceki başarısız denemenin izi silinir (sıradaki yerine döner). */
126
+ const delivered: DueRow[] = []
127
+ let sent = 0
128
+ let waiting = 0
129
+ let consentNeeded = false
130
+ for (const group of groups) {
131
+ const items = group.items.filter((i) => claimed.has(i.id))
132
+ if (items.length === 0) continue
133
+ const more = Math.max(0, (totals.get(group.responsible) ?? 0) - group.items.length)
134
+ const outcome = await sendJobMail(ctx, api, reminderMessage(items, today, more), [group.responsible])
135
+ if (outcome === 'sent') {
136
+ sent++
137
+ delivered.push(...items)
138
+ } else {
139
+ waiting++
140
+ consentNeeded ||= outcome === 'consent-needed'
141
+ rollback.push(...items.map((item) => ({ item, failed: outcome === 'failed' })))
142
+ }
143
+ }
144
+ // Gönderilemeyenlerin damgası geri alınır (yalnız bu koşumun koyduğu damga) ve günün kaydı yazılır — tek toplu yazım.
145
+ await env.DB.batch([
146
+ ...delivered.map((item) => env.DB.prepare('UPDATE "equipment" SET reminder_failed_on = NULL WHERE id = ? AND reminder_failed_on IS NOT NULL').bind(item.id)),
147
+ ...rollback.map(({ item, failed }) =>
148
+ env.DB.prepare('UPDATE "equipment" SET reminder_sent_for = ?, reminder_failed_on = coalesce(?, reminder_failed_on) WHERE id = ? AND reminder_sent_for = ?').bind(
149
+ item.reminder_sent_for,
150
+ failed ? today : null,
151
+ item.id,
152
+ item.next_due_date,
153
+ ),
154
+ ),
155
+ env.DB.prepare('UPDATE "reminder_runs" SET due_count = ?, sent_count = sent_count + ?, waiting_count = ?, consent_needed = ?, updated_at = ? WHERE run_key = ? AND deleted_at IS NULL').bind(
156
+ dueCount,
157
+ sent,
158
+ waiting,
159
+ consentNeeded ? 1 : 0,
160
+ now,
161
+ today,
162
+ ),
163
+ env.DB.prepare(
164
+ 'INSERT INTO "reminder_runs" (id, created_at, created_by, updated_at, updated_by, run_key, due_count, sent_count, waiting_count, consent_needed) ' +
165
+ 'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ? WHERE NOT EXISTS (SELECT 1 FROM "reminder_runs" WHERE run_key = ? AND deleted_at IS NULL)',
166
+ ).bind(crypto.randomUUID(), now, SYSTEM_ACTOR, now, SYSTEM_ACTOR, today, dueCount, sent, waiting, consentNeeded ? 1 : 0, today),
167
+ ])
168
+ },
169
+ },
170
+ })
@@ -0,0 +1,5 @@
1
+ // Uygulamanın adı ve izin kodları — sunucu, zamanlanmış iş ve sayfa aynı değerleri kullanır (döngüsel içe aktarma olmasın
2
+ // diye ayrı dosyada).
3
+ export const APP_NAME = { tr: {{nameTrJs}}, en: {{nameEnJs}} }
4
+ /** Bakım sorumlusu izni (manifest `permissions`): ekipman tanımlar, her ekipmanın bakımını kaydeder. */
5
+ export const MANAGE_PERMISSION = '{{permissionPrefix}}Manage'
@@ -0,0 +1,71 @@
1
+ // Veritabanı okumaları — ekran, zamanlanmış iş ve Devoplus yapay zekâsı araçları aynı sorguları kullanır.
2
+ import type { BaseEnv } from './lib/server.ts'
3
+ import { addDays, daysBetween, dueStatus } from './schedule.ts'
4
+
5
+ /**
6
+ * Sıradaki bakımın SQL karşılığı: son bakım + aralık (gün). ⛔ `schedule.ts` → `nextDueDate` ile AYNI kural; ikisi
7
+ * birlikte değişir (iş kuralı testi ikisinin aynı sonucu verdiğini sınar).
8
+ */
9
+ export const NEXT_DUE_SQL = `date(last_maintenance_date, '+' || CAST(period_days AS INTEGER) || ' days')`
10
+
11
+ const ACTIVE = 'active = 1 AND deleted_at IS NULL'
12
+
13
+ export interface ScheduleRow {
14
+ code: string
15
+ name: string
16
+ location: string | null
17
+ responsible_name: string | null
18
+ last_maintenance_date: string
19
+ next_due_date: string
20
+ }
21
+
22
+ function withStatus(today: string) {
23
+ return (r: ScheduleRow) => ({ ...r, days_left: daysBetween(today, r.next_due_date), status: dueStatus(r.next_due_date, today) })
24
+ }
25
+
26
+ /** Sıradaki bakımı `until` gününe kadar (dahil) olan ya da geçmiş kullanımdaki ekipmanlar — en acil önce. */
27
+ export async function upcoming(env: BaseEnv, today: string, days: number, limit: number) {
28
+ const until = addDays(today, days)
29
+ const [count, rows] = await Promise.all([
30
+ env.DB.prepare(`SELECT COUNT(*) AS n FROM "equipment" WHERE ${ACTIVE} AND next_due_date <= ?`).bind(until).first<{ n: number }>(),
31
+ env.DB.prepare(
32
+ `SELECT code, name, location, responsible_name, last_maintenance_date, next_due_date FROM "equipment" WHERE ${ACTIVE} AND next_due_date <= ? ORDER BY next_due_date, code LIMIT ?`,
33
+ )
34
+ .bind(until, limit)
35
+ .all<ScheduleRow>(),
36
+ ])
37
+ return { today, until, count: count?.n ?? 0, items: rows.results.map(withStatus(today)) }
38
+ }
39
+
40
+ /** Ekipman arama: kod, ad ya da konum içinde geçen (büyük/küçük harf yalnız Latin harflerde yok sayılır). */
41
+ export async function searchEquipment(env: BaseEnv, today: string, query: string, limit: number) {
42
+ const q = query.trim().toLowerCase()
43
+ const rows = await env.DB.prepare(
44
+ `SELECT code, name, location, responsible_name, last_maintenance_date, next_due_date FROM "equipment" WHERE ${ACTIVE} ` +
45
+ `AND (instr(lower(code), ?) > 0 OR instr(lower(name), ?) > 0 OR instr(lower(coalesce(location, '')), ?) > 0) ORDER BY name LIMIT ?`,
46
+ )
47
+ .bind(q, q, q, limit)
48
+ .all<ScheduleRow>()
49
+ return rows.results.map(withStatus(today))
50
+ }
51
+
52
+ /** Özet: geciken ve `windowDays` içinde yaklaşan bakım sayısı (sayılar sunucuda hesaplanır). */
53
+ export async function counts(env: BaseEnv, today: string, windowDays: number) {
54
+ const row = await env.DB.prepare(
55
+ `SELECT SUM(CASE WHEN next_due_date < ? THEN 1 ELSE 0 END) AS overdue, ` +
56
+ `SUM(CASE WHEN next_due_date >= ? AND next_due_date <= ? THEN 1 ELSE 0 END) AS due_soon, COUNT(*) AS total FROM "equipment" WHERE ${ACTIVE}`,
57
+ )
58
+ .bind(today, today, addDays(today, windowDays))
59
+ .first<{ overdue: number | null; due_soon: number | null; total: number }>()
60
+ return { overdue: row?.overdue ?? 0, dueSoon: row?.due_soon ?? 0, total: row?.total ?? 0 }
61
+ }
62
+
63
+ export async function lastRun(env: BaseEnv) {
64
+ return env.DB.prepare('SELECT run_key, due_count, sent_count, waiting_count, consent_needed FROM "reminder_runs" WHERE deleted_at IS NULL ORDER BY run_key DESC LIMIT 1').first<{
65
+ run_key: string
66
+ due_count: number
67
+ sent_count: number
68
+ waiting_count: number
69
+ consent_needed: number
70
+ }>()
71
+ }