From f88d8e53cb04efbcd781bbd1bd984f9546156abe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=ED=98=9C=EC=9D=B8?= Date: Mon, 23 Mar 2026 16:59:45 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=20?= =?UTF-8?q?=EC=8B=9C=EA=B3=B5=20=EC=8B=9C=EC=9E=91=EC=9D=BC=20=EB=B0=8F=20?= =?UTF-8?q?=EC=A2=85=EB=A3=8C=EC=9D=BC=20=EA=B4=80=EB=A6=AC=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PTC/index.html | 1829 ++++++++++++++++++++------------------ PTC_ISSUES_LIST.md | 50 ++ server/ptc_api_server.py | 162 +++- 3 files changed, 1153 insertions(+), 888 deletions(-) create mode 100644 PTC_ISSUES_LIST.md diff --git a/PTC/index.html b/PTC/index.html index 8e5f8af..88c3ec7 100644 --- a/PTC/index.html +++ b/PTC/index.html @@ -104,20 +104,78 @@ } .project-head-grid { display: grid; - grid-template-columns: minmax(0, 1fr) 520px; + grid-template-columns: minmax(260px, 0.72fr) minmax(760px, 1.28fr); gap: 18px; align-items: start; } + .project-title-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + } + .project-title-main { + min-width: 0; + flex: 1; + } + .project-inline-meta { + margin-top: 10px; + display: grid; + gap: 6px; + } + .project-inline-meta-line { + color: var(--muted); + font-size: 14px; + line-height: 1.45; + word-break: break-word; + } + .project-inline-meta-line strong { + color: var(--text); + font-weight: 700; + } + .project-inline-edit { + flex-shrink: 0; + min-width: 92px; + height: 42px; + border: none; + border-radius: 12px; + padding: 0 16px; + background: var(--blue); + color: white; + font-size: 13px; + font-weight: 700; + cursor: pointer; + } .project-editor-card { border: 1px solid var(--line); border-radius: 22px; background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(244,248,252,0.98)); - padding: 18px; + padding: 14px 16px; + } + .project-meta-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px 18px; + } + .project-meta-item { + min-width: 0; + } + .project-meta-label { + color: var(--muted); + font-size: 12px; + font-weight: 700; + margin-bottom: 4px; + } + .project-meta-value { + font-size: 15px; + font-weight: 600; + line-height: 1.45; + word-break: break-word; } .project-editor-actions { display: flex; justify-content: flex-end; - margin-top: 14px; + margin-top: 10px; } .summary-stack { display: grid; @@ -224,7 +282,22 @@ .list-mode-toggle { display: grid; grid-template-columns: 1fr 1fr; - gap: 8px; + gap: 10px; + margin-top: 14px; + } + .list-mode-toggle .mode-chip { + height: 42px; + border-radius: 12px; + font-size: 14px; + } + .vendor-search-field { + height: 42px; + border-radius: 12px; + font-size: 15px; + padding: 0 12px; + } + .vendor-search-toolbar { + grid-template-columns: 1fr; margin-top: 14px; } .badge { @@ -639,6 +712,35 @@ return acc; }, { income_count: 0, income_sum: 0, expense_count: 0, expense_sum: 0 }); }; + const DATE_YEAR_OPTIONS = Array.from({ length: 11 }, (_, index) => String(2021 + index)); + const DATE_MONTH_OPTIONS = Array.from({ length: 12 }, (_, index) => String(index + 1).padStart(2, "0")); + function daysInMonth(year, month) { + const y = Number(year); + const m = Number(month); + if (!y || !m) return 31; + return new Date(y, m, 0).getDate(); + } + function splitDateParts(value) { + const match = String(value || "").match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (!match) return { year: "", month: "", day: "" }; + return { year: match[1], month: match[2], day: match[3] }; + } + function joinDateParts(year, month, day) { + if (!year || !month || !day) return ""; + return `${year}-${month}-${day}`; + } + function decoratePileProgressRow(row = {}) { + return { + ...row, + start_date: row.start_date || "", + end_date: row.end_date || "", + start_date_parts: row.start_date_parts || splitDateParts(row.start_date), + end_date_parts: row.end_date_parts || splitDateParts(row.end_date), + }; + } + function normalizePileProgressRows(rows = []) { + return rows.map((row) => decoratePileProgressRow(row)); + } function buildBudgetSections(rows) { const sectionOrder = []; @@ -850,7 +952,12 @@ const [remapSavingCode, setRemapSavingCode] = useState(""); const [budgetRows, setBudgetRows] = useState([]); const [progressRate, setProgressRate] = useState("0"); + const [contractPileCount, setContractPileCount] = useState("0"); + const [constructedPileCount, setConstructedPileCount] = useState("0"); const [budgetSaving, setBudgetSaving] = useState(false); + const [pileProgressModalOpen, setPileProgressModalOpen] = useState(false); + const [pileProgressRows, setPileProgressRows] = useState([]); + const [pileProgressSaving, setPileProgressSaving] = useState(false); const [budgetModalItem, setBudgetModalItem] = useState(null); const [budgetModalAccounts, setBudgetModalAccounts] = useState([]); const [budgetModalTotalBudget, setBudgetModalTotalBudget] = useState(0); @@ -882,12 +989,19 @@ const [vendorAccountModalLoading, setVendorAccountModalLoading] = useState(false); const [accountVendorModal, setAccountVendorModal] = useState(null); const [accountVendorModalLoading, setAccountVendorModalLoading] = useState(false); + const [vendorAccountDateFrom, setVendorAccountDateFrom] = useState(""); + const [vendorAccountDateTo, setVendorAccountDateTo] = useState(""); + const [accountVendorDateFrom, setAccountVendorDateFrom] = useState(""); + const [accountVendorDateTo, setAccountVendorDateTo] = useState(""); + const [projectEditModalOpen, setProjectEditModalOpen] = useState(false); const [detail, setDetail] = useState(null); const [editor, setEditor] = useState({ project_name: "", project_type: "", construction_family: "", construction_method: "", + start_date: "", + end_date: "", note: "" }); @@ -1009,6 +1123,9 @@ setIssueSelections(nextIssueSelections); setBudgetRows(data?.budget_analysis?.rows || []); setProgressRate(String(data?.budget_analysis?.progress_rate ?? 0)); + setContractPileCount(String(data?.budget_analysis?.contract_pile_count ?? 0)); + setConstructedPileCount(String(data?.budget_analysis?.constructed_pile_count ?? 0)); + setPileProgressRows(normalizePileProgressRows(data?.budget_analysis?.pile_progress_entries || [])); setBudgetModalItem(null); setBudgetModalAccounts([]); setActualModalItem(null); @@ -1021,6 +1138,8 @@ project_type: data?.summary?.project_type || "", construction_family: data?.summary?.construction_family || "", construction_method: data?.summary?.construction_method || "", + start_date: data?.summary?.start_date || "", + end_date: data?.summary?.end_date || "", note: data?.summary?.note || "" }); } @@ -1154,6 +1273,19 @@ const selectedProject = useMemo(() => ( projects.find(item => item.project_code === selectedProjectCode) || null ), [projects, selectedProjectCode]); + const isPileProject = useMemo(() => { + const family = detail?.summary?.construction_family || editor.construction_family || ""; + const method = detail?.summary?.construction_method || editor.construction_method || ""; + return family === "복합말뚝" || ["HCP", "CFT", "DDH", "PHC"].includes(method); + }, [detail?.summary?.construction_family, detail?.summary?.construction_method, editor.construction_family, editor.construction_method]); + const effectiveProgressRate = useMemo(() => { + if (isPileProject) { + const contractCount = Number(contractPileCount) || 0; + const currentCount = Number(constructedPileCount) || 0; + return contractCount > 0 ? (currentCount / contractCount) * 100 : 0; + } + return Number(progressRate) || 0; + }, [isPileProject, contractPileCount, constructedPileCount, progressRate]); const selectedVendor = useMemo(() => ( vendors.find((item) => item.vendor_name === selectedVendorName) || null @@ -1177,11 +1309,11 @@ budgetCompareGroups.map((group) => ({ key: group.key, label: group.label, - progressRate: Number(progressRate) || 0, + progressRate: effectiveProgressRate, executionRate: group.rateTotal || 0, - gapRate: (group.rateTotal || 0) - (Number(progressRate) || 0), + gapRate: (group.rateTotal || 0) - effectiveProgressRate, })) - ), [budgetCompareGroups, progressRate]); + ), [budgetCompareGroups, effectiveProgressRate]); const profitSummary = useMemo(() => { const revenue = Number(detail?.budget_analysis?.revenue_actual_total || 0); const expense = Number(detail?.budget_analysis?.expense_actual_total || 0); @@ -1231,6 +1363,8 @@ project_type: editor.project_type, construction_family: editor.construction_family, construction_method: editor.construction_method, + start_date: editor.start_date, + end_date: editor.end_date, note: editor.note }) }); @@ -1244,6 +1378,8 @@ project_type: saved.item?.project_type || prev.summary.project_type, construction_family: saved.item?.construction_family || "", construction_method: saved.item?.construction_method || "", + start_date: saved.item?.start_date || "", + end_date: saved.item?.end_date || "", note: saved.item?.note || "" }, project_master: saved.item @@ -1271,6 +1407,8 @@ body: JSON.stringify({ project_code: selectedProjectCode, progress_rate: Number(nextProgressRate) || 0, + contract_pile_count: Number(contractPileCount) || 0, + constructed_pile_count: Number(constructedPileCount) || 0, item_rows: nextRows.map((item) => ({ section: item.section, group: item.group, @@ -1296,6 +1434,9 @@ setDetail(data); setBudgetRows(data?.budget_analysis?.rows || []); setProgressRate(String(data?.budget_analysis?.progress_rate ?? 0)); + setContractPileCount(String(data?.budget_analysis?.contract_pile_count ?? 0)); + setConstructedPileCount(String(data?.budget_analysis?.constructed_pile_count ?? 0)); + setPileProgressRows(normalizePileProgressRows(data?.budget_analysis?.pile_progress_entries || [])); } return true; } catch (err) { @@ -1307,7 +1448,7 @@ } async function saveProjectBudget() { - await persistProjectBudget(budgetRows, progressRate); + await persistProjectBudget(budgetRows, effectiveProgressRate); } function openBudgetModal(item) { @@ -1322,6 +1463,84 @@ setActualModalItem(item); } + function openPileProgressModal() { + setPileProgressModalOpen(true); + setPileProgressRows((prev) => ( + prev.length ? normalizePileProgressRows(prev) : normalizePileProgressRows([{ start_date: "", end_date: "", pile_count: 0, note: "" }]) + )); + } + + function addPileProgressRow() { + setPileProgressRows((prev) => [...prev, decoratePileProgressRow({ start_date: "", end_date: "", pile_count: 0, note: "" })]); + } + + function updatePileProgressRow(index, field, value) { + setPileProgressRows((prev) => prev.map((row, rowIndex) => ( + rowIndex === index + ? { ...row, [field]: field === "pile_count" ? (Number(value) || 0) : value } + : row + ))); + } + + function updatePileProgressDatePart(index, field, part, value) { + setPileProgressRows((prev) => prev.map((row, rowIndex) => { + if (rowIndex !== index) return row; + const partsKey = `${field}_parts`; + const current = row[partsKey] || splitDateParts(row[field]); + const next = { ...current, [part]: value }; + const maxDay = daysInMonth(next.year, next.month); + if (Number(next.day || 0) > maxDay) { + next.day = String(maxDay).padStart(2, "0"); + } + return { ...row, [partsKey]: next, [field]: joinDateParts(next.year, next.month, next.day) }; + })); + } + + function removePileProgressRow(index) { + setPileProgressRows((prev) => prev.filter((_, rowIndex) => rowIndex !== index)); + } + + async function savePileProgress() { + if (!selectedProjectCode) return; + setPileProgressSaving(true); + setError(""); + try { + const entries = pileProgressRows + .map((row) => ({ + start_date: String(row.start_date || "").trim(), + end_date: String(row.end_date || "").trim(), + pile_count: Number(row.pile_count) || 0, + note: String(row.note || "").trim(), + })) + .filter((row) => row.start_date); + const res = await fetch(`${API_BASE}/api/project-pile-progress/upsert`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + project_code: selectedProjectCode, + contract_pile_count: Number(contractPileCount) || 0, + entries, + }) + }); + if (!res.ok) throw new Error("pile progress save failed"); + const detailRes = await fetch(`${API_BASE}/api/project-detail?${detailQuery}`); + if (detailRes.ok) { + const data = await detailRes.json(); + setDetail(data); + setBudgetRows(data?.budget_analysis?.rows || []); + setProgressRate(String(data?.budget_analysis?.progress_rate ?? 0)); + setContractPileCount(String(data?.budget_analysis?.contract_pile_count ?? 0)); + setConstructedPileCount(String(data?.budget_analysis?.constructed_pile_count ?? 0)); + setPileProgressRows(normalizePileProgressRows(data?.budget_analysis?.pile_progress_entries || [])); + } + setPileProgressModalOpen(false); + } catch (err) { + setError("시공실적 저장에 실패했습니다."); + } finally { + setPileProgressSaving(false); + } + } + async function toggleBudgetAccountDetail(account) { const code = account?.account_code || ""; if (!code || !selectedProjectCode) return; @@ -1372,7 +1591,7 @@ } return row; }); - const ok = await persistProjectBudget(nextRows, progressRate); + const ok = await persistProjectBudget(nextRows, effectiveProgressRate); if (ok) { setBudgetModalItem(null); setBudgetModalAccounts([]); @@ -1536,6 +1755,8 @@ project_type: data?.summary?.project_type || "", construction_family: data?.summary?.construction_family || "", construction_method: data?.summary?.construction_method || "", + start_date: data?.summary?.start_date || "", + end_date: data?.summary?.end_date || "", note: data?.summary?.note || "" }); } @@ -1552,6 +1773,8 @@ async function openVendorAccountModal(account) { if (!selectedVendorName || !account?.account_code) return; setVendorAccountModalLoading(true); + setVendorAccountDateFrom(""); + setVendorAccountDateTo(""); try { const params = new URLSearchParams({ vendor_name: selectedVendorName, @@ -1577,6 +1800,8 @@ async function openAccountVendorModal(vendor) { if (!selectedAccountCode || !vendor) return; + setAccountVendorDateFrom(""); + setAccountVendorDateTo(""); if (!vendor.vendor_name) { setAccountVendorModal({ vendor_name: "전체 거래처", @@ -1611,6 +1836,15 @@ } } + function filterTransactionsByDateRange(rows, dateFrom, dateTo) { + return (rows || []).filter((row) => { + const date = String(row?.transaction_date || ""); + if (dateFrom && date < dateFrom) return false; + if (dateTo && date > dateTo) return false; + return true; + }); + } + return (
@@ -1618,11 +1852,6 @@
{currentTab === "vendor" ? "거래내역확인" : "프로젝트 관리"}
-
- {currentTab === "vendor" - ? "같은 DB를 기준으로 거래처별 거래내역과 연결 프로젝트를 확인합니다." - : "같은 DB를 기준으로 프로젝트 관리와 거래처별 거래내역을 탭으로 확인합니다."} -
-
- 선택한 프로젝트들의 공법종류는 상세 공법에 맞춰 자동 반영됩니다. -
-
Selected Project
-

{selectedProject.project_name || "(이름없음)"}

-
- {selectedProject.project_code} · {selectedProject.project_type || "미지정"} +
+
+

{selectedProject.project_name || "(이름없음)"}

+
+
+ {detail?.summary?.project_code || "-"} + {" · "} + {editor.project_type || "구분미지정"} + {" · "} + {editor.construction_family || "종류미지정"} + {" · "} + {editor.construction_method || "공법미지정"} +
+ {!!editor.note && ( +
{editor.note}
+ )} +
+
+
@@ -1788,90 +2030,6 @@
- -
-
-
-
프로젝트 코드
-
- {detail?.summary?.project_code || "-"} -
-
-
-
프로젝트명
- setEditor(prev => ({ ...prev, project_name: e.target.value }))} - placeholder="프로젝트명" - /> -
-
-
프로젝트 구분
- -
-
-
공법 종류
- -
-
-
공법
- -
-
-
메모
- setEditor(prev => ({ ...prev, note: e.target.value }))} - placeholder="프로젝트 설명 또는 관리 메모" - /> -
-
-
- -
-
@@ -1903,25 +2061,118 @@
+ {isPileProject ? ( +
+ +
+
누적 시공본수
+
{fmt(constructedPileCount)}본
+
+
+
공정률
+
{effectiveProgressRate.toFixed(1)}%
+
+ + +
+ ) : ( +
+ +
+
공정률
+
{effectiveProgressRate.toFixed(1)}%
+
+ +
+ )} +
+
+
+
기준 공정률
+
+
+
+
+ {effectiveProgressRate.toFixed(1)}% +
+
{budgetCompareRows.map((item) => (
{item.label}
-
공정 {item.progressRate.toFixed(1)}%
집행 {item.executionRate.toFixed(1)}%
-
-
-
-
+
-
- {item.gapRate > 0 ? "+" : ""}{item.gapRate.toFixed(1)}%p -
))}
@@ -1930,7 +2181,7 @@ [집행 총 합계] {fmt(detail?.budget_analysis?.expense_actual_total || 0)}원 {" "}|{" "} [집행 총 합계 / 실행계획] {((detail?.budget_analysis?.execution_rate_total) || 0).toFixed(1)}% - {" "}(기준 공정률 {Number(progressRate || 0).toFixed(1)}%) + {" "}(기준 공정률 {effectiveProgressRate.toFixed(1)}%)
@@ -1941,16 +2192,6 @@
메인 표에서는 항목 합계만 보고, 항목명을 클릭해서 팝업 안에서 계정별 실행계획을 입력합니다.
- setProgressRate(e.target.value)} - placeholder="공정률" - />
- - - - - - - - - - - - - - - - - - - {group.items.map((item) => { - const diff = (Number(item.budget_amount) || 0) - (Number(item.actual_amount) || 0); - return ( - - - - - - - - ); - })} - - - - - - - - -
항목실행계획집행금액차이(예산-집행)집행률
- - 0 ? "warning-cell" : ""}>{fmt(item.budget_amount || 0)}원 - - 0 ? "#b42318" : "var(--ink)"), fontWeight: 700 }}>{fmt(diff)}원 0 ? "warning-text" : ""}>{(item.executionRate || item.execution_rate || 0).toFixed(1)}%
{group.groupName} 소계 0 ? "warning-cell" : ""}>{fmt(group.budgetTotal)}원 0 ? "warning-cell" : ""}>{fmt(group.actualTotal)}원 0 ? "#b42318" : "var(--ink)"), fontWeight: 700 }}>{fmt(group.diffTotal)}원 0 ? "warning-text" : ""} style={{ fontWeight: 700 }}>{group.rateTotal.toFixed(1)}%
+
+ + + + + + + + + + + + + + + + + + + {group.items.map((item, idx) => { + const diff = (Number(item.budget_amount) || 0) - (Number(item.actual_amount) || 0); + const rate = (Number(item.budget_amount) || 0) > 0 ? (Number(item.actual_amount) || 0) / (Number(item.budget_amount) || 0) * 100 : 0; + return ( + + + + + + + + ); + })} + +
항목명 (계정수)실행계획집행금액차이집행률
+ + {fmt(item.budget_amount || 0)}원 + + {fmt(diff)}원 100 ? "#b42318" : "var(--blue)" }}>{rate.toFixed(1)}%
+
); })} - {!detailLoading && !budgetGroups.length && ( -
입력할 예산 항목이 없습니다.
- )}
-
-
-
-
-
계정 검토
-
프로젝트 구분 기준에 맞지 않는 계정을 확인하고 권장 계정으로 정리할 수 있습니다.
-
- {detailLoading && 불러오는 중} -
-
- - - - - - - - - - - - {(detail?.account_issues || []).map((item) => { - const allowedCodes = allowedAccountCodesByProjectType[detail?.summary?.project_type] || []; - return ( +
+
계정 매핑 이슈 ({detail?.account_issues?.length || 0}건)
+
PTC 프로젝트 성격에 맞지 않는 계정으로 분류된 거래들입니다. 적절한 계정으로 일괄 또는 개별 변경할 수 있습니다.
+
+
현재 계정건수공급가액권장 변경실행
+ + + + + + + + + + + {(detail?.account_issues || []).map((item) => ( - - + + - ); - })} - {!detailLoading && !(detail?.account_issues || []).length && ( - - )} - -
현재 계정거래건수합계금액변경 추천 계정작업
{item.account_code} · {item.account_name} - - {fmt(item.txn_count)}건{fmt(item.total_sum)}원
현재 프로젝트 구분 기준에서 점검할 계정 이상이 없습니다.
+ ))} + {!(detail?.account_issues || []).length && ( + 매핑 이슈가 없습니다. + )} + + +
- )} @@ -2146,26 +2356,16 @@ {currentTab === "vendor" && (
- {vendorListMode === "vendor" && !selectedVendor && !vendorLoading && ( -
선택된 거래처가 없습니다.
- )} - {vendorListMode === "account" && !selectedAccountCode && !accountLoading && ( -
선택된 계정이 없습니다.
- )} - {vendorListMode === "vendor" && selectedVendor && ( <>
-
Vendor Detail
-

{selectedVendor.vendor_name}

-
거래처 기준 거래내역 확인
+

{selectedVendor.vendor_name}

@@ -2266,10 +2449,8 @@
-
프로젝트 선택 후 계정 보기
-
왼쪽에서 프로젝트를 고르면 오른쪽에서 계정별 거래건수와 공급가액을 확인하고, 계정까지 눌러 상세 거래를 좁혀볼 수 있습니다.
+
프로젝트별 / 계정별 요약
- {vendorDetailLoading && 불러오는 중}
@@ -2284,10 +2465,7 @@ { - setSelectedVendorProjectCode(""); - setVendorAccountModal(null); - }} + onClick={() => setSelectedVendorProjectCode("")} > 전체 프로젝트 {fmt(vendorDetail?.summary?.income_count || 0)}건 @@ -2297,10 +2475,7 @@ { - setSelectedVendorProjectCode(item.project_code || ""); - setVendorAccountModal(null); - }} + onClick={() => setSelectedVendorProjectCode(item.project_code || "")} >
@@ -2312,51 +2487,32 @@ {fmt(item.expense_count || 0)}건 ))} - {!vendorDetailLoading && !(vendorDetail?.projects || []).length && ( - 연결된 프로젝트가 없습니다. - )}
-
-
- - - - - - +
+
계정거래건수공급가액
+ + + + + + + + + {(vendorDetail?.accounts || []).map((item) => ( + + + + - - - {(vendorDetail?.accounts || []).map((item) => ( - openVendorAccountModal(item)} - > - - - - - ))} - {!vendorDetailLoading && !(vendorDetail?.accounts || []).length && ( - - )} - -
계정거래건수공급가액
+ + {fmt(item.txn_count || 0)}건{fmt(item.supply_sum || 0)}원
- - {fmt(item.txn_count || 0)}건{fmt(item.supply_sum || 0)}원
표시할 계정이 없습니다.
-
-
-
선택 상태
-
-
프로젝트: {selectedVendorProjectCode || "전체 프로젝트"}
-
계정 상세: 계정번호 클릭 시 팝업
-
-
+ ))} + +
@@ -2368,11 +2524,9 @@
-
Account Detail
-

