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
66 changes: 66 additions & 0 deletions backend/src/__tests__/dev-auto-login.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, it, expect, afterAll } from 'vitest';
import { readFileSync } from 'node:fs';
import path from 'node:path';

/**
* **로컬 자동 로그인이 운영으로 새지 않는가.**
*
* 비밀번호 없이 들어오는 길을 하나 냈다(로컬 개발용). 이런 길은 **조용히 열린 채로 배포되는 것**이
* 유일한 실패 방식이다 — 화면에 아무 표시도 없고, 오류도 안 나고, 눈으로는 절대 안 보인다.
* 그래서 막는 겹을 하나하나 못 박는다.
*
* ① 운영에서는 **라우트를 아예 만들지 않는다**(권한 검사를 끄는 것이 아니다)
* ② 그래도 붙었다면 **이 기계에서 온 요청만** 받는다
* ③ 배포가 실제로 `NODE_ENV=production` 으로 띄운다 — ①이 걸리려면 이게 참이어야 한다
* ④ 화면 쪽 코드는 개발 빌드에만 들어간다
*/
const ROOT = path.resolve(__dirname, '../../..');
const read = (rel: string) => readFileSync(path.join(ROOT, rel), 'utf8');

const before = process.env['NODE_ENV'];
afterAll(() => {
if (before === undefined) delete process.env['NODE_ENV'];
else process.env['NODE_ENV'] = before;
});

