feat(esn): e-paper ESL 핵심 플로우 추가 — 태그 바인딩 + 가격 전파 큐

- 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 <noreply@anthropic.com>
This commit is contained in:
DESKTOP-TKLFCPR\ython 2026-06-16 01:57:28 +09:00
parent 5774735d92
commit 67e1c5eeef
18 changed files with 1123 additions and 29 deletions

27
.gitignore vendored Normal file
View File

@ -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

View File

@ -29,9 +29,9 @@ public class PosCvtController {
}
@PutMapping("/{id}/process")
public ApiResponse<Void> process(@PathVariable Long id) {
service.process(id);
return ApiResponse.ok("처리 완료", null);
public ApiResponse<Integer> process(@PathVariable Long id) {
int queued = service.process(id);
return ApiResponse.ok("처리 완료 (태그 큐 " + queued + "건)", queued);
}
@PutMapping("/{id}/ignore")

View File

@ -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<TagBindingVo>> 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<TagBindingVo> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
@GetMapping("/device/{deviceId}")
public ApiResponse<TagBindingVo> getByDevice(@PathVariable String deviceId) {
return ApiResponse.ok(service.getByDeviceId(deviceId));
}
/** 태그에 상품+위치 바인딩 */
@PostMapping
public ApiResponse<TagBindingVo> bind(@RequestBody TagBindingVo vo) {
return ApiResponse.ok(service.bind(vo));
}
@PutMapping("/{id}")
public ApiResponse<TagBindingVo> update(@PathVariable Long id, @RequestBody TagBindingVo vo) {
vo.setId(id);
return ApiResponse.ok(service.bind(vo));
}
/** 태그 언바인딩 (선반에서 태그 제거 시) */
@PutMapping("/{id}/unbind")
public ApiResponse<Void> unbind(@PathVariable Long id) {
service.unbind(id);
return ApiResponse.ok("언바인딩 완료", null);
}
}

View File

@ -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<UpdateQueueVo>> 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<UpdateQueueVo> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
/** Gateway 폴링 엔드포인트: PENDING → SENDING 전환 후 반환 */
@GetMapping("/poll")
public ApiResponse<List<UpdateQueueVo>> 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<Void> sent(@PathVariable Long id) {
service.markSent(id);
return ApiResponse.ok("전송 완료", null);
}
/** 태그로부터 확인 응답 수신 (device_id 기반 일괄 확인) */
@PutMapping("/confirm")
public ApiResponse<Void> confirm(@RequestParam String deviceId) {
service.confirm(deviceId);
return ApiResponse.ok("확인 완료", null);
}
/** 전송 실패 처리 */
@PutMapping("/{id}/failed")
public ApiResponse<Void> failed(@PathVariable Long id,
@RequestBody Map<String, String> body) {
service.markFailed(id, body.getOrDefault("message", "전송 실패"));
return ApiResponse.ok("실패 처리 완료", null);
}
/** 큐 항목 취소 */
@PutMapping("/{id}/cancel")
public ApiResponse<Void> cancel(@PathVariable Long id) {
service.cancel(id);
return ApiResponse.ok("취소 완료", null);
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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<TagBindingVo> 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<TagBindingVo> 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);
}

View File

@ -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<UpdateQueueVo> findAll(@Param("tenantCode") String tenantCode,
@Param("storeId") Long storeId,
@Param("status") String status);
List<UpdateQueueVo> 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);
}

View File

@ -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<PosCvtVo> 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) {

View File

@ -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<TagBindingVo> 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<TagBindingVo> 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();
}
}

View File

@ -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<UpdateQueueVo> 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<UpdateQueueVo> pollPending(String tenantCode, int limit) {
List<UpdateQueueVo> 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);
}
}

View File

@ -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,

View File

