727 lines
36 KiB
Markdown
727 lines
36 KiB
Markdown
# 작업지시서 — Laser Recipe Save 교차 오염 **진단 계측** (2026-08-04)
|
||
|
||
| 항목 | 내용 |
|
||
|---|---|
|
||
| 대상 | LGES-OC5-PtoP-Welder / ILSOS_PrismaticRD_PTOP |
|
||
| 분류 | **진단 전용 계측** (기능 수정 아님) |
|
||
| 선행 문서 | `docs/작업지시서/작업지시서_2026-08-03_Laser_Recipe_Save_교차오염.txt` |
|
||
| 원본 분석 | `docs/분석/2026/08/03/레시피저장_활성vs선택_교차오염_원인분석.md` |
|
||
| 상태 | **적용 완료 + 빌드 성공 (2026-08-04, 브랜치 `feature/ubisam-pptx-integration`, 미커밋)**. 현장 검증 대기. |
|
||
| 원복 문서 | `docs/작업지시서/2026-08-04_Laser_Recipe_Save_진단계측_원복.md` |
|
||
|
||
> **적용 시 실제 소스에는 원복용 마커가 추가되었습니다.** 본 문서의 `[변경안]` 코드 블록에는 마커가 없으나,
|
||
> 소스에는 삽입 구간마다 아래 형태의 시작/끝 주석이 들어가 있습니다. 검색 한 번으로 전부 찾을 수 있습니다.
|
||
>
|
||
> ```
|
||
> [진단계측 2026-08-04]
|
||
> ```
|
||
>
|
||
> 각 블록 머리 주석에 **원본 코드가 그대로 적혀 있어** 그것만 보고도 되돌릴 수 있습니다.
|
||
> `RecipeHelper.cs` 에 3쌍, `WeldingCommon.csproj` 에 주석 1줄이 있습니다.
|
||
|
||
## 이 작업의 목적
|
||
|
||
**저장을 막지 않습니다.** 레시피 신원이 **언제 어긋나는지**, Save 로 **무엇이 무엇으로 바뀌는지**를 데이터로 확정하기 위한 계측만 넣습니다.
|
||
원인 경로가 확정되면 선행 문서의 2~4단계 중 필요한 것만 적용하고, **본 계측은 통째로 제거**합니다.
|
||
|
||
> [!] 경고 1 — `WeldingComponents` 하위를 수정합니다. 프로젝트 규약상 사전 승인 대상입니다.
|
||
> [!] 경고 2 — 한글 포함 파일은 **UTF-8 인코딩을 유지**하고 편집하십시오. 새 인코딩으로 저장 금지.
|
||
> [!] 경고 3 — 작업 전 `D:\ILSOSConfig\ILSOS_PrismaticRD_PTOP\_config_recipe` 폴더를 통째로 백업하십시오.
|
||
> 본 계측은 **저장을 막지 않으므로** 검증 중에도 레시피가 오염될 수 있습니다.
|
||
|
||
## 수정 요약
|
||
|
||
| # | 파일 | 위치 | 작업 |
|
||
|---|---|---|---|
|
||
| 1 | `WeldingComponents\WeldingCommon\Utils\RecipeTrace.cs` | 신규 | 파일 생성 (전문 아래) |
|
||
| 2 | `WeldingComponents\WeldingCommon\WeldingCommon.csproj` | 272행 뒤 | `<Compile Include>` 1줄 추가 |
|
||
| 3 | `WeldingComponents\WeldingCommon\Utils\RecipeHelper.cs` | 18행 | 필드 → 프로퍼티 |
|
||
| 4 | `WeldingComponents\WeldingCommon\Utils\RecipeHelper.cs` | 36~39행 | `SaveRecipe` 전/후 계측 |
|
||
| 5 | `WeldingComponents\WeldingCommon\Utils\RecipeHelper.cs` | 64~69행 | `ReloadRecipe` 성공/실패/무동작 계측 |
|
||
| 6 | `WeldingComponents\LASA\GUI\Page\MainPage.cs` | 535행 | Page(FieldHelper.json) 기록 훅 (작업 6) |
|
||
| 7 | `WeldingComponents\WeldingCommon\Utils\CommonHelper.cs` | 13~17행 | 위 훅 연결 (작업 7) |
|
||
|
||
`ILSOS_PrismaticRD_PTOP` 소스는 수정하지 않습니다.
|
||
배포용 파일 목록은 `docs/작업지시서/2026-08-04_Laser_Recipe_Save_진단계측_변경파일목록.md` 참조.
|
||
|
||
> **3~5는 같은 파일입니다.** 위에서부터 고치면 줄 번호가 밀리므로, **줄 번호보다 `[현재 코드]` 문자열을 기준으로** 찾아서 바꾸십시오.
|
||
|
||
## 왜 이 3곳인가 (근거)
|
||
|
||
저장소 전체를 조사한 결과, 레시피 신원이 바뀌는 지점은 **정확히 3종류**뿐입니다.
|
||
|
||
**(가) 저장 "대상"이 바뀌는 곳 — `RecipeHelper.SelectedRecipe` 대입, 전 저장소에 4곳**
|
||
|
||
| 위치 | 경로 |
|
||
|---|---|
|
||
| `UCScannerObjectTeachingViewModel.cs:114` | 신규 레시피 생성 |
|
||
| `UCScannerObjectTeachingViewModel.cs:165` | `OnChanged` |
|
||
| `UCScannerObjectTeachingViewModel.cs:172` | `Reloaded` |
|
||
| `RecipeHelper.cs:76` | `OnActiveReloaded` |
|
||
|
||
나머지는 전부 `.name` / `.id` **읽기**뿐이고, 튜플 멤버에 직접 대입하는 코드는 **없습니다**.
|
||
→ **필드를 프로퍼티로 바꿔도 컴파일이 깨지지 않으며**, setter 한 곳에서 4개 지점을 호출자까지 전부 자동으로 잡습니다.
|
||
|
||
**(나) 저장 "내용"이 바뀌는 곳 — `MainPage.ReloadRecipe` 하나뿐**
|
||
|
||
`WeldingManager.ReloadWeldingRecipe`(WeldingManager.cs:43)라는 우회 경로가 있으나 호출부가 전부 주석(`CommonHelper.cs:22`, `UCScannerObjectTeaching.xaml.cs:37`)이라 **죽어 있습니다.** 무시해도 됩니다.
|
||
|
||
**(다) 사고가 디스크에 확정되는 곳 — `RecipeHelper.SaveRecipe`**
|
||
|
||
`MainPage.SaveRecipe`(MainPage.cs:425-434)는 메모리를 지정 경로에 덤프할 뿐, **이 시점에 바뀌는 것은 파일뿐입니다.** 메모리도 대상도 여기서 바뀌지 않습니다. 즉 사고는 Save 이전에 이미 결정되어 있고, Save 는 그것을 파일에 굳힐 뿐입니다. 그래서 **전(원인)과 후(피해)를 모두** 찍습니다.
|
||
|
||
## 참조 가능성 사전 확인 (완료)
|
||
|
||
선행 문서 §3 "구현 시 확인 사항"과 §9 미확정 항목 중 아래는 **확인 완료**입니다. 다시 검증할 필요 없습니다.
|
||
|
||
| 항목 | 결과 |
|
||
|---|---|
|
||
| `DB.Dao.SystemRecipe.WorkRecipeFile` 를 WeldingCommon 에서 참조 가능한가 | **가능.** `WeldingCommon.csproj:581` 이 **LASAGUI 만** 참조하므로 `DB.Dao` 는 `MainPage.cs:466` 이 쓰는 바로 그 정적과 동일 인스턴스. 선례 `RecipeLaserInterlock.cs:25` |
|
||
| `UtilUnit.logUnit` | WeldingCommon 에서 **사용 선례 0건**. → 로그는 파일 append 로 대체함 |
|
||
| `MessageBox` | WeldingCommon 에서 이미 다수 사용 중 (`UCPowerCalibrationViewModel.cs:263` 등) |
|
||
| Auto/Manual 판별 | `WeldingCommon.Status.EquipmentStatus` 정적, `WeldingCommon.Utils` 에서 그대로 해석됨 |
|
||
| `getRecipeInfo` 호출이 안전한가 | **안전.** `LoadFileReadOnly`(RecipeWeldingLines.cs:232)는 자기 인스턴스에만 로드하고 전역을 건드리지 않음. `OnSaved:209` 가 이미 동일하게 사용 중 |
|
||
| `RecipeExIds` | `namespace DB` (LASARecipeHelper.cs:10) |
|
||
| `eEqpStatus` | `namespace WeldingCommon` (EquipDefCommon.cs:20) |
|
||
|
||
**추가 발견 — 신원 마커가 2개입니다.**
|
||
|
||
| 마커 | 기록 지점 |
|
||
|---|---|
|
||
| `DB.Dao.SystemRecipe.WorkRecipeFile` | `MainPage.ReloadRecipe` (MainPage.cs:466) |
|
||
| `DB.Recipe.Recipe.SelectedRecipeFile` | `Recipe.Load` **내부** (Recipe.cs:75). `Recipe.Save` 는 건드리지 않음 |
|
||
|
||
정상이면 항상 같습니다. **다르면 MainPage 를 거치지 않은 Load 경로가 있었다**는 뜻이라, 그 자체로 원인을 좁혀 줍니다.
|
||
|
||
---
|
||
|
||
# 작업 1. 신규 파일 생성
|
||
|
||
**경로**: `WeldingComponents\WeldingCommon\Utils\RecipeTrace.cs`
|
||
**인코딩**: UTF-8 (한글 주석 포함)
|
||
|
||
아래 전문을 그대로 새 파일로 저장하십시오.
|
||
|
||
```csharp
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// [진단 전용 2026-08-04] Laser Recipe Save 교차 오염 — "언제 어긋나는지" 추적.
|
||
// 레시피 신원(대상/내용)이 바뀌는 지점마다 흔적을 남기고,
|
||
// Save 전/후를 비교해 무엇이 무엇으로 바뀌었는지 보여준다.
|
||
// 저장을 막지 않는다. 원인 확정 후 이 파일과 호출부를 통째로 제거할 것.
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
using Aurora;
|
||
using CoreUnit.Control.UserControl.Canvas;
|
||
using CoreUnit.Scanner.UserControl.ScannerCanvas;
|
||
using DB;
|
||
using System;
|
||
using System.Collections.Generic;
|
||
using System.Configuration;
|
||
using System.Diagnostics;
|
||
using System.IO;
|
||
using System.Text;
|
||
using System.Windows;
|
||
|
||
namespace WeldingCommon.Utils
|
||
{
|
||
public static class RecipeTrace
|
||
{
|
||
// 흐름을 눈으로 다 보고 싶으면 true (저장할 때마다 팝업).
|
||
// 평소엔 false 로 두고 불일치가 감지될 때만 뜨게 한다.
|
||
public const bool ALWAYS_SHOW_ON_SAVE = false;
|
||
|
||
// Auto 중 모달은 시퀀스를 세운다. 기본은 파일에만 기록.
|
||
public const bool SHOW_IN_AUTO = false;
|
||
|
||
const int KEEP = 40;
|
||
static readonly List<string> _trail = new List<string>();
|
||
static readonly object _lock = new object();
|
||
|
||
// ── 흔적 남기기 ──────────────────────────────────────────────────────
|
||
public static void Mark(string evt, string detail = "")
|
||
{
|
||
string line;
|
||
try {
|
||
string actName; int actId;
|
||
GetActive(out actName, out actId);
|
||
|
||
line = $"{DateTime.Now:HH:mm:ss.fff} T{System.Threading.Thread.CurrentThread.ManagedThreadId,-3} "
|
||
+ $"{evt,-24} 선택={Sel()} 메모리={Short(Work())} LASA={Short(Lasa())} "
|
||
+ $"활성=[{actId}][{actName}] {Status.EquipmentStatus} | {detail} | ← {Caller()}";
|
||
} catch (Exception ex) {
|
||
line = $"{DateTime.Now:HH:mm:ss.fff} {evt} (기록실패 {ex.Message})";
|
||
}
|
||
|
||
lock (_lock) {
|
||
_trail.Add(line);
|
||
if (_trail.Count > KEEP) _trail.RemoveAt(0);
|
||
}
|
||
AppendFile(line);
|
||
}
|
||
|
||
// ── Save 전/후 스냅샷 ────────────────────────────────────────────────
|
||
public class SaveSnap
|
||
{
|
||
public string RecipeName, TargetPath, ActivePath, ActiveName;
|
||
public string Selected, Work, Lasa, Eqp;
|
||
public string MemFp, TgtFp, ActFp;
|
||
public string TgtHash, ActHash, TgtField, ActField;
|
||
public bool BadArg, BadPath, BadMarker;
|
||
}
|
||
|
||
// 저장 직전 — "왜 어긋났나"
|
||
public static SaveSnap BeforeSave(string recipeName, string targetPath)
|
||
{
|
||
var s = new SaveSnap { RecipeName = recipeName, TargetPath = targetPath };
|
||
try {
|
||
string actName; int actId;
|
||
GetActive(out actName, out actId);
|
||
s.ActiveName = actName;
|
||
s.ActivePath = string.IsNullOrWhiteSpace(actName) ? "" : TeachingPath(actName);
|
||
|
||
s.Selected = Sel();
|
||
s.Work = Work();
|
||
s.Lasa = Lasa();
|
||
s.Eqp = Status.EquipmentStatus.ToString();
|
||
|
||
s.MemFp = MemFingerprint(); // 메모리 = 저장될 내용
|
||
s.TgtFp = FileFingerprint(targetPath); // 저장될 자리에 원래 있던 내용
|
||
s.ActFp = FileFingerprint(s.ActivePath); // 활성 레시피 내용
|
||
|
||
s.TgtHash = Hash(targetPath);
|
||
s.ActHash = Hash(s.ActivePath);
|
||
s.TgtField = Hash(FieldPath(targetPath)); // Page 데이터(FieldHelper.json)
|
||
s.ActField = Hash(FieldPath(s.ActivePath));
|
||
|
||
// 호출 인자와 전역 대상이 다르다 (OnSaved 가 인자를 버리는 경로)
|
||
s.BadArg = !string.Equals(recipeName, RecipeHelper.SelectedRecipe.name,
|
||
StringComparison.OrdinalIgnoreCase);
|
||
// 저장 대상과 메모리에 로드된 파일이 다르다
|
||
s.BadPath = !string.IsNullOrWhiteSpace(s.Work) && !SamePath(s.Work, targetPath);
|
||
// 두 신원 마커가 서로 다르다 (MainPage 를 안 거친 Load 존재)
|
||
s.BadMarker = !string.IsNullOrWhiteSpace(s.Work) && !string.IsNullOrWhiteSpace(s.Lasa)
|
||
&& !SamePath(s.Work, s.Lasa);
|
||
|
||
Mark("SAVE 직전",
|
||
$"요청=[{recipeName}] arg={s.BadArg} path={s.BadPath} marker={s.BadMarker} "
|
||
+ $"메모리={s.MemFp} 대상파일={s.TgtFp}");
|
||
} catch { }
|
||
return s;
|
||
}
|
||
|
||
// 저장 직후 — "무엇이 무엇으로 바뀌었나"
|
||
public static void AfterSave(SaveSnap s)
|
||
{
|
||
try {
|
||
if (s == null) return;
|
||
|
||
string tgtFpAfter = FileFingerprint(s.TargetPath);
|
||
string actFpAfter = FileFingerprint(s.ActivePath);
|
||
string tgtHashA = Hash(s.TargetPath);
|
||
string actHashA = Hash(s.ActivePath);
|
||
string tgtFieldA = Hash(FieldPath(s.TargetPath));
|
||
string actFieldA = Hash(FieldPath(s.ActivePath));
|
||
|
||
bool targetIsActive = SamePath(s.TargetPath, s.ActivePath);
|
||
bool tgtChanged = s.TgtHash != tgtHashA;
|
||
bool actChanged = s.ActHash != actHashA;
|
||
bool tgtFieldChg = s.TgtField != tgtFieldA;
|
||
bool actFieldChg = s.ActField != actFieldA;
|
||
|
||
// 핵심 : 대상 파일이 활성 레시피 내용으로 바뀌었다 = 신고된 증상
|
||
bool becameActive = !targetIsActive && tgtChanged
|
||
&& tgtFpAfter == actFpAfter && s.TgtFp != s.ActFp;
|
||
// 역방향 : 엉뚱하게 활성 파일이 바뀌었다 (선행문서 원인 2)
|
||
bool activeHit = !targetIsActive && (actChanged || actFieldChg);
|
||
|
||
bool bad = becameActive || activeHit || s.BadArg || s.BadPath || s.BadMarker;
|
||
|
||
string conclusion =
|
||
becameActive ? $"★ 대상 [{s.RecipeName}] 이 활성 레시피 [{s.ActiveName}] 내용으로 덮어써짐"
|
||
: activeHit ? $"★ 대상은 [{s.RecipeName}] 인데 활성 파일 [{s.ActiveName}] 이 함께 변경됨"
|
||
: (s.BadArg || s.BadPath || s.BadMarker)
|
||
? "△ 저장은 대상 파일에만 됐지만 신원이 어긋난 상태였음"
|
||
: "정상 (대상 파일만 변경)";
|
||
|
||
Mark(bad ? "*** SAVE 직후 (의심) ***" : "SAVE 직후",
|
||
$"{conclusion} | 대상변경={tgtChanged} 활성변경={actChanged} "
|
||
+ $"페이지변경(대상)={tgtFieldChg} 페이지변경(활성)={actFieldChg}");
|
||
|
||
if (!bad && !ALWAYS_SHOW_ON_SAVE) return;
|
||
if (s.Eqp != eEqpStatus.Manual.ToString() && !SHOW_IN_AUTO) return;
|
||
|
||
var sb = new StringBuilder();
|
||
sb.AppendLine("[Recipe Save 진단] 이 화면을 촬영해 주세요. 저장은 그대로 진행되었습니다.");
|
||
sb.AppendLine();
|
||
sb.AppendLine($"결론 : {conclusion}");
|
||
sb.AppendLine();
|
||
sb.AppendLine($"저장 요청 : [{s.RecipeName}]");
|
||
sb.AppendLine($"SelectedRecipe : {s.Selected}");
|
||
sb.AppendLine($"활성 레시피 : [{s.ActiveName}]");
|
||
sb.AppendLine($"설비상태 : {s.Eqp}");
|
||
sb.AppendLine($"신원 어긋남 : arg={s.BadArg} path={s.BadPath} marker={s.BadMarker}");
|
||
sb.AppendLine($" 메모리 WorkRecipeFile : {s.Work}");
|
||
sb.AppendLine($" 메모리 SelectedRecipeFile : {s.Lasa}");
|
||
sb.AppendLine($" 저장대상 target : {s.TargetPath}");
|
||
sb.AppendLine();
|
||
sb.AppendLine("──────── 무엇이 무엇으로 바뀌었나 ────────");
|
||
sb.AppendLine($"저장된 내용(메모리) : {s.MemFp}");
|
||
sb.AppendLine();
|
||
sb.AppendLine($"대상파일 전 : {s.TgtFp}");
|
||
sb.AppendLine($"대상파일 후 : {tgtFpAfter} 변경={tgtChanged}");
|
||
sb.AppendLine($"활성파일 전 : {s.ActFp}");
|
||
sb.AppendLine($"활성파일 후 : {actFpAfter} 변경={actChanged}");
|
||
sb.AppendLine();
|
||
sb.AppendLine($"Page(FieldHelper) 대상 변경 : {tgtFieldChg} 활성 변경 : {actFieldChg}");
|
||
sb.AppendLine();
|
||
sb.AppendLine("──────── 직전 이력 (오래된 것 → 최근) ────────");
|
||
lock (_lock) { foreach (var l in _trail) sb.AppendLine(l); }
|
||
|
||
MessageBox.Show(sb.ToString(), "Recipe Save 진단",
|
||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||
} catch { }
|
||
}
|
||
|
||
// ── 지문 ────────────────────────────────────────────────────────────
|
||
// 메모리의 실제 용접 파라미터. ScannerCanvasUserCtrl.ScannerParams 가 실체다.
|
||
public static string MemFingerprint()
|
||
{
|
||
try { return Fp(ScannerCanvasUserCtrl.ScannerParams); } catch { return "?"; }
|
||
}
|
||
|
||
public static string FileFingerprint(string fullPath)
|
||
{
|
||
try {
|
||
if (string.IsNullOrWhiteSpace(fullPath) || !File.Exists(fullPath)) return "(파일없음)";
|
||
return Fp(CommonHelper.GetMainPage().getRecipeInfo(fullPath).ReadOnlyScanParams);
|
||
} catch (Exception ex) { return "(읽기실패)" + ex.Message; }
|
||
}
|
||
|
||
// OnSaved 가 비교하는 필드와 동일한 6개, 앞 3개 파라미터
|
||
static string Fp(List<ScannerParam> p)
|
||
{
|
||
if (p == null) return "null";
|
||
var sb = new StringBuilder($"n={p.Count}");
|
||
for (int i = 0; i < 3 && i < p.Count; i++)
|
||
sb.Append($"|{p[i].MarkDelay},{p[i].JumpDelay},{p[i].LaserOnDelay},"
|
||
+ $"{p[i].LaserOffDelay},{p[i].LaserFrequency},{p[i].NumberOfCount}");
|
||
return sb.ToString();
|
||
}
|
||
|
||
// 파일↔파일 비교는 파싱 없이 해시로 (빠름)
|
||
static string Hash(string path)
|
||
{
|
||
try {
|
||
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) return "(없음)";
|
||
using (var md5 = System.Security.Cryptography.MD5.Create())
|
||
using (var fs = File.OpenRead(path)) {
|
||
var h = BitConverter.ToString(md5.ComputeHash(fs)).Replace("-", "");
|
||
return h.Substring(0, 8) + "/" + new FileInfo(path).Length;
|
||
}
|
||
} catch { return "(해시실패)"; }
|
||
}
|
||
|
||
// ── 잡동사니 ────────────────────────────────────────────────────────
|
||
static string Sel() { var s = RecipeHelper.SelectedRecipe; return $"[{s.id}][{s.name}]"; }
|
||
static string Work() { try { return DB.Dao.SystemRecipe.WorkRecipeFile; } catch { return ""; } }
|
||
static string Lasa() { try { return DB.Recipe.Recipe.SelectedRecipeFile; } catch { return ""; } }
|
||
|
||
// Aurora 바이너리 타입명을 쓰지 않기 위해 out 으로 받는다
|
||
static void GetActive(out string name, out int id)
|
||
{
|
||
name = ""; id = -1;
|
||
try {
|
||
var a = Application.Current.AuroraEnv().RecipeManager.ActivatedRecipe();
|
||
if (a != null) { name = a.Name; id = a.Id; }
|
||
} catch { }
|
||
}
|
||
|
||
// 같은 폴더의 Page 데이터
|
||
static string FieldPath(string recipeFullPath)
|
||
{
|
||
try {
|
||
return string.IsNullOrWhiteSpace(recipeFullPath) ? ""
|
||
: recipeFullPath.Replace(Path.GetFileName(recipeFullPath), "FieldHelper.json");
|
||
} catch { return ""; }
|
||
}
|
||
|
||
static string TeachingPath(string recipeName)
|
||
{
|
||
var root = ConfigurationManager.AppSettings.Get("DATA_PATH");
|
||
return Path.Combine(root, RecipeExIds.RECIPE_DIR, recipeName, RecipeExIds.TEACHING_EDITOR_RECIPE);
|
||
}
|
||
|
||
// 전체 경로 대신 레시피 폴더명만 (…/_config_recipe/<레시피명>/TeachingEditor…)
|
||
static string Short(string p)
|
||
{
|
||
try {
|
||
return string.IsNullOrWhiteSpace(p) ? "(비어있음)"
|
||
: Path.GetFileName(Path.GetDirectoryName(p));
|
||
} catch { return p; }
|
||
}
|
||
|
||
static bool SamePath(string a, string b)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(a) || string.IsNullOrWhiteSpace(b)) return false;
|
||
try {
|
||
return string.Equals(Path.GetFullPath(a), Path.GetFullPath(b),
|
||
StringComparison.OrdinalIgnoreCase);
|
||
} catch { return string.Equals(a, b, StringComparison.OrdinalIgnoreCase); }
|
||
}
|
||
|
||
// RecipeTrace 프레임을 건너뛰고 실제 호출자 2단계
|
||
static string Caller()
|
||
{
|
||
try {
|
||
var st = new StackTrace(true);
|
||
var sb = new StringBuilder();
|
||
int found = 0;
|
||
for (int i = 1; i < st.FrameCount && found < 2; i++) {
|
||
var m = st.GetFrame(i).GetMethod();
|
||
var t = m.DeclaringType == null ? "?" : m.DeclaringType.Name;
|
||
if (t == "RecipeTrace") continue;
|
||
if (found > 0) sb.Append(" ← ");
|
||
sb.Append($"{t}.{m.Name}:{st.GetFrame(i).GetFileLineNumber()}");
|
||
found++;
|
||
}
|
||
return sb.ToString();
|
||
} catch { return "?"; }
|
||
}
|
||
|
||
static void AppendFile(string line)
|
||
{
|
||
try {
|
||
var root = ConfigurationManager.AppSettings.Get("DATA_PATH");
|
||
File.AppendAllText(Path.Combine(root, "RecipeTrace.txt"), line + "\r\n", Encoding.UTF8);
|
||
} catch { }
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
# 작업 2. csproj 에 파일 등록
|
||
|
||
**파일**: `WeldingComponents\WeldingCommon\WeldingCommon.csproj`
|
||
|
||
구식 csproj 라 **파일만 만들면 빌드에 포함되지 않습니다.** Visual Studio 에서 [기존 항목 추가]로 넣으면 자동 처리되고, 직접 편집한다면 아래와 같이 합니다.
|
||
|
||
**[현재 272~273행]**
|
||
|
||
```xml
|
||
<Compile Include="Utils\RecipeHelper.cs" />
|
||
<Compile Include="Utils\RegistrySerializer.cs" />
|
||
```
|
||
|
||
**[변경안]**
|
||
|
||
```xml
|
||
<Compile Include="Utils\RecipeHelper.cs" />
|
||
<Compile Include="Utils\RecipeTrace.cs" />
|
||
<Compile Include="Utils\RegistrySerializer.cs" />
|
||
```
|
||
|
||
---
|
||
|
||
# 작업 3. `SelectedRecipe` 필드 → 프로퍼티
|
||
|
||
**파일**: `WeldingComponents\WeldingCommon\Utils\RecipeHelper.cs`
|
||
**위치**: 18행
|
||
|
||
**[현재 코드]**
|
||
|
||
```csharp
|
||
public static (int id, string name) SelectedRecipe;
|
||
```
|
||
|
||
**[변경안]**
|
||
|
||
```csharp
|
||
// [진단 2026-08-04] 대상이 바뀌는 순간을 잡기 위해 필드 → 프로퍼티.
|
||
// 외부에서의 읽기/쓰기 방식은 동일하다(대입 4곳, 나머지는 .name/.id 읽기뿐).
|
||
static (int id, string name) _SelectedRecipe;
|
||
public static (int id, string name) SelectedRecipe {
|
||
get { return _SelectedRecipe; }
|
||
set {
|
||
var old = _SelectedRecipe;
|
||
_SelectedRecipe = value;
|
||
RecipeTrace.Mark("대상변경 SELECTED",
|
||
$"[{old.id}][{old.name}] → [{value.id}][{value.name}]");
|
||
}
|
||
}
|
||
```
|
||
|
||
**확인 사항** — 빌드 시 `SelectedRecipe` 관련 에러가 나면 튜플 멤버에 직접 대입하는 코드(`SelectedRecipe.name = ...`)가 어딘가 있다는 뜻입니다. 조사 시점에는 **0건**이었습니다.
|
||
|
||
---
|
||
|
||
# 작업 4. `SaveRecipe` 전/후 계측
|
||
|
||
**파일**: `WeldingComponents\WeldingCommon\Utils\RecipeHelper.cs`
|
||
**위치**: 36~39행
|
||
|
||
**[현재 코드]**
|
||
|
||
```csharp
|
||
public static void SaveRecipe(string recipeName, bool includeModel = true)
|
||
{
|
||
CommonHelper.GetMainPage().SaveRecipe(GetTeachingEditorFullPath(recipeName), includeModel);
|
||
}
|
||
```
|
||
|
||
**[변경안]**
|
||
|
||
```csharp
|
||
public static void SaveRecipe(string recipeName, bool includeModel = true)
|
||
{
|
||
var targetPath = GetTeachingEditorFullPath(recipeName);
|
||
|
||
// [진단 2026-08-04] 저장 전/후를 모두 찍어 무엇이 무엇으로 바뀌었는지 확인한다.
|
||
// 저장 자체는 막지 않는다.
|
||
var snap = RecipeTrace.BeforeSave(recipeName, targetPath);
|
||
|
||
CommonHelper.GetMainPage().SaveRecipe(targetPath, includeModel);
|
||
|
||
RecipeTrace.AfterSave(snap);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
# 작업 5. `ReloadRecipe` 성공/실패/무동작 계측
|
||
|
||
**파일**: `WeldingComponents\WeldingCommon\Utils\RecipeHelper.cs`
|
||
**위치**: 64~69행
|
||
|
||
**[현재 코드]**
|
||
|
||
```csharp
|
||
public static void ReloadRecipe(string recipeName)
|
||
{
|
||
var camConfig = VisionEnv.AuroraEnv.DeviceManager.GetDeviceConfig(eCamId.CAM1.ToString()) as CamConfig;
|
||
CommonHelper.GetMainPage().UpdateDefaultField(camConfig.ChildImage.ChildBufRowCols);
|
||
CommonHelper.GetMainPage().ReloadRecipe(GetTeachingEditorFullPath(recipeName));
|
||
}
|
||
```
|
||
|
||
**[변경안]**
|
||
|
||
```csharp
|
||
public static void ReloadRecipe(string recipeName)
|
||
{
|
||
// [진단 2026-08-04] 내용 전환의 성공/실패/무동작을 구분해 기록. 동작은 그대로.
|
||
var before = RecipeTrace.MemFingerprint();
|
||
RecipeTrace.Mark("내용변경 RELOAD 진입", $"요청=[{recipeName}] 지문(전)={before}");
|
||
try {
|
||
var camConfig = VisionEnv.AuroraEnv.DeviceManager.GetDeviceConfig(eCamId.CAM1.ToString()) as CamConfig;
|
||
CommonHelper.GetMainPage().UpdateDefaultField(camConfig.ChildImage.ChildBufRowCols);
|
||
CommonHelper.GetMainPage().ReloadRecipe(GetTeachingEditorFullPath(recipeName));
|
||
|
||
var after = RecipeTrace.MemFingerprint();
|
||
RecipeTrace.Mark(before == after ? "RELOAD 완료(내용무변화!)" : "RELOAD 완료",
|
||
$"요청=[{recipeName}] 지문(후)={after}");
|
||
} catch (System.Exception ex) {
|
||
RecipeTrace.Mark("*** RELOAD 실패 ***",
|
||
$"요청=[{recipeName}] {ex.GetType().Name}: {ex.Message}");
|
||
throw; // 기존 동작 보존 — 예외 전파는 그대로
|
||
}
|
||
}
|
||
```
|
||
|
||
`throw;`(파라미터 없음)이므로 스택 트레이스와 예외 전파 동작이 기존과 동일합니다. `throw ex;` 로 쓰지 마십시오.
|
||
|
||
**이 두 문구가 원인 확정의 핵심입니다.**
|
||
|
||
| 문구 | 의미 |
|
||
|---|---|
|
||
| `*** RELOAD 실패 ***` | 선행문서 **원인 (1)** 확정. 대상만 바뀌고 내용은 직전 레시피에 남음 |
|
||
| `RELOAD 완료(내용무변화!)` | `Recipe.cs:64` **무동작 가설** 확정. 경로 비교로는 절대 못 잡는 케이스 |
|
||
|
||
같은 레시피를 다시 로드하면 정상적으로도 `내용무변화` 가 뜹니다. **요청 레시피명이 직전과 다른데** 무변화면 그때가 사고입니다.
|
||
|
||
---
|
||
|
||
---
|
||
|
||
# 작업 6. Page(FieldHelper.json) 기록 훅 — `MainPage.cs`
|
||
|
||
**파일**: `WeldingComponents\LASA\GUI\Page\MainPage.cs`
|
||
**위치**: `SaveAndLoadFieldScanFromLasaDb` (535행)
|
||
|
||
## 왜 필요한가
|
||
|
||
`FieldHelper.json`(Page 데이터)을 실제로 쓰는 건 `SaveAndLoadFieldScanFromLasaDb()`(내부 `:539 SaveFieldHelper`)인데, 호출처 4곳 중 `SaveRecipe` 안에 있는 건 **1곳뿐**입니다.
|
||
|
||
| 호출처 | Save 경로 안인가 |
|
||
|---|---|
|
||
| `MainPage.cs:433` — `SaveRecipe` | ✓ |
|
||
| `MainPage.cs:448,452` — `ReloadSetting()` | ✗ |
|
||
| `MainPage.cs:502,505` — `UpdateScoutField()` (Scout 필드 조작) | ✗ |
|
||
| `MainPage.cs:1433` — 콤보박스 `SelectedIndexChanged` (**페이지 바꿀 때마다**) | ✗ |
|
||
|
||
게다가 경로를 `WorkRecipeFile` 기준으로 정합니다(`:537`). **`WorkRecipeFile` 이 어긋난 상태에서 페이지를 넘기기만 해도** 엉뚱한 레시피 폴더의 `FieldHelper.json` 이 덮어써질 수 있습니다. `_suppressPageFieldSync` 가드(`:1422`)는 `ReloadRecipe` **도중**의 발화만 막고 평상시 페이지 전환은 그대로 씁니다.
|
||
|
||
함수 한 곳에 훅을 넣으면 **호출처 4곳이 전부** 잡힙니다.
|
||
|
||
## 6-1. `Tracer` 필드 추가
|
||
|
||
**[현재 코드]**
|
||
|
||
```csharp
|
||
void SaveAndLoadFieldScanFromLasaDb(string inputStr = null)
|
||
```
|
||
|
||
**[변경안]**
|
||
|
||
```csharp
|
||
// ═══ [진단계측 2026-08-04] 시작 ═══════════════════════════════════════
|
||
// WeldingCommon(RecipeTrace)이 LASAGUI 를 참조하므로 역방향 호출이 불가능하다.
|
||
// 델리게이트로 우회한다. CommonHelper.SetMainPage 에서 RecipeTrace.Mark 가 꽂힌다.
|
||
// 원복 : 이 블록과 SaveAndLoadFieldScanFromLasaDb 안의 Tracer?.Invoke 블록을 삭제.
|
||
public static Action<string, string> Tracer;
|
||
// ═══ [진단계측 2026-08-04] 끝 ═════════════════════════════════════════
|
||
void SaveAndLoadFieldScanFromLasaDb(string inputStr = null)
|
||
```
|
||
|
||
## 6-2. 기록 호출 추가
|
||
|
||
**[현재 코드]**
|
||
|
||
```csharp
|
||
string newpath = fullpath.Replace(Path.GetFileName(fullpath), "FieldHelper.json");
|
||
DB.RecipeFieldScan.Instance.SaveFieldHelper(newpath);
|
||
```
|
||
|
||
**[변경안]**
|
||
|
||
```csharp
|
||
string newpath = fullpath.Replace(Path.GetFileName(fullpath), "FieldHelper.json");
|
||
// ═══ [진단계측 2026-08-04] 시작 — 원복 시 이 블록 삭제 ═══
|
||
Tracer?.Invoke("PAGE 저장 FieldHelper",
|
||
$"경로={newpath} 기준={(inputStr == null ? "WorkRecipeFile" : "인자지정")}");
|
||
// ═══ [진단계측 2026-08-04] 끝 ═══
|
||
DB.RecipeFieldScan.Instance.SaveFieldHelper(newpath);
|
||
```
|
||
|
||
`기준=WorkRecipeFile` 이면 그 시점 `WorkRecipeFile` 값이 곧 기록 대상이라는 뜻입니다. 어긋나 있으면 그대로 오염입니다.
|
||
|
||
`Tracer` 는 `Action` 이라 `LASAGUI` → `WeldingCommon` 역참조가 생기지 않습니다. 연결이 안 돼 있으면 `?.` 로 조용히 무시되므로 **계측이 없어도 원래대로 동작합니다.**
|
||
|
||
---
|
||
|
||
# 작업 7. 훅 연결 — `CommonHelper.cs`
|
||
|
||
**파일**: `WeldingComponents\WeldingCommon\Utils\CommonHelper.cs`
|
||
**위치**: `SetMainPage()` (13~17행)
|
||
|
||
**[현재 코드]**
|
||
|
||
```csharp
|
||
public static void SetMainPage(MainPage mainPage)
|
||
{
|
||
MainPage = mainPage;
|
||
//If Using WeldingManager, This Method Not Necessary.
|
||
}
|
||
```
|
||
|
||
**[변경안]**
|
||
|
||
```csharp
|
||
public static void SetMainPage(MainPage mainPage)
|
||
{
|
||
MainPage = mainPage;
|
||
//If Using WeldingManager, This Method Not Necessary.
|
||
|
||
// ═══ [진단계측 2026-08-04] 시작 — 원복 시 이 블록 삭제 ═══
|
||
RecipeTrace.Attach(); // MainPage.Tracer 에 RecipeTrace.Mark 를 연결(참조 방향 우회)
|
||
// ═══ [진단계측 2026-08-04] 끝 ═══
|
||
}
|
||
```
|
||
|
||
`SetMainPage` 는 기동 시 `MainPage` 가 준비되는 시점에 호출되므로, 사용자가 페이지를 조작하기 전에 훅이 걸립니다.
|
||
|
||
> `RecipeTrace.Attach()` 는 작업 1의 `RecipeTrace.cs` 에 포함되어 있습니다. 별도 추가 불필요.
|
||
|
||
---
|
||
|
||
# 빌드
|
||
|
||
1. `ILSOS_All.sln` 을 Visual Studio 2022 로 열고 **Release | x64** 빌드.
|
||
2. 빌드 실패 시 진행 중단. 특히 아래를 확인:
|
||
- `RecipeTrace.cs` 가 csproj 에 등록됐는지 (작업 2)
|
||
- `SelectedRecipe` 프로퍼티 전환 관련 에러 (작업 3 확인 사항)
|
||
|
||
# 현장 확인 절차
|
||
|
||
1. `_config_recipe` 폴더 백업 완료 확인.
|
||
2. 기동 후 Manual 상태에서 레시피 A 선택 → 파라미터 수정 → Save.
|
||
- 정상이면 **팝업이 뜨지 않습니다.**
|
||
- `D:\ILSOSConfig\ILSOS_PrismaticRD_PTOP\RecipeTrace.txt` 에 `SAVE 직전` / `SAVE 직후` 줄이 남았는지 확인.
|
||
3. 레시피 전환(A→B→A)을 10회 반복하며 팝업이 뜨는지 관찰.
|
||
4. AutoRun 기동 → PLC 레시피 전환 발생 → Manual 복귀 → 레시피 선택 후 Save.
|
||
5. 팝업이 뜨면 **화면 전체를 촬영**하고 `RecipeTrace.txt` 를 함께 회수.
|
||
|
||
**흐름을 눈으로 직접 보고 싶으면** `RecipeTrace.cs` 의 `ALWAYS_SHOW_ON_SAVE` 를 `true` 로 바꾸면 저장할 때마다 팝업이 뜹니다. 확인이 끝나면 `false` 로 되돌리십시오.
|
||
|
||
# 결과 읽는 법
|
||
|
||
팝업 맨 위 `결론` 한 줄이 판정입니다.
|
||
|
||
```
|
||
결론 : ★ 대상 [RCP_B] 이 활성 레시피 [RCP_A] 내용으로 덮어써짐
|
||
|
||
──────── 무엇이 무엇으로 바뀌었나 ────────
|
||
저장된 내용(메모리) : n=128|300,100,50,50,2000,1
|
||
|
||
대상파일 전 : n=128|500,200,80,80,3000,2
|
||
대상파일 후 : n=128|300,100,50,50,2000,1 변경=True ← 활성 것과 같아짐
|
||
활성파일 전 : n=128|300,100,50,50,2000,1
|
||
활성파일 후 : n=128|300,100,50,50,2000,1 변경=False
|
||
Page(FieldHelper) 대상 변경 : True 활성 변경 : False
|
||
|
||
──────── 직전 이력 (오래된 것 → 최근) ────────
|
||
14:22:07.113 T1 대상변경 SELECTED 선택=[3][RCP_B] 메모리=RCP_A … | [1][RCP_A] → [3][RCP_B] | ← UCScannerObjectTeachingViewModel.OnChanged:165
|
||
14:22:07.115 T1 내용변경 RELOAD 진입 선택=[3][RCP_B] 메모리=RCP_A … | 요청=[RCP_B] 지문(전)=n=128|300,…
|
||
14:22:07.140 T1 *** RELOAD 실패 *** 선택=[3][RCP_B] 메모리=RCP_A … | 요청=[RCP_B] NullReferenceException: …
|
||
14:23:51.902 T1 *** SAVE 직후 (의심) *** …
|
||
```
|
||
|
||
- `대상파일 전 → 후` 가 곧 **무엇이 무엇으로 바뀌었나**.
|
||
- 그 값이 `활성파일` 과 같아지면 **교차 오염 확정**.
|
||
- 그 아래 이력에서 **언제 어긋났는지**(`RELOAD 실패` 또는 `대상변경 SELECTED` 뒤에 대응하는 `RELOAD 완료` 가 없음)가 보입니다.
|
||
- `← 호출자` 컬럼으로 **어느 화면 조작이었는지**까지 특정됩니다.
|
||
|
||
# 원인별 후속 조치 (확정 후)
|
||
|
||
| 팝업/이력에서 관찰된 것 | 적용할 것 |
|
||
|---|---|
|
||
| `*** RELOAD 실패 ***` 직후의 Save | 선행문서 **2단계** (대상·내용 갱신 순서 역전) |
|
||
| `RELOAD 완료(내용무변화!)` 인데 요청 레시피가 바뀐 경우 | `Recipe.cs:64` 무동작. 별도 분석 필요 (선행문서 §9-5) |
|
||
| `SAVE 직전` 의 호출자가 `OnActivatedRecipe:156` | 선행문서 **3단계** (A안 권장) |
|
||
| `SAVE 직전` 의 호출자가 `OnRecipeEntryChanged:115` | 선행문서 **4단계** |
|
||
| `arg=True` (인자 ≠ SelectedRecipe) | `OnSaved` 의 저장 대상을 `recipeName` 으로 통일 |
|
||
| `marker=True` (두 마커 불일치) | MainPage 를 거치지 않은 Load 경로 존재. 재분석 필요 |
|
||
|
||
# 알아두실 점 / 한계
|
||
|
||
- **저장을 막지 않습니다.** 오염이 일어나면 팝업은 뜨지만 파일은 이미 덮어써진 뒤입니다. 데이터를 지키는 게 목적이면 선행문서 **1단계 가드**(저장 거부)를 넣어야 하지만, 그러면 원인 경로 데이터를 모을 수 없습니다. **백업을 전제로 진단을 먼저** 도는 순서입니다.
|
||
- **Save 가 느려질 수 있습니다.** 저장 1회당 레시피 파일 파싱이 4회(대상·활성 × 전·후) 늘어납니다. `TeachingEditor` 에 Model 까지 들어 있어 레시피가 크면 체감될 수 있습니다. 느리면 `BeforeSave`/`AfterSave` 에서 **활성 파일 지문(`ActFp`/`actFpAfter`) 계산만 제거**하십시오. `becameActive` 판정만 약해지고 나머지는 그대로 동작합니다.
|
||
- **Auto 중에는 팝업이 뜨지 않습니다** (`SHOW_IN_AUTO = false`). `OnActivatedRecipe`(VM:156)의 Save 는 PLC 레시피 전환 중 디스패처에서 돌기 때문에, 여기서 모달이 뜨면 확인을 누를 때까지 전환이 멈춥니다. 파일에는 그대로 남으므로 양산 중이면 이대로 두십시오.
|
||
- **지문은 ScanParam 앞 3개 기준**입니다. 두 레시피의 파라미터가 완전히 동일하면 `becameActive` 가 구분하지 못합니다. 이 경우 `Page(FieldHelper) 변경` 과 해시로 판단하십시오.
|
||
- `MainPage.SaveRecipe` 가 예외를 던지면 `AfterSave` 는 실행되지 않습니다. 이때는 `SAVE 직전` 만 남습니다 — 그 자체가 저장 실패의 증거입니다.
|
||
|
||
# 원복 방법
|
||
|
||
1. `RecipeTrace.cs` 삭제
|
||
2. csproj 의 `<Compile Include="Utils\RecipeTrace.cs" />` 삭제
|
||
3. `RecipeHelper.cs` 의 작업 3·4·5 를 원래 코드로 되돌림 (본 문서의 `[현재 코드]` 블록)
|
||
4. `D:\ILSOSConfig\ILSOS_PrismaticRD_PTOP\RecipeTrace.txt` 삭제
|
||
|
||
# 이번 작업에서 하지 않는 것
|
||
|
||
- 저장 차단 가드 (선행문서 1단계) — 원인 데이터 수집 후 판단
|
||
- `OnActiveReloaded` / `OnChanged` / `Reloaded` 의 순서 교정 (2단계)
|
||
- `OnActivatedRecipe` 의 `SaveRecipe` 제거 (3단계)
|
||
- `RMSHandlerForPTOP` 수정 — 죽은 코드 (선행문서 §7)
|
||
- `EquipmentSetup.cs:282` `RollbackRecipe` 주석 해제 — 의미 없는 동작 (선행문서 §7)
|