Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions backend/src/__tests__/due-nudge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@ describe('언제 보내는가', () => {
return nudgeKindFor(i.days, i.state);
};

it('🔴 3일 전 하루만 — 매일 보내면 재촉이 무뎌진다', () => {
expect(kindOf('2026-09-07')).toBe('soon'); // 3일 전
expect(kindOf('2026-09-06')).toBeNull(); // 2일 전 — 안 보낸다
it('🔴 임박 첫날 하루만 — 매일 보내면 재촉이 무뎌진다', () => {
// 임박 기준은 `DUE_SOON_DAYS`(2026-09-16 지시로 3일 → 7일). 그 첫날에만 한 번 간다
expect(kindOf('2026-09-11')).toBe('soon'); // 7일 전
expect(kindOf('2026-09-10')).toBeNull(); // 6일 전 — 안 보낸다
expect(kindOf('2026-09-07')).toBeNull(); // 3일 전 — 안 보낸다
expect(kindOf('2026-09-05')).toBeNull(); // 1일 전 — 안 보낸다
});

Expand Down
25 changes: 25 additions & 0 deletions backend/src/__tests__/pnl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,31 @@ describe.runIf(live)('손익 — 세금계산서 발행일이 달을 정한다',
expect((await request(app).post(`/api/v1/pnl/${id}/void`).set('Cookie', looker).send({ reason: 'x' })).status).toBe(403);
}, 30_000);

it('🔴 임시저장 — 발행일 없이 적어 두면 「입력 필요」에 남고, 다시 열면 그대로 열린다', async () => {
const id = await acceptedQuote();
// 발행일 없이 저장한다(임시저장)
const draft = await request(app).put(`/api/v1/pnl/${id}`).set('Cookie', keeper)
.send({ biz_name: '임시상사', capital: 7_777_000, memo: '확인 중', cost: 1_000_000 });
expect(draft.status, JSON.stringify(draft.body)).toBe(200);
expect(draft.body.data.invoice_on, '발행일 없이 저장했는데 날짜가 생겼다').toBeNull();

const v = await request(app).get('/api/v1/pnl').set('Cookie', keeper);
const p = v.body.data.pending.find((x: { quote_id: number }) => x.quote_id === id);
expect(p, '임시저장한 건이 입력 필요에서 사라졌다').toBeTruthy();
// ⚠️ 적어 둔 값이 실려 와야 다시 열었을 때 그대로 열린다
expect(p.draft).toMatchObject({ biz_name: '임시상사', capital: 7_777_000, memo: '확인 중', cost: 1_000_000 });

// 발행일을 적으면 그제야 그 달 표로 내려간다
await request(app).put(`/api/v1/pnl/${id}`).set('Cookie', keeper).send({ invoice_on: '2026-09-18' });
const sep = await request(app).get('/api/v1/pnl?month=2026-09').set('Cookie', keeper);
const row = sep.body.data.rows.find((r: { quote_id: number }) => r.quote_id === id);
expect(row, '등록했는데 표에 없다').toBeTruthy();
// 임시저장해 둔 값이 그대로 따라 내려간다
expect(row.biz_name).toBe('임시상사');
expect(row.capital).toBe(7_777_000);
expect(sep.body.data.pending.some((x: { quote_id: number }) => x.quote_id === id)).toBe(false);
}, 60_000);

it('🔴 요약 — 이번 달과 전체를 한 번에 주고, 삭제한 줄은 빼고 센다', async () => {
const ym = new Date(Date.now() + 9 * 60 * 60 * 1000).toISOString().slice(0, 7);
const before = await request(app).get('/api/v1/pnl/summary').set('Cookie', keeper);
Expand Down
4 changes: 2 additions & 2 deletions backend/src/services/due-nudge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* 특장사에게는 아무도 알려 주지 않았다. 지금까지 알림은 **채팅·배정 때만** 갔다.
*
* ## 언제
* - 납기 **3일 전** 한 번
* - 납기 **7일 전** 한 번(`DUE_SOON_DAYS`)
* - 납기 **당일** 한 번
* - 납기가 **지난 동안 날마다** 한 번 — 지난 건은 재촉이 목적이라 하루치로 끝내지 않는다
*
Expand Down Expand Up @@ -36,7 +36,7 @@ export function nudgeKindFor(days: number, state: string): NudgeKind | null {
if (state === 'overdue') return 'overdue';
if (state === 'soon') {
if (days === 0) return 'today';
if (days === DUE_SOON_DAYS) return 'soon'; // 3일 전 하루만 — 매일 보내면 재촉이 무뎌진다
if (days === DUE_SOON_DAYS) return 'soon'; // 임박 첫날 하루만 — 매일 보내면 재촉이 무뎌진다
}
return null;
}
Expand Down
22 changes: 21 additions & 1 deletion backend/src/services/pnl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,18 @@ export interface PnlPending {
/** 계약서에서 가져온 값들 — 「입력 필요」 칸에 그대로 보여 준다(고칠 수 없다) */
supply_default: number | null;
deposit_default: number;
/**
* **임시저장해 둔 값** — 아직 등록하지 않았지만 적어 둔 것이 있으면 여기 실린다.
* 없으면 null. 다시 열었을 때 적어 둔 대로 열려야 임시저장이 뜻을 갖는다.
*/
draft: {
biz_name: string | null;
capital: number;
deposit_paid_on: string | null;
capital_paid_on: string | null;
cost: number;
memo: string | null;
} | null;
}

const n = (v: bigint | number | null | undefined): number => Number(v ?? 0);
Expand Down Expand Up @@ -176,7 +188,7 @@ export async function pnlPending(): Promise<PnlPending[]> {
id: true, quote_no: true, created_at: true,
customer: { select: { name: true } },
order: { select: { accepted_at: true } },
pnl: { select: { supply_amount: true, deposit: true } },
pnl: { select: { supply_amount: true, deposit: true, biz_name: true, capital: true, deposit_paid_on: true, capital_paid_on: true, cost: true, memo: true } },
},
// 수락이 오래된 것부터 — 화면에서 다시 정렬하지 않게 여기서 못 박는다
orderBy: { order: { accepted_at: 'asc' } },
Expand All @@ -199,6 +211,14 @@ export async function pnlPending(): Promise<PnlPending[]> {
accepted_on: day(q.order?.accepted_at ?? null) ?? day(q.created_at),
supply_default: supply,
deposit_default: deposit,
draft: kept ? {
biz_name: kept.biz_name,
capital: n(kept.capital),
deposit_paid_on: day(kept.deposit_paid_on),
capital_paid_on: day(kept.capital_paid_on),
cost: n(kept.cost),
memo: kept.memo,
} : null,
});
}
return out;
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/api/pnl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@ export interface PnlPending {
accepted_on: string | null
supply_default: number | null
deposit_default: number
/** 임시저장해 둔 값 — 다시 열면 적어 둔 대로 열린다. 없으면 null */
draft: {
biz_name: string | null
capital: number
deposit_paid_on: string | null
capital_paid_on: string | null
cost: number
memo: string | null
} | null
}

export interface PnlView {
Expand Down
Loading
Loading