@ -0,0 +1,139 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zioinfo.esn.mapper.TagBindingMapper">
<resultMap id="bindingMap" type="com.zioinfo.esn.domain.TagBindingVo">
<id property="id" column="id"/>
<result property="tenantCode" column="tenant_code"/>
<result property="storeId" column="store_id"/>
<result property="storeName" column="store_name"/>
<result property="deviceId" column="device_id"/>
<result property="productCode" column="product_code"/>
<result property="productName" column="product_name"/>
<result property="locationCode" column="location_code"/>
<result property="templateCode" column="template_code"/>
<result property="displayData" column="display_data"/>
<result property="lastPrice" column="last_price"/>
<result property="lastUpdatedAt" column="last_updated_at"/>
<result property="bindStatus" column="bind_status"/>
<result property="deviceStatus" column="device_status"/>
<result property="batteryLevel" column="battery_level"/>
<result property="signalStrength" column="signal_strength"/>
<result property="createdAt" column="created_at"/>
</resultMap>
<select id="findAll" resultMap="bindingMap">
SELECT b.id, b.tenant_code, b.store_id, s.store_name,
b.device_id, b.product_code, p.product_name,
b.location_code, b.template_code, b.display_data,
b.last_price, b.last_updated_at, b.bind_status,
h.status AS device_status, h.battery_level, h.signal_strength,
b.created_at
FROM esn_tag_binding b
LEFT JOIN esn_store s ON b.store_id = s.id
LEFT JOIN esn_product p ON b.store_id = p.store_id AND b.product_code = p.product_code
LEFT JOIN esn_hcore_device h ON b.device_id = h.device_id
<where>
<if test="tenantCode != null and tenantCode != ''">AND b.tenant_code = #{tenantCode}</if>
<if test="storeId != null">AND b.store_id = #{storeId}</if>
<if test="bindStatus != null and bindStatus != ''">AND b.bind_status = #{bindStatus}</if>
<if test="keyword != null and keyword != ''">
AND (b.device_id ILIKE '%'||#{keyword}||'%'
OR b.product_code ILIKE '%'||#{keyword}||'%'
OR b.location_code ILIKE '%'||#{keyword}||'%'
OR p.product_name ILIKE '%'||#{keyword}||'%')
</if>
</where>
ORDER BY b.store_id, b.location_code
</select>
<select id="findById" resultMap="bindingMap">
SELECT b.id, b.tenant_code, b.store_id, s.store_name,
b.device_id, b.product_code, p.product_name,
b.location_code, b.template_code, b.display_data,
b.last_price, b.last_updated_at, b.bind_status,
h.status AS device_status, h.battery_level, h.signal_strength,
b.created_at
FROM esn_tag_binding b
LEFT JOIN esn_store s ON b.store_id = s.id
LEFT JOIN esn_product p ON b.store_id = p.store_id AND b.product_code = p.product_code
LEFT JOIN esn_hcore_device h ON b.device_id = h.device_id
WHERE b.id = #{id}
</select>
<select id="findByDeviceId" resultMap="bindingMap">
SELECT b.id, b.tenant_code, b.store_id, s.store_name,
b.device_id, b.product_code, p.product_name,
b.location_code, b.template_code, b.display_data,
b.last_price, b.last_updated_at, b.bind_status,
h.status AS device_status, h.battery_level, h.signal_strength,
b.created_at
FROM esn_tag_binding b
LEFT JOIN esn_store s ON b.store_id = s.id
LEFT JOIN esn_product p ON b.store_id = p.store_id AND b.product_code = p.product_code
LEFT JOIN esn_hcore_device h ON b.device_id = h.device_id
WHERE b.device_id = #{deviceId}
</select>
<select id="findByProductCode" resultMap="bindingMap">
SELECT b.id, b.tenant_code, b.store_id, s.store_name,
b.device_id, b.product_code, p.product_name,
b.location_code, b.template_code, b.display_data,
b.last_price, b.last_updated_at, b.bind_status,
h.status AS device_status, h.battery_level, h.signal_strength,
b.created_at
FROM esn_tag_binding b
LEFT JOIN esn_store s ON b.store_id = s.id
LEFT JOIN esn_product p ON b.store_id = p.store_id AND b.product_code = p.product_code
LEFT JOIN esn_hcore_device h ON b.device_id = h.device_id
WHERE b.tenant_code = #{tenantCode}
AND b.store_id = #{storeId}
AND b.product_code = #{productCode}
AND b.bind_status = 'ACTIVE'
</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
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')
</insert>
<update id="update">
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>
<update id="unbind">
UPDATE esn_tag_binding SET
bind_status = 'UNBOUND',
product_code = NULL,
last_updated_at = NOW()
WHERE id = #{id}
</update>
<delete id="delete">DELETE FROM esn_tag_binding WHERE id = #{id}</delete>
<select id="countAll" resultType="long">
SELECT COUNT(*) FROM esn_tag_binding
<where>
<if test="tenantCode != null and tenantCode != ''">AND tenant_code = #{tenantCode}</if>
</where>
</select>
<select id="countActive" resultType="long">
SELECT COUNT(*) FROM esn_tag_binding WHERE bind_status = 'ACTIVE'
<if test="tenantCode != null and tenantCode != ''">AND tenant_code = #{tenantCode}</if>
</select>
</mapper>

View File

@ -0,0 +1,131 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zioinfo.esn.mapper.UpdateQueueMapper">
<resultMap id="queueMap" type="com.zioinfo.esn.domain.UpdateQueueVo">
<id property="id" column="id"/>
<result property="tenantCode" column="tenant_code"/>
<result property="storeId" column="store_id"/>
<result property="storeName" column="store_name"/>
<result property="bindingId" column="binding_id"/>
<result property="deviceId" column="device_id"/>
<result property="locationCode" column="location_code"/>
<result property="productCode" column="product_code"/>
<result property="productName" column="product_name"/>
<result property="newPrice" column="new_price"/>
<result property="newSalePrice" column="new_sale_price"/>
<result property="templateCode" column="template_code"/>
<result property="renderData" column="render_data"/>
<result property="priority" column="priority"/>
<result property="status" column="status"/>
<result property="retryCount" column="retry_count"/>
<result property="maxRetry" column="max_retry"/>
<result property="errorMessage" column="error_message"/>
<result property="posCvtId" column="pos_cvt_id"/>
<result property="sentAt" column="sent_at"/>
<result property="confirmedAt" column="confirmed_at"/>
<result property="createdAt" column="created_at"/>
</resultMap>
<select id="findAll" resultMap="queueMap">
SELECT q.id, q.tenant_code, q.store_id, s.store_name,
q.binding_id, q.device_id, b.location_code,
q.product_code, p.product_name,
q.new_price, q.new_sale_price, q.template_code, q.render_data,
q.priority, q.status, q.retry_count, q.max_retry, q.error_message,
q.pos_cvt_id, q.sent_at, q.confirmed_at, q.created_at
FROM esn_update_queue q
LEFT JOIN esn_store s ON q.store_id = s.id
LEFT JOIN esn_tag_binding b ON q.binding_id = b.id
LEFT JOIN esn_product p ON q.store_id = p.store_id AND q.product_code = p.product_code
<where>
<if test="tenantCode != null and tenantCode != ''">AND q.tenant_code = #{tenantCode}</if>
<if test="storeId != null">AND q.store_id = #{storeId}</if>
<if test="status != null and status != ''">AND q.status = #{status}</if>
</where>
ORDER BY q.priority ASC, q.created_at ASC
LIMIT 200
</select>
<!-- Gateway가 전송할 PENDING 목록 (우선순위·시간 순) -->
<select id="findPending" resultMap="queueMap">
SELECT q.id, q.tenant_code, q.store_id, s.store_name,
q.binding_id, q.device_id, b.location_code,
q.product_code, p.product_name,
q.new_price, q.new_sale_price, q.template_code, q.render_data,
q.priority, q.status, q.retry_count, q.max_retry, q.error_message,
q.pos_cvt_id, q.sent_at, q.confirmed_at, q.created_at
FROM esn_update_queue q
LEFT JOIN esn_store s ON q.store_id = s.id
LEFT JOIN esn_tag_binding b ON q.binding_id = b.id
LEFT JOIN esn_product p ON q.store_id = p.store_id AND q.product_code = p.product_code
WHERE q.status = 'PENDING'
AND q.retry_count &lt; q.max_retry
<if test="tenantCode != null and tenantCode != ''">AND q.tenant_code = #{tenantCode}</if>
ORDER BY q.priority ASC, q.created_at ASC
LIMIT #{limit}
</select>
<select id="findById" resultMap="queueMap">
SELECT q.id, q.tenant_code, q.store_id, s.store_name,
q.binding_id, q.device_id, b.location_code,
q.product_code, p.product_name,
q.new_price, q.new_sale_price, q.template_code, q.render_data,
q.priority, q.status, q.retry_count, q.max_retry, q.error_message,
q.pos_cvt_id, q.sent_at, q.confirmed_at, q.created_at
FROM esn_update_queue q
LEFT JOIN esn_store s ON q.store_id = s.id
LEFT JOIN esn_tag_binding b ON q.binding_id = b.id
LEFT JOIN esn_product p ON q.store_id = p.store_id AND q.product_code = p.product_code
WHERE q.id = #{id}
</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
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})
</insert>
<update id="updateStatus">
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>
<!-- 태그가 확인 응답 시 — device_id로 SENT → CONFIRMED -->
<update id="confirmByDeviceId">
UPDATE esn_update_queue SET
status = 'CONFIRMED',
confirmed_at = NOW()
WHERE device_id = #{deviceId}
AND status IN ('SENDING', 'SENT')
</update>
<update id="incrementRetry">
UPDATE esn_update_queue SET retry_count = retry_count + 1
WHERE id = #{id}
</update>
<select id="countPending" resultType="long">
SELECT COUNT(*) FROM esn_update_queue WHERE status = 'PENDING'
<if test="tenantCode != null and tenantCode != ''">AND tenant_code = #{tenantCode}</if>
</select>
<select id="countFailed" resultType="long">
SELECT COUNT(*) FROM esn_update_queue WHERE status = 'FAILED'
<if test="tenantCode != null and tenantCode != ''">AND tenant_code = #{tenantCode}</if>
</select>
<select id="countByStatus" resultType="long">
SELECT COUNT(*) FROM esn_update_queue WHERE status = #{status}
<if test="tenantCode != null and tenantCode != ''">AND tenant_code = #{tenantCode}</if>
</select>
</mapper>

