[feat] negodata: 협력사 총매출액(total_revenue) 추가 + 우선선정(priority) 제거

Helped-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Mina Choi 2026-07-06 17:27:59 +09:00
parent 3176ec4262
commit 1ac42ddc9c
24 changed files with 61 additions and 124 deletions

View File

@ -124,7 +124,7 @@ class suppliers(MainTableMixin, MAIN_BASE):
manager_name = Column(String(50), nullable=True)
manager_email = Column(String(255), nullable=True)
manager_contact_number = Column(String(20), nullable=True) # ERD 오타(manger) 교정
priority = Column(String(10), nullable=True) # True/False 가 아닌 string value 가능
total_revenue = Column(BigInteger, nullable=True) # 총매출액(원)
class nego_cards(MainTableMixin, MAIN_BASE):

View File

@ -14,7 +14,7 @@ from common.utils.gtime import GTime
# 협력사 CRUD. 모든 조회/변경은 company_id 로 스코프된다(멀티테넌트).
class ISupplierCRUD(ABC):
@abstractmethod
async def search(self, cdb: AsyncSession, company_id, search, priority, skip, limit) -> Tuple[ErrorType, list, int]:
async def search(self, cdb: AsyncSession, company_id, search, skip, limit) -> Tuple[ErrorType, list, int]:
pass
@abstractmethod
@ -44,7 +44,7 @@ class ISupplierCRUD(ABC):
class SupplierCRUD(ISupplierCRUD):
async def search(
self, cdb: AsyncSession, company_id, search: Optional[str], priority: Optional[str], skip: int, limit: int
self, cdb: AsyncSession, company_id, search: Optional[str], skip: int, limit: int
) -> Tuple[ErrorType, list, int]:
try:
conditions = [suppliers.deleted == False, suppliers.company_id == company_id] # noqa: E712
@ -56,8 +56,6 @@ class SupplierCRUD(ISupplierCRUD):
suppliers.manager_name.ilike(f"%{search}%"),
)
)
if priority:
conditions.append(suppliers.priority == priority)
where = and_(*conditions)
cnt_err, cnt_rows = await DB_SESSION_MNG.execute(cdb, select(func.count()).select_from(suppliers).where(where))

View File

@ -17,7 +17,7 @@ class Req_CreateSupplier(SupplierProtocol):
manager_name: Optional[str] = None
manager_email: Optional[str] = None
manager_contact_number: Optional[str] = None
priority: Optional[str] = None
total_revenue: Optional[int] = None # 총매출액(원)
class Req_UpdateSupplier(SupplierProtocol):
@ -26,7 +26,7 @@ class Req_UpdateSupplier(SupplierProtocol):
manager_name: Optional[str] = None
manager_email: Optional[str] = None
manager_contact_number: Optional[str] = None
priority: Optional[str] = None
total_revenue: Optional[int] = None
class SupplierData(WebPacketProtocol):
@ -40,7 +40,7 @@ class SupplierData(WebPacketProtocol):
manager_name: Optional[str] = None
manager_email: Optional[str] = None
manager_contact_number: Optional[str] = None
priority: Optional[str] = None
total_revenue: Optional[int] = None # 총매출액(원)
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None

View File

@ -24,10 +24,9 @@ async def list_suppliers(
service: SupplierService = Depends(),
user_info: UserInfo = Depends(IsValidAccessToken),
search: str | None = Query(None, description="협력사명/코드/담당자명 검색"),
priority: str | None = Query(None, description="우선순위 필터(HIGH/MEDIUM/LOW)"),
pg: PageParams = Depends(),
):
return RemoveNoneResponse(await service.list_suppliers(user_info.company_id, search, priority, pg))
return RemoveNoneResponse(await service.list_suppliers(user_info.company_id, search, pg))
@router.post(path="/create", response_model=Res_Supplier, summary="협력사 등록")

View File

