내장 Learning 스타일 2회차 · 요청 2: 급한 순서를 정해야 하는 요청 · Claude Code · Opus 5.5 · 생각 깊이 중간 · 목록

내장 Learning 스타일 2회차

요청 2: 급한 순서를 정해야 하는 요청

요청문: src/stock.mjs에 todayTasks(items, today) 함수를 만들어 줘. 아침에 카페 사장님이 보는 '오늘 할 일' 목록이야. 유통기한이 지난 것, 3일 안에 끝나는 것, 수량이 minQty보다 적은 것을 한 목록으로 합쳐서 급한 것부터 보여 주고 싶어. today는 'YYYY-MM-DD' 문자열이야. test/stock.test.mjs에 테스트도 추가하고 돌려 봐.

클로드의 마지막 답변 (그대로)

`src/stock.mjs`에 `todayTasks(items, today)`를 추가했고 테스트도 넣었습니다. `node test/stock.test.mjs`를 돌리니 기존 테스트와 새 테스트가 모두 통과했습니다. **`data.json` 기준 오늘(2026-10-18) 결과:** | 순서 | 재료 | 사유 | 남은 날 | |---|---|---|---| | 1 | 딸기 | 유통기한 지남 + 수량 부족 | -1 | | 2 | 생크림 | 3일 안에 끝남 | 0 | | 3 | 우유 | 3일 안에 끝남 + 수량 부족 | 2 | | 4 | 바닐라 시럽 | 3일 안에 끝남 | 3 | | 5 | 치즈케이크 | 수량 부족 | — | 원두는 문제가 없어서 목록에 나오지 않습니다. `★ Insight ─────────────────────────────────────` - **재료 하나에 한 줄:** 세 가지 조건을 따로 걸러서 이어 붙이면 우유가 두 번 나옵니다. 그래서 재료마다 `reasons` 배열을 두고, 가장 급한 사유(`reasons[0]`)로 순서를 정했습니다. - **두 조건이 서로 영향을 줌:** 딸기는 1개 / 최소 1개라서 재고만 보면 부족하지 않습니다. 하지만 유통기한이 지나 버려야 하니 실제로 쓸 수 있는 건 0개입니다. 그래서 유통기한이 지난 재료는 수량을 0으로 보고 부족 여부를 다시 판단합니다. - **재고가 0개면 유통기한은 보지 않음:** 치즈케이크는 0판이라 "내일 끝나요"라고 알려 줘도 할 일이 없습니다. 그래서 부족 사유만 붙입니다. `─────────────────────────────────────────────────` **제가 임의로 정한 기준 (다르면 말씀해 주세요):** - **"3일 안"은 남은 날 0~3일입니다.** 오늘 끝나는 것(0일)은 아직 "지남"이 아니라 "곧 끝남"으로 봅니다. 3일 기준은 `SOON_DAYS` 상수로 바꿀 수 있습니다. - **같은 등급 안에서의 순서:** 지난 것은 더 오래 지난 것이 먼저, 곧 끝나는 것은 남은 날이 적은 것이 먼저입니다. 부족하기만 한 것은 `qty/minQty` 비율이 낮은 것이 먼저입니다. - **재고가 다 떨어진 재료가 뒤로 밀립니다.** 지금 규칙에서는 치즈케이크(0판, 팔 수 없음)가 바닐라 시럽(3일 남음, 아직 씀)보다 뒤에 나옵니다. 사장님 입장에서는 팔 수 없는 쪽이 더 급할 수 있습니다. 그렇게 바꾸려면 `rank`에 "재고 0" 등급을 하나 더 넣으면 됩니다. **테스트에서 확인한 것:** - `data.json` 전체에 대한 결과 - 경계값: 4일 남은 건 빠지고, `qty === minQty`는 부족이 아님 - 같은 등급 안에서의 순서 - 원래 배열을 바꾸지 않음 결과는 `{ name, reasons, daysLeft, qty, minQty, unit }` 형태의 데이터입니다. 화면에 띄울 한국어 문구는 아직 만들지 않았습니다.

바뀐 코드 (git diff)

diff --git a/src/stock.mjs b/src/stock.mjs
index f81b897..c4ddde8 100644
--- a/src/stock.mjs
+++ b/src/stock.mjs
@@ -19,3 +19,35 @@ export function orderList(items) {
     .filter((it) => it.qty < it.minQty)
     .map((it) => ({ name: it.name, need: it.minQty * 2 - it.qty, unit: it.unit }));
 }