View File

@ -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()
@ -28,6 +30,8 @@ export default function App() {
<Route path="/stores" element={<StoreList />} />
<Route path="/templates" element={<TemplateList />} />
<Route path="/pos-cvt" element={<PosCvtList />} />
<Route path="/tag-binding" element={<TagBindingList />} />
<Route path="/update-queue" element={<UpdateQueueList />} />
<Route path="/alarms" element={<AlarmList />} />
<Route path="/hcore" element={<HCoreStatus />} />
<Route path="/works" element={<WorkHistory />} />

View File

@ -1,14 +1,17 @@
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: '/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: '작업 이력' },

View File

@ -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<string, string> = {
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' ? <Wifi size={13} className="text-green-400" /> : <WifiOff size={13} className="text-red-400" />
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<BindForm>({
tenantCode:'', storeId:'', deviceId:'', productCode:'', locationCode:'', templateCode:'EM-PRICE-STD'
})
const { data: bindings = [], isLoading } = useQuery<TagBinding[]>({
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 <div className="text-gray-400 p-8"> ...</div>
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-white"> </h1>
<button
onClick={() => setShowForm(v => !v)}
className="flex items-center gap-2 px-3 py-1.5 bg-brand text-white rounded text-sm"
>
<Link2 size={14} />
</button>
</div>
{/* 바인딩 추가 폼 */}
{showForm && (
<div className="bg-panel border border-edge rounded-lg p-4 space-y-3">
<h2 className="text-sm font-medium text-white">e-paper </h2>
<div className="grid grid-cols-3 gap-3">
{[
{ 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 }) => (
<div key={key}>
<label className="text-xs text-gray-400 mb-1 block">{label}</label>
<input
value={form[key as keyof BindForm]}
onChange={e => 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"
/>
</div>
))}
</div>
<div className="flex gap-2">
<button
onClick={() => bindMut.mutate(form)}
className="px-3 py-1.5 bg-brand text-white rounded text-sm"
> </button>
<button onClick={() => setShowForm(false)} className="px-3 py-1.5 bg-edge text-gray-300 rounded text-sm"></button>
</div>
</div>
)}
{/* 필터 */}
<div className="flex gap-3">
{[
{ val: tenantCode, set: setTenantCode, ph: '테넌트 필터' },
{ val: keyword, set: setKeyword, ph: '태그ID / 상품코드 / 위치 검색' },
].map(({ val, set, ph }, i) => (
<input key={i} value={val} onChange={e => 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"
/>
))}
</div>
{/* 태그 목록 */}
<div className="grid grid-cols-1 gap-3">
{bindings.map(b => (
<div key={b.id}
className={`bg-panel border rounded-lg p-4 flex items-center justify-between
${b.deviceStatus === 'ONLINE' ? 'border-green-500/20' : 'border-red-500/20'}`}
>
<div className="flex items-center gap-4">
{/* 태그 상태 */}
<div className="flex flex-col items-center gap-1 w-16">
{deviceStatusIcon(b.deviceStatus)}
{b.batteryLevel !== null && (
<div className="flex items-center gap-1 text-xs text-gray-400">
<Battery size={11} />
<span className={b.batteryLevel < 20 ? 'text-red-400' : ''}>{b.batteryLevel}%</span>
</div>
)}
</div>
{/* 태그 정보 */}
<div>
<div className="text-xs font-mono text-brand">{b.deviceId}</div>
<div className="flex items-center gap-2 mt-1">
<MapPin size={11} className="text-gray-500" />
<span className="text-xs text-gray-400">{b.locationCode || '위치 미지정'}</span>
<span className="text-xs text-gray-600">|</span>
<span className="text-xs text-gray-300">{b.storeName}</span>
</div>
</div>
{/* 상품 정보 */}
<div className="ml-4 pl-4 border-l border-edge">
<div className="text-sm text-white">{b.productName || b.productCode}</div>
<div className="text-xs text-gray-400 mt-0.5">
: <span className="text-brand font-mono">
{b.lastPrice ? b.lastPrice.toLocaleString() + '원' : '미전송'}
</span>
{b.lastUpdatedAt && (
<span className="ml-2 text-gray-600">
: {new Date(b.lastUpdatedAt).toLocaleString()}
</span>
)}
</div>
<div className="text-xs text-gray-500 mt-0.5">릿: {b.templateCode}</div>
</div>
</div>
{/* 상태 + 액션 */}
<div className="flex items-center gap-3">
<span className={`text-xs px-2 py-0.5 rounded border ${bindStatusColor[b.bindStatus] || ''}`}>
{b.bindStatus}
</span>
<button
onClick={() => { if (confirm(`태그 ${b.deviceId} 언바인딩하겠습니까?`)) unbindMut.mutate(b.id) }}
className="p-1.5 rounded bg-edge text-gray-400 hover:text-red-400"
title="언바인딩"
>
<Link2Off size={14} />
</button>
</div>
</div>
))}
{bindings.length === 0 && (
<div className="text-center text-gray-500 py-12">
. "바인딩 추가" .
</div>
)}
</div>
</div>
)
}

View File

@ -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<string, { icon: React.ReactNode; color: string; label: string }> = {
PENDING: { icon: <Clock size={13} />, color: 'text-yellow-400 bg-yellow-500/10 border-yellow-500/30', label: '대기' },
SENDING: { icon: <Send size={13} />, color: 'text-blue-400 bg-blue-500/10 border-blue-500/30', label: '전송중' },
SENT: { icon: <Send size={13} />, color: 'text-cyan-400 bg-cyan-500/10 border-cyan-500/30', label: '전송됨' },
CONFIRMED: { icon: <CheckCircle size={13} />, color: 'text-green-400 bg-green-500/10 border-green-500/30', label: '확인됨' },
FAILED: { icon: <XCircle size={13} />, color: 'text-red-400 bg-red-500/10 border-red-500/30', label: '실패' },
CANCELLED: { icon: <AlertCircle size={13} />, 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<QueueItem[]>({
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 <div className="text-gray-400 p-8"> ...</div>
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-white">e-paper </h1>
<button onClick={() => refetch()} className="flex items-center gap-1.5 px-3 py-1.5 bg-edge text-gray-300 rounded text-sm">
<RefreshCw size={13} />
</button>
</div>
{/* 통계 카드 */}
<div className="grid grid-cols-3 gap-3">
{[
{ 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 }) => (
<div key={label} className={`border rounded-lg p-3 ${bg}`}>
<div className={`text-2xl font-bold font-mono ${color}`}>{count}</div>
<div className="text-xs text-gray-400 mt-0.5">{label}</div>
</div>
))}
</div>
{/* 필터 */}
<div className="flex gap-3">
<input value={tenantCode} onChange={e => setTenantCode(e.target.value)}
placeholder="테넌트 필터"
className="bg-panel border border-edge rounded px-3 py-1.5 text-sm text-gray-300 w-40"
/>
<select value={status} onChange={e => setStatus(e.target.value)}
className="bg-panel border border-edge rounded px-3 py-1.5 text-sm text-gray-300"
>
<option value=""> </option>
{Object.entries(statusMeta).map(([k, v]) => (
<option key={k} value={k}>{v.label}</option>
))}
</select>
</div>
{/* 큐 목록 */}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-edge text-xs text-gray-500">
<th className="text-left py-2 px-3">#</th>
<th className="text-left py-2 px-3"> ID</th>
<th className="text-left py-2 px-3"></th>
<th className="text-left py-2 px-3"></th>
<th className="text-right py-2 px-3"></th>
<th className="text-center py-2 px-3"></th>
<th className="text-center py-2 px-3"></th>
<th className="text-center py-2 px-3"></th>
<th className="text-left py-2 px-3"> </th>
<th className="text-center py-2 px-3"></th>
</tr>
</thead>
<tbody>
{items.map(item => {
const meta = statusMeta[item.status] || statusMeta['PENDING']
return (
<tr key={item.id} className="border-b border-edge/50 hover:bg-edge/30">
<td className="py-2 px-3 text-gray-500 font-mono text-xs">{item.id}</td>
<td className="py-2 px-3 text-brand font-mono text-xs">{item.deviceId}</td>
<td className="py-2 px-3 text-gray-300 text-xs">{item.locationCode || '-'}</td>
<td className="py-2 px-3 text-white text-xs">{item.productName || item.productCode}</td>
<td className="py-2 px-3 text-right font-mono text-xs">
{item.newPrice?.toLocaleString()}
{item.newSalePrice && item.newSalePrice !== item.newPrice && (
<span className="ml-1 text-red-400"> {item.newSalePrice.toLocaleString()}</span>
)}
</td>
<td className="py-2 px-3 text-center">
<span className={`text-xs font-mono ${item.priority <= 2 ? 'text-red-400' : 'text-gray-400'}`}>
P{item.priority}
</span>
</td>
<td className="py-2 px-3 text-center">
<span className={`inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded border ${meta.color}`}>
{meta.icon} {meta.label}
</span>
</td>
<td className="py-2 px-3 text-center text-xs text-gray-400">
{item.retryCount}/{item.maxRetry}
</td>
<td className="py-2 px-3 text-xs text-gray-500">
{new Date(item.createdAt).toLocaleString()}
</td>
<td className="py-2 px-3 text-center">
{['PENDING', 'FAILED'].includes(item.status) && (
<button
onClick={() => cancelMut.mutate(item.id)}
className="text-xs text-gray-500 hover:text-red-400 px-2 py-0.5 rounded bg-edge"
></button>
)}
</td>
</tr>
)
})}
</tbody>
</table>
{items.length === 0 && (
<div className="text-center text-gray-500 py-12"> .</div>
)}
</div>
</div>
)
}