Project

General

Profile

Feature #27129 » notification-stock-alert-spec.md

Parujee Pangsriuthai, 05/20/2026 02:37 PM

 
1
# Spec: Notification Stock Alert — แจ้งเตือนอัตโนมัติเมื่อ Stock ต่ำกว่า Minimum
2

    
3
> สำหรับ Dev — พร้อม implement
4

    
5
---
6

    
7
## ภาพรวม
8

    
9
เปลี่ยนระบบ notification จาก "แจ้งเฉพาะตอน user กด search ที่หน้า Stock Retrieval" → เป็น "แจ้งอัตโนมัติทันทีที่ stock ต่ำกว่า minimum" โดยเก็บ notification ใน DB แทน localStorage
10

    
11
---
12

    
13
## กฎหลัก
14

    
15
1. **แจ้ง 1 ครั้งต่อ 1 part** จนกว่า stock กลับ >= minimum
16
2. **ไม่แจ้งซ้ำ** ตราบใดที่ stock ยังต่ำอยู่
17
3. **แจ้งใหม่ได้** ถ้า stock กลับปกติแล้วต่ำอีกรอบ
18
4. **Global notification** — ทุกคนเห็นเหมือนกัน ไม่ต้อง config role
19
5. **ไม่มี seen/unseen mode** — กระดิ่งแสดง noti ที่ยัง unresolved ทั้งหมดเสมอ (หายจากกระดิ่งเมื่อ stock กลับปกติเท่านั้น)
20

    
21
---
22

    
23
## 1. Database — สร้างตาราง `notify_log`
24

    
25
```sql
26
CREATE TABLE notify_log (
27
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
28
  notify_category VARCHAR(50) NOT NULL,  -- เช่น 'stock_alert'
29
  ref_id          VARCHAR(100) NOT NULL, -- part code (ma_part_code)
30
  description     TEXT,                  -- รายละเอียด เช่น "P-BRG-6205 : Bearing 6205 — Stock: 3 < Min: 10"
31
  is_resolved     BOOLEAN DEFAULT false, -- stock กลับปกติแล้วหรือยัง
32
  created_date    TIMESTAMPTZ DEFAULT NOW()
33
);
34

    
35
CREATE INDEX idx_notify_log_unresolved ON notify_log (ref_id, notify_category) WHERE is_resolved = false;
36
```
37

    
38
**Prisma model:**
39
```prisma
40
model notify_log {
41
  id              String   @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
42
  notify_category String   @db.VarChar(50)
43
  ref_id          String   @db.VarChar(100)
44
  description     String?
45
  is_resolved     Boolean  @default(false)
46
  created_date    DateTime @default(now()) @db.Timestamptz()
47
}
48
```
49

    
50
---
51

    
52
## 2. Backend — เช็ค Stock หลัง Part Ship
53

    
54
**ไฟล์:** `src/modules/call-monitor/part-ship/part-ship.service.ts`
55

    
56
**Logic เพิ่มหลัง ship สำเร็จ:**
57

    
58
```typescript
59
// หลังจาก UPDATE part_stock_item.status = 'SHIPPED' สำเร็จแล้ว
60
async function checkAndNotifyLowStock(maPartId: string) {
61
  // 1. นับ stock ปัจจุบัน
62
  const currentStock = await prisma.part_stock_item.count({
63
    where: {
64
      part_stock_detail: { ma_part_id: maPartId },
65
      status: 'STOCK',
66
    },
67
  });
68

    
69
  // 2. ดึง minimum_qty
70
  const part = await prisma.ms_ma_part.findUnique({
71
    where: { id: maPartId },
72
    select: { ma_part_code: true, ma_part_name: true, minimum_qty: true },
73
  });
74

    
75
  if (!part || part.minimum_qty <= 0) return;
76

    
77
  // 3. เช็คว่าต่ำกว่า min มั้ย
78
  if (currentStock >= part.minimum_qty) return;
79

    
80
  // 4. เช็คว่ามี unresolved alert อยู่แล้วมั้ย
81
  const existing = await prisma.notify_log.findFirst({
82
    where: {
83
      notify_category: 'stock_alert',
84
      ref_id: part.ma_part_code,
85
      is_resolved: false,
86
    },
87
  });
88

    
89
  if (existing) return; // เคยแจ้งแล้ว ยังไม่ resolved
90

    
91
  // 5. INSERT notify_log
92
  await prisma.notify_log.create({
93
    data: {
94
      notify_category: 'stock_alert',
95
      ref_id: part.ma_part_code,
96
      description: `${part.ma_part_code} : ${part.ma_part_name} — Stock: ${currentStock} < Min: ${part.minimum_qty}`,
97
      is_resolved: false,
98
    },
99
  });
100
}
101
```
102

    
103
---
104

    
105
## 3. Backend — Resolve ตอน Stock-In
106

    
107
**ไฟล์:** service ที่ทำ stock-in (Inspection Good / รับของเข้าคลัง)
108

    
109
**Logic เพิ่มหลัง stock-in สำเร็จ:**
110

    
111
```typescript
112
// หลังจาก INSERT part_stock_item (status = 'STOCK') สำเร็จแล้ว
113
async function resolveAlertIfStockRecovered(maPartId: string) {
114
  const currentStock = await prisma.part_stock_item.count({
115
    where: {
116
      part_stock_detail: { ma_part_id: maPartId },
117
      status: 'STOCK',
118
    },
119
  });
120

    
121
  const part = await prisma.ms_ma_part.findUnique({
122
    where: { id: maPartId },
123
    select: { ma_part_code: true, minimum_qty: true },
124
  });
125

    
126
  if (!part) return;
127

    
128
  // ถ้า stock กลับ >= minimum → resolve alert
129
  if (currentStock >= part.minimum_qty) {
130
    await prisma.notify_log.updateMany({
131
      where: {
132
        notify_category: 'stock_alert',
133
        ref_id: part.ma_part_code,
134
        is_resolved: false,
135
      },
136
      data: { is_resolved: true },
137
    });
138
  }
139
}
140
```
141

    
142
---
143

    
144
## 4. Backend — API Endpoints
145

    
146
**สร้าง module ใหม่:** `src/modules/notify-log/`
147

    
148
### GET /api/v1/notify-log/active
149

    
150
ดึง notification ที่ยัง unresolved (สำหรับ Frontend poll แสดงในกระดิ่ง)
151

    
152
```typescript
153
// Response
154
{
155
  data: [
156
    {
157
      id: "uuid",
158
      notify_category: "stock_alert",
159
      ref_id: "P-BRG-6205",
160
      description: "P-BRG-6205 : Bearing 6205 — Stock: 3 < Min: 10",
161
      created_date: "2026-05-20T10:30:00Z"
162
    }
163
  ],
164
  total: 5
165
}
166
```
167

    
168
**Logic:** ดึง notify_log ที่ `is_resolved = false` เรียงตาม `created_date DESC` จำกัด 15 รายการ
169

    
170
### POST /api/v1/notify-log/mark-seen
171

    
172
> ไม่ต้องทำ — ไม่มี seen mode
173

    
174
---
175

    
176
## 5. Frontend — เปลี่ยน useNotifications
177

    
178
**ไฟล์:** `packages/shared/feature/notification/hooks/use-notifications.ts`
179

    
180
**เปลี่ยนจาก:**
181
- เก็บ noti ใน localStorage
182
- เพิ่ม noti เฉพาะตอน user กด search
183

    
184
**เป็น:**
185
- Poll API `GET /api/v1/notify-log/active` ทุก 60 วินาที
186
- แสดง noti ที่ยัง unresolved ทั้งหมดในกระดิ่ง (ไม่มี seen/unseen)
187
- Badge = จำนวน noti ที่ยัง unresolved
188
- noti หายจากกระดิ่งเมื่อ stock กลับปกติ (is_resolved = true) เท่านั้น
189
- เสียง 🔔 ดังเมื่อมี noti ใหม่ (เทียบ count กับรอบก่อน)
190

    
191
```typescript
192
// Pseudo-code
193
const { data } = useQuery({
194
  queryKey: ['notify-log-active'],
195
  queryFn: () => fetch('/api/v1/notify-log/active'),
196
  refetchInterval: 60_000, // poll ทุก 1 นาที
197
});
198

    
199
const badgeCount = data?.total ?? 0; // แสดงจำนวน unresolved ทั้งหมด
200
```
201

    
202
---
203

    
204
## 6. Frontend — กระดิ่ง (NotificationBell)
205

    
206
**ไม่เปลี่ยน UI** — ยังใช้ popover + badge เหมือนเดิม
207

    
208
**เปลี่ยนแค่ data source + behavior:**
209
- Badge = จำนวน noti ที่ `is_resolved = false` (unresolved)
210
- Popover แสดง noti จาก API (ไม่เกิน 15 ตัว)
211
- **ไม่มี seen mode** — เปิดกระดิ่ง badge ไม่หาย (ยังแสดงจำนวน unresolved อยู่)
212
- noti หายจาก popover เมื่อ stock กลับปกติเท่านั้น (backend resolve)
213

    
214
---
215

    
216
## 7. สิ่งที่ไม่ต้องแก้
217

    
218
- ❌ แถบแดงในตาราง Stock Retrieval — ยังใช้ logic เดิม
219
- ❌ เสียง notification — ยังใช้ Web Audio API เดิม
220
- ❌ ไม่ต้องเพิ่ม role/permission
221
- ❌ ไม่ต้องทำ Cron Job (stock เปลี่ยนผ่าน Part Ship เท่านั้น)
222
- ❌ ลบ `notifyLowStockParts()` ใน `useStockRetrievalLocation.ts` ออกได้ (เพราะ backend จัดการแทนแล้ว)
223

    
224
---
225

    
226
## 8. ไฟล์ที่ต้องแก้/สร้าง
227

    
228
| ไฟล์ | Action | รายละเอียด |
229
|---|---|---|
230
| `prisma/schema.prisma` | แก้ | เพิ่ม model `notify_log` |
231
| `src/modules/notify-log/` | สร้างใหม่ | controller, service, repository |
232
| `src/modules/call-monitor/part-ship/part-ship.service.ts` | แก้ | เพิ่ม `checkAndNotifyLowStock()` หลัง ship |
233
| service ที่ทำ stock-in | แก้ | เพิ่ม `resolveAlertIfStockRecovered()` หลัง stock-in |
234
| `packages/shared/feature/notification/hooks/use-notifications.ts` | แก้ | เปลี่ยนจาก localStorage → poll API |
235
| `packages/shared/feature/stock-retrieval/hooks/useStockRetrievalLocation.ts` | แก้ | ลบ `notifyLowStockParts()` |
236

    
237
---
238

    
239
## 9. Seed Query — รันครั้งแรกตอน Deploy
240

    
241
> รันครั้งเดียวหลัง deploy เพื่อ INSERT notify_log สำหรับ part ที่ stock ต่ำกว่า min อยู่แล้ว ณ ปัจจุบัน
242
> (เพราะ part เหล่านี้ไม่เคยผ่าน Part Ship flow ใหม่ จึงยังไม่มี record ใน notify_log)
243

    
244
```sql
245
INSERT INTO notify_log (notify_category, ref_id, description, is_resolved, created_date)
246
SELECT
247
  'stock_alert',
248
  p.ma_part_code,
249
  p.ma_part_code || ' : ' || p.ma_part_name || ' — Stock: ' || COALESCE(stock.current_stock, 0) || ' < Min: ' || p.minimum_qty,
250
  false,
251
  NOW()
252
FROM ms_ma_part p
253
LEFT JOIN (
254
  SELECT
255
    psd.ma_part_id,
256
    COUNT(psi.id) AS current_stock
257
  FROM part_stock_detail psd
258
  JOIN part_stock_item psi ON psi.part_stock_detail_id = psd.id
259
  WHERE psi.status = 'STOCK'
260
  GROUP BY psd.ma_part_id
261
) stock ON stock.ma_part_id = p.id
262
WHERE p.is_active = true
263
  AND p.is_deleted = false
264
  AND p.minimum_qty > 0
265
  AND COALESCE(stock.current_stock, 0) < p.minimum_qty
266
  -- ไม่ INSERT ซ้ำถ้ามี record อยู่แล้ว (กรณีรัน query ซ้ำ)
267
  AND NOT EXISTS (
268
    SELECT 1 FROM notify_log nl
269
    WHERE nl.ref_id = p.ma_part_code
270
      AND nl.notify_category = 'stock_alert'
271
      AND nl.is_resolved = false
272
  );
273
```
274

    
275
**หมายเหตุ:** รัน query นี้ 1 ครั้งหลัง deploy ฟีเจอร์ใหม่ หลังจากนั้น Part Ship flow จะจัดการ INSERT ให้อัตโนมัติ
276

    
277
---
278

    
279
## 10. Flow Diagram
280

    
281
```
282
Part Ship (เบิกอะไหล่)
283

284
  ├─ UPDATE part_stock_item.status = 'SHIPPED'
285

286
  ├─ นับ stock ที่เหลือ (status = 'STOCK')
287

288
  ├─ stock < minimum_qty?
289
  │   ├─ NO → จบ
290
  │   └─ YES → มี unresolved alert อยู่แล้ว?
291
  │       ├─ YES → จบ (ไม่แจ้งซ้ำ)
292
  │       └─ NO → INSERT notify_log → กระดิ่ง 🔔
293

294
Stock-In (รับอะไหล่เข้าคลัง)
295

296
  ├─ INSERT part_stock_item (status = 'STOCK')
297

298
  ├─ นับ stock ใหม่
299

300
  ├─ stock >= minimum_qty?
301
  │   ├─ NO → จบ
302
  │   └─ YES → UPDATE notify_log SET is_resolved = true
303

304
Frontend (ทุก 1 นาที)
305

306
  ├─ GET /api/v1/notify-log/unseen
307

308
  ├─ มี noti ใหม่ (created_date > lastSeenDate)?
309
  │   ├─ NO → จบ
310
  │   └─ YES → badge +N + เสียง 🔔
311
```
(1-1/3)