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
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- 손익 — 자동 기입 칸도 사람이 고칠 수 있게 됐다(2026-09-17 지시).
-- 공급가액·계약금은 이미 칸이 있다. VAT 는 **저장하지 않는 계산값**이라, 고친 값을 담을 칸만 더한다.
-- 비어 있으면(null) 예전처럼 엑셀 식(공급가액 × 10% 버림)을 쓴다. 기존 줄은 모두 null 로 시작한다.
-- ⚠️ 칸을 더하기만 한다 — 기존 칸·줄은 건드리지 않는다.
ALTER TABLE "order_pnl" ADD COLUMN IF NOT EXISTS "vat_override" BIGINT;
2 changes: 2 additions & 0 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,8 @@ model OrderPnl {
/// 공급가액(VAT 별도)·계약금 — **계약서에서 가져와 굳힌다.** 화면에서 고치지 않는다(2026-09-16 지시)
supply_amount BigInt @default(0)
deposit BigInt @default(0)
/// 손으로 고친 VAT — 없으면 엑셀 식(공급가액 × 10% 버림)을 쓴다. 세금계산서가 반올림이면 1원이 다르다
vat_override BigInt?
/// 캐피탈 — 경영관리가 직접 적는다
capital BigInt @default(0)

Expand Down
42 changes: 32 additions & 10 deletions backend/src/__tests__/pnl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
* ① 돈에 관한 표라 **보는 것부터** 권한을 건다(`pnl.view`), 적는 것은 `pnl.manage`
* ② 「입력 필요」에 서는 시점은 **특장사 수락**이다 — 계약만 끝난 건은 아직 만들 차가 없다
* ③ 달을 가르는 기준은 **세금계산서 발행일** — 적는 순간 그 달 표로 들어가고 「입력 필요」에서 빠진다
* ④ 계약서 금액(공급가액·계약금)은 **굳는다** — 화면에서도 서버에서도 못 고친다
* ④ 계약서 금액(공급가액·계약금·VAT)은 줄을 만들 때 채우고, 그 뒤로는 **사람이 고칠 때만** 바뀐다
* ⑤ 삭제는 줄을 지우지 않는다 — 사유를 남기고 합계에서만 뺀다
*/
process.env['ALLOW_TEST_PERMISSION_BYPASS'] = 'false';
Expand Down Expand Up @@ -164,17 +164,39 @@ describe.runIf(live)('손익 — 세금계산서 발행일이 달을 정한다',
expect(r.body.data.invoice_on).toBe('2026-09-12');
}, 30_000);

