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.
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 +286 -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,204 @@
1
+ // {{nameTr}} — envanter (stok takibi). Uçlar (hepsi oturum ister; kimlik platformdan gelir):
2
+ // GET /api/items[?…] stok kalemleri (herkes) — hazır liste + Excel
3
+ // POST /api/items · PATCH/DELETE /api/items/{id} kalem tanımı (stok sorumlusu); MİKTAR bu uçlardan YAZILMAZ
4
+ // POST /api/items/{id}/movements { kind: in|out|count, quantity, note? } (stok sorumlusu) — stok eksiye düşmez
5
+ // GET /api/movements[?…] stok hareketleri (herkes) — hazır liste + Excel; değiştirilemez
6
+ // GET /api/summary azalan kalemler, konuma göre dağılım, son günlük kontrol
7
+ // Miktar YALNIZ hareketle değişir; hareket kaydı ve miktar değişikliği tek toplu yazımdır (ikisi birlikte ya da
8
+ // hiçbiri). Azalma e-postası, miktarı eşiğin altına düşüren harekette kalemin sorumlusuna BİR KEZ gider (miktar yeniden
9
+ // eşiğe çıkınca yeniden kurulur).
10
+ import {
11
+ OneError,
12
+ createCrudHandler,
13
+ getMyOrgContext,
14
+ logAudit,
15
+ logError,
16
+ requirePermission,
17
+ requireUser,
18
+ systemNotice,
19
+ } from '@devoplus/one-sdk'
20
+ import manifest from '../one.manifest.json' with { type: 'json' }
21
+ import { agentTools } from './agent.ts'
22
+ import { dataModel } from './data/types.ts'
23
+ import { jobs } from './jobs.ts'
24
+ import { fieldError, forward, matchId, nowIso, onlyFields, personValue, readJsonObject } from './lib/http.ts'
25
+ import { sendMailSafely } from './lib/mail.ts'
26
+ import { createApp, json, type AppDefinition, type BaseEnv, type RouteInput } from './lib/server.ts'
27
+ import { byLocation, lastCheck, lowStock } from './queries.ts'
28
+ import { renderShell, renderShellScript } from './shell.ts'
29
+ import { checkMovement, isLow } from './stock.ts'
30
+
31
+ export type Env = BaseEnv
32
+
33
+ export const APP_NAME = { tr: {{nameTrJs}}, en: {{nameEnJs}} }
34
+ /** Stok sorumlusu izni (manifest `permissions`): kalem tanımlar, hareket girer. */
35
+ export const MANAGE_PERMISSION = '{{permissionPrefix}}Manage'
36
+
37
+ export const items = createCrudHandler({ model: dataModel, entity: 'items', records: 'shared', canWrite: MANAGE_PERMISSION })
38
+ export const movements = createCrudHandler({ model: dataModel, entity: 'movements', records: 'shared' })
39
+
40
+ const ITEM_FIELDS = ['sku', 'name', 'category', 'unit', 'location', 'min_quantity', 'active', 'responsible']
41
+ const NOTE_MAX = 200
42
+ const CONTROL_RE = /[\u0000-\u001f\u007f]/
43
+ const UNIT_LABELS: Record<string, string> = { piece: 'adet', box: 'kutu', kg: 'kg', litre: 'litre', metre: 'metre' }
44
+
45
+ async function myName(input: RouteInput<Env>): Promise<string | null> {
46
+ try {
47
+ return (await getMyOrgContext(input.api()))?.fullName ?? null
48
+ } catch (error) {
49
+ logError(input.ctx, 'kişi adı okunamadı', error)
50
+ return null
51
+ }
52
+ }
53
+
54
+ /** Kalem gövdesi: kişi seçicinin değeri iki kolona ayrılır; miktar ve bildirim damgası istemciden ALINMAZ. */
55
+ function itemRecord(body: Record<string, unknown>): Record<string, unknown> {
56
+ const { responsible, ...rest } = body
57
+ if (!('responsible' in body)) return rest
58
+ const person = personValue(responsible, 'responsible')
59
+ return { ...rest, responsible_org_id: person?.id ?? null, responsible_name: person?.name ?? null }
60
+ }
61
+
62
+ async function rearm(env: Env, id: string): Promise<void> {
63
+ await env.DB.prepare('UPDATE "items" SET low_notified_at = NULL WHERE id = ? AND low_notified_at IS NOT NULL AND (min_quantity = 0 OR quantity >= min_quantity)')
64
+ .bind(id)
65
+ .run()
66
+ }
67
+
68
+ async function itemWrite(input: RouteInput<Env>, id: string | null): Promise<Response> {
69
+ const { request, env, ctx, method } = input
70
+ requirePermission(ctx, MANAGE_PERMISSION)
71
+ if (id && method === 'DELETE') return (await items.handle(request, env.DB, ctx))!
72
+ const record = itemRecord(onlyFields(await readJsonObject(request), ITEM_FIELDS))
73
+ const res = (await items.handle(forward(request, id ? `${items.path}/${id}` : items.path, id ? 'PATCH' : 'POST', record), env.DB, ctx))!
74
+ if (id && res.ok) await rearm(env, id) // eşik değiştiyse azalma bildirimi yeniden kurulur
75
+ return res
76
+ }
77
+
78
+ async function notifyLow(input: RouteInput<Env>, item: { id: string; name: string; quantity: number; min_quantity: number; unit: string; responsible_org_id: string | null }) {
79
+ const { env, ctx } = input
80
+ const stamp = nowIso()
81
+ // ⛔ "Bir kez" kuralı koşullu güncellemededir: iki eşzamanlı çıkıştan yalnız biri damgayı koyar ve e-postayı gönderir.
82
+ const mark = await env.DB.prepare(
83
+ 'UPDATE "items" SET low_notified_at = ? WHERE id = ? AND low_notified_at IS NULL AND min_quantity > 0 AND quantity < min_quantity',
84
+ )
85
+ .bind(stamp, item.id)
86
+ .run()
87
+ if (Number(mark.meta.changes ?? 0) !== 1 || !item.responsible_org_id) return 'skipped'
88
+ const unit = UNIT_LABELS[item.unit] ?? item.unit
89
+ const outcome = await sendMailSafely(
90
+ ctx,
91
+ input.api(),
92
+ systemNotice({
93
+ appName: APP_NAME.tr,
94
+ title: `Stok azaldı: ${item.name}`,
95
+ message: `Eldeki miktar ${item.quantity} ${unit}; en az miktar ${item.min_quantity} ${unit}. Uygulamanın Stok sekmesinden ayrıntıyı görebilirsiniz.`,
96
+ }),
97
+ { toOrgUserIds: [item.responsible_org_id] },
98
+ )
99
+ // Gönderilemediyse damga geri alınır: bir sonraki hareket (belki e-posta izni olan bir kullanıcının) yeniden dener.
100
+ if (outcome !== 'sent') await env.DB.prepare('UPDATE "items" SET low_notified_at = NULL WHERE id = ? AND low_notified_at = ?').bind(item.id, stamp).run()
101
+ return outcome
102
+ }
103
+
104
+ async function move(input: RouteInput<Env>, id: string): Promise<Response> {
105
+ const { request, env, ctx } = input
106
+ const user = requireUser(ctx)
107
+ requirePermission(ctx, MANAGE_PERMISSION)
108
+ const body = onlyFields(await readJsonObject(request), ['kind', 'quantity', 'note'])
109
+ const item = await env.DB.prepare('SELECT id, name, unit, active FROM "items" WHERE id = ? AND deleted_at IS NULL').bind(id).first<{ id: string; name: string; unit: string; active: number }>()
110
+ if (!item) throw new OneError(404, 'APP-E-404', 'Stok kalemi bulunamadı.')
111
+ if (item.active !== 1) throw new OneError(409, 'APP-E-021', 'Kullanımda olmayan kaleme hareket girilmez.')
112
+ const check = checkMovement(body.kind, body.quantity, item.unit)
113
+ if (!check.ok) throw fieldError(check.field, check.message)
114
+ const note = typeof body.note === 'string' ? body.note.trim() : ''
115
+ if ([...note].length > NOTE_MAX || CONTROL_RE.test(note)) throw fieldError('note', `Açıklama en çok ${NOTE_MAX} karakterlik tek satır olmalı.`)
116
+ const now = nowIso()
117
+ const movementId = crypto.randomUUID()
118
+ const actor = await myName(input)
119
+ // ⛔ Hareket kaydı ve miktar değişikliği TEK toplu yazımdır (batch = tek işlem): ikisi birlikte yazılır ya da hiçbiri.
120
+ // 1. Hareket yalnız kalem kullanımdaysa (çıkışta: eldeki miktar yetiyorsa) eklenir — koşul aynı ifadede.
121
+ // 2. Miktar yalnız BU hareket eklendiyse değişir (`EXISTS … movementId`).
122
+ // Toplu yazım veritabanında sırayla ve başka bir yazımla iç içe geçmeden yürür: eşzamanlı iki çıkışta ikincisi
123
+ // birincinin düşürdüğü miktarı görür; stok eksiye düşmez. Yazımlardan biri düşerse ikisi de geri alınır.
124
+ // Miktar kuralı okunan birime göre denetlendi (ör. adet tam sayıdır): birim bu arada değiştiyse hareket eklenmez.
125
+ // Kalemin adı hareketin kendi ifadesinde kalemden okunur (bayat ad kopyalanmaz).
126
+ const stockGuard = check.kind === 'out' ? ' AND i.quantity >= ?' : ''
127
+ const insert = env.DB.prepare(
128
+ 'INSERT INTO "movements" (id, created_at, created_by, updated_at, updated_by, item_id, item_name, kind, quantity, note, actor_name) ' +
129
+ `SELECT ?, ?, ?, ?, ?, i.id, i.name, ?, ?, ?, ? FROM "items" i WHERE i.id = ? AND i.active = 1 AND i.deleted_at IS NULL AND i.unit = ?${stockGuard}`,
130
+ ).bind(
131
+ movementId,
132
+ now,
133
+ user.sub,
134
+ now,
135
+ user.sub,
136
+ check.kind,
137
+ check.quantity,
138
+ note || null,
139
+ actor,
140
+ id,
141
+ item.unit,
142
+ ...(check.kind === 'out' ? [check.quantity] : []),
143
+ )
144
+ const quantity = check.kind === 'in' ? 'quantity + ?' : check.kind === 'out' ? 'quantity - ?' : '?'
145
+ const update = env.DB.prepare(
146
+ `UPDATE "items" SET quantity = ${quantity}, updated_at = ?, updated_by = ? WHERE id = ? AND EXISTS (SELECT 1 FROM "movements" WHERE id = ?)`,
147
+ ).bind(check.quantity, now, user.sub, id, movementId)
148
+ const [inserted, changed] = await env.DB.batch([insert, update])
149
+ if (Number(inserted?.meta.changes ?? 0) !== 1 || Number(changed?.meta.changes ?? 0) !== 1) {
150
+ const current = await env.DB.prepare('SELECT quantity, unit, active FROM "items" WHERE id = ? AND deleted_at IS NULL').bind(id).first<{ quantity: number; unit: string; active: number }>()
151
+ if (!current) throw new OneError(404, 'APP-E-404', 'Stok kalemi bulunamadı.')
152
+ if (current.active !== 1) throw new OneError(409, 'APP-E-021', 'Kullanımda olmayan kaleme hareket girilmez.')
153
+ if (check.kind === 'out' && current.unit === item.unit && current.quantity < check.quantity)
154
+ throw fieldError('quantity', `Stokta yeterli miktar yok (eldeki: ${current.quantity}).`, 409)
155
+ throw new OneError(409, 'APP-E-021', 'Kalem bu arada değişti; sayfayı yenileyip yeniden deneyin.')
156
+ }
157
+ logAudit(ctx, `stock.${check.kind}`, id, { quantity: check.quantity })
158
+ const after = await env.DB.prepare('SELECT id, name, unit, quantity, min_quantity, responsible_org_id FROM "items" WHERE id = ?')
159
+ .bind(id)
160
+ .first<{ id: string; name: string; unit: string; quantity: number; min_quantity: number; responsible_org_id: string | null }>()
161
+ let notification = 'skipped'
162
+ if (after && isLow(after.quantity, after.min_quantity)) notification = await notifyLow(input, after)
163
+ else await rearm(env, id)
164
+ return json({ id: movementId, item: { id, quantity: after?.quantity ?? null, low: after ? isLow(after.quantity, after.min_quantity) : false }, notification }, 201)
165
+ }
166
+
167
+ async function summary({ env, ctx }: RouteInput<Env>): Promise<Response> {
168
+ requireUser(ctx)
169
+ const [low, locations, check] = await Promise.all([lowStock(env, 20), byLocation(env), lastCheck(env)])
170
+ return json({ low, byLocation: locations, lastCheck: check })
171
+ }
172
+
173
+ async function routes(input: RouteInput<Env>): Promise<Response | null> {
174
+ const { url, method, env, ctx } = input
175
+ const path = url.pathname
176
+ if (path === '/api/summary' && method === 'GET') return summary(input)
177
+ const moveId = matchId(path, items.path, 'movements')
178
+ if (moveId && method === 'POST') return move(input, moveId)
179
+ if (path === items.path && method === 'POST') return itemWrite(input, null)
180
+ const itemId = matchId(path, items.path)
181
+ if (itemId && (method === 'PATCH' || method === 'PUT' || method === 'DELETE')) {
182
+ if (method === 'PUT') throw new OneError(405, 'APP-E-405', 'Kalem PATCH ile güncellenir.')
183
+ return itemWrite(input, itemId)
184
+ }
185
+ if (path === items.path || path.startsWith(`${items.path}/`)) return items.handle(input.request, env.DB, ctx)
186
+ if (path === movements.path || path.startsWith(`${movements.path}/`)) {
187
+ if (method !== 'GET' && method !== 'HEAD') throw new OneError(405, 'APP-E-405', 'Stok hareketi değiştirilemez; düzeltme için yeni bir hareket (ya da sayım) girin.')
188
+ return movements.handle(input.request, env.DB, ctx)
189
+ }
190
+ return null
191
+ }
192
+
193
+ export const definition: Omit<AppDefinition<Env>, 'guide'> = {
194
+ manifest,
195
+ tools: agentTools,
196
+ jobs,
197
+ page: renderShell,
198
+ script: renderShellScript,
199
+ routes,
200
+ }
201
+
202
+ export function buildApp(guide: string) {
203
+ return createApp<Env>({ ...definition, guide })
204
+ }
@@ -0,0 +1,30 @@
1
+ // Zamanlanmış görevler (manifest `jobs`). Zamanlanmış iş KULLANICISIZDIR: Devoplus servislerini (e-posta, organizasyon
2
+ // şeması) kullanmaz, yalnız uygulamanın kendi veritabanı. Bu yüzden günlük kontrol e-posta GÖNDERMEZ; sonucu kayda
3
+ // yazar, uygulama ve Devoplus yapay zekâsı oradan okur. Azalma e-postası, miktarı eşiğin altına düşüren hareketle
4
+ // (kullanıcının işlemi) gider — `app.ts`.
5
+ // Aynı iş birden çok kez gelebilir: kayıt gün anahtarıyla TEKİLDİR, tekrar yeni kayıt açmaz.
6
+ import { defineJobs } from '@devoplus/one-sdk'
7
+ import type { Env } from './app.ts'
8
+ import { lowStock } from './queries.ts'
9
+ import { checkDayKey } from './stock.ts'
10
+
11
+ /** Sistemin yazdığı kayıtlarda `created_by` değeri (kişi kimliği değildir). */
12
+ export const SYSTEM_ACTOR = 'system:daily-stock-check'
13
+ /** Kontrol kaydına yazılan en çok kalem (sayı her zaman tamdır). */
14
+ export const CHECK_ITEMS_MAX = 100
15
+
16
+ export const jobs = defineJobs<Env>({
17
+ schedules: {
18
+ // Her sabah 07:00 (İstanbul): en az miktarın altındaki kalemler o günün kontrol kaydına yazılır.
19
+ 'daily-stock-check': async (job, env) => {
20
+ const at = job.scheduledTime ? new Date(job.scheduledTime) : new Date()
21
+ const now = at.toISOString()
22
+ const low = await lowStock(env, CHECK_ITEMS_MAX)
23
+ await env.DB.prepare(
24
+ 'INSERT OR IGNORE INTO "stock_checks" (id, created_at, created_by, updated_at, updated_by, run_key, low_count, low_items) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
25
+ )
26
+ .bind(crypto.randomUUID(), now, SYSTEM_ACTOR, now, SYSTEM_ACTOR, checkDayKey(at), low.count, JSON.stringify(low.items))
27
+ .run()
28
+ },
29
+ },
30
+ })
@@ -0,0 +1,53 @@
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
+
4
+ const LOW = 'active = 1 AND deleted_at IS NULL AND min_quantity > 0 AND quantity < min_quantity'
5
+
6
+ export interface LowItem {
7
+ sku: string
8
+ name: string
9
+ quantity: number
10
+ min_quantity: number
11
+ unit: string
12
+ location: string | null
13
+ }
14
+
15
+ /** Şu an en az miktarın altındaki kalemler (en çok `limit`) ve toplam sayıları. */
16
+ export async function lowStock(env: BaseEnv, limit = 50): Promise<{ count: number; items: LowItem[] }> {
17
+ const [count, rows] = await Promise.all([
18
+ env.DB.prepare(`SELECT COUNT(*) AS n FROM "items" WHERE ${LOW}`).first<{ n: number }>(),
19
+ env.DB.prepare(`SELECT sku, name, quantity, min_quantity, unit, location FROM "items" WHERE ${LOW} ORDER BY (quantity * 1.0 / min_quantity), name LIMIT ?`)
20
+ .bind(limit)
21
+ .all<LowItem>(),
22
+ ])
23
+ return { count: count?.n ?? 0, items: rows.results }
24
+ }
25
+
26
+ /** Kalem arama: stok kodu, ad ya da konum içinde geçen (büyük/küçük harf yalnız Latin harflerde yok sayılır). */
27
+ export async function searchItems(env: BaseEnv, query: string, limit: number) {
28
+ const q = query.trim().toLowerCase()
29
+ const rows = await env.DB.prepare(
30
+ 'SELECT sku, name, quantity, min_quantity, unit, location FROM "items" WHERE active = 1 AND deleted_at IS NULL ' +
31
+ 'AND (instr(lower(sku), ?) > 0 OR instr(lower(name), ?) > 0 OR instr(lower(coalesce(location, \'\')), ?) > 0) ORDER BY name LIMIT ?',
32
+ )
33
+ .bind(q, q, q, limit)
34
+ .all<LowItem>()
35
+ return rows.results.map((r) => ({ ...r, low: r.min_quantity > 0 && r.quantity < r.min_quantity }))
36
+ }
37
+
38
+ /** Konuma göre kalem sayısı ve eşiğin altındakiler (grafik için; sayılar sunucuda hesaplanır). */
39
+ export async function byLocation(env: BaseEnv) {
40
+ const rows = await env.DB.prepare(
41
+ `SELECT coalesce(location, '') AS location, COUNT(*) AS total, SUM(CASE WHEN min_quantity > 0 AND quantity < min_quantity THEN 1 ELSE 0 END) AS low ` +
42
+ 'FROM "items" WHERE active = 1 AND deleted_at IS NULL GROUP BY coalesce(location, \'\') ORDER BY total DESC LIMIT 12',
43
+ ).all<{ location: string; total: number; low: number }>()
44
+ return rows.results
45
+ }
46
+
47
+ export async function lastCheck(env: BaseEnv) {
48
+ return env.DB.prepare('SELECT run_key, created_at, low_count FROM "stock_checks" WHERE deleted_at IS NULL ORDER BY run_key DESC LIMIT 1').first<{
49
+ run_key: string
50
+ created_at: string
51
+ low_count: number
52
+ }>()
53
+ }
@@ -0,0 +1,265 @@
1
+ // {{nameTr}} — sayfa (üç görünüm: Stok · Hareketler · Kalemler). Liste, süzgeç, form, kişi seçici ve grafik hazır
2
+ // bileşenlerdir; seçenekler veri modelinden gelir. Azalan kalem sayısı ve grafik verisi sunucuda hesaplanır.
3
+ import type { OneNavigationItem } from '@devoplus/one-sdk'
4
+ import { dataModel } from './data/types.ts'
5
+ import { renderClientScript } from './lib/client.ts'
6
+ import { choices, label } from './lib/model.ts'
7
+ import { escapeHtml, i18n, renderPage, translator, view, type Lang, type Strings, type Theme } from './lib/page.ts'
8
+
9
+ export const STRINGS: Strings = {
10
+ tr: {
11
+ title: {{nameTrJs}},
12
+ waiting: "Portal'dan oturum bekleniyor…",
13
+ networkError: 'İstek gönderilemedi (ağ hatası).',
14
+ requestFailed: 'İşlem yapılamadı.',
15
+ readOnly: 'Vekalet ya da Yerine Görüntüle oturumundasınız: bu uygulamada yalnız görüntüleme yapabilirsiniz.',
16
+ stockIntro: 'Kalemlerin eldeki miktarları. Stok kodu, ad ya da konumla arayabilirsiniz.',
17
+ lowSome: '{n} kalem en az miktarın altında.',
18
+ lowNone: 'Bütün kalemler en az miktarın üzerinde.',
19
+ lastCheck: 'Son günlük kontrol: {day} — {n} kalem eşiğin altındaydı.',
20
+ noCheck: 'Henüz günlük kontrol yapılmadı.',
21
+ low: 'Azaldı',
22
+ search: 'Ara',
23
+ stockTable: 'Stok',
24
+ chartTitle: 'Konuma göre kalemler',
25
+ seriesTotal: 'Kalem',
26
+ seriesLow: 'Eşiğin altında',
27
+ noLocation: '(konumsuz)',
28
+ managersOnly: 'Bu işlem yalnız stok sorumlularına açıktır.',
29
+ movementsIntro: 'Giriş ve çıkışlar miktarı değiştirir; sayım eldeki miktarı sayılan değere eşitler. Stok eksiye düşmez.',
30
+ movementForm: 'Yeni hareket',
31
+ moved: 'Hareket kaydedildi.',
32
+ lowWarn: 'Kalem en az miktarın altına düştü.',
33
+ consentNeeded: 'Hareket kaydedildi ama sorumluya e-posta gönderilemedi: portaldaki "İzinleri incele" düğmesiyle e-posta izni verin.',
34
+ movementsTable: 'Stok hareketleri',
35
+ itemsIntro: 'Stok kalemlerinin tanımı. Miktar buradan değişmez; Hareketler sekmesinden girilir.',
36
+ itemsTable: 'Kalemler',
37
+ itemForm: 'Kalem bilgileri',
38
+ newItem: 'Yeni kalem',
39
+ itemSaved: 'Kalem kaydedildi.',
40
+ responsible: 'Sorumlu (azalma e-postası alır)',
41
+ },
42
+ en: {
43
+ title: {{nameEnJs}},
44
+ waiting: 'Waiting for the session from the Portal…',
45
+ networkError: 'Request could not be sent (network error).',
46
+ requestFailed: 'The action could not be completed.',
47
+ readOnly: 'You are in a Delegation or View as User session: you can only view this app.',
48
+ stockIntro: 'Quantities on hand. You can search by SKU, name or location.',
49
+ lowSome: '{n} items are below the minimum quantity.',
50
+ lowNone: 'All items are above the minimum quantity.',
51
+ lastCheck: 'Latest daily check: {day} — {n} items were below the minimum.',
52
+ noCheck: 'No daily check has run yet.',
53
+ low: 'Low',
54
+ search: 'Search',
55
+ stockTable: 'Stock',
56
+ chartTitle: 'Items by location',
57
+ seriesTotal: 'Items',
58
+ seriesLow: 'Below minimum',
59
+ noLocation: '(no location)',
60
+ managersOnly: 'Only stock managers can do this.',
61
+ movementsIntro: 'In and out movements change the quantity; a count sets the quantity on hand to the counted value. Stock never goes below zero.',
62
+ movementForm: 'New movement',
63
+ moved: 'Movement saved.',
64
+ lowWarn: 'The item fell below the minimum quantity.',
65
+ consentNeeded: 'Movement saved, but no e-mail was sent to the responsible person: allow e-mail with the "Review permissions" button in the Portal.',
66
+ movementsTable: 'Stock movements',
67
+ itemsIntro: 'Stock item definitions. The quantity does not change here; enter it on the Movements tab.',
68
+ itemsTable: 'Items',
69
+ itemForm: 'Item details',
70
+ newItem: 'New item',
71
+ itemSaved: 'Item saved.',
72
+ responsible: 'Responsible (gets the low stock e-mail)',
73
+ },
74
+ }
75
+
76
+ const MODEL = {
77
+ manage: '{{permissionPrefix}}Manage',
78
+ unit: choices(dataModel, 'items', 'unit'),
79
+ kind: choices(dataModel, 'movements', 'kind'),
80
+ labels: {
81
+ sku: label(dataModel, 'items', 'sku'),
82
+ name: label(dataModel, 'items', 'name'),
83
+ category: label(dataModel, 'items', 'category'),
84
+ unit: label(dataModel, 'items', 'unit'),
85
+ location: label(dataModel, 'items', 'location'),
86
+ quantity: label(dataModel, 'items', 'quantity'),
87
+ min: label(dataModel, 'items', 'min_quantity'),
88
+ responsibleName: label(dataModel, 'items', 'responsible_name'),
89
+ active: label(dataModel, 'items', 'active'),
90
+ item: label(dataModel, 'movements', 'item_id'),
91
+ itemName: label(dataModel, 'movements', 'item_name'),
92
+ kind: label(dataModel, 'movements', 'kind'),
93
+ moveQuantity: label(dataModel, 'movements', 'quantity'),
94
+ note: label(dataModel, 'movements', 'note'),
95
+ actor: label(dataModel, 'movements', 'actor_name'),
96
+ },
97
+ }
98
+
99
+ export function renderShell(lang: Lang, theme: Theme, path: string): string {
100
+ const tx = translator(STRINGS, lang)
101
+ const btn = (id: string, key: string, cls = 'one-btn') => `<button type="button" class="${cls}" id="${id}" data-i18n="${key}">${escapeHtml(tx(key))}</button>`
102
+ const body = [
103
+ view(
104
+ '/',
105
+ path,
106
+ `${i18n(tx, 'stockIntro', 'p', 'class="one-muted"')}
107
+ <p><strong id="lowBanner"></strong></p>
108
+ <p class="one-muted" id="checkNote"></p>
109
+ <one-filter id="stockFilter" for="stockTable"></one-filter>
110
+ <one-table id="stockTable" src="/api/items?active=true" paging="page" export="xlsx csv" caption="${escapeHtml(tx('stockTable'))}"></one-table>
111
+ <one-chart id="locationChart" type="bar"></one-chart>`,
112
+ ),
113
+ view(
114
+ '/movements',
115
+ path,
116
+ `${i18n(tx, 'movementsIntro', 'p', 'class="one-muted"')}
117
+ ${i18n(tx, 'managersOnly', 'p', 'class="one-muted" id="moveDenied"')}
118
+ <section id="moveBox" class="one-card" hidden>
119
+ <h2 data-i18n="movementForm">${escapeHtml(tx('movementForm'))}</h2>
120
+ <one-form id="moveForm" no-cancel></one-form>
121
+ </section>
122
+ <one-table id="movementsTable" src="/api/movements" paging="page" export="xlsx csv" caption="${escapeHtml(tx('movementsTable'))}"></one-table>`,
123
+ ),
124
+ view(
125
+ '/items',
126
+ path,
127
+ `${i18n(tx, 'itemsIntro', 'p', 'class="one-muted"')}
128
+ <one-table id="itemsTable" src="/api/items" paging="page" export="xlsx csv" clickable caption="${escapeHtml(tx('itemsTable'))}"></one-table>
129
+ ${i18n(tx, 'managersOnly', 'p', 'class="one-muted" id="itemsDenied"')}
130
+ <section id="itemEditor" class="one-card" hidden>
131
+ <h2 data-i18n="itemForm">${escapeHtml(tx('itemForm'))}</h2>
132
+ <div class="app-toolbar">${btn('newItem', 'newItem')}</div>
133
+ <one-form id="itemForm" action="/api/items" method="POST"></one-form>
134
+ </section>`,
135
+ ),
136
+ ].join('\n')
137
+ return renderPage({ lang, theme, title: tx('title'), body })
138
+ }
139
+
140
+ export function renderShellScript(navigation: readonly OneNavigationItem[], options: { actingWrites?: boolean } = {}): string {
141
+ return renderClientScript({ strings: STRINGS, navigation, ...(options.actingWrites !== undefined ? { actingWrites: options.actingWrites } : {}), body: pageScript() })
142
+ }
143
+
144
+ function pageScript(): string {
145
+ return `
146
+ var M = ${JSON.stringify(MODEL).replace(/<\//g, '<\\/')};
147
+ var stockTable = $('stockTable'), stockFilter = $('stockFilter'), chart = $('locationChart');
148
+ var moveForm = $('moveForm'), movementsTable = $('movementsTable'), itemsTable = $('itemsTable'), itemForm = $('itemForm');
149
+ var itemOptions = [];
150
+
151
+ function isLow(row) { return Number(row.min_quantity) > 0 && Number(row.quantity) < Number(row.min_quantity); }
152
+ stockTable.columns = [
153
+ { key: 'sku', label: M.labels.sku },
154
+ { key: 'name', label: M.labels.name },
155
+ { key: 'category', label: M.labels.category },
156
+ { key: 'location', label: M.labels.location },
157
+ { key: 'quantity', label: M.labels.quantity, type: 'number' },
158
+ { key: 'unit', label: M.labels.unit, type: 'choice', options: M.unit },
159
+ { key: 'min_quantity', label: M.labels.min, type: 'number' },
160
+ { key: 'low', label: { tr: S.tr.low, en: S.en.low }, sortable: false, format: function (v, row) { return isLow(row) ? t('low') : ''; } },
161
+ { key: 'responsible_name', label: M.labels.responsibleName }
162
+ ];
163
+ stockFilter.fields = [{ key: 'q', label: { tr: S.tr.search, en: S.en.search }, type: 'text' }];
164
+ movementsTable.columns = [
165
+ { key: 'created_at', label: { tr: 'Zaman', en: 'Time' }, type: 'datetime' },
166
+ { key: 'item_name', label: M.labels.itemName },
167
+ { key: 'kind', label: M.labels.kind, type: 'choice', options: M.kind },
168
+ { key: 'quantity', label: M.labels.moveQuantity, type: 'number' },
169
+ { key: 'note', label: M.labels.note },
170
+ { key: 'actor_name', label: M.labels.actor }
171
+ ];
172
+ itemsTable.columns = [
173
+ { key: 'sku', label: M.labels.sku },
174
+ { key: 'name', label: M.labels.name },
175
+ { key: 'category', label: M.labels.category },
176
+ { key: 'location', label: M.labels.location },
177
+ { key: 'min_quantity', label: M.labels.min, type: 'number' },
178
+ { key: 'responsible_name', label: M.labels.responsibleName },
179
+ { key: 'active', label: M.labels.active, type: 'boolean' }
180
+ ];
181
+ itemForm.fields = [
182
+ { key: 'sku', label: M.labels.sku, required: true, maxLength: 40 },
183
+ { key: 'name', label: M.labels.name, required: true, maxLength: 120 },
184
+ { key: 'category', label: M.labels.category, maxLength: 60 },
185
+ { key: 'unit', label: M.labels.unit, type: 'choice', options: M.unit, required: true },
186
+ { key: 'location', label: M.labels.location, maxLength: 120 },
187
+ { key: 'min_quantity', label: M.labels.min, type: 'number', min: 0, required: true },
188
+ { key: 'responsible', label: { tr: S.tr.responsible, en: S.en.responsible }, type: 'person' },
189
+ { key: 'active', label: M.labels.active, type: 'boolean' }
190
+ ];
191
+ function newItem() { itemForm.setAttribute('action', '/api/items'); itemForm.setAttribute('method', 'POST'); itemForm.value = { unit: 'piece', min_quantity: 0, active: true }; one.setTitle(null); }
192
+ newItem();
193
+
194
+ function moveFields() {
195
+ moveForm.fields = [
196
+ { key: 'item', label: M.labels.item, type: 'choice', options: itemOptions, required: true },
197
+ { key: 'kind', label: M.labels.kind, type: 'choice', options: M.kind, required: true },
198
+ { key: 'quantity', label: M.labels.moveQuantity, type: 'number', min: 0, required: true },
199
+ { key: 'note', label: M.labels.note, maxLength: 200 }
200
+ ];
201
+ }
202
+ function loadItems() {
203
+ return loadAll('/api/items?active=true&_sort=name').then(function (r) {
204
+ if (!r.ok) { one.toast({ severity: 'error', summary: problem(r) }); return; }
205
+ itemOptions = r.body.items.map(function (x) { return { value: x.id, label: x.sku + ' - ' + x.name }; });
206
+ moveFields();
207
+ });
208
+ }
209
+ function loadSummary() {
210
+ return api('GET', '/api/summary').then(function (r) {
211
+ if (!r.ok) { one.toast({ severity: 'error', summary: problem(r) }); return; }
212
+ var s = r.body;
213
+ $('lowBanner').textContent = s.low.count > 0 ? t('lowSome', { n: fmtNumber(s.low.count) }) : t('lowNone');
214
+ $('checkNote').textContent = s.lastCheck ? t('lastCheck', { day: fmtDate(s.lastCheck.run_key), n: fmtNumber(s.lastCheck.low_count) }) : t('noCheck');
215
+ chart.label = { tr: S.tr.chartTitle, en: S.en.chartTitle };
216
+ chart.data = {
217
+ labels: s.byLocation.map(function (x) { return x.location || t('noLocation'); }),
218
+ series: [
219
+ { name: { tr: S.tr.seriesTotal, en: S.en.seriesTotal }, values: s.byLocation.map(function (x) { return x.total; }) },
220
+ { name: { tr: S.tr.seriesLow, en: S.en.seriesLow }, values: s.byLocation.map(function (x) { return x.low; }) }
221
+ ]
222
+ };
223
+ });
224
+ }
225
+ // Hareket formu kendisi göndermez: kalem seçimi adrese yazılır (gövde yalnız tür, miktar ve açıklamayı taşır).
226
+ moveForm.addEventListener('save', function (e) {
227
+ e.preventDefault();
228
+ var v = e.detail.value;
229
+ api('POST', '/api/items/' + encodeURIComponent(v.item) + '/movements', { kind: v.kind, quantity: v.quantity, note: v.note || '' }).then(function (r) {
230
+ if (!r.ok) {
231
+ if (r.body && r.body.errors) moveForm.setErrors(r.body.errors, problem(r));
232
+ else one.toast({ severity: 'error', summary: problem(r) });
233
+ return;
234
+ }
235
+ one.toast({ severity: 'success', summary: t('moved') });
236
+ if (r.body.item && r.body.item.low) one.toast({ severity: 'warn', summary: t('lowWarn') });
237
+ if (r.body.notification === 'consent-needed') one.toast({ severity: 'warn', summary: t('consentNeeded') });
238
+ moveForm.value = { kind: v.kind };
239
+ movementsTable.reload(); stockTable.reload(); loadSummary();
240
+ });
241
+ });
242
+ itemsTable.addEventListener('rowclick', function (e) {
243
+ if (!can(M.manage) || !canWrite()) return;
244
+ var r = e.detail.row;
245
+ itemForm.setAttribute('action', '/api/items/' + encodeURIComponent(r.id));
246
+ itemForm.setAttribute('method', 'PATCH');
247
+ itemForm.value = Object.assign({}, r, { responsible: r.responsible_org_id ? { id: r.responsible_org_id, name: r.responsible_name || '' } : null });
248
+ one.setTitle(r.name);
249
+ });
250
+ $('newItem').addEventListener('click', newItem);
251
+ itemForm.addEventListener('saved', function (e) {
252
+ e.preventDefault();
253
+ one.toast({ severity: 'success', summary: t('itemSaved') });
254
+ newItem(); itemsTable.reload(); stockTable.reload(); loadItems(); loadSummary();
255
+ });
256
+ onSession(function () {
257
+ var manager = can(M.manage) && canWrite();
258
+ $('moveBox').toggleAttribute('hidden', !manager); $('moveDenied').toggleAttribute('hidden', manager);
259
+ $('itemEditor').toggleAttribute('hidden', !manager); $('itemsDenied').toggleAttribute('hidden', manager);
260
+ if (manager) loadItems();
261
+ });
262
+ onLang(function () { loadSummary(); });
263
+ onReady(function () { loadSummary(); });
264
+ `
265
+ }
@@ -0,0 +1,31 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { MOVEMENT_MAX, checkDayKey, checkMovement, isLow } from './stock.ts'
3
+
4
+ describe('stok kuralları', () => {
5
+ it('giriş ve çıkışta miktar pozitif; sayımda 0 geçerli', () => {
6
+ expect(checkMovement('in', 5, 'piece')).toEqual({ ok: true, kind: 'in', quantity: 5 })
7
+ expect(checkMovement('out', '0', 'piece')).toMatchObject({ ok: false, field: 'quantity' })
8
+ expect(checkMovement('count', 0, 'piece')).toEqual({ ok: true, kind: 'count', quantity: 0 })
9
+ expect(checkMovement('count', -1, 'piece')).toMatchObject({ ok: false, field: 'quantity' })
10
+ })
11
+
12
+ it('adet ve kutuda tam sayı; kg ve litrede en çok üç ondalık; tavan', () => {
13
+ expect(checkMovement('in', 1.5, 'piece')).toMatchObject({ ok: false })
14
+ expect(checkMovement('in', 1.255, 'kg')).toEqual({ ok: true, kind: 'in', quantity: 1.255 })
15
+ expect(checkMovement('in', 1.2555, 'kg')).toMatchObject({ ok: false })
16
+ expect(checkMovement('in', MOVEMENT_MAX + 1, 'piece')).toMatchObject({ ok: false })
17
+ expect(checkMovement('transfer', 1, 'piece')).toMatchObject({ ok: false, field: 'kind' })
18
+ expect(checkMovement('in', 'beş', 'piece')).toMatchObject({ ok: false, field: 'quantity' })
19
+ })
20
+
21
+ it('eşik: altında azalmış; eşitte değil; eşik 0 iken hiç', () => {
22
+ expect(isLow(4, 5)).toBe(true)
23
+ expect(isLow(5, 5)).toBe(false)
24
+ expect(isLow(0, 0)).toBe(false)
25
+ })
26
+
27
+ it('günlük kontrol anahtarı İstanbul gününe göre', () => {
28
+ expect(checkDayKey(new Date('2026-10-20T20:59:00Z'))).toBe('2026-10-20')
29
+ expect(checkDayKey(new Date('2026-10-20T21:00:00Z'))).toBe('2026-10-21')
30
+ })
31
+ })
@@ -0,0 +1,33 @@
1
+ // Stok kuralları — SAF fonksiyonlar (veritabanı yok): hareket doğrulama, eşik ve günlük kontrol anahtarı. Sunucu her
2
+ // hareketi burada doğrular; kuralı değiştiren bu dosyayı ve `stock.test.ts`'i birlikte değiştirir.
3
+ export const MOVEMENT_KINDS = ['in', 'out', 'count'] as const
4
+ export type MovementKind = (typeof MOVEMENT_KINDS)[number]
5
+ /** Tek hareketin en büyük miktarı (yanlış yazılan fazladan sıfırı yakalar). */
6
+ export const MOVEMENT_MAX = 1_000_000
7
+ /** Miktar en çok üç ondalık (kg, litre); adet ve kutuda tam sayı. */
8
+ export const DECIMALS = 3
9
+ const WHOLE_UNITS = new Set(['piece', 'box'])
10
+
11
+ export type MovementCheck = { ok: true; kind: MovementKind; quantity: number } | { ok: false; field: 'kind' | 'quantity'; message: string }
12
+
13
+ export function checkMovement(kind: unknown, raw: unknown, unit: string): MovementCheck {
14
+ if (typeof kind !== 'string' || !(MOVEMENT_KINDS as readonly string[]).includes(kind)) return { ok: false, field: 'kind', message: 'Giriş, çıkış ya da sayım seçin.' }
15
+ const quantity = typeof raw === 'number' ? raw : typeof raw === 'string' && raw.trim() !== '' ? Number(raw) : NaN
16
+ if (!Number.isFinite(quantity)) return { ok: false, field: 'quantity', message: 'Miktar bir sayı olmalı.' }
17
+ if (WHOLE_UNITS.has(unit) && !Number.isInteger(quantity)) return { ok: false, field: 'quantity', message: 'Bu birimde miktar tam sayı olmalı.' }
18
+ if (Math.abs(Math.round(quantity * 10 ** DECIMALS) - quantity * 10 ** DECIMALS) > 1e-6) return { ok: false, field: 'quantity', message: `Miktar en çok ${DECIMALS} ondalık olabilir.` }
19
+ if (quantity > MOVEMENT_MAX) return { ok: false, field: 'quantity', message: `Tek harekette en çok ${MOVEMENT_MAX} girilebilir.` }
20
+ // Sayımda 0 geçerlidir (raf boş); giriş ve çıkışta değildir.
21
+ if (kind === 'count' ? quantity < 0 : quantity <= 0) return { ok: false, field: 'quantity', message: kind === 'count' ? 'Sayım sonucu 0 ya da daha büyük olmalı.' : 'Miktar 0\'dan büyük olmalı.' }
22
+ return { ok: true, kind: kind as MovementKind, quantity }
23
+ }
24
+
25
+ /** Eşik: miktar en az miktarın ALTINDAYSA azalmıştır (eşik 0 ise kalem hiç azalmış sayılmaz). */
26
+ export function isLow(quantity: number, minQuantity: number): boolean {
27
+ return minQuantity > 0 && quantity < minQuantity
28
+ }
29
+
30
+ /** Günlük kontrolün anahtarı: İstanbul saatiyle gün (aynı gün ikinci kez koşan iş yeni kayıt açmaz). */
31
+ export function checkDayKey(at: Date): string {
32
+ return new Date(at.getTime() + 3 * 3_600_000).toISOString().slice(0, 10)
33
+ }