@ -38,14 +38,14 @@ class SupplierService:
return ErrorType.SUPPLIER_NOT_FOUND, None
return ErrorType.SUCCESS, supplier
async def list_suppliers(self, company_id: str, search, priority, pg: PageParams) -> Res_SupplierList:
async def list_suppliers(self, company_id: str, search, pg: PageParams) -> Res_SupplierList:
res = Res_SupplierList(page=pg.page, size=pg.size)
company_uuid = uuid.UUID(company_id)
err_type, rows, total = await DB_SESSION_MNG.execute_lambda(
suppliers.DBType(),
DBWRType.DB_READ.value,
lambda s: self.supplier_crud.search(s, company_uuid, search, priority, pg.skip, pg.size),
lambda s: self.supplier_crud.search(s, company_uuid, search, pg.skip, pg.size),
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
@ -105,7 +105,7 @@ class SupplierService:
manager_name=req.manager_name,
manager_email=req.manager_email,
manager_contact_number=req.manager_contact_number,
priority=req.priority,
total_revenue=req.total_revenue,
)
err_type = await DB_SESSION_MNG.execute_lambda_run(
[suppliers.DBType()],

View File

@ -163,7 +163,7 @@ export * from './reqCreateSupplierCode';
export * from './reqCreateSupplierManagerContactNumber';
export * from './reqCreateSupplierManagerEmail';
export * from './reqCreateSupplierManagerName';
export * from './reqCreateSupplierPriority';
export * from './reqCreateSupplierTotalRevenue';
export * from './reqLogin';
export * from './reqRegenerateQuotation';
export * from './reqUpdateCard';
@ -216,7 +216,7 @@ export * from './reqUpdateSupplierManagerContactNumber';
export * from './reqUpdateSupplierManagerEmail';
export * from './reqUpdateSupplierManagerName';
export * from './reqUpdateSupplierName';
export * from './reqUpdateSupplierPriority';
export * from './reqUpdateSupplierTotalRevenue';
export * from './resCard';
export * from './resCardCard';
export * from './resCardList';
@ -339,7 +339,7 @@ export * from './supplierDataCreatedAt';
export * from './supplierDataManagerContactNumber';
export * from './supplierDataManagerEmail';
export * from './supplierDataManagerName';
export * from './supplierDataPriority';
export * from './supplierDataTotalRevenue';
export * from './supplierDataUpdatedAt';
export * from './supplierType';
export * from './targetCandidate';

View File

@ -10,10 +10,6 @@ export type ListSuppliersParams = {
* 협력사명/코드/담당자명 검색
*/
search?: string | null;
/**
* 우선순위 필터(HIGH/MEDIUM/LOW)
*/
priority?: string | null;
/**
* @minimum 1
*/

View File

@ -8,7 +8,7 @@ import type { ReqCreateSupplierCode } from './reqCreateSupplierCode';
import type { ReqCreateSupplierManagerName } from './reqCreateSupplierManagerName';
import type { ReqCreateSupplierManagerEmail } from './reqCreateSupplierManagerEmail';
import type { ReqCreateSupplierManagerContactNumber } from './reqCreateSupplierManagerContactNumber';
import type { ReqCreateSupplierPriority } from './reqCreateSupplierPriority';
import type { ReqCreateSupplierTotalRevenue } from './reqCreateSupplierTotalRevenue';
export interface ReqCreateSupplier {
name?: string;
@ -16,5 +16,5 @@ export interface ReqCreateSupplier {
manager_name?: ReqCreateSupplierManagerName;
manager_email?: ReqCreateSupplierManagerEmail;
manager_contact_number?: ReqCreateSupplierManagerContactNumber;
priority?: ReqCreateSupplierPriority;
total_revenue?: ReqCreateSupplierTotalRevenue;
}

View File

@ -5,4 +5,4 @@
* OpenAPI spec version: 0.1.0
*/
export type ReqCreateSupplierPriority = string | null;
export type ReqCreateSupplierTotalRevenue = number | null;

View File

@ -9,7 +9,7 @@ import type { ReqUpdateSupplierCode } from './reqUpdateSupplierCode';
import type { ReqUpdateSupplierManagerName } from './reqUpdateSupplierManagerName';
import type { ReqUpdateSupplierManagerEmail } from './reqUpdateSupplierManagerEmail';
import type { ReqUpdateSupplierManagerContactNumber } from './reqUpdateSupplierManagerContactNumber';
import type { ReqUpdateSupplierPriority } from './reqUpdateSupplierPriority';
import type { ReqUpdateSupplierTotalRevenue } from './reqUpdateSupplierTotalRevenue';
export interface ReqUpdateSupplier {
name?: ReqUpdateSupplierName;
@ -17,5 +17,5 @@ export interface ReqUpdateSupplier {
manager_name?: ReqUpdateSupplierManagerName;
manager_email?: ReqUpdateSupplierManagerEmail;
manager_contact_number?: ReqUpdateSupplierManagerContactNumber;
priority?: ReqUpdateSupplierPriority;
total_revenue?: ReqUpdateSupplierTotalRevenue;
}

View File

@ -5,4 +5,4 @@
* OpenAPI spec version: 0.1.0
*/
export type ReqUpdateSupplierPriority = string | null;
export type ReqUpdateSupplierTotalRevenue = number | null;

View File

@ -8,7 +8,7 @@ import type { SupplierDataCode } from './supplierDataCode';
import type { SupplierDataManagerName } from './supplierDataManagerName';
import type { SupplierDataManagerEmail } from './supplierDataManagerEmail';
import type { SupplierDataManagerContactNumber } from './supplierDataManagerContactNumber';
import type { SupplierDataPriority } from './supplierDataPriority';
import type { SupplierDataTotalRevenue } from './supplierDataTotalRevenue';
import type { SupplierDataCreatedAt } from './supplierDataCreatedAt';
import type { SupplierDataUpdatedAt } from './supplierDataUpdatedAt';
@ -21,7 +21,7 @@ export interface SupplierData {
manager_name?: SupplierDataManagerName;
manager_email?: SupplierDataManagerEmail;
manager_contact_number?: SupplierDataManagerContactNumber;
priority?: SupplierDataPriority;
total_revenue?: SupplierDataTotalRevenue;
created_at?: SupplierDataCreatedAt;
updated_at?: SupplierDataUpdatedAt;
}

View File

@ -5,4 +5,4 @@
* OpenAPI spec version: 0.1.0
*/
export type SupplierDataPriority = string | null;
export type SupplierDataTotalRevenue = number | null;

View File

@ -17,13 +17,13 @@ type RawRow = {
code: string;
managerName: string;
managerEmail: string;
priority: string;
totalRevenue: string;
};
type ValidatedRow = RawRow & { status: '정상' | '오류'; message: string };
// 업로드 양식 한 줄(예시 행)
type TemplateRow = { name: string; code: string; managerName: string; managerEmail: string; priority: string };
type TemplateRow = { name: string; code: string; managerName: string; managerEmail: string; totalRevenue: string };
type ExcelUploadModalProps = {
open: boolean;
@ -68,7 +68,7 @@ function toSupplierCreate(row: RawRow): SupplierCreate {
manager_name: row.managerName,
manager_email: row.managerEmail,
manager_contact_number: '010-0000-0000',
priority: row.priority,
total_revenue: row.totalRevenue?.trim() ? Number(row.totalRevenue.replace(/[^0-9]/g, '')) : undefined,
};
}
@ -81,9 +81,9 @@ export function downloadPartnerTemplate() {
{ header: '식별코드', value: (r) => r.code },
{ header: '담당자명', value: (r) => r.managerName },
{ header: '담당자이메일', value: (r) => r.managerEmail },
{ header: '우선순위', value: (r) => r.priority },
{ header: '총매출액', value: (r) => r.totalRevenue },
],
[{ name: '예시) (주)한빛정밀', code: 'PART-EXAMPLE-001', managerName: '김철수 과장', managerEmail: 'cs.kim@example.com', priority: 'HIGH' }],
[{ name: '예시) (주)한빛정밀', code: 'PART-EXAMPLE-001', managerName: '김철수 과장', managerEmail: 'cs.kim@example.com', totalRevenue: '5000000000' }],
);
}
@ -123,7 +123,7 @@ export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUp
code: r['식별코드'] ?? '',
managerName: r['담당자명'] ?? '',
managerEmail: r['담당자이메일'] ?? '',
priority: r['우선순위'] ?? 'MEDIUM',
totalRevenue: r['총매출액'] ?? '',
}));
setExcelFile(file.name);
setRows(loaded);