it('🔴 계약서 금액은 굳는다 — 보내도 안 바뀐다(화면에서 빼는 것만으로는 막은 것이 아니다)', async () => {
it('🔴 자동 기입 칸도 사람이 고친다 — 공급가액·계약금·VAT(2026-09-17 지시)', async () => {
const id = await acceptedQuote();
const made = await request(app).put(`/api/v1/pnl/${id}`).set('Cookie', keeper).send({ invoice_on: '2026-09-14' });
const supply = made.body.data.supply_amount as number;
const deposit = made.body.data.deposit as number;

const hack = await request(app).put(`/api/v1/pnl/${id}`).set('Cookie', keeper)
.send({ supply_amount: 1, deposit: 2, biz_name: '바꿔치기' });
expect(hack.status).toBe(200);
expect(hack.body.data.supply_amount, '공급가액이 바뀌었다').toBe(supply);
expect(hack.body.data.deposit, '계약금이 바뀌었다').toBe(deposit);
expect(made.body.data.vat_override, '처음에는 식을 쓴다').toBeNull();

// 실제 세금계산서가 계약과 달랐다 — 고친다
const fix = await request(app).put(`/api/v1/pnl/${id}`).set('Cookie', keeper)
.send({ supply_amount: 18_463_636, deposit: 500_000, vat_override: 1_846_364 });
expect(fix.status, JSON.stringify(fix.body)).toBe(200);
expect(fix.body.data.supply_amount).toBe(18_463_636);
expect(fix.body.data.deposit).toBe(500_000);
expect(fix.body.data.vat_override).toBe(1_846_364);

// VAT 를 비우면(null) 식으로 돌아간다
const back = await request(app).put(`/api/v1/pnl/${id}`).set('Cookie', keeper).send({ vat_override: null });
expect(back.body.data.vat_override).toBeNull();
// 다른 칸만 고쳐도 고친 금액은 그대로다(부분 저장)
const memo = await request(app).put(`/api/v1/pnl/${id}`).set('Cookie', keeper).send({ memo: 'x' });
expect(memo.body.data.supply_amount).toBe(18_463_636);
}, 30_000);

it('🔴 처음 만들 때 사람이 보낸 금액이 계약서 값을 이긴다', async () => {
const id = await acceptedQuote();
const r = await request(app).put(`/api/v1/pnl/${id}`).set('Cookie', keeper)
.send({ supply_amount: 1_234_000, deposit: 111_000 });
expect(r.body.data.supply_amount).toBe(1_234_000);
expect(r.body.data.deposit).toBe(111_000);
}, 30_000);

it('🔴 보기 권한만으로는 금액을 고치지 못한다', async () => {
const id = await acceptedQuote();
await request(app).put(`/api/v1/pnl/${id}`).set('Cookie', keeper).send({ invoice_on: '2026-09-14' });
expect((await request(app).put(`/api/v1/pnl/${id}`).set('Cookie', looker).send({ supply_amount: 1 })).status).toBe(403);
}, 30_000);

it('🔴 삭제는 줄을 지우지 않는다 — 사유를 남기고 합계에서만 뺀다', async () => {
Expand Down
31 changes: 22 additions & 9 deletions backend/src/services/pnl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export interface PnlRow {
invoice_on: string | null;
supply_amount: number;
deposit: number;
/** 손으로 고친 VAT — null 이면 식(버림 10%) */
vat_override: number | null;
capital: number;
deposit_paid_on: string | null;
capital_paid_on: string | null;
Expand Down Expand Up @@ -55,6 +57,7 @@ export interface PnlPending {
*/
draft: {
biz_name: string | null;
vat_override: number | null;
capital: number;
deposit_paid_on: string | null;
capital_paid_on: string | null;
Expand All @@ -69,7 +72,7 @@ const day = (d: Date | null | undefined): string | null => (d ? d.toISOString().
/** DB 행 + 견적을 화면 모양으로 */
type RowWithQuote = {
quote_id: number; invoice_on: Date | null; biz_name: string | null;
supply_amount: bigint; deposit: bigint; capital: bigint;
supply_amount: bigint; deposit: bigint; vat_override: bigint | null; capital: bigint;
deposit_paid_on: Date | null; capital_paid_on: Date | null;
cost: bigint; cost_source: string; memo: string | null; updated_by: string | null;
voided_at: Date | null; voided_by: string | null; void_reason: string | null;
Expand All @@ -91,6 +94,7 @@ function toRow(r: RowWithQuote): PnlRow {
invoice_on: day(r.invoice_on),
supply_amount: n(r.supply_amount),
deposit: n(r.deposit),
vat_override: r.vat_override === null ? null : n(r.vat_override),
capital: n(r.capital),
deposit_paid_on: day(r.deposit_paid_on),
capital_paid_on: day(r.capital_paid_on),
Expand Down Expand Up @@ -188,7 +192,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, biz_name: true, capital: true, deposit_paid_on: true, capital_paid_on: true, cost: true, memo: true } },
pnl: { select: { supply_amount: true, deposit: true, vat_override: 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 @@ -213,6 +217,7 @@ export async function pnlPending(): Promise<PnlPending[]> {
deposit_default: deposit,
draft: kept ? {
biz_name: kept.biz_name,
vat_override: kept.vat_override === null ? null : n(kept.vat_override),
capital: n(kept.capital),
deposit_paid_on: day(kept.deposit_paid_on),
capital_paid_on: day(kept.capital_paid_on),
Expand All @@ -228,13 +233,17 @@ export async function pnlPending(): Promise<PnlPending[]> {
/**
* 화면이 적을 수 있는 칸 — 안 보낸 칸은 그대로 둔다(부분 저장).
*
* ⚠️ **공급가액·계약금은 여기 없다.** 계약서에서 가져와 줄을 만들 때 굳히고, 그 뒤로는 고치지 않는다
* (2026-09-16 지시 — 고쳐야 하는 예외가 생기면 그때 다시 본다). 화면에서 빼는 것만으로는
* 막은 것이 아니라 **서버가 받지 않는다.**
* **자동 기입 칸(공급가액·계약금·VAT)도 고칠 수 있다**(2026-09-17 지시 — 실제 계산서가 계약과 다른 일이 있었다).
* 다만 **저절로 다시 계산되지는 않는다** — 줄을 만들 때 계약서에서 한 번 채우고, 그 뒤로 바뀌는 것은
* 사람이 고칠 때뿐이다. 단가표를 나중에 고쳐도 이미 나간 줄이 소급해 바뀌지 않는다.
*/
export interface PnlPatch {
invoice_on?: string | null;
biz_name?: string | null;
supply_amount?: number;
deposit?: number;
/** null 을 보내면 식(버림 10%)으로 되돌린다 */
vat_override?: number | null;
capital?: number;
deposit_paid_on?: string | null;
capital_paid_on?: string | null;
Expand Down Expand Up @@ -269,17 +278,20 @@ export async function savePnl(quoteId: number, patch: PnlPatch, by: string): Pro
const put = (key: string, v: unknown) => { if (v !== undefined) data[key] = v; };
put('invoice_on', dateOf(patch.invoice_on));
put('biz_name', text(patch.biz_name, 120));
put('supply_amount', money(patch.supply_amount));
put('deposit', money(patch.deposit));
put('vat_override', patch.vat_override === null ? null : money(patch.vat_override));
put('capital', money(patch.capital));
put('deposit_paid_on', dateOf(patch.deposit_paid_on));
put('capital_paid_on', dateOf(patch.capital_paid_on));
put('cost', money(patch.cost));
put('memo', text(patch.memo, 500));

if (!exists) {
// 계약서 금액은 **줄을 만들 때 한 번** 굳힌다 — 그 뒤로는 어떤 요청도 이 둘을 못 바꾼다
// 처음 만들 때 계약서에서 채운다 — 사람이 함께 보낸 값이 있으면 그 값이 이긴다(고친 것이다)
const d = await contractDefaults(quoteId);
data['supply_amount'] = BigInt(d.supply ?? 0);
data['deposit'] = BigInt(d.deposit);
if (data['supply_amount'] === undefined) data['supply_amount'] = BigInt(d.supply ?? 0);
if (data['deposit'] === undefined) data['deposit'] = BigInt(d.deposit);
await prisma.orderPnl.create({ data: { quote_id: quoteId, ...data } as never });
} else {
await prisma.orderPnl.update({ where: { quote_id: quoteId }, data: data as never });
Expand Down Expand Up @@ -339,14 +351,15 @@ export async function pnlSummary(ym: string): Promise<PnlSummary> {
const empty = sumP([]);
if (!prisma) return { month: ym, pending: 0, month_total: empty, all_total: empty };
const { from, to } = monthBounds(ym);
const pick = { supply_amount: true, deposit: true, capital: true, cost: true, invoice_on: true } as const;
const pick = { supply_amount: true, deposit: true, vat_override: true, capital: true, cost: true, invoice_on: true } as const;
const [rows, pending] = await Promise.all([
// 삭제한 줄과 발행일이 없는 줄은 어느 달의 숫자도 아니다
prisma.orderPnl.findMany({ where: { voided_at: null, invoice_on: { not: null } }, select: pick }),
pnlPending(),
]);
const num = (r: typeof rows[number]) => ({
supply_amount: n(r.supply_amount), deposit: n(r.deposit), capital: n(r.capital), cost: n(r.cost),
vat_override: r.vat_override === null ? null : n(r.vat_override),
});
const inMonth = (r: typeof rows[number]) =>
!!r.invoice_on && r.invoice_on >= new Date(from) && r.invoice_on < new Date(to);
Expand Down
8 changes: 6 additions & 2 deletions frontend/src/api/pnl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export interface PnlRow {
invoice_on: string | null
supply_amount: number
deposit: number
/** 손으로 고친 VAT — null 이면 식(공급가액 × 10% 버림) */
vat_override: number | null
capital: number
deposit_paid_on: string | null
capital_paid_on: string | null
Expand Down Expand Up @@ -40,6 +42,7 @@ export interface PnlPending {
/** 임시저장해 둔 값 — 다시 열면 적어 둔 대로 열린다. 없으면 null */
draft: {
biz_name: string | null
vat_override: number | null
capital: number
deposit_paid_on: string | null
capital_paid_on: string | null
Expand All @@ -58,10 +61,11 @@ export interface PnlView {

/**
* 적을 수 있는 칸 — 안 보낸 칸은 그대로 둔다(부분 저장).
* ⚠️ **공급가액·계약금은 없다** — 계약서에서 가져와 굳힌 값이라 고치지 않는다(서버도 받지 않는다).
* 자동 기입 칸(공급가액·계약금·VAT)도 고칠 수 있다(2026-09-17). `vat_override: null` 은 식으로 되돌린다.
*/
export type PnlPatch = Partial<Pick<PnlRow,
'invoice_on' | 'biz_name' | 'capital' | 'deposit_paid_on' | 'capital_paid_on' | 'cost' | 'memo'
| 'invoice_on' | 'biz_name' | 'supply_amount' | 'deposit' | 'vat_override'
| 'capital' | 'deposit_paid_on' | 'capital_paid_on' | 'cost' | 'memo'
>>

async function jsonOf<T>(res: Response): Promise<T> {
Expand Down
Loading
Loading