+

{accountDetail.summary.account_code} {accountDetail.summary.account_name ? `· ${accountDetail.summary.account_name}` : ""}

-
계정번호 기준 거래내역 확인
@@ -2412,19 +2566,11 @@
프로젝트별 / 거래처별 요약
-
선택한 계정이 어느 프로젝트와 거래처에 연결되는지 한눈에 봅니다.
- {accountDetailLoading && 불러오는 중}
- - - - - - @@ -2436,30 +2582,22 @@ { - setSelectedAccountProjectCode(""); - setAccountVendorModal(null); - }} + onClick={() => setSelectedAccountProjectCode("")} > - - + + {(accountDetail?.projects || []).map((item) => ( { - setSelectedAccountProjectCode(item.project_code || ""); - setAccountVendorModal(null); - }} + onClick={() => setSelectedAccountProjectCode(item.project_code || "")} > - - + + ))} - {!accountDetailLoading && !(accountDetail?.projects || []).length && ( - - )}
프로젝트
전체 프로젝트 -
{fmt(accountDetail?.summary?.income_count || 0)}건
-
{fmt(accountDetail?.summary?.income_supply_sum || 0)}원
-
-
{fmt(accountDetail?.summary?.expense_count || 0)}건
-
{fmt(accountDetail?.summary?.expense_supply_sum || 0)}원
+
+
전체 프로젝트
+
{fmt(accountDetail?.summary?.income_count || 0)}건{fmt(accountDetail?.summary?.expense_count || 0)}건 {fmt(accountDetail?.summary?.supply_sum || 0)}원
@@ -2467,31 +2605,16 @@
{item.project_name || "(이름없음)"}
-
{fmt(item.income_count || 0)}건
-
{fmt(item.income_supply_sum || 0)}원
-
-
{fmt(item.expense_count || 0)}건
-
{fmt(item.expense_supply_sum || 0)}원
-
{fmt(item.income_count || 0)}건{fmt(item.expense_count || 0)}건 {fmt(item.supply_sum || 0)}원
연결된 프로젝트가 없습니다.
- - - - - - @@ -2501,54 +2624,32 @@ - openAccountVendorModal({ vendor_name: "" })}> - + - - + + {(accountDetail?.vendors || []).map((item) => ( - openAccountVendorModal(item)}> + - - + + ))} - {!accountDetailLoading && !(accountDetail?.vendors || []).length && ( - - )}
거래처
-
+ -
{fmt(accountDetail?.summary?.income_count || 0)}건
-
{fmt(accountDetail?.summary?.income_supply_sum || 0)}원
-
-
{fmt(accountDetail?.summary?.expense_count || 0)}건
-
{fmt(accountDetail?.summary?.expense_supply_sum || 0)}원
-
{fmt(accountDetail?.summary?.income_count || 0)}건{fmt(accountDetail?.summary?.expense_count || 0)}건 {fmt(accountDetail?.summary?.supply_sum || 0)}원
- -
{fmt(item.income_count || 0)}건
-
{fmt(item.income_supply_sum || 0)}원
-
-
{fmt(item.expense_count || 0)}건
-
{fmt(item.expense_supply_sum || 0)}원
-
{fmt(item.income_count || 0)}건{fmt(item.expense_count || 0)}건 {fmt(item.supply_sum || 0)}원
연결된 거래처가 없습니다.
-
-
선택 상태
-
-
프로젝트: {selectedAccountProjectCode || "전체 프로젝트"}
-
거래처 상세: 거래처 클릭 시 팝업
-
-
)} @@ -2556,24 +2657,435 @@
)} + {pileProgressModalOpen && ( +
setPileProgressModalOpen(false)}> +
e.stopPropagation()}> +
+
+
시공실적 입력
+
+ 시작일과 종료일 기준으로 시공본수를 입력하면 누적 시공본수와 공정률이 자동 계산됩니다. +
+
+ +
+ +
+
+
+
계약본수
+
{fmt(contractPileCount)}본
+
+
+
누적 시공본수
+
+ {fmt(pileProgressRows.reduce((sum, row) => sum + (Number(row.pile_count) || 0), 0))}본 +
+
+
+
자동 공정률
+
+ {((Number(contractPileCount) || 0) > 0 + ? (pileProgressRows.reduce((sum, row) => sum + (Number(row.pile_count) || 0), 0) / (Number(contractPileCount) || 0)) * 100 + : 0 + ).toFixed(1)}% +
+
+
+
+ +
+
기간별 시공실적을 관리합니다.
+ +
+ +
+ + + + + + + + + + + + {pileProgressRows.map((row, index) => ( + + + + + + + + ))} + {!pileProgressRows.length && ( + + )} + +
시작일종료일시공본수메모삭제
+ {(() => { + const parts = row.start_date_parts || splitDateParts(row.start_date); + const dayOptions = Array.from({ length: daysInMonth(parts.year, parts.month) }, (_, dayIndex) => ( + String(dayIndex + 1).padStart(2, "0") + )); + return ( +
+ + + +
+ ); + })()} +
+ {(() => { + const parts = row.end_date_parts || splitDateParts(row.end_date); + const dayOptions = Array.from({ length: daysInMonth(parts.year, parts.month) }, (_, dayIndex) => ( + String(dayIndex + 1).padStart(2, "0") + )); + return ( +
+ + + +
+ ); + })()} +
+ updatePileProgressRow(index, "pile_count", e.target.value)} + /> + + updatePileProgressRow(index, "note", e.target.value)} + placeholder="메모" + /> + + +
입력된 시공실적이 없습니다.
+
+ +
+ + +
+
+
+ )} + + {projectEditModalOpen && ( +
setProjectEditModalOpen(false)}> +
e.stopPropagation()}> +
+
+
프로젝트 정보 수정
+
+ +
+
+
+
프로젝트 코드
+
+ {detail?.summary?.project_code || "-"} +
+
+
+
프로젝트명
+ setEditor(prev => ({ ...prev, project_name: e.target.value }))} + placeholder="프로젝트명" + /> +
+
+
프로젝트 구분
+ +
+
+
공법 종류
+ +
+
+
공법
+ +
+
+
시공 시작일
+ setEditor(prev => ({ ...prev, start_date: e.target.value }))} + /> +
+
+
시공 종료일
+ setEditor(prev => ({ ...prev, end_date: e.target.value }))} + /> +
+
+
메모
+ setEditor(prev => ({ ...prev, note: e.target.value }))} + placeholder="프로젝트 설명 또는 관리 메모" + /> +
+
+
+ + +
+
+
+ )} + + {vendorAccountModal && ( +
setVendorAccountModal(null)}> +
e.stopPropagation()}> +
+
+
+ {vendorAccountModal.project_code} / {vendorAccountModal.account_code}{vendorAccountModal.account_name ? ` · ${vendorAccountModal.account_name}` : ""} +
+
+ +
+
+ {(() => { + const filteredTransactions = filterTransactionsByDateRange( + vendorAccountModal.transactions || [], + vendorAccountDateFrom, + vendorAccountDateTo + ); + const io = summarizeInOutTransactions(filteredTransactions); + return ( +
+
+ + +
+ 기간별 조회 건수 {fmt(filteredTransactions.length)}건 +
+
+
+
입금 건수
{fmt(io.income_count)}건
+
출금 건수
{fmt(io.expense_count)}건
+
입금액
{fmt(io.income_sum)}원
+
출금액
{fmt(io.expense_sum)}원
+
+
+ ); + })()} +
+
+ + + + + + + + + + + + + {filterTransactionsByDateRange( + vendorAccountModal.transactions || [], + vendorAccountDateFrom, + vendorAccountDateTo + ).map((row) => ( + + + + + + + + + ))} + {!filterTransactionsByDateRange( + vendorAccountModal.transactions || [], + vendorAccountDateFrom, + vendorAccountDateTo + ).length && ( + + )} + +
거래일입/출금프로젝트부서적요공급가액
{row.transaction_date || "-"}{row.in_out || "-"}{row.project_code || "-"}{row.project_name ? ` / ${row.project_name}` : ""}{row.department_name || "-"}{row.description || "-"}{fmt(row.supply_amount || 0)}원
표시할 거래내역이 없습니다.
+
+
+
+ )} + + {accountVendorModal && ( +
setAccountVendorModal(null)}> +
e.stopPropagation()}> +
+
+
+ {accountVendorModal.project_code} / {accountVendorModal.account_code}{accountVendorModal.account_name ? ` · ${accountVendorModal.account_name}` : ""} / {accountVendorModal.vendor_name} +
+
+ +
+
+ {(() => { + const filteredTransactions = filterTransactionsByDateRange( + accountVendorModal.transactions || [], + accountVendorDateFrom, + accountVendorDateTo + ); + const io = summarizeInOutTransactions(filteredTransactions); + return ( +
+
+ + +
+ 기간별 조회 건수 {fmt(filteredTransactions.length)}건 +
+
+
+
입금 건수
{fmt(io.income_count)}건
+
출금 건수
{fmt(io.expense_count)}건
+
입금액
{fmt(io.income_sum)}원
+
출금액
{fmt(io.expense_sum)}원
+
+
+ ); + })()} +
+
+ + + + + + + + + + + + + {filterTransactionsByDateRange( + accountVendorModal.transactions || [], + accountVendorDateFrom, + accountVendorDateTo + ).map((row) => ( + + + + + + + + + ))} + {!filterTransactionsByDateRange( + accountVendorModal.transactions || [], + accountVendorDateFrom, + accountVendorDateTo + ).length && ( + + )} + +
거래일입/출금프로젝트부서적요공급가액
{row.transaction_date || "-"}{row.in_out || "-"}{row.project_code || "-"}{row.project_name ? ` / ${row.project_name}` : ""}{row.department_name || "-"}{row.description || "-"}{fmt(row.supply_amount || 0)}원
표시할 거래내역이 없습니다.
+
+
+
+ )} + {budgetModalItem && (
{ setBudgetModalItem(null); setBudgetModalAccounts([]); setBudgetModalTotalBudget(0); }}>
e.stopPropagation()}>
-
Budget Detail
-
+
{budgetModalItem.section} / {budgetModalItem.group} / {budgetModalItem.category}
-
- 계정별 실행계획을 입력하면 메인 화면의 실행계획, 차이, 집행률, 그래프가 함께 다시 계산됩니다. -
- +
-
@@ -2587,7 +3099,7 @@ style={{ marginTop: 8 }} />
- 계정별 입력과 별도로 항목 총액만 바로 저장할 수 있습니다. + 자재비처럼 항목 전체 금액을 먼저 잡을 수 있습니다.
@@ -2604,492 +3116,53 @@
미배분 금액
-
+
sum + (Number(account.budget_amount) || 0), 0)) < 0 ? "#b42318" : "var(--ink)" }}> {fmt((Number(budgetModalTotalBudget) || 0) - budgetModalAccounts.reduce((sum, account) => sum + (Number(account.budget_amount) || 0), 0))}원
- -
-
-
계정번호 / 계정명
-
집행금액
-
실행계획
-
차이
-
-
- {budgetModalAccounts.map((account, index) => { - const diff = (Number(account.budget_amount) || 0) - (Number(account.actual_amount) || 0); - const detailData = budgetAccountDetailMap[account.account_code]; - const isExpanded = budgetAccountExpandedCode === account.account_code; - return ( - -
-
- -
-
{fmt(account.actual_amount || 0)}원
-
+
+ + + + + + + + + + + {budgetModalAccounts.map((account, index) => { + const diff = (Number(account.budget_amount) || 0) - (Number(account.actual_amount) || 0); + return ( + + + + + + + ); + })} + +
계정번호 / 계정명집행금액실행계획차이
{account.account_code} {account.account_name}{fmt(account.actual_amount || 0)}원 updateBudgetModalAccount(index, e.target.value)} /> - -
+
{fmt(diff)}원 - - - {isExpanded && ( -
-
- 거래 건수 {fmt(detailData?.summary?.txn_count || 0)}건 - 공급가액 합계 {fmt(detailData?.summary?.supply_sum || 0)}원 - 기간 {detailData?.summary?.min_date || "-"} {detailData?.summary?.max_date ? `~ ${detailData.summary.max_date}` : ""} -
- {budgetAccountDetailLoading && !detailData ? ( -
상세내역을 불러오는 중입니다.
- ) : ( -
- - - - - - - - - - - - - {(detailData?.items || []).slice(0, 20).map((row) => ( - - - - - - - - - ))} - {!(detailData?.items || []).length && ( - - )} - -
거래일입/출금부서거래처적요공급가액
{row.transaction_date || "-"}{row.in_out || "-"}{row.department_name || "-"}{row.vendor_name || "-"}{row.description || "-"}{fmt(row.supply_amount || 0)}원
표시할 상세내역이 없습니다.
-
- )} -
- )} - - ); - })} - {!budgetModalAccounts.length && ( -
이 항목에 연결된 계정이 없습니다.
- )} - +
-
- - + +
)} - - {actualModalItem && ( -
setActualModalItem(null)}> -
e.stopPropagation()}> -
-
-
Actual Detail
-
- {actualModalItem.section} / {actualModalItem.group} / {actualModalItem.category} -
-
- 집행금액을 구성하는 계정별 실제 집행 상세입니다. -
-
- -
- -
-
-
-
집행금액 합계
-
- {fmt(actualModalItem.actual_amount || 0)}원 -
-
-
-
실행계획 합계
-
- {fmt(actualModalItem.budget_amount || 0)}원 -
-
-
-
상세 계정 수
-
- {fmt((actualModalItem.account_items || []).length)}개 -
-
-
-
- -
-
-
계정번호 / 계정명
-
집행금액
-
실행계획
-
차이
-
-
- {(actualModalItem.account_items || []).map((account, index) => { - const diff = (Number(account.budget_amount) || 0) - (Number(account.actual_amount) || 0); - return ( -
-
{account.account_code} {account.account_name}
-
{fmt(account.actual_amount || 0)}원
-
{fmt(account.budget_amount || 0)}원
-
{fmt(diff)}원
-
- ); - })} - {!(actualModalItem.account_items || []).length && ( -
표시할 계정 상세가 없습니다.
- )} -
-
-
-
- )} - - {vendorAccountModal && ( -
setVendorAccountModal(null)}> -
e.stopPropagation()}> -
-
-
Vendor Account Detail
-
- {vendorAccountModal.project_code} / {vendorAccountModal.account_code} {vendorAccountModal.account_name ? `· ${vendorAccountModal.account_name}` : ""} -
-
- 선택 거래처와 프로젝트 기준으로 해당 계정의 거래내역만 표시합니다. -
-
- -
- -
- {(() => { - const summary = summarizeInOutTransactions(vendorAccountModal.transactions); - return ( -
-
-
입금 건수
-
{fmt(summary.income_count)}건
-
-
-
출금 건수
-
{fmt(summary.expense_count)}건
-
-
-
입금액
-
{fmt(summary.income_sum)}원
-
-
-
출금액
-
{fmt(summary.expense_sum)}원
-
-
- ); - })()} -
- -
- - - - - - - - - - - - - {(vendorAccountModal.transactions || []).map((row) => ( - - - - - - - - - ))} - {!vendorAccountModalLoading && !(vendorAccountModal.transactions || []).length && ( - - )} - -
거래일입/출금프로젝트부서적요공급가액
{row.transaction_date || "-"}{row.in_out || "-"} -
-
{row.project_code || "-"}
-
{row.project_name || "-"}
-
-
{row.department_name || "-"}{row.description || "-"}{fmt(row.supply_amount || 0)}원
거래내역이 없습니다.
-
-
-
- )} - - {accountVendorModal && ( -
setAccountVendorModal(null)}> -
e.stopPropagation()}> -
-
-
Account Vendor Detail
-
- {accountVendorModal.account_code} {accountVendorModal.account_name ? `· ${accountVendorModal.account_name}` : ""} / {accountVendorModal.vendor_name} -
-
- 선택한 계정 기준으로 해당 거래처의 거래내역을 표시합니다. -
-
- -
- -
- {(() => { - const summary = summarizeInOutTransactions(accountVendorModal.transactions); - return ( -
-
-
입금 건수
-
{fmt(summary.income_count)}건
-
-
-
출금 건수
-
{fmt(summary.expense_count)}건
-
-
-
입금액
-
{fmt(summary.income_sum)}원
-
-
-
출금액
-
{fmt(summary.expense_sum)}원
-
-
- ); - })()} -
- -
- - - - - - - - - - - - - - {(accountVendorModal.transactions || []).map((row) => ( - - - - - - - - - - ))} - {!accountVendorModalLoading && !(accountVendorModal.transactions || []).length && ( - - )} - -
거래일입/출금프로젝트거래처부서적요공급가액
{row.transaction_date || "-"}{row.in_out || "-"} -
-
{row.project_code || "-"}
-
{row.project_name || "-"}
-
-
{row.vendor_name || "-"}{row.department_name || "-"}{row.description || "-"}{fmt(row.supply_amount || 0)}원
거래내역이 없습니다.
-
-
-
- )} - - {issueDetailModal && ( -
setIssueDetailModal(null)}> -
e.stopPropagation()}> -
-
-
Issue Detail
-
- {issueDetailModal.account_code} {issueDetailModal.account_name} -
-
- 이 계정이 어떤 거래들로 구성됐는지 먼저 확인한 뒤 계정 변경을 진행할 수 있습니다. -
-
- -
- -
-
-
-
거래 건수
-
- {fmt(issueDetailModal.summary?.txn_count || 0)}건 -
-
-
-
공급가액 합계
-
- {fmt(issueDetailModal.summary?.supply_sum || 0)}원 -
-
-
-
최초 거래일
-
- {issueDetailModal.summary?.min_date || "-"} -
-
-
-
최근 거래일
-
- {issueDetailModal.summary?.max_date || "-"} -
-
-
-
- -
-
- - - -
-
- -
- - - - - - - - - - - - - - - {(issueDetailModal.items || []).map((row) => ( - - - - - - - - - - - ))} - {!(issueDetailModal.items || []).length && ( - - )} - -
선택거래일입/출금부서거래처적요공급가액변경 계정
- toggleIssueCheckedRow(row.source_row_no)} - /> - {row.transaction_date || "-"}{row.in_out || "-"}{row.department_name || "-"}{row.vendor_name || "-"}{row.description || "-"}{fmt(row.supply_amount || 0)}원 - -
표시할 상세 거래가 없습니다.
-
- -
- - -
-
-
- )} - - {issueDetailLoading && !issueDetailModal && ( -
-
-
계정 상세를 불러오는 중입니다.
-
-
- )}
); } diff --git a/PTC_ISSUES_LIST.md b/PTC_ISSUES_LIST.md new file mode 100644 index 0000000..689e37f --- /dev/null +++ b/PTC_ISSUES_LIST.md @@ -0,0 +1,50 @@ +# PTC 프로젝트 이슈 정리 (레이블: ptc 실행분석) + +## 1. [마스터] [PTC::실행분석] 전용 실행 분석 및 계정 관리 시스템 구축 +**설명**: PTC 전용 실행 분석 시스템 구축을 위한 전체 진행 상황을 관리하는 마스터 이슈입니다. + +### 체크리스트 +- [ ] UI 렌더링 완성 (PTC 데이터 선택 시 테이블 공백 문제 해결) +- [ ] PTC 고유 계정 체계(7xx, 8xx, 513) 분류 로직 고도화 +- [ ] PTC 전용 실행 예산 보고서 양식 개발 및 출력 기능 +- [ ] PTC 대시보드 고도화 (순유입/유출 잔액 합계 및 시각화 개선) + +--- + +## 2. [PTC::UI] 테이블 렌더링 오류 수정 +**설명**: `index.html` 등에서 PTC 데이터를 불러올 때 실행 예산 테이블이 빈 공백으로 표시되는 문제를 해결합니다. + +### 주요 작업 +- PTC 전용 데이터 매핑 정의 추가 (7xx, 8xx 계정 대응) +- 데이터 로드 후 UI 렌더링 분기 로직 점검 및 수정 +- 테이블 데이터가 없을 경우의 예외 처리 강화 + +--- + +## 3. [PTC::계정] 전용 계정 코드(7xx, 8xx, 513) 분류 로직 강화 +**설명**: 분석 로직에서 PTC 고유의 계정 체계를 정확히 인식하도록 개선합니다. + +### 주요 작업 +- 7xx(시공), 8xx(관리) 계정 코드에 대한 분류 로직 최적화 +- 513(시공 퇴직금) 항목의 프로젝트별 분리 및 예산 대비 실적 비교 기능 검증 +- PTC 프로젝트 성격 기반 계정 추천 로직 최적화 + +--- + +## 4. [PTC::보고서] 전용 실행 예산 보고서 양식 개발 +**설명**: PTC의 공사원가 및 관리비 기준에 최적화된 보고서 출력 양식을 구현합니다. + +### 주요 작업 +- PTC 전용 엑셀/PDF 출력 템플릿 설계 +- 실행 예산 보고서 내 계정별 집계 데이터 매핑 +- PTC 특화 항목(현장운영비, 보증료 등) 반영 + +--- + +## 5. [PTC::대시보드] 순유입/유출 잔액 합계 및 시각화 개선 +**설명**: 대시보드에서 PTC 데이터를 보여줄 때, 잔액 계산 방식과 시각적 표현을 개선합니다. + +### 주요 작업 +- PTC 순유입 및 유출 잔액 합계 산출 로직 개선 +- 대시보드 상의 차트 및 요약 테이블에 실시간 데이터 반영 +- 데이터 동기화 및 탭 전환 최적화 diff --git a/server/ptc_api_server.py b/server/ptc_api_server.py index ec9fe09..6f98e59 100644 --- a/server/ptc_api_server.py +++ b/server/ptc_api_server.py @@ -433,6 +433,8 @@ def init_db() -> None: project_type text, construction_family text, construction_method text, + start_date text, + end_date text, note text, updated_at text not null ) @@ -456,6 +458,8 @@ def init_db() -> None: create table if not exists project_progress ( project_code text primary key, progress_rate real not null default 0, + contract_pile_count real not null default 0, + constructed_pile_count real not null default 0, updated_at text not null ) """ @@ -475,9 +479,43 @@ def init_db() -> None: ) """ ) + cur.execute( + """ + create table if not exists project_pile_progress_entries ( + id integer primary key autoincrement, + project_code text not null, + work_date text not null, + start_date text not null, + end_date text, + pile_count real not null default 0, + note text, + sort_order integer not null default 0, + updated_at text not null + ) + """ + ) existing_cols = [row["name"] for row in cur.execute("pragma table_info(project_master)").fetchall()] if "construction_family" not in existing_cols: cur.execute("alter table project_master add column construction_family text") + if "start_date" not in existing_cols: + cur.execute("alter table project_master add column start_date text") + if "end_date" not in existing_cols: + cur.execute("alter table project_master add column end_date text") + progress_cols = [row["name"] for row in cur.execute("pragma table_info(project_progress)").fetchall()] + if "contract_pile_count" not in progress_cols: + cur.execute("alter table project_progress add column contract_pile_count real not null default 0") + if "constructed_pile_count" not in progress_cols: + cur.execute("alter table project_progress add column constructed_pile_count real not null default 0") + pile_progress_cols = [row["name"] for row in cur.execute("pragma table_info(project_pile_progress_entries)").fetchall()] + if "work_date" not in pile_progress_cols: + cur.execute("alter table project_pile_progress_entries add column work_date text") + if "start_date" not in pile_progress_cols and "work_date" in pile_progress_cols: + cur.execute("alter table project_pile_progress_entries add column start_date text") + cur.execute("update project_pile_progress_entries set start_date = coalesce(nullif(start_date, ''), work_date)") + if "end_date" not in pile_progress_cols: + cur.execute("alter table project_pile_progress_entries add column end_date text") + cur.execute("update project_pile_progress_entries set work_date = coalesce(nullif(work_date, ''), start_date)") + cur.execute("update project_pile_progress_entries set end_date = coalesce(nullif(end_date, ''), start_date)") txn_cols = [row["name"] for row in cur.execute("pragma table_info(ptc_transactions)").fetchall()] if "account_code_final" not in txn_cols: cur.execute("alter table ptc_transactions add column account_code_final text") @@ -577,7 +615,7 @@ def init_db() -> None: def fetch_project_master(conn: sqlite3.Connection, project_code: str) -> dict | None: row = conn.execute( """ - select project_code, project_name, project_type, construction_family, construction_method, note, updated_at + select project_code, project_name, project_type, construction_family, construction_method, start_date, end_date, note, updated_at from project_master where project_code = ? """, @@ -728,6 +766,21 @@ def build_account_structure_rows(account_rows: list[sqlite3.Row]) -> list[dict]: def build_budget_analysis(conn: sqlite3.Connection, project_code: str, account_structure_rows: list[dict]) -> dict: + pile_progress_rows = conn.execute( + """ + select + id, + coalesce(nullif(start_date, ''), work_date) as start_date, + coalesce(nullif(end_date, ''), nullif(start_date, ''), work_date) as end_date, + pile_count, + note, + sort_order + from project_pile_progress_entries + where project_code = ? + order by coalesce(nullif(start_date, ''), work_date) asc, sort_order asc, id asc + """, + (project_code,), + ).fetchall() item_budget_rows = conn.execute( """ select section, group_name, category, budget_amount @@ -753,10 +806,17 @@ def build_budget_analysis(conn: sqlite3.Connection, project_code: str, account_s for row in budget_rows } progress_row = conn.execute( - "select progress_rate from project_progress where project_code = ?", + "select progress_rate, contract_pile_count, constructed_pile_count from project_progress where project_code = ?", (project_code,), ).fetchone() progress_rate = progress_row["progress_rate"] if progress_row else 0 + contract_pile_count = float(progress_row["contract_pile_count"] or 0) if progress_row else 0 + constructed_pile_count = float(progress_row["constructed_pile_count"] or 0) if progress_row else 0 + entry_pile_total = sum(float(row["pile_count"] or 0) for row in pile_progress_rows) + if pile_progress_rows: + constructed_pile_count = entry_pile_total + if contract_pile_count > 0: + progress_rate = (constructed_pile_count / contract_pile_count) * 100 rows = [] expense_budget_total = 0.0 @@ -801,6 +861,9 @@ def build_budget_analysis(conn: sqlite3.Connection, project_code: str, account_s execution_rate_total = (expense_actual_total / expense_budget_total * 100) if expense_budget_total > 0 else 0 return { "progress_rate": progress_rate, + "contract_pile_count": contract_pile_count, + "constructed_pile_count": constructed_pile_count, + "pile_progress_entries": [dict(row) for row in pile_progress_rows], "execution_rate_total": execution_rate_total, "expense_budget_total": expense_budget_total, "expense_actual_total": expense_actual_total, @@ -977,23 +1040,27 @@ class Handler(BaseHTTPRequestHandler): self._send(400, {"ok": False, "message": "invalid construction_method"}) return construction_family = resolve_construction_family(construction_method, construction_family) + start_date = str(payload.get("start_date", "")).strip() + end_date = str(payload.get("end_date", "")).strip() note = str(payload.get("note", "")).strip() updated_at = datetime.now().isoformat() conn.execute( """ insert into project_master ( - project_code, project_name, project_type, construction_family, construction_method, note, updated_at - ) values (?, ?, ?, ?, ?, ?, ?) + project_code, project_name, project_type, construction_family, construction_method, start_date, end_date, note, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?) on conflict(project_code) do update set project_name = excluded.project_name, project_type = excluded.project_type, construction_family = excluded.construction_family, construction_method = excluded.construction_method, + start_date = excluded.start_date, + end_date = excluded.end_date, note = excluded.note, updated_at = excluded.updated_at """, - (project_code, project_name, project_type, construction_family, construction_method, note, updated_at), + (project_code, project_name, project_type, construction_family, construction_method, start_date, end_date, note, updated_at), ) conn.commit() self._send(200, {"ok": True, "item": fetch_project_master(conn, project_code)}) @@ -1030,16 +1097,21 @@ class Handler(BaseHTTPRequestHandler): ) merged_note = note if note else (existing.get("note") or "") + start_date = existing.get("start_date") or "" + end_date = existing.get("end_date") or "" + conn.execute( """ insert into project_master ( - project_code, project_name, project_type, construction_family, construction_method, note, updated_at - ) values (?, ?, ?, ?, ?, ?, ?) + project_code, project_name, project_type, construction_family, construction_method, start_date, end_date, note, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?) on conflict(project_code) do update set project_name = excluded.project_name, project_type = excluded.project_type, construction_family = excluded.construction_family, construction_method = excluded.construction_method, + start_date = excluded.start_date, + end_date = excluded.end_date, note = excluded.note, updated_at = excluded.updated_at """, @@ -1049,6 +1121,8 @@ class Handler(BaseHTTPRequestHandler): project_type, construction_family, construction_method, + start_date, + end_date, merged_note, updated_at, ), @@ -1181,6 +1255,8 @@ class Handler(BaseHTTPRequestHandler): item_rows = payload.get("item_rows", []) account_rows = payload.get("account_rows", []) progress_rate = float(payload.get("progress_rate", 0) or 0) + contract_pile_count = float(payload.get("contract_pile_count", 0) or 0) + constructed_pile_count = float(payload.get("constructed_pile_count", 0) or 0) if not project_code: self._send(400, {"ok": False, "message": "project_code is required"}) return @@ -1228,18 +1304,84 @@ class Handler(BaseHTTPRequestHandler): ) conn.execute( """ - insert into project_progress (project_code, progress_rate, updated_at) - values (?, ?, ?) + insert into project_progress ( + project_code, progress_rate, contract_pile_count, constructed_pile_count, updated_at + ) + values (?, ?, ?, ?, ?) on conflict(project_code) do update set progress_rate = excluded.progress_rate, + contract_pile_count = excluded.contract_pile_count, + constructed_pile_count = excluded.constructed_pile_count, updated_at = excluded.updated_at """, - (project_code, progress_rate, updated_at), + (project_code, progress_rate, contract_pile_count, constructed_pile_count, updated_at), ) conn.commit() self._send(200, {"ok": True, "project_code": project_code, "updated_at": updated_at}) return + if parsed.path == "/api/project-pile-progress/upsert": + payload = self._read_json() + project_code = str(payload.get("project_code", "")).strip() + contract_pile_count = float(payload.get("contract_pile_count", 0) or 0) + entries = payload.get("entries", []) + if not project_code: + self._send(400, {"ok": False, "message": "project_code is required"}) + return + if not isinstance(entries, list): + self._send(400, {"ok": False, "message": "entries must be a list"}) + return + + updated_at = datetime.now().isoformat() + conn.execute("delete from project_pile_progress_entries where project_code = ?", (project_code,)) + constructed_pile_count = 0.0 + for idx, item in enumerate(entries): + start_date = str(item.get("start_date", "")).strip() + end_date = str(item.get("end_date", "")).strip() + pile_count = float(item.get("pile_count", 0) or 0) + note = str(item.get("note", "")).strip() + if not start_date: + continue + if not end_date: + end_date = start_date + constructed_pile_count += pile_count + conn.execute( + """ + insert into project_pile_progress_entries ( + project_code, work_date, start_date, end_date, pile_count, note, sort_order, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?) + """, + (project_code, start_date, start_date, end_date, pile_count, note, idx, updated_at), + ) + progress_rate = (constructed_pile_count / contract_pile_count * 100) if contract_pile_count > 0 else 0 + conn.execute( + """ + insert into project_progress ( + project_code, progress_rate, contract_pile_count, constructed_pile_count, updated_at + ) + values (?, ?, ?, ?, ?) + on conflict(project_code) do update set + progress_rate = excluded.progress_rate, + contract_pile_count = excluded.contract_pile_count, + constructed_pile_count = excluded.constructed_pile_count, + updated_at = excluded.updated_at + """, + (project_code, progress_rate, contract_pile_count, constructed_pile_count, updated_at), + ) + conn.commit() + self._send( + 200, + { + "ok": True, + "project_code": project_code, + "contract_pile_count": contract_pile_count, + "constructed_pile_count": constructed_pile_count, + "progress_rate": progress_rate, + "updated_at": updated_at, + }, + ) + return + self._send(404, {"ok": False, "message": "Not found"}) finally: conn.close()