수량을 고쳤는데 이전의 확인 표시가 남아 있다면 무엇을 확인했다는 뜻일까. 나는 공개 ERP 체험의 단계 초기화 판단을 더 작고 분명하게 재사용할 수 있도록 수정 번호를 가진 모듈을 새로 만들었다.

실제 승인자나 회사의 원본 자료는 들어 있지 않다. 현재 수정본에 대한 준비와 확인을 구별하는 순수 상태 모델이며, 서버 승인·전자서명·실제 발주 기능으로 오해하지 않게 범위를 함께 적었다.

확인을 참과 거짓 하나로 두지 않았다

상태에는 quantity, revision, preparedFor, confirmedFor, cancelled가 있다. revision은 내용을 바꿀 때 올라가는 수정 번호다. preparedFor와 confirmedFor는 어느 번호를 준비하고 확인했는지 가리키며 아직 하지 않았다면 null이다. 준비와 확인이 현재 번호에 속해야 다음 단계로 넘어갈 수 있다. 확인했다는 표시가 바뀐 내용으로 무심코 넘어가지 않게 한 구조다.

예시 수량은 문자열로 입력하고 1~20의 정수일 때만 준비할 수 있다. 6개를 준비하고 확인한 다음 7개로 고치면 번호가 증가하고 두 흔적이 비워진다. 같은 6개를 다시 입력하는 동작은 내용 변화가 없으므로 번호와 확인을 유지한다. 이 차이는 수정이 일어났다는 사실과 단순히 입력 이벤트가 발생했다는 사실을 나누기 위한 것이다.

입력 오류와 중복 행동을 다른 경우로 보았다

빈 문자열이나 0, 21, 소수 표현은 길이 12자 이내의 초안으로 보관할 수 있지만 준비할 수는 없다. 화면은 입력값을 지우지 않고 바꿀 위치를 남긴다. 길이 제한을 넘기는 값은 전환 함수에서 거절한다. 화면 입력의 최대 길이와 모델의 최대 길이를 함께 두어 UI를 거치지 않은 호출에서도 같은 경계가 적용되도록 했다.

준비 전 확인, 이미 끝난 확인의 반복 같은 행동은 진행을 새로 만들지 않는다. 같은 상태를 돌려주는 방식은 단순한 페이지 안의 중복 조작을 설명하기 쉽다. 다만 이것을 네트워크 중복 요청 방지라고 부르지는 않는다. 실무 서버의 요청 키, 트랜잭션과 동시 수정 충돌은 별도 책임이다. 작은 상태 전환의 무해한 반복과 서버의 중복 처리 방지를 구별해 공유한다.

취소한 예시에는 처음부터라는 별도 길을 두었다

취소하면 준비와 확인 흔적을 비우고 내용 수정과 다음 진행을 멈춘다. 버튼을 다시 눌러 취소를 조용히 해제하지 않는다. 새 예시가 필요하면 처음부터 동작으로 초기 상태를 만든다. 실제 업무에서는 취소 권한이나 취소 사유, 취소 전표가 필요할 수 있지만 이 모듈은 그 정책을 갖고 있지 않다. 취소 이후 아무 동작이나 허용하지 않는 경계만 명시했다.

아래 구현에서 workflowView는 상태를 보고 허용되는 행동을 계산하고 transitionWorkflow는 새 상태를 돌려준다. 기존 상태를 직접 고치지 않으므로 같은 입력과 행동을 테스트하기 쉽다. 화면의 disabled 표시도 같은 view에서 가져온다. 모델이 금지한 행동을 화면만 허용하거나, 화면만 막고 함수는 진행하는 어긋남을 줄이기 위한 선택이다.

코드와 구현 살펴보기

독립 합성 상태 전환기 전체 구현