View File

@ -1,4 +1,4 @@
import { useForm, Controller } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Trash2 } from 'lucide-react';
@ -9,8 +9,7 @@ import { Typography } from '@/components/ui/typography';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Sheet } from '@/components/ui/sheet';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { type Partner, priorityOptions } from '../types';
import { type Partner } from '../types';
const schema = z.object({
name: z.string().trim().min(1, '회사명/협력사명을 작성해 주십시오.'),
@ -18,7 +17,7 @@ const schema = z.object({
managerName: z.string().trim().min(1, '담당자명을 입력해 주십시오.'),
managerEmail: z.string().trim().email('정확한 담당자 이메일 형식을 점검해 주십시오.'),
managerPhone: z.string().trim().min(1, '담당자 연락처를 입력해 주십시오.'),
priority: z.string(),
totalRevenue: z.string().trim().optional(), // 총매출액(원)
});
type FormValues = z.infer<typeof schema>;
@ -42,7 +41,7 @@ function buildDefaults(mode: 'create' | 'edit', partner: Partner | null): FormVa
managerName: partner.manager_name || '',
managerEmail: partner.manager_email || '',
managerPhone: partner.manager_contact_number || '',
priority: partner.priority || 'MEDIUM',
totalRevenue: partner.total_revenue != null ? String(partner.total_revenue) : '',
};
}
return {
@ -51,7 +50,7 @@ function buildDefaults(mode: 'create' | 'edit', partner: Partner | null): FormVa
managerName: '',
managerEmail: '',
managerPhone: '010-',
priority: 'MEDIUM',
totalRevenue: '',
};
}
@ -68,7 +67,6 @@ export function PartnerFormSheet({
}: PartnerFormSheetProps) {
const {
register,
control,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormValues>({
@ -83,7 +81,7 @@ export function PartnerFormSheet({
manager_name: v.managerName,
manager_email: v.managerEmail,
manager_contact_number: v.managerPhone,
priority: v.priority,
total_revenue: v.totalRevenue?.trim() ? Number(v.totalRevenue.replace(/[^0-9]/g, '')) : undefined,
};
if (mode === 'create') {
@ -138,26 +136,16 @@ export function PartnerFormSheet({
/>
{errors.code && <p className="text-[10px] text-rose-500">{errors.code.message}</p>}
</div>
{/* Priority */}
{/* 총매출액 */}
<div className="space-y-1">
<Typography as="label" variant="label">우선 선정 대상자</Typography>
<Controller
control={control}
name="priority"
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger id="form-partner-priority" className="w-full">
<SelectValue>
{(value) => priorityOptions.find((o) => o.value === value)?.label ?? ''}
</SelectValue>
</SelectTrigger>
<SelectContent>
{priorityOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
)}
<Typography as="label" variant="label">총매출액 (원, 선택)</Typography>
<Input
id="form-partner-revenue"
type="number"
min={0}
{...register('totalRevenue')}
className={inputClass}
placeholder="예: 5000000000"
/>
</div>
</div>

View File

@ -1,4 +1,3 @@
import { Badge } from '@/components/ui/badge';
import { DataTable } from '@/components/ui/data-table';
import { TablePagination } from '@/components/ui/table-pagination';
import type { Partner } from '../types';
@ -13,14 +12,6 @@ type PartnerTableProps = {
onPageChange: (page: number) => void;
};
// 우선순위 배지 색상 — HIGH(빨강)/MEDIUM(주황)/그외(회색)
const priorityBadgeClass = (priority?: string | null) =>
priority === 'HIGH'
? 'bg-red-50 text-red-700 dark:bg-rose-950/20 dark:text-rose-400 border border-red-200'
: priority === 'MEDIUM'
? 'bg-amber-50 text-amber-700 dark:bg-amber-950/20 dark:text-amber-400 border border-amber-200'
: 'bg-zinc-100 text-zinc-600 border border-zinc-300';
export function PartnerTable({
data,
onRowClick,
@ -74,16 +65,10 @@ export function PartnerTable({
),
},
{
header: '우선 선정 대상자',
align: 'center',
cell: (part) => (
<Badge
variant="outline"
className={`inline-flex items-center px-2 py-0.5 text-[10px] font-bold rounded-full ${priorityBadgeClass(part.priority)}`}
>
{part.priority}
</Badge>
),
header: '총매출액',
align: 'right',
cellClassName: 'font-mono text-muted-foreground',
cell: (part) => (part.total_revenue != null ? `₩${Number(part.total_revenue).toLocaleString()}` : '-'),
},
]}
/>

View File

@ -1,11 +1 @@
export type { Partner } from '@/types';
// 우선순위 필터 목록. 'ALL'은 필터 전용(폼에서는 제외).
export const prioritiesList = ['ALL', 'HIGH', 'MEDIUM', 'LOW'];
// 폼 우선순위 선택지(라벨 포함).
export const priorityOptions: { value: string; label: string }[] = [
{ value: 'HIGH', label: 'HIGH (핵심 조달처)' },
{ value: 'MEDIUM', label: 'MEDIUM (일반 벤더)' },
{ value: 'LOW', label: 'LOW (서브 보조처)' },
];

View File

@ -345,16 +345,9 @@ export function QuotationCreateModal({
/>
<div>
<Typography as="span" variant="small" className="font-semibold block">{part.name}</Typography>
<Typography as="span" variant="small" className="text-muted-foreground">이메일: {part.managerEmail} · 등급: {part.rank}</Typography>
<Typography as="span" variant="small" className="text-muted-foreground">이메일: {part.managerEmail}</Typography>
</div>
</div>
<span
className={`text-[9px] font-mono px-2 py-0.5 rounded ${
part.priority === 'HIGH' ? 'bg-red-50 text-red-700' : 'bg-zinc-100 text-zinc-600'
}`}
>
{part.priority}
</span>
</label>
);
})}

View File

@ -78,7 +78,7 @@ export function RegenerateModal({ open, partners, sessionStatusBySupplier, defau
<div className="min-w-0">
<Typography as="span" variant="small" className="font-semibold block truncate">{part.name}</Typography>
<Typography as="span" variant="small" className="text-muted-foreground">
이메일: {part.managerEmail} · 등급: {part.rank}
이메일: {part.managerEmail}
</Typography>
</div>
</div>

View File

@ -55,7 +55,6 @@ export function mapSupplier(sp: SupplierData): Partner {
managerName: sp.manager_name || '',
managerEmail: sp.manager_email || '',
managerPhone: sp.manager_contact_number || '',
rank: sp.priority === 'HIGH' ? 'S' : sp.priority === 'MEDIUM' ? 'A' : 'B',
status: 'ACTIVE',
};
}

View File

@ -4,7 +4,6 @@ import { showToast } from '@/lib/notify';
import { confirm } from '@/lib/confirm';
import { PageContainer } from '@/components/layout/PageContainer';
import { PageToolbar, SearchInput } from '@/components/layout/PageToolbar';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu';
import { useServerList } from '@/lib/useServerList';
@ -13,15 +12,13 @@ import type { ListSuppliersParams } from '@/api/generated/model/listSuppliersPar
import { PartnerTable } from '@/features/partners/components/PartnerTable';
import { PartnerFormSheet } from '@/features/partners/components/PartnerFormSheet';
import { ExcelUploadModal, downloadPartnerTemplate } from '@/features/partners/components/ExcelUploadModal';
import { prioritiesList, type Partner } from '@/features/partners/types';
import { type Partner } from '@/features/partners/types';
export default function PartnersPage() {
// 검색/우선순위/페이지 상태(재사용 훅) → 서버 쿼리 파라미터로 변환.
const list = useServerList({ pageSize: 10, initialFilters: { priority: 'ALL' } });
const priorityFilter = list.filters.priority;
// 검색/페이지 상태(재사용 훅) → 서버 쿼리 파라미터로 변환.
const list = useServerList({ pageSize: 10 });
const params: ListSuppliersParams = {
search: list.debouncedSearch || undefined,
priority: priorityFilter !== 'ALL' ? priorityFilter : undefined,
page: list.page,
size: list.pageSize,
};
@ -91,21 +88,6 @@ export default function PartnersPage() {
onClear={list.clearSearch}
placeholder="협력사명, 코드 또는 담당자명으로 추적 검색..."
/>
<Select value={priorityFilter} onValueChange={(v) => list.setFilter('priority', v as string)}>
<SelectTrigger id="partner-priority-filter" className="w-full sm:w-48">
<SelectValue>
{(value) => (value === 'ALL' ? '우선순위 가중치 (전체)' : `우선도: ${value}`)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{prioritiesList.map((prio) => (
<SelectItem key={prio} value={prio}>
{prio === 'ALL' ? '우선순위 가중치 (전체)' : `우선도: ${prio}`}
</SelectItem>
))}
</SelectContent>
</Select>
</PageToolbar>
<PartnerTable

View File

@ -15,7 +15,6 @@ export type Partner = SupplierData & {
managerName?: string;
managerEmail?: string;
managerPhone?: string;
rank?: 'A' | 'B' | 'C' | 'S';
status?: string;
memo?: string;
};

View File

@ -130,7 +130,7 @@ CREATE TABLE IF NOT EXISTS partner.suppliers (
manager_name VARCHAR(50) NULL, -- 담당자명
manager_email VARCHAR(255) NULL, -- 담당자 이메일
manager_contact_number VARCHAR(20) NULL, -- 담당자 연락처
priority VARCHAR(10) NULL, -- 우선순위 (고객사별로 문자열 값일 수 있어 코드(SMALLINT) 대신 VARCHAR 유지)
total_revenue BIGINT NULL, -- 총매출액(원). KTC suppliers.total_revenue 미러
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부

View File

@ -94,3 +94,11 @@ ALTER TABLE quotation.quotation_settings
DROP COLUMN IF EXISTS over_action,
DROP COLUMN IF EXISTS regen_limit,
DROP COLUMN IF EXISTS anchoring_value;
-- ─────────────────────────────────────────────────────────────
-- [2026-07-06] 협력사: 총매출액 추가 + 우선선정(priority) 제거.
-- priority(우선순위 문자열)와 그 파생 등급(rank)은 폐지 — 협력사에서 완전 제거.
ALTER TABLE partner.suppliers
ADD COLUMN IF NOT EXISTS total_revenue BIGINT; -- 총매출액(원, KTC total_revenue 미러)
ALTER TABLE partner.suppliers
DROP COLUMN IF EXISTS priority;