feat(est): exchange quantity and schedule sheets

This commit is contained in:
2026-11-07 08:07:11 +09:00
parent f20cbe44d1
commit a8c20c3f19
4 changed files with 154 additions and 5 deletions
+82
View File
@@ -86,3 +86,85 @@ def test_excel_price_edits_refresh_linked_values_and_keep_binary_fields():
assert result["rejected"]
assert result["next"]["tables"]["EstMainDat"][-1]["TC"] == 300
assert result["next"]["tables"]["StuffcDat"][0]["Editor"]["binary"] == "AAAA"
def test_excel_exports_manual_quantities_and_schedule():
import openpyxl
data = export_workbook(
{
"name": "project",
"tables": {},
"quantities": [
{
"Code": "Q1",
"Name": "quantity",
"formula": "[A]*3",
"digits": 2,
"TargetKey": "id:5",
}
],
"schedule": [
{
"id": "s1",
"SourceKey": "id:5",
"Code": "",
"Name": "work",
"start": "2026-01-01",
"end": "2026-01-10",
"weight": 100,
"progress": 25,
}
],
}
)
workbook = openpyxl.load_workbook(io.BytesIO(data), data_only=False)
assert list(workbook["Quantities"].values)[1] == ("Q1", "quantity", None, "id:5", "[A]*3", 2)
assert list(workbook["Schedule"].values)[1] == (
"s1",
"id:5",
"",
"work",
"2026-01-01",
"2026-01-10",
100,
25,
)
def test_excel_imports_manual_quantities_and_schedule_and_rejects_invalid():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.tables.EstMainDat=[{PkID:5,Code:'',Name:'work',GN:1}];
const qty={Code:'Q1',Name:'quantity',formula:'2*3',digits:2,TargetKey:'id:5',TargetCode:''};
const sched={id:'s1',SourceKey:'id:5',Code:'',Name:'work',start:'2026-01-01',end:'2026-01-10',weight:100,progress:25};
const q=EstExchange.apply(p,{Quantities:[qty],Schedule:[sched]});
EstQuantity.applyTotals(q,EstQuantity.totals(q,'manual'));
let invalid=0;for(const bad of [{Quantities:[qty,qty]},{Quantities:[{...qty,digits:7}]},
{Schedule:[{...sched,progress:101}]}])try{EstExchange.apply(p,bad)}catch{invalid++}
return {q,invalid,original:p};
})()""",
)
assert result["q"]["tables"]["EstMainDat"][0]["GN"] == 6
assert result["q"]["schedule"][0]["SourceKey"] == "id:5"
assert result["q"]["schedule"][0]["progress"] == 25
assert result["original"]["tables"]["EstMainDat"][0]["GN"] == 1
assert result["invalid"] == 3
def test_excel_writes_quantity_marker_controls_as_valid_xml():
import openpyxl
data = export_workbook(
{
"name": "project",
"tables": {"CostMainDat": [{"PkID": 1, "LineData": "A=\x121.5\x12 _x0012_"}]},
}
)
workbook = openpyxl.load_workbook(io.BytesIO(data))
assert workbook["CostMainDat"]["B2"].value == "A=_x0012_1.5_x0012_ _x005F_x0012_"
assert (
run("math", "EstExchange.decodeExcelText('A=_x0012_1.5_x0012_ _x005F_x0012_')")
== "A=\x121.5\x12 _x0012_"
)
+1 -1
View File
@@ -10,7 +10,7 @@
## 추가 입출력·문서 기능 (2026-11-06)
- **Excel `.xlsx`**: CAD를 제외한 전체 표의 스칼라 필드를 숫자·문자·불리언으로 내보냅니다. 첫 행 필드명과 시트 이름을 유지한 파일을 다시 읽어 해당 표를 교체합니다. 기존 PkID의 바이너리는 보존하며 코드 변경·중복과 미계산 수식/오류 셀을 거부합니다. 출처 단가 수정은 연결 내역에 전파합니다. 파일에 바이너리·업무 회차·당초 사본을 넣지는 않으므로 전체 백업은 JSON을 사용합니다.
- **Excel `.xlsx`**: CAD를 제외한 전체 표의 스칼라 필드와 직접 작성 수식표·예정공정표를 내보내고 다시 읽어 해당 자료를 교체합니다. 수식표와 공정표의 내역 행 연결도 보존합니다. 기존 PkID의 바이너리는 보존하며 코드 변경·중복과 미계산 수식/오류 셀을 거부합니다. 산출근거의 내부 표지는 Excel이 열 수 있는 형식으로 바꿔 기록하고 다시 읽을 때 복원합니다. 출처 단가 수정은 연결 내역에 전파합니다. 파일에 바이너리·기성 회차·당초 사본이나 산출서 문서 전체를 넣지는 않으므로 전체 백업은 JSON을 사용합니다.
- **조달청 XML**: DB에 보관된 공사원가호환규정 XSD 1.0.5의 요소 순서와 필드를 사용합니다. 작성업체·공사구분을 입력하며 코드/문구 길이와 연결 자료를 검사합니다. 내역·일위·산근·기계·기초 단가·원가를 내보내고 다시 읽습니다. 연결 없는 고정 금액·기계 상세는 독립 단가로 전개하며 변환 전에 알립니다. 가져온 저장 금액과 원문을 보존하고 자동계산을 끕니다. 이는 제출기관 인증/등록 검사나 최신 규정 적합 판정을 뜻하지 않습니다.
- **JDL 기본 표**: 원본 검사 DLL의 필드 정의와 EST 처리기의 TAB/ENQ·CP949 표 구조에 따라 ZIP 안의 Global.ini·Main.txt·자원/공종/내역/원가 표를 생성·읽습니다. 일위대가 상세는 기본 플래그와 연결 자원이 확인된 행을 저장 금액과 함께 불러오며 특수 행은 원문에 보존하고 경고합니다. 자재·노무로 구성된 기본 일위대가는 상세표로 내보냅니다. 가져온 작업을 변경하지 않았다면 원본 JDL을 바이트 그대로 내보내 확장 자료를 보존합니다. 변경된 작업의 특수 일위·산근·기계는 저장 금액의 고정 단가로 전개하며 제외 행은 생략 사실을 알립니다. 확장 산식·기성/물가 파일의 완전 변환과 원본 검사 DLL 인증은 미확인입니다.
- **원본 구조 유지 ESTX 수정 저장**: 가져온 파일의 공사명·계산 옵션과 행 수 및 인덱스 키가 유지된 단가·수량·문구·금액, 기존 BLOB과 원가 서식을 기록하고 재읽기로 검산합니다. 수량산출서는 기존 서식 구간 안의 텍스트 편집을 저장하며 표지와 문서 구조는 보존합니다. 한 줄에서 표지로 구분된 여러 구간을 함께 수정하거나 연속 글자 서식에 걸친 텍스트를 수정할 수도 있습니다. 새 텍스트에는 변경 시작 위치의 서식이 적용됩니다. 원본 결과가 기존 계산과 모두 일치하면 수정된 계산 결과 표지와 수량 목록을 함께 갱신합니다. 불일치나 계산 오류가 있으면 저장을 거부합니다. 기존 BLOB은 공간이 부족하면 블록을 추가합니다. 원본 파일을 덮어쓰지 않으며 `.idx`와 CAD 바이너리를 보존합니다. 새 문서, 행 추가·삭제·인덱스 키 변경, 표지나 위첨자 등 복잡한 구조를 가로지르는 편집과 목록·연결 변경, 웹 기성·연차·당초 사본은 지원하지 않아 저장을 거부합니다. 이 범위는 JSON으로 보관합니다. 원본 EST 실행 검증은 못했습니다.
+25 -1
View File
@@ -4,6 +4,13 @@ const xml = (value) =>
/[&<>"']/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&apos;" })[c],
);
const excelText = (value) =>
String(value)
.replace(/_x[\da-f]{4}_/gi, (match) => `_x005F_${match.slice(1)}`)
.replace(
/[\x00-\x08\x0b-\x0d\x0e-\x1f\ufffe\uffff]/g,
(char) => `_x${char.charCodeAt(0).toString(16).toUpperCase().padStart(4, "0")}_`,
);
const column = (index) => {
let name = "";
for (index++; index; index = Math.floor((index - 1) / 26))
@@ -27,7 +34,7 @@ function sheet(rows) {
if (typeof v === "boolean") return `<c r="${ref}" t="b"><v>${v ? 1 : 0}</v></c>`;
const text = typeof v === "object" ? JSON.stringify(v) : String(v);
if (text.length > 32767) throw Error("Excel 셀 문자 한도 초과: " + ref);
return `<c r="${ref}" t="inlineStr"><is><t xml:space="preserve">${xml(text)}</t></is></c>`;
return `<c r="${ref}" t="inlineStr"><is><t xml:space="preserve">${xml(excelText(text))}</t></is></c>`;
})
.join("")}</row>`,
)
@@ -59,6 +66,23 @@ function workbook(project) {
],
});
}
for (const [name, rows, keys] of [
[
"Quantities",
project.quantities,
["Code", "Name", "TargetCode", "TargetKey", "formula", "digits"],
],
[
"Schedule",
project.schedule,
["id", "SourceKey", "Code", "Name", "start", "end", "weight", "progress"],
],
])
if (Array.isArray(rows) && rows.length)
sheets.push({
name,
rows: [keys, ...rows.map((row) => keys.map((key) => row[key] ?? null))],
});
sheets.unshift({
name: "공사정보",
rows: [
+46 -3
View File
@@ -1,4 +1,6 @@
const EstExchange = (() => {
const decodeExcelText = (value) =>
value.replace(/_x([\da-f]{4})_/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
function xmlText(buffer) {
const bytes = new Uint8Array(buffer);
if (bytes[0] === 0xff && bytes[1] === 0xfe)
@@ -31,7 +33,11 @@ const EstExchange = (() => {
const tables = {};
for (const sheet of workbook.getElementsByTagNameNS("*", "sheet")) {
const name = sheet.getAttribute("name");
if (!EstNative.empty().tables[name] || name.startsWith("Cad")) continue;
if (
(!EstNative.empty().tables[name] && !["Quantities", "Schedule"].includes(name)) ||
name.startsWith("Cad")
)
continue;
const id = sheet.getAttributeNS(
"http://schemas.openxmlformats.org/officeDocument/2006/relationships",
"id",
@@ -68,7 +74,7 @@ const EstExchange = (() => {
value = Number(v);
if (!Number.isFinite(value)) throw Error("Excel 숫자 오류");
}
values[at] = value;
values[at] = typeof value === "string" ? decodeExcelText(value) : value;
}
return values;
})
@@ -96,6 +102,43 @@ const EstExchange = (() => {
function apply(project, tables) {
const next = structuredClone(project);
for (const [table, rows] of Object.entries(tables)) {
if (table === "Quantities") {
const seen = new Set();
next.quantities = rows.map((row) => {
if (typeof row.Code !== "string" || !row.Code || seen.has(row.Code))
throw Error("수식표 코드가 비어 있거나 중복됩니다.");
seen.add(row.Code);
for (const key of ["Name", "TargetCode", "TargetKey", "formula"])
if (row[key] != null && typeof row[key] !== "string")
throw Error(`수식표 ${key} 형식 오류`);
const digits = row.digits ?? 2;
if (!Number.isInteger(digits) || digits < 0 || digits > 6)
throw Error("수식표 소수자리는 0~6입니다.");
return {
Code: row.Code,
Name: row.Name ?? "",
TargetCode: row.TargetCode ?? "",
TargetKey: row.TargetKey ?? "",
formula: row.formula ?? "0",
digits,
};
});
continue;
}
if (table === "Schedule") {
next.schedule = rows.map((row) => ({
id: row.id,
SourceKey: row.SourceKey ?? "",
Code: row.Code ?? "",
Name: row.Name ?? "",
start: row.start ?? "",
end: row.end ?? "",
weight: row.weight ?? 0,
progress: row.progress ?? 0,
}));
EstSchedule.validate(next);
continue;
}
const old = next.tables[table],
byId = new Map(old.filter((r) => r.PkID != null).map((r) => [r.PkID, r]));
const keys = rows[0] ? Object.keys(rows[0]) : [];
@@ -275,5 +318,5 @@ const EstExchange = (() => {
}
});
}
return { read, apply, exportExcel, exportNative, exportJdl, install };
return { read, apply, decodeExcelText, exportExcel, exportNative, exportJdl, install };
})();