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,15 @@
-- 특장사 거부 · 관리자 치우기 기록.
--
-- ⚠️ **더하기만 한다.** 기존 열을 지우거나 바꾸지 않는다.
-- 「삭제」도 행을 지우지 않고 상태로 남긴다 — 서명된 계약이 연쇄로 지워진 사고 이후의 규칙.
ALTER TABLE "order" ADD COLUMN IF NOT EXISTS "rejected_at" TIMESTAMP(3);
ALTER TABLE "order" ADD COLUMN IF NOT EXISTS "rejected_by" VARCHAR(120);
ALTER TABLE "order" ADD COLUMN IF NOT EXISTS "reject_reason" VARCHAR(500);
ALTER TABLE "order" ADD COLUMN IF NOT EXISTS "canceled_at" TIMESTAMP(3);
ALTER TABLE "order" ADD COLUMN IF NOT EXISTS "canceled_by" VARCHAR(120);
ALTER TABLE "order" ADD COLUMN IF NOT EXISTS "cancel_reason" VARCHAR(500);

-- 주문을 치우는 권한 — 계정별로 켠다. 기본은 아무도 없다.
INSERT INTO "feature_module" ("code", "name", "surface", "sort_order", "active")
VALUES ('order.remove', '주문 치우기(관리자)', '관리자', 11, true)
ON CONFLICT ("code") DO NOTHING;
12 changes: 12 additions & 0 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,18 @@ model Order {
// 구조변경 서류 바인딩용 차량 정보 (특장사 입력)
vehicle_info Json?

/// 특장사가 거부한 시각. 거부하면 배정이 풀려 다른 특장사에 다시 맡길 수 있다.
rejected_at DateTime?
rejected_by String? @db.VarChar(120)
/// 거부 사유 — 필수. 「왜 안 받았는지」가 없으면 다시 배정할 수도, 고칠 수도 없다.
reject_reason String? @db.VarChar(500)

/// 관리자가 치운 시각. 목록에서 빠지지만 행은 그대로 남는다.
/// ⚠️ 지우지 않는다 — 잘못 들어간 것도 상태로 관리한다(CLAUDE.md).
canceled_at DateTime?
canceled_by String? @db.VarChar(120)
cancel_reason String? @db.VarChar(500)

quote Quote @relation(fields: [quote_id], references: [id])
maker_org Org? @relation("OrderMakerOrg", fields: [maker_org_id], references: [code])
options OrderOption[]
Expand Down
113 changes: 113 additions & 0 deletions backend/src/__tests__/order-reject-remove.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import path from 'node:path';

/**
* **거부**(특장사) 와 **치우기**(관리자) — 둘 다 사유가 필수다.
*
* ⚠️ 「치우기」는 **행을 지우지 않는다.** 상태로 남긴다 —
* 누가 언제 왜 치웠는지가 사라지면 나중에 아무도 설명하지 못한다.
* (견적 삭제가 서명된 계약까지 연쇄로 지운 사고 이후의 규칙 — CLAUDE.md)
*/
const ROOT = path.resolve(__dirname, '../../..');
const read = (rel: string) => readFileSync(path.join(ROOT, rel), 'utf8');
const ORDERS = read('backend/src/routes/orders.ts');

const slice = (from: string, to: string) => ORDERS.slice(ORDERS.indexOf(from), ORDERS.indexOf(to));
const REJECT = slice("'/:id/reject'", "'/:id/cancel'");
const CANCEL = slice("'/:id/cancel'", '// ── PATCH /orders/:id/accept');

describe('특장사 주문 거부', () => {
it('🔴 사유가 없으면 거부되지 않는다', () => {
// 「왜 안 받았는지」가 없으면 다시 배정할 수도, 고칠 수도 없다
expect(REJECT).toMatch(/거부 사유를 적어야 합니다/);
expect(REJECT).toMatch(/if \(!reason\)/);
});

it('배정 상태에서만 — 이미 수락해 제작이 도는 건은 거부가 아니다', () => {
expect(REJECT).toMatch(/status !== 'assigned'/);
});

it('자기 조직 것만 거부한다', () => {
expect(REJECT).toContain('ownOrgOnly');
});

it('🔴 배정을 풀어 다시 맡길 수 있게 한다', () => {
// 거부만 하고 배정이 남으면 그 특장사에 계속 걸려 있다
expect(REJECT).toMatch(/maker_org_id: null/);
expect(REJECT).toMatch(/setQuoteStatus\(order\.quote\.id, 'contracted'/);
});
});

describe('관리자 주문 치우기', () => {
it('🔴 행을 지우지 않는다 — 상태로 남긴다', () => {
expect(CANCEL).toMatch(/canceled_at: new Date\(\)/);
expect(CANCEL).not.toMatch(/order\.delete|deleteMany/);
});

it('🔴 누가 언제 왜 치웠는지가 남는다', () => {
for (const f of ['canceled_at', 'canceled_by', 'cancel_reason']) expect(CANCEL, f).toContain(f);
});

it('사유가 없으면 치워지지 않는다', () => {
expect(CANCEL).toMatch(/치우는 사유를 적어야 합니다/);
});

it('🔴 수락 대기·진행중까지만 — 인도가 끝난 건은 이미 일어난 거래다', () => {
expect(CANCEL).toMatch(/!== 'assigned' && order\.quote\.status !== 'ordered'/);
});

it('🔴 권한은 계정별 기능모듈로 — 관리자라고 다 되지 않는다', () => {
const decl = ORDERS.slice(ORDERS.indexOf("'/:id/cancel'"), ORDERS.indexOf("'/:id/cancel'") + 200);
expect(decl).toContain("rbac('ADMIN')");
expect(decl).toContain("requirePermission('order.remove')");
});

it('두 번 치우지 않는다', () => {
expect(CANCEL).toMatch(/if \(order\.canceled_at\)/);
});
});

describe('치운 주문은 일감 목록에서 빠진다', () => {
it('🔴 목록 조회가 걸러 낸다 — 안 그러면 치운 뜻이 없다', () => {
expect(ORDERS).toMatch(/const where: Prisma\.OrderWhereInput = \{ canceled_at: null \}/);
});
});

describe('기능모듈·migration', () => {
const SQL = read('backend/prisma/migrations/20260826010000_order_reject_and_cancel/migration.sql');

it('더하기만 한다 — 기존 열을 지우거나 바꾸지 않는다', () => {
const statements = SQL.replace(/--.*$/gm, '').split(';').map(x => x.trim()).filter(Boolean);
for (const st of statements) {
expect(st, st.slice(0, 60)).toMatch(/^(ALTER TABLE "order" ADD COLUMN IF NOT EXISTS|INSERT INTO "feature_module")/);
}
expect(SQL).toMatch(/ON CONFLICT \("code"\) DO NOTHING/);
});

it('권한 기본값을 만들지 않는다 — 켜야 쓸 수 있다', () => {
expect(SQL.replace(/--.*$/gm, '')).not.toMatch(/INSERT\s+INTO\s+"?access_control"?/i);
});

it('새 환경을 위해 seed 에도 있다', () => {
expect(read('db/seed/feature_module.csv')).toContain('order.remove');
});
});

describe('화면', () => {
const MODAL = read('frontend/src/components/AcceptOrderModal.tsx');
const ADMIN = read('frontend/src/pages/AdminPage.tsx');

it('거부는 사유를 적어야 눌린다', () => {
expect(MODAL).toMatch(/disabled=\{!reason\.trim\(\) \|\| busy\}/);
});

it('치우기 버튼은 권한이 있을 때만 뜬다', () => {
expect(ADMIN).toMatch(/const canRemove = usePermission\('order\.remove'\)/);
expect(ADMIN).toMatch(/\{canRemove && removable &&/);
});

it('치우기도 사유를 적어야 눌린다', () => {
expect(ADMIN).toMatch(/disabled=\{!reason\.trim\(\) \|\| busy\}/);
});
});
121 changes: 120 additions & 1 deletion backend/src/routes/orders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ ordersRouter.get('/', rbac('ADMIN', 'SALES', 'MAKER'), requirePermission('order.
const auth = req.auth!;
const { status, from, to, scope } = req.query as Record<string, string | undefined>;

const where: Prisma.OrderWhereInput = {};
/*
* 치운 주문은 목록에서 뺀다 — **행은 남아 있지만 일감이 아니다.**
* 여기서 거르지 않으면 「삭제」를 눌러도 그대로 보여, 치운 뜻이 없어진다.
*/
const where: Prisma.OrderWhereInput = { canceled_at: null };
/*
* 범위는 **가진 역할 전부**로 정한다. 관리자면 전체, 아니면 겸직한 역할만큼 넓힌다
* (영업+특장 겸직이면 자기 견적의 주문 ∪ 자기 조직에 배정된 주문).
Expand Down Expand Up @@ -299,6 +303,121 @@ ordersRouter.get('/:id', rbac('SALES', 'ADMIN', 'MAKER'), requirePermission('ord
* 두 길을 열어 두면 증빙 없이 상태만 올리는 우회로가 남는다.
*/

/** 사유를 읽어 다듬는다 — 없으면 null. 너무 긴 것은 자른다(칼럼 폭). */
function readReason(body: unknown): string | null {
const raw = (body as { reason?: unknown } | undefined)?.reason;
const t = typeof raw === 'string' ? raw.trim() : '';
return t ? t.slice(0, 500) : null;
}

// ── PATCH /orders/:id/reject — 특장사 주문 거부 (배정 해제, 재배정 가능) ──────
/**
* 특장사가 **못 받겠다**고 하는 문.
*
* 예전에는 수락밖에 없어서, 못 받는 주문을 붙들고 있거나 전화로 알리고 관리자가
* 손으로 되돌려야 했다. 그 사이 그 주문은 **배정된 것처럼 보인다.**
*
* 거부하면 배정이 풀려 **다른 특장사에 다시 맡길 수 있다.** 견적은 계약완료로 돌아간다 —
* 계약이 깨진 것이 아니라 만들 곳을 다시 찾는 것이다.
*
* ⚠️ **사유가 필수다.** 「왜 안 받았는지」가 없으면 다시 배정할 수도, 고칠 수도 없다
* (납기가 안 되는 것인지, 사양을 못 만드는 것인지에 따라 다음 수가 다르다).
*/
ordersRouter.patch('/:id/reject', rbac('ADMIN', 'MAKER'), requirePermission('order.control'), async (req: Request, res): Promise<void> => {
if (!prisma) { res.status(503).json({ error: { code: 'DB_UNAVAILABLE', message: 'DB 연결 필요' } }); return; }
const id = Number(req.params['id']);
if (isNaN(id)) { res.status(400).json({ error: { code: 'BAD_INPUT', message: '유효하지 않은 order id' } }); return; }

const reason = readReason(req.body);
if (!reason) {
res.status(400).json({ error: { code: 'BAD_INPUT', message: '거부 사유를 적어야 합니다' } });
return;
}

try {
const order = await prisma.order.findUnique({ where: { id }, include: { quote: { select: { id: true, status: true } } } });
if (!order) { res.status(404).json({ error: { code: 'NOT_FOUND', message: '주문을 찾을 수 없습니다' } }); return; }
if (ownOrgOnly(req.auth!) && order.maker_org_id !== req.auth!.org_code) {
res.status(403).json({ error: { code: 'FORBIDDEN', message: '자기 조직의 주문만 거부할 수 있습니다' } });
return;
}
/*
* **수락 전에만** 거부할 수 있다. 이미 수락해 제작이 도는 건은 거부가 아니라
* 별도의 사정(중단·재배정)이고, 그때는 관리자가 판단할 일이다.
*/
if (order.quote.status !== 'assigned') {
res.status(409).json({ error: { code: 'CONFLICT', message: `배정 상태에서만 거부할 수 있습니다 (현재 ${order.quote.status})` } });
return;
}

await prisma.order.update({
where: { id },
data: {
rejected_at: new Date(), rejected_by: req.auth?.email ?? 'unknown', reject_reason: reason,
// 배정을 푼다 — 다른 특장사에 다시 맡길 수 있어야 한다
maker_org_id: null, assigned_at: null, delivery_due: null,
},
});
// 계약이 깨진 것이 아니라 만들 곳을 다시 찾는 것이다
await setQuoteStatus(order.quote.id, 'contracted', req.auth?.email ?? 'unknown');
const updated = await prisma.quote.findUnique({ where: { id: order.quote.id } });
res.json({ data: { quote: updated, reason } });
} catch (e) {
console.error('[PATCH /orders/:id/reject]', e);
res.status(500).json({ error: { code: 'INTERNAL', message: '주문 거부 중 오류가 발생했습니다.' } });
}
});

// ── PATCH /orders/:id/cancel — 관리자가 주문을 치운다 (행은 남는다) ──────────
/**
* 잘못 만든 주문을 **목록에서 치운다.**
*
* ⚠️ **행을 지우지 않는다.** 「삭제」라고 부르지만 상태로 남긴다 —
* 누가 언제 왜 치웠는지가 사라지면 나중에 아무도 설명하지 못한다.
* (견적 삭제가 서명된 계약까지 연쇄로 지운 사고 이후의 규칙 — CLAUDE.md)
*
* ⚠️ **수락 대기·진행중까지만.** 인도가 끝난 건은 치우지 않는다 — 이미 일어난 거래다.
*
* 권한은 `order.remove` 로 **계정별로** 켠다. 관리자라고 다 되면 안 되는 일이다.
*/
ordersRouter.patch('/:id/cancel', rbac('ADMIN'), requirePermission('order.remove'), async (req: Request, res): Promise<void> => {
if (!prisma) { res.status(503).json({ error: { code: 'DB_UNAVAILABLE', message: 'DB 연결 필요' } }); return; }
const id = Number(req.params['id']);
if (isNaN(id)) { res.status(400).json({ error: { code: 'BAD_INPUT', message: '유효하지 않은 order id' } }); return; }

const reason = readReason(req.body);
if (!reason) {
res.status(400).json({ error: { code: 'BAD_INPUT', message: '치우는 사유를 적어야 합니다' } });
return;
}

try {
const order = await prisma.order.findUnique({ where: { id }, include: { quote: { select: { id: true, status: true } } } });
if (!order) { res.status(404).json({ error: { code: 'NOT_FOUND', message: '주문을 찾을 수 없습니다' } }); return; }
if (order.canceled_at) { res.status(409).json({ error: { code: 'CONFLICT', message: '이미 치운 주문입니다' } }); return; }

// 수락 대기(assigned) · 진행중(ordered) 까지만
if (order.quote.status !== 'assigned' && order.quote.status !== 'ordered') {
res.status(409).json({
error: { code: 'CONFLICT', message: `수락 대기·진행중 주문만 치울 수 있습니다 (현재 ${order.quote.status})` },
});
return;
}

await prisma.order.update({
where: { id },
data: { canceled_at: new Date(), canceled_by: req.auth?.email ?? 'unknown', cancel_reason: reason },
});
// 견적은 계약완료로 되돌린다 — 계약은 그대로고 만들 곳만 없어진 것이다
await setQuoteStatus(order.quote.id, 'contracted', req.auth?.email ?? 'unknown');
const updated = await prisma.quote.findUnique({ where: { id: order.quote.id } });
res.json({ data: { quote: updated, reason } });
} catch (e) {
console.error('[PATCH /orders/:id/cancel]', e);
res.status(500).json({ error: { code: 'INTERNAL', message: '주문을 치우는 중 오류가 발생했습니다.' } });
}
});

// ── PATCH /orders/:id/accept — 특장사 주문 수락 (배정→주문, 제작 착수) ──────
// 배정된 특장사가 주문을 수락하면 견적 상태 assigned→ordered. 이후 진행은 단계 표가 갖는다.

Expand Down
1 change: 1 addition & 0 deletions db/seed/feature_module.csv
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ subsidy.manage,보조금 관리,관리자,7,Y
account.manage,계정 관리,관리자,8,Y
doc.view,서류 조회,"관리자,특장사",9,Y
notify.assign,제작 배정 알림 메일,관리자,10,Y
order.remove,주문 치우기(관리자),관리자,11,Y
34 changes: 34 additions & 0 deletions frontend/src/api/orders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,37 @@ export async function saveVehicleInfo(orderId: number, info: OrderVehicleInfo):
throw new Error(body.error?.message ?? `차량정보 저장 실패: ${res.status}`)
}
}

/**
* 주문 거부 — 못 받겠다고 알린다. **사유가 필수다.**
* 거부하면 배정이 풀려 다른 특장사에 다시 맡길 수 있다.
*/
export async function rejectOrder(orderId: number, reason: string): Promise<void> {
const res = await fetch(`/api/v1/orders/${orderId}/reject`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason }),
})
if (!res.ok) {
const body = await res.json().catch(() => ({})) as { error?: { message?: string } }
throw new Error(body.error?.message ?? `주문 거부 실패: ${res.status}`)
}
}

/**
* 주문 치우기(관리자) — 목록에서 뺀다. **행은 남는다.**
* 권한은 기능모듈 `order.remove` 로 계정별로 켠다.
*/
export async function cancelOrder(orderId: number, reason: string): Promise<void> {
const res = await fetch(`/api/v1/orders/${orderId}/cancel`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason }),
})
if (!res.ok) {
const body = await res.json().catch(() => ({})) as { error?: { message?: string } }
throw new Error(body.error?.message ?? `주문 치우기 실패: ${res.status}`)
}
}
Loading