describe('로컬 자동 로그인은 운영에 없다', () => {
it('🔴 NODE_ENV=production 이면 꺼진다', async () => {
const { devAutoLoginEnabled } = await import('../routes/dev-auth.js');
process.env['NODE_ENV'] = 'production';
expect(devAutoLoginEnabled(), '운영에서 자동 로그인이 켜져 있다').toBe(false);
for (const v of ['development', 'test', '']) {
process.env['NODE_ENV'] = v;
expect(devAutoLoginEnabled()).toBe(true);
}
delete process.env['NODE_ENV'];
expect(devAutoLoginEnabled(), '값이 없으면 개발로 본다').toBe(true);
});

it('🔴 운영으로 띄운 앱에는 /api/v1/dev/login 이 아예 없다', async () => {
process.env['NODE_ENV'] = 'production';
const request = (await import('supertest')).default;
const { createApp } = await import('../app.js');
const res = await request(createApp()).get('/api/v1/dev/login?email=master@local');
expect(res.status, '운영 앱에서 자동 로그인 경로가 열렸다').toBe(404);
expect(res.headers['set-cookie'], '운영 앱이 세션 쿠키를 심었다').toBeUndefined();
});

it('🔴 라우트가 붙어 있어도 밖에서 온 요청은 받지 않는다', () => {
const src = read('backend/src/routes/dev-auth.ts');
expect(src, '로컬 확인이 사라졌다').toMatch(/127\.0\.0\.1/);
expect(src, '로컬이 아니면 404 로 덮어야 한다').toMatch(/res\.status\(404\)/);
// 두 조건을 **모두** 본다 — 운영이면서 로컬인 요청(서버에 들어가서 부르는 것)도 막는다
expect(src).toMatch(/devAutoLoginEnabled\(\)\s*&&/);
});

it('🔴 배포는 NODE_ENV=production 으로 띄운다 — 이게 거짓이면 위 검사가 다 무의미하다', () => {
expect(read('deploy/remote-deploy.sh')).toMatch(/^NODE_ENV=production/m);
});

it('🔴 화면 쪽 자동 로그인은 개발 빌드에만 들어간다', () => {
const src = read('frontend/src/pages/LoginPage.tsx');
// import.meta.env.DEV 는 운영 빌드에서 false 로 굳어 이 블록이 통째로 사라진다
expect(src).toMatch(/if \(!import\.meta\.env\.DEV/);
expect(src, '호스트 확인까지 겹쳐 둔다').toMatch(/hostname !== 'localhost'/);
});
});
9 changes: 7 additions & 2 deletions backend/src/__tests__/order-dashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,8 +206,13 @@ describe.runIf(!!process.env['DATABASE_URL'] || true)('주문 목록 응답에
expect((await request(app).patch(`/api/v1/quotes/${q.id}/assign`).set('Cookie', admin).send({ maker_org_id: 'ORG_BRAIN' })).status).toBe(200);
const o = await prisma!.order.findFirstOrThrow({ where: { quote_id: q.id } });
expect((await request(app).patch(`/api/v1/orders/${o.id}/accept`).set('Cookie', maker).send({ delivery_due: await businessDue() })).status).toBe(200);
const res = await request(app).get('/api/v1/orders').set('Cookie', admin);
const row = (res.body.data as ApiOrder[]).find(x => x.id === o.id)!;
// 전체 목록 조회라 다른 시험 파일이 같은 순간 자기 데이터를 지우면 드물게 500 이 난다 — 한 번 더 묻는다(운영에는 행 삭제가 없다)
let rows: ApiOrder[] | null = null;
for (let i = 0; i < 3 && !rows; i++) {
const res = await request(app).get('/api/v1/orders').set('Cookie', admin);
if (res.status === 200) rows = res.body.data as ApiOrder[];
}
const row = rows!.find(x => x.id === o.id)!;
expect(row.steps?.lanes?.vehicle?.code).toBe('car_arrived');
expect(row.steps?.lanes?.body?.code).toBe('build_started');
expect(row.steps?.lanes?.tuning).toBeNull();
Expand Down
181 changes: 181 additions & 0 deletions backend/src/__tests__/sales-stats.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { describe, it, expect, afterAll } from 'vitest';

/**
* **영업 성과를 세는 규칙**(2026-09-16 전면 재정비).
*
* 세 가지가 실제로 틀려 있었고, 셋 다 화면에서는 그럴듯해 보였다.
* ① 배정을 취소해 계약완료로 되돌린 건이 **배정완료에 그대로 남았다**(제보)
* ② 한 고객에게 견적을 여러 번 내면 **고객이 여러 명으로** 세어졌다(제보)
* ③ 기간을 견적 **만든 날**로 걸러서, 8월에 만들어 9월에 계약한 건이 「9월 계약」에 없었다
*
* 숫자는 틀려도 오류가 안 난다. 그래서 여기서 못 박는다.
*/
const { prisma } = await import('../lib/prisma.js');
const { salesStats, salesStatsFull, attentionList } = await import('../services/sales-stats.js');

const live = !!prisma;
const MODEL = 'PV5_OPENBED';
/**
* **시험마다 계정을 새로 만든다.** 한 계정을 나눠 쓰면 앞 시험이 만든 견적이
* 뒤 시험의 합계에 섞여, 코드가 멀쩡해도 숫자가 안 맞는다(실제로 두 번 걸렸다).
*/
const USERS: string[] = [];
let seq = 0;
async function user(): Promise<string> {
const email = `stats-${++seq}-${Date.now()}@example.invalid`;
USERS.push(email);
await prisma!.user.create({
data: { email, name: '성과시험', role: 'SALES', extra_roles: [], org_code: 'ORG_HQ', active: true, status: 'active', password_hash: 'x' },
});
return email;
}
const quotes: number[] = [];
const customers: number[] = [];

const day = (iso: string) => new Date(`${iso}T03:00:00Z`); // 한국 정오 — 경계 시비를 피한다

async function customer(name: string, phone?: string): Promise<number> {
const c = await prisma!.customer.create({ data: { name, ...(phone ? { phone } : {}) } });
customers.push(c.id);
return c.id;
}

/** 견적 하나 — 상태·담당·고객·생성일과 전이 이력을 한 번에 심는다 */
async function quote(o: {
user: string; customerId?: number; status: string; price?: number; createdAt?: Date;
/** 상태 전이 이력 — [바뀐 상태, 날짜] */
log?: [string, Date][];
hidden?: boolean;
}): Promise<number> {
const q = await prisma!.quote.create({
data: {
model_code: MODEL, selections: {}, inputs: {},
status: o.status as never, sales_user_id: o.user,
...(o.customerId ? { customer_id: o.customerId } : {}),
final_price: o.price ?? 1_000_000,
...(o.createdAt ? { created_at: o.createdAt } : {}),
...(o.hidden ? { hidden_at: new Date(), hidden_by: 'vitest' } : {}),
},
select: { id: true },
});
quotes.push(q.id);
for (const [status, at] of o.log ?? []) {
await prisma!.quoteChangeLog.create({
data: { quote_id: q.id, section: 'status', field: 'status', new_value: status, changed_by: 'vitest', changed_at: at },
});
}
return q.id;
}

afterAll(async () => {
if (!prisma) return;
await prisma.quoteChangeLog.deleteMany({ where: { quote_id: { in: quotes } } });
await prisma.quote.deleteMany({ where: { id: { in: quotes } } });
await prisma.customer.deleteMany({ where: { id: { in: customers } } });
await prisma.user.deleteMany({ where: { email: { in: USERS } } });
});

describe.runIf(live)('영업 성과 집계', () => {
it('🔴 배정을 취소해 계약완료로 돌아온 건은 배정완료에서 빠진다 — 지금 상태로 센다', async () => {
const A = await user();
const c = await customer('성과시험_배정취소');
await quote({
user: A, customerId: c, status: 'contracted',
log: [['contracted', day('2026-09-02')], ['assigned', day('2026-09-03')], ['contracted', day('2026-09-04')]],
});
const [st] = await salesStats({ salesUser: A });
expect(st!.reached.assigned, '되돌린 건이 배정완료에 남았다').toBe(0);
expect(st!.reached.contracted).toBe(1);
}, 30_000);

it('🔴 한 고객에게 여러 건을 내도 견적완료 고객은 1명 — 건수는 그대로 센다', async () => {
const B = await user();
const c = await customer('성과시험_한고객여러건');
for (const n of [1, 2, 3]) {
await quote({ user: B, customerId: c, status: 'confirmed', price: n * 1_000_000, log: [['confirmed', day('2026-09-05')]] });
}
const [st] = await salesStats({ salesUser: B });
expect(st!.reached.confirmed, '건수는 세 건이어야 한다').toBe(3);
expect(st!.customers.confirmed, '고객은 한 명이어야 한다').toBe(1);
expect(st!.customers.draft, '상담고객도 한 명').toBe(1);
// 금액도 고객당 한 건 — 같은 차를 세 번 견적내면 세 배가 되면 안 된다
expect(st!.amount.confirmed).toBe(3_000_000);
}, 30_000);

it('🔴 같은 사람인데 고객 행이 둘이면 한 명으로 묶는다 — 서류함과 같은 규칙', async () => {
const A = await user();
const one = await customer('성과시험_중복인물', '010-7777-0001');
const two = await customer('성과시험_중복인물', '010-7777-0001');
await quote({ user: A, customerId: one, status: 'confirmed', log: [['confirmed', day('2026-09-06')]] });
await quote({ user: A, customerId: two, status: 'confirmed', log: [['confirmed', day('2026-09-06')]] });
const [st] = await salesStats({ salesUser: A });
expect(st!.customers.confirmed, '고객 행이 둘이라고 두 명으로 세면 안 된다').toBe(1);
}, 30_000);

it('🔴 기간은 「그 단계에 도달한 날」로 가른다 — 지난달 만들어 이번 달 계약한 건', async () => {
const B = await user();
const c = await customer('성과시험_기간');
await quote({
user: B, customerId: c, status: 'contracted', createdAt: day('2026-08-20'),
log: [['confirmed', day('2026-08-21')], ['contracted', day('2026-09-10')]],
});
const sep = { salesUser: B, from: day('2026-09-01'), to: new Date('2026-09-30T23:59:59.999+09:00') };
const [st] = await salesStats(sep);
expect(st!.reached.contracted, '9월에 계약한 건이 9월 집계에 없다').toBe(1);
expect(st!.reached.draft, '8월에 만든 견적은 9월 활동이 아니다').toBe(0);
expect(st!.customers.contracted).toBe(1);
}, 30_000);

it('🔴 이름 없는 임시저장은 상담고객이 아니다 — 건수로만 센다', async () => {
const A = await user();
await quote({ user: A, status: 'draft' });
await quote({ user: A, status: 'draft' });
const [st] = await salesStats({ salesUser: A });
expect(st!.reached.draft, '견적 두 건은 두 건').toBe(2);
expect(st!.customers.draft, '고객 정보가 없는데 두 명을 만난 것이 된다').toBe(0);
}, 30_000);

it('🔴 전이 이력이 없는 옛 건 — 끝이 열린 기간이면 만든 날로 갈음하고, 지난 달을 볼 땐 세지 않는다', async () => {
const A = await user();
const c = await customer('성과시험_이력없음');
const madeToday = new Date();
await quote({ user: A, customerId: c, status: 'contracted', createdAt: madeToday }); // 상태 전이 이력 없음

const from = new Date(madeToday.getFullYear(), madeToday.getMonth(), 1);
const open = await salesStats({ salesUser: A, from });
expect(open[0]!.reached.contracted, '계약완료인데 이번 달 계약이 0 이 된다').toBe(1);

// 끝이 닫힌 지난 기간 — 언제 계약했는지 모르므로 지어내지 않는다
const closed = await salesStats({ salesUser: A, from: day('2026-01-01'), to: day('2026-01-31') });
expect(closed[0]?.reached.contracted ?? 0).toBe(0);
}, 30_000);

it('🔴 전체 합계는 서버가 낸다 — 한 고객을 둘이 맡아도 두 명이 되지 않는다', async () => {
const A = await user();
const B = await user();
const c = await customer('성과시험_공동고객');
const totalCustomers = async () => (await salesStatsFull({})).total.customers.confirmed;

const before = await totalCustomers();
await quote({ user: A, customerId: c, status: 'confirmed', log: [['confirmed', day('2026-09-07')]] });
expect(await totalCustomers(), '새 고객 한 명이 늘어야 한다').toBe(before + 1);

// 같은 고객을 다른 영업도 맡았다 — 계정별로는 각자 1명이지만 **합계는 그대로 1명**이다
await quote({ user: B, customerId: c, status: 'confirmed', log: [['confirmed', day('2026-09-07')]] });
expect(await totalCustomers(), '합계를 더해서 만들면 한 사람이 두 명이 된다').toBe(before + 1);

const { rows } = await salesStatsFull({});
const mine = rows.filter(r => r.sales_user_id === A || r.sales_user_id === B);
expect(mine.map(r => r.customers.confirmed), '계정별로는 각자 한 명').toEqual([1, 1]);
}, 30_000);

it('🔴 숨긴 견적은 성과에도, 「처리 필요 견적」에도 나오지 않는다', async () => {
const A = await user();
const c = await customer('성과시험_숨김');
await quote({ user: A, customerId: c, status: 'draft', createdAt: day('2026-01-02'), hidden: true });
const [st] = await salesStats({ salesUser: A, from: day('2026-01-01'), to: day('2026-01-31') });
expect(st?.reached.draft ?? 0, '숨긴 견적이 성과에 잡혔다').toBe(0);
const att = await attentionList(A);
expect(att.some(x => x.customer === '성과시험_숨김'), '숨긴 견적이 오늘 할 일에 떴다').toBe(false);
}, 30_000);
});
11 changes: 11 additions & 0 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { addonRouter } from './routes/addon.js';
import { docsRouter } from './routes/docs.js';
import { pushRouter } from './routes/push.js';
import { authRouter } from './routes/auth.js';
import { devAuthRouter, devAutoLoginEnabled } from './routes/dev-auth.js';
import { usersRouter } from './routes/users.js';
import { accessControlRouter } from './routes/access-control.js';
import { weightConstantsRouter } from './routes/weight-constants.js';
Expand Down Expand Up @@ -87,6 +88,16 @@ export function createApp() {
app.use('/api/external', externalRouter);

app.use('/api/v1/auth', authRouter);

/*

* 로컬 전용 자동 로그인 — **운영이 아닐 때만 라우트를 만든다**(routes/dev-auth.ts).

* 운영은 NODE_ENV=production 으로 뜨므로 이 경로가 존재하지 않는다.

*/

if (devAutoLoginEnabled()) app.use('/api/v1/dev', devAuthRouter);
app.use('/api/v1/users', usersRouter);
app.use('/api/v1/access-control', accessControlRouter);
app.use('/api/v1/weight-constants', weightConstantsRouter);
Expand Down
38 changes: 38 additions & 0 deletions backend/src/routes/dev-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Router } from 'express';
import type { Request, Response } from 'express';
import { prisma } from '../lib/prisma.js';
import { issueSession } from '../lib/session.js';

/**
* **로컬 전용 자동 로그인** — 개발할 때 비밀번호를 치지 않고 바로 들어간다.
*
* ⚠️ **운영에는 이 경로가 아예 없다.** `app.ts` 가 운영이 아닐 때만 붙인다 — 권한 검사를 끄는 것이 아니라
* **라우트를 만들지 않는다.** 운영은 `NODE_ENV=production` 으로 뜬다(deploy/remote-deploy.sh).
* ⚠️ 그래도 한 겹 더 막는다: 요청이 이 기계(127.0.0.1/::1)에서 온 것이 아니면 404.
* 운영에서 이 파일이 실수로 붙더라도 **밖에서는 열리지 않는다**(그 경우에도 프록시 뒤라 열리지 않게 두 조건을 모두 본다).
*/
export const devAuthRouter = Router();

/** 운영이 아닐 때만 — 값이 없으면 개발로 본다(config.ts 와 같은 기준) */
export const devAutoLoginEnabled = (): boolean => (process.env['NODE_ENV'] ?? 'development') !== 'production';

function localOnly(req: Request, res: Response): boolean {
const ip = req.ip ?? '';
const ok = devAutoLoginEnabled() && (ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1');
if (!ok) { res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Not found' } }); return false; }
return true;
}

// ── GET /dev/login?email=…&to=… — 쿠키를 심고 화면으로 보낸다 ────────────────
devAuthRouter.get('/login', async (req: Request, res: Response): Promise<void> => {
if (!localOnly(req, res)) return;
if (!prisma) { res.status(503).json({ error: { code: 'DB_UNAVAILABLE', message: 'DB 연결 필요' } }); return; }
const email = String(req.query['email'] ?? 'master@local');
const user = await prisma.user.findUnique({ where: { email } });
if (!user || !user.active) {
res.status(404).json({ error: { code: 'NOT_FOUND', message: `로컬 계정 ${email} 이 없습니다` } }); return;
}
issueSession(res, { email: user.email, role: user.role, org_code: user.org_code }, true);
const to = String(req.query['to'] ?? 'http://localhost:5173/admin');
res.redirect(to.startsWith('http://localhost') ? to : 'http://localhost:5173/admin');
});
26 changes: 19 additions & 7 deletions backend/src/routes/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import { Router } from 'express';
import type { Request } from 'express';
import { rbac, requirePermission, hasPermission } from '../middleware/rbac.js';
import { salesStats, attentionList } from '../services/sales-stats.js';
import { salesStatsFull, attentionList } from '../services/sales-stats.js';

export const statsRouter = Router();

Expand All @@ -26,21 +26,33 @@ async function targetUser(req: Request): Promise<string | undefined> {
return q || undefined;
}

function dateOf(v: unknown): Date | undefined {
/**
* 날짜 한 칸 — **한국 시각 기준**으로 읽는다.
*
* `new Date('2026-09-01')` 은 UTC 자정이라 한국에서는 **그날 오전 9시**다. 그대로 쓰면
* 시작일 새벽에 만든 견적이 빠지고, 종료일은 그날 오전 9시에서 잘려 하루가 통째로 날아갔다.
* 그래서 날짜만 온 값은 시작=그날 00:00(KST), 끝=그날 23:59:59.999(KST) 로 편다.
*/
const KST = '+09:00';
function dateOf(v: unknown, edge: 'start' | 'end'): Date | undefined {
if (typeof v !== 'string' || !v.trim()) return undefined;
const d = new Date(v);
const s = v.trim();
const dateOnly = /^\d{4}-\d{2}-\d{2}$/.test(s);
const d = new Date(dateOnly ? `${s}T${edge === 'start' ? '00:00:00.000' : '23:59:59.999'}${KST}` : s);
return Number.isNaN(d.getTime()) ? undefined : d;
}

// ── GET /stats/sales — 계정별 깔때기·금액·활동량·속도 ──────────────────────
statsRouter.get('/sales', rbac('ADMIN', 'SALES'), requirePermission('stats.own'), async (req: Request, res): Promise<void> => {
try {
const data = await salesStats({
from: dateOf(req.query['from']),
to: dateOf(req.query['to']),
// 합계는 **서버가 낸다.** 화면에서 계정별 줄을 더하면 고객 수가 어긋난다
// (한 고객을 두 영업이 나눠 맡으면 각자 1명이라 합이 2명이 된다).
const { rows, total } = await salesStatsFull({
from: dateOf(req.query['from'], 'start'),
to: dateOf(req.query['to'], 'end'),
salesUser: await targetUser(req),
});
res.json({ data });
res.json({ data: rows, total });
} catch (e) {
console.error('[GET /stats/sales]', e);
res.status(500).json({ error: { code: 'INTERNAL', message: '성과 집계 중 오류가 발생했습니다.' } });
Expand Down
Loading
Loading