diff --git a/backend/prisma/migrations/20260826010000_order_reject_and_cancel/migration.sql b/backend/prisma/migrations/20260826010000_order_reject_and_cancel/migration.sql new file mode 100644 index 0000000..360afbf --- /dev/null +++ b/backend/prisma/migrations/20260826010000_order_reject_and_cancel/migration.sql @@ -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; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 720a1f7..b286f5a 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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[] diff --git a/backend/src/__tests__/order-reject-remove.test.ts b/backend/src/__tests__/order-reject-remove.test.ts new file mode 100644 index 0000000..41431f1 --- /dev/null +++ b/backend/src/__tests__/order-reject-remove.test.ts @@ -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\}/); + }); +}); diff --git a/backend/src/routes/orders.ts b/backend/src/routes/orders.ts index 935dbe1..c177415 100644 --- a/backend/src/routes/orders.ts +++ b/backend/src/routes/orders.ts @@ -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; - const where: Prisma.OrderWhereInput = {}; + /* + * 치운 주문은 목록에서 뺀다 — **행은 남아 있지만 일감이 아니다.** + * 여기서 거르지 않으면 「삭제」를 눌러도 그대로 보여, 치운 뜻이 없어진다. + */ + const where: Prisma.OrderWhereInput = { canceled_at: null }; /* * 범위는 **가진 역할 전부**로 정한다. 관리자면 전체, 아니면 겸직한 역할만큼 넓힌다 * (영업+특장 겸직이면 자기 견적의 주문 ∪ 자기 조직에 배정된 주문). @@ -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 => { + 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 => { + 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. 이후 진행은 단계 표가 갖는다. diff --git a/db/seed/feature_module.csv b/db/seed/feature_module.csv index 1feedf8..880ff48 100644 --- a/db/seed/feature_module.csv +++ b/db/seed/feature_module.csv @@ -9,3 +9,4 @@ subsidy.manage,보조금 관리,관리자,7,Y account.manage,계정 관리,관리자,8,Y doc.view,서류 조회,"관리자,특장사",9,Y notify.assign,제작 배정 알림 메일,관리자,10,Y +order.remove,주문 치우기(관리자),관리자,11,Y diff --git a/frontend/src/api/orders.ts b/frontend/src/api/orders.ts index 889adbd..2b80cef 100644 --- a/frontend/src/api/orders.ts +++ b/frontend/src/api/orders.ts @@ -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 { + 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 { + 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}`) + } +} diff --git a/frontend/src/components/AcceptOrderModal.tsx b/frontend/src/components/AcceptOrderModal.tsx index bf8f305..c3986c0 100644 --- a/frontend/src/components/AcceptOrderModal.tsx +++ b/frontend/src/components/AcceptOrderModal.tsx @@ -15,7 +15,7 @@ import { BTN } from '../styles/buttons' * * 특장사가 보는 서류는 발주서뿐이다(계약서·견적서는 서버에서 막았다). */ -export function AcceptOrderModal({ orderId, makerOrgName, orderedAt, busy, error, onAccept, onClose }: { +export function AcceptOrderModal({ orderId, makerOrgName, orderedAt, busy, error, onAccept, onClose , onReject}: { orderId: number makerOrgName: string /** 납기 한도의 기산점 = 배정일(발주일) */ @@ -24,6 +24,8 @@ export function AcceptOrderModal({ orderId, makerOrgName, orderedAt, busy, error error: string onAccept: (deliveryDue: string) => void onClose: () => void + /** 거부 — 사유를 받아 넘긴다. 없으면 거부 버튼이 뜨지 않는다. */ + onReject?: (reason: string) => void }) { const base = useMemo(() => { const d = new Date(orderedAt) @@ -31,6 +33,10 @@ export function AcceptOrderModal({ orderId, makerOrgName, orderedAt, busy, error }, [orderedAt]) const limit = useMemo(() => deliveryDueLimit(base), [base]) + const [rejecting, setRejecting] = useState(false) + + const [reason, setReason] = useState('') + const [due, setDue] = useState('') const [modelCode, setModelCode] = useState('') const [options, setOptions] = useState([]) @@ -100,17 +106,52 @@ export function AcceptOrderModal({ orderId, makerOrgName, orderedAt, busy, error + {/* + 거부 — **못 받겠다고 알리는 문.** + 예전에는 수락밖에 없어서, 못 받는 주문을 붙들고 있거나 전화로 알려야 했다. + 그 사이 그 주문은 배정된 것처럼 보인다. + + 사유를 적어야 눌린다 — 「왜 안 받았는지」가 없으면 다시 배정할 수도, 고칠 수도 없다. + */} + {rejecting && ( +
+
이 주문을 거부합니다
+
+ 배정이 풀려 다른 특장사에 다시 맡길 수 있게 됩니다. 계약은 그대로입니다. +
+ +