// Independent synthetic preparation/confirmation model. No production writes.
const keys = 'cancelled,confirmedFor,preparedFor,quantity,revision';
const validQuantity = q => typeof q === 'string' && /^(?:[1-9]|1\d|20)$/.test(q);
export function initialWorkflow() {
  return {quantity:'6', revision:0, preparedFor:null, confirmedFor:null, cancelled:false};
}
export function validateWorkflow(s) {
  if (!s || typeof s !== 'object' || Array.isArray(s) || Object.keys(s).sort().join(',') !== keys) return false;
  if (typeof s.quantity !== 'string' || s.quantity.length > 12 || !Number.isSafeInteger(s.revision) || s.revision < 0 || typeof s.cancelled !== 'boolean') return false;
  for (const key of ['preparedFor','confirmedFor']) if (s[key] !== null && s[key] !== s.revision) return false;
  if (s.confirmedFor !== null && s.preparedFor !== s.revision) return false;
  if ((s.preparedFor !== null || s.confirmedFor !== null) && (!validQuantity(s.quantity) || s.cancelled)) return false;
  return true;
}
export function workflowView(s) {
  if (!validateWorkflow(s)) throw new TypeError('Invalid workflow state');
  const valid = validQuantity(s.quantity);
  return {valid, canPrepare:valid && !s.cancelled && s.preparedFor === null,
    canConfirm:valid && !s.cancelled && s.preparedFor === s.revision && s.confirmedFor === null,
    state:s.cancelled ? 'cancelled' : !valid ? 'invalid_quantity' : s.confirmedFor !== null ? 'confirmed' : s.preparedFor !== null ? 'prepared' : 'draft'};
}
export function transitionWorkflow(s, action) {
  if (!validateWorkflow(s)) throw new TypeError('Invalid workflow state');
  if (!action || typeof action !== 'object') throw new TypeError('Invalid action');
  if (action.type === 'reset') return initialWorkflow();
  if (!['edit','prepare','confirm','cancel'].includes(action.type)) throw new TypeError('Unknown action');
  if (s.cancelled) return s;
  if (action.type === 'edit') {
    if (typeof action.quantity !== 'string' || action.quantity.length > 12) throw new TypeError('Draft text too long');
    if (action.quantity === s.quantity) return s;
    if (!Number.isSafeInteger(s.revision + 1)) throw new RangeError('Revision exhausted');
    return {...s, quantity:action.quantity, revision:s.revision+1, preparedFor:null, confirmedFor:null};
  }
  if (action.type === 'cancel') return {...s, cancelled:true, preparedFor:null, confirmedFor:null};
  const v = workflowView(s);
  if (action.type === 'prepare' && v.canPrepare) return {...s, preparedFor:s.revision};
  if (action.type === 'confirm' && v.canConfirm) return {...s, confirmedFor:s.revision};
  return s;
}
export function snapshotWorkflow(s) {
  if (!validateWorkflow(s)) throw new TypeError('Invalid workflow state');
  return JSON.stringify({format:'synthetic-review-v1', synthetic:true, workflow:s});
}
export function restoreWorkflow(text) {
  if (typeof text !== 'string' || text.length > 1200) throw new TypeError('Invalid snapshot text');
  const p = JSON.parse(text);
  if (!p || typeof p !== 'object' || Object.keys(p).sort().join(',') !== 'format,synthetic,workflow' || p.format !== 'synthetic-review-v1' || p.synthetic !== true || !validateWorkflow(p.workflow)) throw new TypeError('Unsupported synthetic snapshot');
  return {...p.workflow};
}

복원할 상태도 같은 규칙을 통과한다

내보낸 문자열은 형식 버전과 합성 자료 표시를 갖고 있다. 복원은 길이 1200자 이내와 정확한 키 목록, 안전한 수정 번호, 준비와 확인 번호의 관계를 검사한다. 현재 번호와 다른 흔적이나 준비 없는 확인, 취소와 함께 남아 있는 확인은 받아들이지 않는다. 알 수 없는 키를 그대로 복사해 이후 로직에 흘려보내지도 않는다.

초안 입력이 아직 틀렸다면 그 초안을 복원할 수 있다. 대신 준비·확인 흔적이 없어야 한다. 메모리 복사 버튼은 이 페이지가 살아 있는 동안만 문자열을 보관하고 복원 버튼은 그 문자열을 읽는다. 취소 전에 복사한 예시로도 돌아갈 수 있으므로 실제 취소 기록을 보호하는 기능은 아니다. 이것은 서명된 승인 기록이나 영구 백업이 아니다. 실제 데이터베이스로 연결할 때는 형식 검사에 더해 작성자 권한과 서버의 버전 충돌 검사도 필요하다.

작은 예시로 직접 조합해 본다

아래 데모에서 수량을 준비하고 확인한 뒤 바꾸어 보자. 두 흔적이 비워지고 다시 준비해야 한다. 빈칸, 취소, 처음부터, 메모리 복사와 복원도 각각 시도할 수 있다. 외부 요청과 회사 자료 입력은 없으며 새로고침하면 메모리 예시가 사라진다. 한영 화면은 같은 구현을 포함한다. 표시만 바꾸어 서로 다른 규칙을 갖게 하지 않았다.

ZIP에는 전체 구현, 테스트와 두 언어의 독립 HTML 데모, 사용 범위 및 재사용 허용 문서가 들어 있다. 압축을 풀고 node test.mjs를 실행한 다음 자신의 시스템에 연결할 책임을 정할 수 있다. 수정·재배포·상업적 사용이 가능하고 무보증으로 제공한다. 이 파일은 실제 업체의 운영 코드나 승인 자료를 담고 있지 않다.

합성 상태 · 메모리 복사만 제공 · 실제 발주 없음

확인한 범위와 다음 구현의 경계

추출한 패키지에서 25개 테스트 그룹을 실행했다. 수량 1~20과 준비·확인·취소·초기화·잘못된 입력·변경 입력의 짧은 조합 4320개를 거쳐 상태가 유효하고 복원 결과가 같은지 대조했다. 미래 번호와 오래된 확인, 알 수 없는 키, 범위를 넘는 번호와 잘못된 형식도 거절하는지 확인했다. 결과는 이 합성 상태 계약에 대한 것이다.

한영 데모의 이벤트 연결을 코드로 실행했지만 실제 브라우저 배치와 키보드 초점, 보조기기 읽기는 이번에 검수하지 않았다. 회사에 붙일 때는 어느 필드를 바꾸면 재확인이 필요한지 먼저 결정해야 한다. 그 다음 사용자 권한과 승인 주체, 동시 편집, 저장, 발송과 실패 재시도를 별도로 설계한다. 이 모듈은 그 판단을 숨기는 완제품이 아니라 확인의 대상을 명확히 하는 출발점이다.

구현·테스트·한영 데모 ZIP 받기
실제 업체 자료 없는 독립 합성 예제