+
+export const SOON_DAYS = 3;
+
+// 아침 '오늘 할 일': 재료당 한 줄, 급한 순서
+// 순서: 유통기한 지남(오래된 것 먼저) → 3일 안에 끝남(남은 날 적은 것 먼저) → 수량 부족만(재고 비율 낮은 것 먼저)
+export function todayTasks(items, today) {
+  const rank = { expired: 0, expiring: 1, low: 2 };
+  return items
+    .map((it) => {
+      const daysLeft = daysBetween(today, it.expires);
+      const reasons = [];
+      // 재고가 0이면 유통기한은 의미가 없음 (버리거나 빨리 쓸 게 없음)
+      if (it.qty > 0 && daysLeft < 0) reasons.push('expired');
+      else if (it.qty > 0 && daysLeft <= SOON_DAYS) reasons.push('expiring');
+      // 지난 재료는 버려야 하므로 실제 남는 수량은 0
+      const usableQty = reasons[0] === 'expired' ? 0 : it.qty;
+      if (usableQty < it.minQty) reasons.push('low');
+      return { name: it.name, reasons, daysLeft, qty: it.qty, minQty: it.minQty, unit: it.unit };
+    })
+    .filter((t) => t.reasons.length > 0)
+    .sort((a, b) => {
+      const ra = rank[a.reasons[0]];
+      const rb = rank[b.reasons[0]];
+      if (ra !== rb) return ra - rb;
+      if (ra < 2 && a.daysLeft !== b.daysLeft) return a.daysLeft - b.daysLeft;
+      if (ra === 2) {
+        const diff = a.qty / a.minQty - b.qty / b.minQty;
+        if (diff !== 0) return diff;
+      }
+      return a.name.localeCompare(b.name, 'ko');
+    });
+}
diff --git a/test/stock.test.mjs b/test/stock.test.mjs
index 71de632..5f87e65 100644
--- a/test/stock.test.mjs
+++ b/test/stock.test.mjs
@@ -1,5 +1,6 @@
 import assert from 'node:assert/strict';
-import { lowStock, orderList, daysBetween } from '../src/stock.mjs';
+import { readFileSync } from 'node:fs';
+import { lowStock, orderList, daysBetween, todayTasks } from '../src/stock.mjs';
 const items = [
   { name: '우유', qty: 3, unit: 'L', expires: '2026-10-20', minQty: 5 },
   { name: '원두', qty: 4, unit: 'kg', expires: '2027-01-10', minQty: 2 },
@@ -8,3 +9,43 @@ assert.deepEqual(lowStock(items), ['우유']);
 assert.deepEqual(orderList(items), [{ name: '우유', need: 7, unit: 'L' }]);
 assert.equal(daysBetween('2026-10-18', '2026-10-20'), 2);
 console.log('기본 테스트 통과');
+
+// todayTasks: data.json 전체, 오늘 = 2026-10-18
+const data = JSON.parse(readFileSync(new URL('../data.json', import.meta.url), 'utf8'));
+const tasks = todayTasks(data, '2026-10-18');
+assert.deepEqual(
+  tasks.map((t) => [t.name, t.reasons, t.daysLeft]),
+  [
+    ['딸기', ['expired', 'low'], -1], // 어제 지남 → 버리면 0개라 부족도 함께
+    ['생크림', ['expiring'], 0], // 오늘까지 (0일 = 아직 지난 건 아님)
+    ['우유', ['expiring', 'low'], 2], // 한 재료가 두 사유여도 한 줄
+    ['바닐라 시럽', ['expiring'], 3], // 3일째 경계 포함
+    ['치즈케이크', ['low'], 1], // 0개면 유통기한 사유 없음
+  ],
+);
+assert.ok(!tasks.some((t) => t.name === '원두'), '문제없는 재료는 빠짐');
+
+// 경계: 4일 남음은 제외, qty === minQty는 부족 아님
+assert.deepEqual(
+  todayTasks([{ name: 'A', qty: 2, unit: '개', expires: '2026-10-22', minQty: 2 }], '2026-10-18'),
+  [],
+);
+
+// 지난 것끼리는 더 오래 지난 것 먼저, 부족만인 것끼리는 재고 비율 낮은 것 먼저
+const order = todayTasks(
+  [
+    { name: '부족-절반', qty: 5, unit: '개', expires: '2027-01-01', minQty: 10 },
+    { name: '지남-1일', qty: 1, unit: '개', expires: '2026-10-17', minQty: 0 },
+    { name: '부족-0개', qty: 0, unit: '개', expires: '2027-01-01', minQty: 3 },
+    { name: '지남-5일', qty: 1, unit: '개', expires: '2026-10-13', minQty: 0 },
+  ],
+  '2026-10-18',
+).map((t) => t.name);
+assert.deepEqual(order, ['지남-5일', '지남-1일', '부족-0개', '부족-절반']);
+
+// 원본 배열을 바꾸지 않음
+const copy = structuredClone(data);
+todayTasks(data, '2026-10-18');
+assert.deepEqual(data, copy);
+
+console.log('todayTasks 테스트 통과');