From 67e1c5eeef7b1742a2720ec6f834ee531991b458 Mon Sep 17 00:00:00 2001 From: "DESKTOP-TKLFCPR\\ython" Date: Tue, 16 Jun 2026 01:57:28 +0900 Subject: [PATCH] =?UTF-8?q?feat(esn):=20e-paper=20ESL=20=ED=95=B5=EC=8B=AC?= =?UTF-8?q?=20=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EC=B6=94=EA=B0=80=20=E2=80=94?= =?UTF-8?q?=20=ED=83=9C=EA=B7=B8=20=EB=B0=94=EC=9D=B8=EB=94=A9=20+=20?= =?UTF-8?q?=EA=B0=80=EA=B2=A9=20=EC=A0=84=ED=8C=8C=20=ED=81=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - esn_tag_binding: ESL 태그 ↔ 선반위치 ↔ 상품 매핑 테이블 - esn_update_queue: POS 가격변경 → 태그 RF 전파 추적 큐 - TagBindingController/Service/Mapper: 태그 바인딩 CRUD + 언바인딩 - UpdateQueueController/Service/Mapper: Gateway 폴링(/poll), 태그 ACK(/confirm), 실패재시도 - PosCvtService.process(): 가격 처리 시 바인딩된 e-paper 태그에 자동 큐 생성 - 프론트: TagBindingList.tsx (배터리/신호강도), UpdateQueueList.tsx (5초 자동갱신) - 사이드바: 태그 바인딩 / 업데이트 큐 메뉴 추가 Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 27 +++ .../esn/controller/PosCvtController.java | 6 +- .../esn/controller/TagBindingController.java | 57 ++++++ .../esn/controller/UpdateQueueController.java | 75 +++++++ .../com/zioinfo/esn/domain/TagBindingVo.java | 27 +++ .../com/zioinfo/esn/domain/UpdateQueueVo.java | 31 +++ .../zioinfo/esn/mapper/TagBindingMapper.java | 25 +++ .../zioinfo/esn/mapper/UpdateQueueMapper.java | 24 +++ .../zioinfo/esn/service/PosCvtService.java | 22 +- .../esn/service/TagBindingService.java | 70 +++++++ .../esn/service/UpdateQueueService.java | 60 ++++++ backend/src/main/resources/db/schema.sql | 54 +++++ .../resources/mapper/TagBindingMapper.xml | 139 +++++++++++++ .../resources/mapper/UpdateQueueMapper.xml | 131 ++++++++++++ frontend/src/App.tsx | 28 +-- frontend/src/components/Sidebar.tsx | 29 +-- frontend/src/pages/TagBindingList.tsx | 193 ++++++++++++++++++ frontend/src/pages/UpdateQueueList.tsx | 154 ++++++++++++++ 18 files changed, 1123 insertions(+), 29 deletions(-) create mode 100644 .gitignore create mode 100644 backend/src/main/java/com/zioinfo/esn/controller/TagBindingController.java create mode 100644 backend/src/main/java/com/zioinfo/esn/controller/UpdateQueueController.java create mode 100644 backend/src/main/java/com/zioinfo/esn/domain/TagBindingVo.java create mode 100644 backend/src/main/java/com/zioinfo/esn/domain/UpdateQueueVo.java create mode 100644 backend/src/main/java/com/zioinfo/esn/mapper/TagBindingMapper.java create mode 100644 backend/src/main/java/com/zioinfo/esn/mapper/UpdateQueueMapper.java create mode 100644 backend/src/main/java/com/zioinfo/esn/service/TagBindingService.java create mode 100644 backend/src/main/java/com/zioinfo/esn/service/UpdateQueueService.java create mode 100644 backend/src/main/resources/mapper/TagBindingMapper.xml create mode 100644 backend/src/main/resources/mapper/UpdateQueueMapper.xml create mode 100644 frontend/src/pages/TagBindingList.tsx create mode 100644 frontend/src/pages/UpdateQueueList.tsx diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..69c2988 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# Maven build +target/ +*.jar +*.war +*.class + +# Node +node_modules/ +dist/ +.vite/ + +# IDE +.idea/ +*.iml +.vscode/ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +logs/ + +# Env +.env.local +.env.production diff --git a/backend/src/main/java/com/zioinfo/esn/controller/PosCvtController.java b/backend/src/main/java/com/zioinfo/esn/controller/PosCvtController.java index 423e01d..89c3b21 100644 --- a/backend/src/main/java/com/zioinfo/esn/controller/PosCvtController.java +++ b/backend/src/main/java/com/zioinfo/esn/controller/PosCvtController.java @@ -29,9 +29,9 @@ public class PosCvtController { } @PutMapping("/{id}/process") - public ApiResponse process(@PathVariable Long id) { - service.process(id); - return ApiResponse.ok("처리 완료", null); + public ApiResponse process(@PathVariable Long id) { + int queued = service.process(id); + return ApiResponse.ok("처리 완료 (태그 큐 " + queued + "건)", queued); } @PutMapping("/{id}/ignore") diff --git a/backend/src/main/java/com/zioinfo/esn/controller/TagBindingController.java b/backend/src/main/java/com/zioinfo/esn/controller/TagBindingController.java new file mode 100644 index 0000000..308c787 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/esn/controller/TagBindingController.java @@ -0,0 +1,57 @@ +package com.zioinfo.esn.controller; + +import com.zioinfo.esn.common.ApiResponse; +import com.zioinfo.esn.domain.TagBindingVo; +import com.zioinfo.esn.service.TagBindingService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; +import java.util.List; + +/** + * e-paper ESL 태그 ↔ 선반 위치 ↔ 상품 바인딩 관리 + * 핵심 플로우: 태그 스캔 → 상품 선택 → 위치 지정 → bind() → 가격 변경 시 자동 큐 + */ +@RestController +@RequestMapping("/api/tag-binding") +@RequiredArgsConstructor +public class TagBindingController { + private final TagBindingService service; + + @GetMapping + public ApiResponse> list( + @RequestParam(required = false) String tenantCode, + @RequestParam(required = false) Long storeId, + @RequestParam(required = false) String bindStatus, + @RequestParam(required = false) String keyword) { + return ApiResponse.ok(service.list(tenantCode, storeId, bindStatus, keyword)); + } + + @GetMapping("/{id}") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.ok(service.get(id)); + } + + @GetMapping("/device/{deviceId}") + public ApiResponse getByDevice(@PathVariable String deviceId) { + return ApiResponse.ok(service.getByDeviceId(deviceId)); + } + + /** 태그에 상품+위치 바인딩 */ + @PostMapping + public ApiResponse bind(@RequestBody TagBindingVo vo) { + return ApiResponse.ok(service.bind(vo)); + } + + @PutMapping("/{id}") + public ApiResponse update(@PathVariable Long id, @RequestBody TagBindingVo vo) { + vo.setId(id); + return ApiResponse.ok(service.bind(vo)); + } + + /** 태그 언바인딩 (선반에서 태그 제거 시) */ + @PutMapping("/{id}/unbind") + public ApiResponse unbind(@PathVariable Long id) { + service.unbind(id); + return ApiResponse.ok("언바인딩 완료", null); + } +} diff --git a/backend/src/main/java/com/zioinfo/esn/controller/UpdateQueueController.java b/backend/src/main/java/com/zioinfo/esn/controller/UpdateQueueController.java new file mode 100644 index 0000000..3802ca5 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/esn/controller/UpdateQueueController.java @@ -0,0 +1,75 @@ +package com.zioinfo.esn.controller; + +import com.zioinfo.esn.common.ApiResponse; +import com.zioinfo.esn.domain.UpdateQueueVo; +import com.zioinfo.esn.service.UpdateQueueService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; +import java.util.List; +import java.util.Map; + +/** + * e-paper 태그 가격 업데이트 전파 큐 + * Gateway가 이 API를 주기적으로 폴링 → 태그에 RF 전송 → confirm 호출 + * + * Gateway 연동 흐름: + * GET /api/update-queue/poll?tenantCode=EMART&limit=50 → PENDING 목록 수신 + * PUT /api/update-queue/{id}/sent → RF 전송 완료 + * PUT /api/update-queue/confirm?deviceId=ESL-001 → 태그 ACK 수신 + */ +@RestController +@RequestMapping("/api/update-queue") +@RequiredArgsConstructor +public class UpdateQueueController { + private final UpdateQueueService service; + + @GetMapping + public ApiResponse> list( + @RequestParam(required = false) String tenantCode, + @RequestParam(required = false) Long storeId, + @RequestParam(required = false) String status) { + return ApiResponse.ok(service.list(tenantCode, storeId, status)); + } + + @GetMapping("/{id}") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.ok(service.get(id)); + } + + /** Gateway 폴링 엔드포인트: PENDING → SENDING 전환 후 반환 */ + @GetMapping("/poll") + public ApiResponse> poll( + @RequestParam(required = false) String tenantCode, + @RequestParam(defaultValue = "50") int limit) { + return ApiResponse.ok(service.pollPending(tenantCode, limit)); + } + + /** Gateway가 RF 전송 완료 후 호출 */ + @PutMapping("/{id}/sent") + public ApiResponse sent(@PathVariable Long id) { + service.markSent(id); + return ApiResponse.ok("전송 완료", null); + } + + /** 태그로부터 확인 응답 수신 (device_id 기반 일괄 확인) */ + @PutMapping("/confirm") + public ApiResponse confirm(@RequestParam String deviceId) { + service.confirm(deviceId); + return ApiResponse.ok("확인 완료", null); + } + + /** 전송 실패 처리 */ + @PutMapping("/{id}/failed") + public ApiResponse failed(@PathVariable Long id, + @RequestBody Map body) { + service.markFailed(id, body.getOrDefault("message", "전송 실패")); + return ApiResponse.ok("실패 처리 완료", null); + } + + /** 큐 항목 취소 */ + @PutMapping("/{id}/cancel") + public ApiResponse cancel(@PathVariable Long id) { + service.cancel(id); + return ApiResponse.ok("취소 완료", null); + } +} diff --git a/backend/src/main/java/com/zioinfo/esn/domain/TagBindingVo.java b/backend/src/main/java/com/zioinfo/esn/domain/TagBindingVo.java new file mode 100644 index 0000000..e1e508b --- /dev/null +++ b/backend/src/main/java/com/zioinfo/esn/domain/TagBindingVo.java @@ -0,0 +1,27 @@ +package com.zioinfo.esn.domain; + +import lombok.Data; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +public class TagBindingVo { + private Long id; + private String tenantCode; + private Long storeId; + private String storeName; + private String deviceId; // ESL_DEVICE의 device_id + private String productCode; + private String productName; + private String locationCode; // 선반 위치 (예: A01-03) + private String templateCode; + private String displayData; // 현재 화면 렌더링 데이터 (JSON) + private BigDecimal lastPrice; + private LocalDateTime lastUpdatedAt; + private String bindStatus; // ACTIVE, UNBOUND, ERROR + // HCore 장치 정보 (join) + private String deviceStatus; // ONLINE, OFFLINE 등 + private Integer batteryLevel; + private String signalStrength; + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/com/zioinfo/esn/domain/UpdateQueueVo.java b/backend/src/main/java/com/zioinfo/esn/domain/UpdateQueueVo.java new file mode 100644 index 0000000..6873d0d --- /dev/null +++ b/backend/src/main/java/com/zioinfo/esn/domain/UpdateQueueVo.java @@ -0,0 +1,31 @@ +package com.zioinfo.esn.domain; + +import lombok.Data; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +public class UpdateQueueVo { + private Long id; + private String tenantCode; + private Long storeId; + private String storeName; + private Long bindingId; + private String deviceId; + private String locationCode; + private String productCode; + private String productName; + private BigDecimal newPrice; + private BigDecimal newSalePrice; + private String templateCode; + private String renderData; + private Integer priority; // 1(긴급)~10(일반) + private String status; // PENDING, SENDING, SENT, CONFIRMED, FAILED, CANCELLED + private Integer retryCount; + private Integer maxRetry; + private String errorMessage; + private Long posCvtId; + private LocalDateTime sentAt; + private LocalDateTime confirmedAt; + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/com/zioinfo/esn/mapper/TagBindingMapper.java b/backend/src/main/java/com/zioinfo/esn/mapper/TagBindingMapper.java new file mode 100644 index 0000000..1a02e60 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/esn/mapper/TagBindingMapper.java @@ -0,0 +1,25 @@ +package com.zioinfo.esn.mapper; + +import com.zioinfo.esn.domain.TagBindingVo; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import java.util.List; + +@Mapper +public interface TagBindingMapper { + List findAll(@Param("tenantCode") String tenantCode, + @Param("storeId") Long storeId, + @Param("bindStatus") String bindStatus, + @Param("keyword") String keyword); + TagBindingVo findById(@Param("id") Long id); + TagBindingVo findByDeviceId(@Param("deviceId") String deviceId); + List findByProductCode(@Param("tenantCode") String tenantCode, + @Param("storeId") Long storeId, + @Param("productCode") String productCode); + int insert(TagBindingVo vo); + int update(TagBindingVo vo); + int unbind(@Param("id") Long id); + int delete(@Param("id") Long id); + long countAll(@Param("tenantCode") String tenantCode); + long countActive(@Param("tenantCode") String tenantCode); +} diff --git a/backend/src/main/java/com/zioinfo/esn/mapper/UpdateQueueMapper.java b/backend/src/main/java/com/zioinfo/esn/mapper/UpdateQueueMapper.java new file mode 100644 index 0000000..5f51822 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/esn/mapper/UpdateQueueMapper.java @@ -0,0 +1,24 @@ +package com.zioinfo.esn.mapper; + +import com.zioinfo.esn.domain.UpdateQueueVo; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import java.util.List; + +@Mapper +public interface UpdateQueueMapper { + List findAll(@Param("tenantCode") String tenantCode, + @Param("storeId") Long storeId, + @Param("status") String status); + List findPending(@Param("tenantCode") String tenantCode, + @Param("limit") int limit); + UpdateQueueVo findById(@Param("id") Long id); + int insert(UpdateQueueVo vo); + int updateStatus(@Param("id") Long id, @Param("status") String status, + @Param("errorMessage") String errorMessage); + int confirmByDeviceId(@Param("deviceId") String deviceId); + int incrementRetry(@Param("id") Long id); + long countPending(@Param("tenantCode") String tenantCode); + long countFailed(@Param("tenantCode") String tenantCode); + long countByStatus(@Param("tenantCode") String tenantCode, @Param("status") String status); +} diff --git a/backend/src/main/java/com/zioinfo/esn/service/PosCvtService.java b/backend/src/main/java/com/zioinfo/esn/service/PosCvtService.java index f72271b..072a764 100644 --- a/backend/src/main/java/com/zioinfo/esn/service/PosCvtService.java +++ b/backend/src/main/java/com/zioinfo/esn/service/PosCvtService.java @@ -3,13 +3,17 @@ package com.zioinfo.esn.service; import com.zioinfo.esn.domain.PosCvtVo; import com.zioinfo.esn.mapper.PosCvtMapper; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.util.List; +@Slf4j @Service @RequiredArgsConstructor public class PosCvtService { private final PosCvtMapper mapper; + private final TagBindingService tagBindingService; public List list(String tenantCode, Long storeId, String status, String keyword) { return mapper.findAll(tenantCode, storeId, status, keyword); @@ -17,8 +21,24 @@ public class PosCvtService { public PosCvtVo get(Long id) { return mapper.findById(id); } - public void process(Long id) { + /** + * POS 가격변경 처리 → 바인딩된 e-paper 태그에 업데이트 큐 자동 생성 + */ + @Transactional + public int process(Long id) { + PosCvtVo pos = mapper.findById(id); + if (pos == null) return 0; + mapper.updateStatus(id, "PROCESSED", null); + + // 이 상품에 바인딩된 e-paper 태그들에 가격 업데이트 큐 생성 + int queued = tagBindingService.enqueueByProductCode( + pos.getTenantCode(), pos.getStoreId(), + pos.getProductCode(), pos.getPrice(), pos.getSalePrice(), id); + + log.info("POS 처리 완료: posCvtId={}, product={}, 큐생성={}개", + id, pos.getProductCode(), queued); + return queued; } public void markError(Long id, String errorMessage) { diff --git a/backend/src/main/java/com/zioinfo/esn/service/TagBindingService.java b/backend/src/main/java/com/zioinfo/esn/service/TagBindingService.java new file mode 100644 index 0000000..03de68d --- /dev/null +++ b/backend/src/main/java/com/zioinfo/esn/service/TagBindingService.java @@ -0,0 +1,70 @@ +package com.zioinfo.esn.service; + +import com.zioinfo.esn.domain.TagBindingVo; +import com.zioinfo.esn.domain.UpdateQueueVo; +import com.zioinfo.esn.mapper.TagBindingMapper; +import com.zioinfo.esn.mapper.UpdateQueueMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class TagBindingService { + private final TagBindingMapper bindingMapper; + private final UpdateQueueMapper queueMapper; + + public List list(String tenantCode, Long storeId, + String bindStatus, String keyword) { + return bindingMapper.findAll(tenantCode, storeId, bindStatus, keyword); + } + + public TagBindingVo get(Long id) { return bindingMapper.findById(id); } + + public TagBindingVo getByDeviceId(String deviceId) { + return bindingMapper.findByDeviceId(deviceId); + } + + @Transactional + public TagBindingVo bind(TagBindingVo vo) { + // 기존 바인딩이 있으면 해제 후 재바인딩 + TagBindingVo existing = bindingMapper.findByDeviceId(vo.getDeviceId()); + if (existing != null) { + bindingMapper.unbind(existing.getId()); + } + bindingMapper.insert(vo); + return bindingMapper.findById(vo.getId()); + } + + @Transactional + public void unbind(Long id) { + bindingMapper.unbind(id); + } + + /** + * 가격 변경 시 해당 상품에 바인딩된 모든 태그에 업데이트 큐 생성 + * POS 가격변경 → 이 메서드 → e-paper 태그들에게 전파 + */ + @Transactional + public int enqueueByProductCode(String tenantCode, Long storeId, + String productCode, java.math.BigDecimal newPrice, + java.math.BigDecimal newSalePrice, Long posCvtId) { + List bindings = bindingMapper.findByProductCode(tenantCode, storeId, productCode); + for (TagBindingVo b : bindings) { + UpdateQueueVo q = new UpdateQueueVo(); + q.setTenantCode(tenantCode); + q.setStoreId(storeId); + q.setBindingId(b.getId()); + q.setDeviceId(b.getDeviceId()); + q.setProductCode(productCode); + q.setNewPrice(newPrice); + q.setNewSalePrice(newSalePrice); + q.setTemplateCode(b.getTemplateCode()); + q.setPriority(5); + q.setPosCvtId(posCvtId); + queueMapper.insert(q); + } + return bindings.size(); + } +} diff --git a/backend/src/main/java/com/zioinfo/esn/service/UpdateQueueService.java b/backend/src/main/java/com/zioinfo/esn/service/UpdateQueueService.java new file mode 100644 index 0000000..48f72fe --- /dev/null +++ b/backend/src/main/java/com/zioinfo/esn/service/UpdateQueueService.java @@ -0,0 +1,60 @@ +package com.zioinfo.esn.service; + +import com.zioinfo.esn.domain.UpdateQueueVo; +import com.zioinfo.esn.mapper.UpdateQueueMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class UpdateQueueService { + private final UpdateQueueMapper mapper; + + public List list(String tenantCode, Long storeId, String status) { + return mapper.findAll(tenantCode, storeId, status); + } + + public UpdateQueueVo get(Long id) { return mapper.findById(id); } + + /** Gateway 폴링: PENDING 큐 N건 반환 후 SENDING으로 전환 */ + @Transactional + public List pollPending(String tenantCode, int limit) { + List items = mapper.findPending(tenantCode, limit); + for (UpdateQueueVo q : items) { + mapper.updateStatus(q.getId(), "SENDING", null); + } + return items; + } + + /** Gateway → 태그 전송 완료 */ + @Transactional + public void markSent(Long id) { + mapper.updateStatus(id, "SENT", null); + } + + /** 태그 확인 응답 수신 (device_id 기반) */ + @Transactional + public void confirm(String deviceId) { + mapper.confirmByDeviceId(deviceId); + } + + /** 전송 실패 처리 — max_retry 초과 시 FAILED */ + @Transactional + public void markFailed(Long id, String errorMessage) { + UpdateQueueVo q = mapper.findById(id); + if (q == null) return; + mapper.incrementRetry(id); + if (q.getRetryCount() + 1 >= q.getMaxRetry()) { + mapper.updateStatus(id, "FAILED", errorMessage); + } else { + mapper.updateStatus(id, "PENDING", errorMessage); + } + } + + @Transactional + public void cancel(Long id) { + mapper.updateStatus(id, "CANCELLED", null); + } +} diff --git a/backend/src/main/resources/db/schema.sql b/backend/src/main/resources/db/schema.sql index f6b0171..fad17b9 100644 --- a/backend/src/main/resources/db/schema.sql +++ b/backend/src/main/resources/db/schema.sql @@ -261,6 +261,60 @@ SELECT 'EMART', s.id, 'P-001', '신라면', '라면/면류', 850, 800 FROM esn_store s WHERE s.store_code = 'EM-001' LIMIT 1 ON CONFLICT DO NOTHING; +-- ── 태그 위치/상품 바인딩 ────────────────────────────────────────────────────── +-- e-paper 태그와 선반 위치 + 상품을 연결하는 핵심 테이블 +CREATE TABLE IF NOT EXISTS esn_tag_binding ( + id BIGSERIAL PRIMARY KEY, + tenant_code VARCHAR(20) NOT NULL REFERENCES esn_tenant(tenant_code), + store_id BIGINT NOT NULL REFERENCES esn_store(id), + device_id VARCHAR(100) NOT NULL, -- ESL_DEVICE device_id + product_code VARCHAR(100), -- 현재 표시 중인 상품 + location_code VARCHAR(100), -- 선반 위치코드 (예: A01-03, 통로A-1단) + template_code VARCHAR(50), -- 적용 템플릿 + display_data TEXT, -- 현재 화면에 표시된 데이터 (JSON) + last_price NUMERIC(12,2), -- 마지막으로 전송된 가격 + last_updated_at TIMESTAMP, -- 마지막 화면 갱신 시각 + bind_status VARCHAR(20) DEFAULT 'ACTIVE', -- ACTIVE, UNBOUND, ERROR + created_at TIMESTAMP DEFAULT NOW(), + UNIQUE (device_id) +); +CREATE INDEX IF NOT EXISTS idx_binding_store ON esn_tag_binding(store_id, bind_status); +CREATE INDEX IF NOT EXISTS idx_binding_product ON esn_tag_binding(tenant_code, product_code); +CREATE INDEX IF NOT EXISTS idx_binding_loc ON esn_tag_binding(store_id, location_code); + +-- 샘플 바인딩 (이마트 강남점 ESL-EM-001-0001 ↔ 신라면) +INSERT INTO esn_tag_binding (tenant_code, store_id, device_id, product_code, location_code, template_code, last_price) +SELECT 'EMART', s.id, 'ESL-EM-001-0001', 'P-001', 'A01-03', 'EM-PRICE-STD', 850 +FROM esn_store s WHERE s.store_code = 'EM-001' LIMIT 1 +ON CONFLICT (device_id) DO NOTHING; + +-- ── 가격 업데이트 전파 큐 ──────────────────────────────────────────────────────── +-- POS 가격 변경이 e-paper 태그에 도달하는 전 과정을 추적 +CREATE TABLE IF NOT EXISTS esn_update_queue ( + id BIGSERIAL PRIMARY KEY, + tenant_code VARCHAR(20) NOT NULL REFERENCES esn_tenant(tenant_code), + store_id BIGINT REFERENCES esn_store(id), + binding_id BIGINT REFERENCES esn_tag_binding(id), + device_id VARCHAR(100) NOT NULL, + product_code VARCHAR(100), + new_price NUMERIC(12,2), + new_sale_price NUMERIC(12,2), + template_code VARCHAR(50), + render_data TEXT, -- 렌더링된 화면 데이터 (JSON) + priority INTEGER DEFAULT 5, -- 1(긴급) ~ 10(일반) + status VARCHAR(20) DEFAULT 'PENDING', -- PENDING, SENDING, SENT, CONFIRMED, FAILED, CANCELLED + retry_count INTEGER DEFAULT 0, + max_retry INTEGER DEFAULT 3, + error_message TEXT, + sent_at TIMESTAMP, + confirmed_at TIMESTAMP, + pos_cvt_id BIGINT REFERENCES esn_pos_cvt(id), + created_at TIMESTAMP DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_queue_device ON esn_update_queue(device_id, status); +CREATE INDEX IF NOT EXISTS idx_queue_store ON esn_update_queue(store_id, status, created_at); +CREATE INDEX IF NOT EXISTS idx_queue_pend ON esn_update_queue(status, priority, created_at) WHERE status = 'PENDING'; + -- ── 감사 로그 ───────────────────────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS esn_audit_log ( id BIGSERIAL PRIMARY KEY, diff --git a/backend/src/main/resources/mapper/TagBindingMapper.xml b/backend/src/main/resources/mapper/TagBindingMapper.xml new file mode 100644 index 0000000..fb48a89 --- /dev/null +++ b/backend/src/main/resources/mapper/TagBindingMapper.xml @@ -0,0 +1,139 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO esn_tag_binding + (tenant_code, store_id, device_id, product_code, location_code, + template_code, last_price, bind_status) + VALUES + (#{tenantCode}, #{storeId}, #{deviceId}, #{productCode}, #{locationCode}, + #{templateCode}, #{lastPrice}, 'ACTIVE') + + + + UPDATE esn_tag_binding SET + product_code = #{productCode}, + location_code = #{locationCode}, + template_code = #{templateCode}, + display_data = #{displayData}, + last_price = #{lastPrice}, + last_updated_at = NOW(), + bind_status = #{bindStatus} + WHERE id = #{id} + + + + UPDATE esn_tag_binding SET + bind_status = 'UNBOUND', + product_code = NULL, + last_updated_at = NOW() + WHERE id = #{id} + + + DELETE FROM esn_tag_binding WHERE id = #{id} + + + + + + diff --git a/backend/src/main/resources/mapper/UpdateQueueMapper.xml b/backend/src/main/resources/mapper/UpdateQueueMapper.xml new file mode 100644 index 0000000..94c7432 --- /dev/null +++ b/backend/src/main/resources/mapper/UpdateQueueMapper.xml @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO esn_update_queue + (tenant_code, store_id, binding_id, device_id, product_code, + new_price, new_sale_price, template_code, priority, status, pos_cvt_id) + VALUES + (#{tenantCode}, #{storeId}, #{bindingId}, #{deviceId}, #{productCode}, + #{newPrice}, #{newSalePrice}, #{templateCode}, #{priority}, 'PENDING', #{posCvtId}) + + + + UPDATE esn_update_queue SET + status = #{status}, + error_message = #{errorMessage}, + sent_at = CASE WHEN #{status} = 'SENT' THEN NOW() ELSE sent_at END, + confirmed_at = CASE WHEN #{status} = 'CONFIRMED' THEN NOW() ELSE confirmed_at END + WHERE id = #{id} + + + + + UPDATE esn_update_queue SET + status = 'CONFIRMED', + confirmed_at = NOW() + WHERE device_id = #{deviceId} + AND status IN ('SENDING', 'SENT') + + + + UPDATE esn_update_queue SET retry_count = retry_count + 1 + WHERE id = #{id} + + + + + + + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bed5840..92f2120 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,8 @@ import UserList from './pages/UserList' import ProductList from './pages/ProductList' import TenantAdmin from './pages/TenantAdmin' import AiAnalysis from './pages/AiAnalysis' +import TagBindingList from './pages/TagBindingList' +import UpdateQueueList from './pages/UpdateQueueList' const qc = new QueryClient() @@ -24,18 +26,20 @@ export default function App() { } /> }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 7758bb5..f704c69 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,22 +1,25 @@ import { NavLink } from 'react-router-dom' import { LayoutDashboard, Store, FileText, RefreshCw, - Bell, Cpu, ClipboardList, Zap, Users, Package, Building2, Brain + Bell, Cpu, ClipboardList, Zap, Users, Package, Building2, Brain, + Link2, ListOrdered } from 'lucide-react' const nav = [ - { to: '/dashboard', icon: LayoutDashboard, label: '대시보드' }, - { to: '/stores', icon: Store, label: '매장 관리' }, - { to: '/templates', icon: FileText, label: 'ESL 템플릿' }, - { to: '/pos-cvt', icon: RefreshCw, label: 'POS 변환' }, - { to: '/alarms', icon: Bell, label: '알람 관리' }, - { to: '/hcore', icon: Cpu, label: 'HCore 장치' }, - { to: '/works', icon: ClipboardList, label: '작업 이력' }, - { to: '/firmware', icon: Zap, label: '펌웨어' }, - { to: '/products', icon: Package, label: '상품/가격' }, - { to: '/users', icon: Users, label: '사용자' }, - { to: '/tenants', icon: Building2, label: '테넌트 관리' }, - { to: '/ai', icon: Brain, label: 'AI 분석' }, + { to: '/dashboard', icon: LayoutDashboard, label: '대시보드' }, + { to: '/stores', icon: Store, label: '매장 관리' }, + { to: '/templates', icon: FileText, label: 'ESL 템플릿' }, + { to: '/pos-cvt', icon: RefreshCw, label: 'POS 가격변환' }, + { to: '/tag-binding', icon: Link2, label: '태그 바인딩' }, + { to: '/update-queue',icon: ListOrdered, label: '업데이트 큐' }, + { to: '/alarms', icon: Bell, label: '알람 관리' }, + { to: '/hcore', icon: Cpu, label: 'HCore 장치' }, + { to: '/works', icon: ClipboardList, label: '작업 이력' }, + { to: '/firmware', icon: Zap, label: '펌웨어' }, + { to: '/products', icon: Package, label: '상품/가격' }, + { to: '/users', icon: Users, label: '사용자' }, + { to: '/tenants', icon: Building2, label: '테넌트 관리' }, + { to: '/ai', icon: Brain, label: 'AI 분석' }, ] export default function Sidebar() { diff --git a/frontend/src/pages/TagBindingList.tsx b/frontend/src/pages/TagBindingList.tsx new file mode 100644 index 0000000..ccdb3bf --- /dev/null +++ b/frontend/src/pages/TagBindingList.tsx @@ -0,0 +1,193 @@ +import React, { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { Link2, Link2Off, MapPin, Battery, Wifi, WifiOff, RefreshCw } from 'lucide-react' +import api from '../api/client' + +interface TagBinding { + id: number; tenantCode: string; storeId: number; storeName: string + deviceId: string; productCode: string; productName: string + locationCode: string; templateCode: string; lastPrice: number + lastUpdatedAt: string; bindStatus: string + deviceStatus: string; batteryLevel: number; signalStrength: string + createdAt: string +} + +interface BindForm { + tenantCode: string; storeId: string; deviceId: string + productCode: string; locationCode: string; templateCode: string +} + +const bindStatusColor: Record = { + ACTIVE: 'text-green-400 bg-green-500/10 border-green-500/30', + UNBOUND: 'text-gray-400 bg-gray-500/10 border-gray-500/30', + ERROR: 'text-red-400 bg-red-500/10 border-red-500/30', +} + +const deviceStatusIcon = (s: string) => + s === 'ONLINE' ? : + +export default function TagBindingList() { + const qc = useQueryClient() + const [tenantCode, setTenantCode] = useState('') + const [storeId, setStoreId] = useState('') + const [keyword, setKeyword] = useState('') + const [showForm, setShowForm] = useState(false) + const [form, setForm] = useState({ + tenantCode:'', storeId:'', deviceId:'', productCode:'', locationCode:'', templateCode:'EM-PRICE-STD' + }) + + const { data: bindings = [], isLoading } = useQuery({ + queryKey: ['tag-binding', tenantCode, storeId, keyword], + queryFn: () => api.get('/tag-binding', { params: { + tenantCode: tenantCode || undefined, + storeId: storeId || undefined, + keyword: keyword || undefined, + bindStatus: 'ACTIVE', + }}).then(r => r.data.data), + refetchInterval: 20000, + }) + + const bindMut = useMutation({ + mutationFn: (data: BindForm) => api.post('/tag-binding', { + ...data, + storeId: Number(data.storeId), + }), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['tag-binding'] }); setShowForm(false) }, + }) + + const unbindMut = useMutation({ + mutationFn: (id: number) => api.put(`/tag-binding/${id}/unbind`), + onSuccess: () => qc.invalidateQueries({ queryKey: ['tag-binding'] }), + }) + + if (isLoading) return
로딩 중...
+ + return ( +
+
+

태그 바인딩 관리

+ +
+ + {/* 바인딩 추가 폼 */} + {showForm && ( +
+

e-paper 태그 → 상품 바인딩

+
+ {[ + { key:'tenantCode', label:'테넌트', ph:'EMART' }, + { key:'storeId', label:'매장ID', ph:'1' }, + { key:'deviceId', label:'태그 Device ID', ph:'ESL-EM-001-0001' }, + { key:'productCode', label:'상품코드', ph:'P-001' }, + { key:'locationCode', label:'선반 위치코드', ph:'A01-03' }, + { key:'templateCode', label:'템플릿', ph:'EM-PRICE-STD' }, + ].map(({ key, label, ph }) => ( +
+ + setForm(f => ({ ...f, [key]: e.target.value }))} + placeholder={ph} + className="w-full bg-edge border border-gray-600 rounded px-2 py-1.5 text-sm text-white" + /> +
+ ))} +
+
+ + +
+
+ )} + + {/* 필터 */} +
+ {[ + { val: tenantCode, set: setTenantCode, ph: '테넌트 필터' }, + { val: keyword, set: setKeyword, ph: '태그ID / 상품코드 / 위치 검색' }, + ].map(({ val, set, ph }, i) => ( + set(e.target.value)} placeholder={ph} + className="bg-panel border border-edge rounded px-3 py-1.5 text-sm text-gray-300 w-64" + /> + ))} +
+ + {/* 태그 목록 */} +
+ {bindings.map(b => ( +
+
+ {/* 태그 상태 */} +
+ {deviceStatusIcon(b.deviceStatus)} + {b.batteryLevel !== null && ( +
+ + {b.batteryLevel}% +
+ )} +
+ + {/* 태그 정보 */} +
+
{b.deviceId}
+
+ + {b.locationCode || '위치 미지정'} + | + {b.storeName} +
+
+ + {/* 상품 정보 */} +
+
{b.productName || b.productCode}
+
+ 현재가: + {b.lastPrice ? b.lastPrice.toLocaleString() + '원' : '미전송'} + + {b.lastUpdatedAt && ( + + 갱신: {new Date(b.lastUpdatedAt).toLocaleString()} + + )} +
+
템플릿: {b.templateCode}
+
+
+ + {/* 상태 + 액션 */} +
+ + {b.bindStatus} + + +
+
+ ))} + {bindings.length === 0 && ( +
+ 바인딩된 태그가 없습니다. 우측 상단 "바인딩 추가"를 클릭하세요. +
+ )} +
+
+ ) +} diff --git a/frontend/src/pages/UpdateQueueList.tsx b/frontend/src/pages/UpdateQueueList.tsx new file mode 100644 index 0000000..3e20d6d --- /dev/null +++ b/frontend/src/pages/UpdateQueueList.tsx @@ -0,0 +1,154 @@ +import React, { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { CheckCircle, XCircle, Clock, Send, AlertCircle, RefreshCw } from 'lucide-react' +import api from '../api/client' + +interface QueueItem { + id: number; tenantCode: string; storeId: number; storeName: string + deviceId: string; locationCode: string; productCode: string; productName: string + newPrice: number; newSalePrice: number; templateCode: string + priority: number; status: string; retryCount: number; maxRetry: number + errorMessage: string; sentAt: string; confirmedAt: string; createdAt: string +} + +const statusMeta: Record = { + PENDING: { icon: , color: 'text-yellow-400 bg-yellow-500/10 border-yellow-500/30', label: '대기' }, + SENDING: { icon: , color: 'text-blue-400 bg-blue-500/10 border-blue-500/30', label: '전송중' }, + SENT: { icon: , color: 'text-cyan-400 bg-cyan-500/10 border-cyan-500/30', label: '전송됨' }, + CONFIRMED: { icon: , color: 'text-green-400 bg-green-500/10 border-green-500/30', label: '확인됨' }, + FAILED: { icon: , color: 'text-red-400 bg-red-500/10 border-red-500/30', label: '실패' }, + CANCELLED: { icon: , color: 'text-gray-400 bg-gray-500/10 border-gray-500/30', label: '취소됨' }, +} + +export default function UpdateQueueList() { + const qc = useQueryClient() + const [tenantCode, setTenantCode] = useState('') + const [status, setStatus] = useState('') + + const { data: items = [], isLoading, refetch } = useQuery({ + queryKey: ['update-queue', tenantCode, status], + queryFn: () => api.get('/update-queue', { params: { + tenantCode: tenantCode || undefined, + status: status || undefined, + }}).then(r => r.data.data), + refetchInterval: 5000, + }) + + const cancelMut = useMutation({ + mutationFn: (id: number) => api.put(`/update-queue/${id}/cancel`), + onSuccess: () => qc.invalidateQueries({ queryKey: ['update-queue'] }), + }) + + const pendingCount = items.filter(i => i.status === 'PENDING').length + const failedCount = items.filter(i => i.status === 'FAILED').length + const confirmedCount = items.filter(i => i.status === 'CONFIRMED').length + + if (isLoading) return
로딩 중...
+ + return ( +
+
+

e-paper 가격 업데이트 큐

+ +
+ + {/* 통계 카드 */} +
+ {[ + { label: '대기중', count: pendingCount, color: 'text-yellow-400', bg: 'bg-yellow-500/5 border-yellow-500/20' }, + { label: '실패', count: failedCount, color: 'text-red-400', bg: 'bg-red-500/5 border-red-500/20' }, + { label: '완료', count: confirmedCount, color: 'text-green-400', bg: 'bg-green-500/5 border-green-500/20' }, + ].map(({ label, count, color, bg }) => ( +
+
{count}
+
{label}
+
+ ))} +
+ + {/* 필터 */} +
+ setTenantCode(e.target.value)} + placeholder="테넌트 필터" + className="bg-panel border border-edge rounded px-3 py-1.5 text-sm text-gray-300 w-40" + /> + +
+ + {/* 큐 목록 */} +
+ + + + + + + + + + + + + + + + + {items.map(item => { + const meta = statusMeta[item.status] || statusMeta['PENDING'] + return ( + + + + + + + + + + + + + ) + })} + +
#태그 ID위치상품가격우선순위상태재시도등록 시각액션
{item.id}{item.deviceId}{item.locationCode || '-'}{item.productName || item.productCode} + {item.newPrice?.toLocaleString()}원 + {item.newSalePrice && item.newSalePrice !== item.newPrice && ( + → {item.newSalePrice.toLocaleString()}원 + )} + + + P{item.priority} + + + + {meta.icon} {meta.label} + + + {item.retryCount}/{item.maxRetry} + + {new Date(item.createdAt).toLocaleString()} + + {['PENDING', 'FAILED'].includes(item.status) && ( + + )} +
+ {items.length === 0 && ( +
업데이트 큐가 비어있습니다.
+ )} +
+
+ ) +}