This commit is contained in:
seo-amugae committed 2026-02-24 18:30:51 +09:00
1 parent e429161a89
commit 0c55b8f1aa
134 files changed
+6071 -5801

No files matched your search

+62
View File
@@ -0,0 +1,62 @@
from fastapi import APIRouter
from pydantic import BaseModel
from datetime import datetime
router = APIRouter(
prefix="/dashboard",
tags=["Dashboard"]
)
bot_state = {
"status": "OFFLINE",
"last_updated": datetime.now(),
"battery": 0.0,
"speed": 0.0
}
BOT_TIMEOUT_SEC = 5 # 5초간 보고 없으면 오프라인
class BotReport(BaseModel):
status: str
battery: float = 0.0
speed: float = 0.0
class BotConnectionInfo(BaseModel):
bot_status: str
battery: float
speed: float
last_update: str
# 로봇 -> 서버
@router.post("/bot/report")
def report_bot_status(report: BotReport):
global bot_state
bot_state["status"] = report.status
bot_state["last_updated"] = datetime.now()
bot_state["battery"] = report.battery
bot_state["speed"] = report.speed
return {"msg": "status report received"}
# 서버 -> 프론트엔드
@router.get("/bot/check", response_model=BotConnectionInfo)
def check_bot_connection():
global bot_state
elapsed = (datetime.now() - bot_state["last_updated"]).total_seconds()
current_status = bot_state["status"]
if elapsed > BOT_TIMEOUT_SEC:
current_status = "OFFLINE"
bot_state["speed"] = 0.0
return {
"bot_status": current_status,
"battery": bot_state["battery"],
"speed": bot_state["speed"],
"last_update": bot_state["last_updated"].strftime("%H:%M:%S")
}
# 서버 연결 확인
@router.get("/health")
def health_check():
return {"status": "ok", "time": datetime.now().isoformat()}
+20
View File
@@ -0,0 +1,20 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
SQLALCHEMY_DATABASE_URL = "sqlite:///./shopping_cart.db"
engine = create_engine(
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
+74
View File
@@ -0,0 +1,74 @@
import lgpio
import time
class HX711:
def __init__(self, dout, pd_sck, gain=128):
self.DOUT = dout
self.PD_SCK = pd_sck
self.chip = lgpio.gpiochip_open(0)
lgpio.gpio_claim_input(self.chip, self.DOUT)
lgpio.gpio_claim_output(self.chip, self.PD_SCK)
self.GAIN = 1 if gain == 128 else 3 if gain == 64 else 2
self.OFFSET = 0
self.REFERENCE_UNIT = 1
self.set_gain(gain)
def is_ready(self):
return lgpio.gpio_read(self.chip, self.DOUT) == 0
def set_gain(self, gain):
if gain == 128:
self.GAIN = 1
elif gain == 64:
self.GAIN = 3
elif gain == 32:
self.GAIN = 2
self.read_raw()
def read_raw(self):
while not self.is_ready():
pass
data = 0
for _ in range(24):
lgpio.gpio_write(self.chip, self.PD_SCK, 1)
data = (data << 1) | lgpio.gpio_read(self.chip, self.DOUT)
lgpio.gpio_write(self.chip, self.PD_SCK, 0)
for _ in range(self.GAIN):
lgpio.gpio_write(self.chip, self.PD_SCK, 1)
lgpio.gpio_write(self.chip, self.PD_SCK, 0)
if data & 0x800000:
data -= 0x1000000
return data
def set_offset(self, offset):
self.OFFSET = offset
def set_reference_unit(self, value):
self.REFERENCE_UNIT = value
def get_value(self, times=5):
return sum(self.read_raw() for _ in range(times)) / times
def get_weight(self, times=5):
value = self.get_value(times) - self.OFFSET
return value / self.REFERENCE_UNIT
def power_down(self):
lgpio.gpio_write(self.chip, self.PD_SCK, 0)
lgpio.gpio_write(self.chip, self.PD_SCK, 1)
time.sleep(0.0001)
def power_up(self):
lgpio.gpio_write(self.chip, self.PD_SCK, 0)
time.sleep(0.0001)
+4
View File
@@ -0,0 +1,4 @@
{
"offset": 449326.96,
"scale": 311.0220000000001
}
+38
View File
@@ -0,0 +1,38 @@
from hx711 import HX711
import time, json
DT_PIN = 5
SCK_PIN = 6
hx = HX711(DT_PIN, SCK_PIN)
print("HX711 보정 시작")
time.sleep(2)
# 1) 빈 상태
print("1️⃣ 아무것도 안 올리고 기다리세요...")
N = 30
empty = sum(hx.get_value(5) for _ in range(N)) / N
print("OFFSET =", empty)
known = float(input("2️⃣ 기준 무게(g): "))
print("3️⃣ 기준 무게 올리고 기다리세요...")
raw = sum(hx.get_value(5) for _ in range(N)) / N
scale = (raw - empty) / known
print("\n보정 완료")
print("offset =", empty)
print("scale =", scale)
calib = {
"offset": empty,
"scale": scale
}
with open("hx711_calib.json", "w") as f:
json.dump(calib, f, indent=4)
print("✔ hx711_calib.json 저장 완료")
+23
View File
@@ -0,0 +1,23 @@
from hx711 import HX711
import json, time
DT_PIN = 5
SCK_PIN = 6
with open("hx711_calib.json") as f:
calib = json.load(f)
OFFSET = calib["offset"]
SCALE = calib["scale"]
hx = HX711(DT_PIN, SCK_PIN)
hx.set_offset(OFFSET)
hx.set_reference_unit(SCALE)
print(" 무게 측정 시작")
while True:
weight = hx.get_weight(10)
print(f"무게: {weight:.1f} g")
time.sleep(0.5)
+200
View File
@@ -0,0 +1,200 @@
import uvicorn
from fastapi import FastAPI, Depends, HTTPException
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from typing import List
from datetime import datetime
import models, database
import dashboard
import logging
from read_weight import read_cart_weight
CART_ITEMS = []
EXPECTED_WEIGHT = 0.0
app = FastAPI()
models.Base.metadata.create_all(bind=database.engine)
# 대시보드 라우터 등록
app.include_router(dashboard.router)
app.mount("/static", StaticFiles(directory="static"), name="static")
def get_db():
db = database.SessionLocal()
try: yield db
finally: db.close()
# 무게 검증 로직
def is_movable(expected, real):
if expected <= 0: return True
diff_ratio = abs(real - expected) / expected
return diff_ratio <= 0.2
# 페이지 라우팅
@app.get("/dashboard")
async def dashboard_view(): return FileResponse("templates/dashboard.html")
@app.get("/inventory")
async def inventory_view(): return FileResponse("templates/inventory.html")
# 카트 무게 기준값 설정
@app.post("/cart/tare")
def tare_cart():
global EXPECTED_WEIGHT
EXPECTED_WEIGHT = read_cart_weight()
return {"expected_weight": EXPECTED_WEIGHT}
@app.get("/items", response_model=List[models.ItemSchema])
def read_all_items(db: Session = Depends(get_db)):
return db.query(models.Item).all()
# 상품 추가
@app.post("/cart/add/{item_id}")
def add_item(item_id: int, db: Session = Depends(get_db)):
global EXPECTED_WEIGHT, CART_ITEMS
item = db.query(models.Item).filter(models.Item.id == item_id).first()
if not item:
raise HTTPException(404, "Item not found")
CART_ITEMS.append({
"id": item.id,
"name": item.name,
"price": item.price,
"weight": item.weight
})
EXPECTED_WEIGHT += item.weight
real = read_cart_weight()
movable = is_movable(EXPECTED_WEIGHT, real)
return {
"action": "add",
"id": item.id,
"item": item.name,
"price": item.price,
"expected_weight": EXPECTED_WEIGHT,
"real_weight": real,
"diff": abs(real - EXPECTED_WEIGHT),
"movable": movable
}
# 상품 제거
@app.post("/cart/remove/{item_id}")
def remove_item(item_id: int):
global EXPECTED_WEIGHT, CART_ITEMS
for i, item in enumerate(CART_ITEMS):
if item["id"] == item_id:
removed = CART_ITEMS.pop(i)
EXPECTED_WEIGHT -= removed["weight"]
break
else:
raise HTTPException(404, "Item not in cart")
EXPECTED_WEIGHT = max(EXPECTED_WEIGHT, 0)
real = read_cart_weight()
movable = is_movable(EXPECTED_WEIGHT, real)
return {
"action": "remove",
"id": removed["id"],
"item": removed["name"],
"price": removed["price"],
"expected_weight": EXPECTED_WEIGHT,
"real_weight": real,
"movable": movable
}
# 장바구니 조회
@app.get("/cart")
def get_cart():
total_price = sum(item["price"] for item in CART_ITEMS)
return {
"items": CART_ITEMS,
"total_count": len(CART_ITEMS),
"total_price": total_price,
"expected_weight": EXPECTED_WEIGHT
}
# 무게 체크
@app.get("/cart/check_weight")
def check_cart():
real = read_cart_weight()
movable = is_movable(EXPECTED_WEIGHT, real)
return {
"expected_weight": EXPECTED_WEIGHT,
"real_weight": real,
"diff": abs(real - EXPECTED_WEIGHT),
"movable": movable
}
# 대시보드 상태 조회
@app.get("/cart/status")
def get_cart_status_for_dashboard():
global EXPECTED_WEIGHT, CART_ITEMS
real = read_cart_weight()
movable = is_movable(EXPECTED_WEIGHT, real)
system_status = "NORMAL" if movable else "WARNING_WEIGHT_MISMATCH"
return {
"real_weight": real,
"expected_weight": EXPECTED_WEIGHT,
"cart_items": CART_ITEMS,
"total_price": sum(item["price"] for item in CART_ITEMS),
"total_count": len(CART_ITEMS),
"system_status": system_status,
"movable": movable
}
# 결제
@app.post("/cart/pay")
def pay_cart(db: Session = Depends(get_db)):
global CART_ITEMS, EXPECTED_WEIGHT
if not CART_ITEMS:
raise HTTPException(status_code=400, detail="장바구니가 비어있습니다.")
total_price = sum(item["price"] for item in CART_ITEMS)
new_pay = models.Pay(
total_price=total_price,
created_at=datetime.now()
)
db.add(new_pay)
db.commit()
reset_cart()
return {"msg": "결제 완료", "amount": total_price}
# 결제 내역 조회
@app.get("/payments", response_model=List[models.PaySchema])
def get_payment_history(db: Session = Depends(get_db)):
return db.query(models.Pay).order_by(models.Pay.created_at.desc()).all()
# 카트 초기화
@app.post("/cart/reset")
def reset_cart():
global EXPECTED_WEIGHT, CART_ITEMS
EXPECTED_WEIGHT = 0.0
CART_ITEMS.clear()
return {"ok": True}
# 로그 필터
class EndpointFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
msg = record.getMessage()
return all(x not in msg for x in ["/bot/check", "/bot/report", "/cart/status"])
logging.getLogger("uvicorn.access").addFilter(EndpointFilter())
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)
+74
View File
@@ -0,0 +1,74 @@
from sqlalchemy import Column, Integer, String, Float, DateTime
from database import Base
from pydantic import BaseModel
from datetime import datetime
# 상품 테이블
class Item(Base):
__tablename__ = "items"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, index=True)
price = Column(Float)
stock = Column(Integer)
weight = Column(Float) # 무게 로직용
expiry_date = Column(String) # 대시보드 유통기한 관리용
# 결제 내역 테이블
class Pay(Base):
__tablename__ = "payments"
id = Column(Integer, primary_key=True, index=True)
total_price = Column(Float)
created_at = Column(DateTime, default=datetime.now)
# 상품 생성 요청
class ItemCreate(BaseModel):
name: str
price: float
stock: int
weight: float
expiry_date: str
# 상품 조회 응답
class ItemSchema(BaseModel):
id: int
name: str
price: float
stock: int
weight: float
expiry_date: str
class Config:
from_attributes = True
# 결제 내역 응답 (대시보드 차트용)
class PaySchema(BaseModel):
id: int
total_price: float
created_at: datetime
class Config:
from_attributes = True
class CartScanResponse(BaseModel):
item: ItemSchema
cart_weight: float
expected_weight: float
weight_ok: bool
diff: float
allowed: float
# 무게 검증 응답
class CartScanResponse(BaseModel):
item: ItemSchema # 스캔된 상품 정보
cart_weight: float # 현재 실제 무게
expected_weight: float # 예상 무게
weight_ok: bool # 무게 일치 여부
diff: float # 오차
allowed: float # 허용 오차 범위
# 무게 포함 아이템 정보
class ItemWithWeight(ItemSchema):
cart_weight: float
+33
View File
@@ -0,0 +1,33 @@
from hx711 import HX711
import json, time, threading
# --- 보정값 로드 ---
with open("hx711_calib.json", "r") as f:
calib = json.load(f)
OFFSET = calib["offset"]
SCALE = calib["scale"]
hx = HX711(5, 6)
hx_lock = threading.Lock()
def read_cart_weight():
"""현재 카트 무게(g)를 반환"""
with hx_lock:
# 여러 번 평균 내서 노이즈 줄이기
samples = 10
total = 0
for _ in range(samples):
total += hx.read_raw()
time.sleep(0.02)
raw = total / samples
weight = (raw - OFFSET) / SCALE
# 음수/미세값 보호
if weight < 0:
weight = 0
return round(weight, 2)
+4
View File
@@ -0,0 +1,4 @@
fastapi
uvicorn[standard]
SQLAlchemy
pydantic
+310
View File
@@ -0,0 +1,310 @@
import rclpy
from rclpy.node import Node
from rclpy.action import ActionClient
from geometry_msgs.msg import TwistStamped, PoseWithCovarianceStamped
from nav2_msgs.action import NavigateToPose
from sensor_msgs.msg import BatteryState
from nav_msgs.msg import Odometry
from rclpy.qos import QoSProfile, ReliabilityPolicy, DurabilityPolicy, HistoryPolicy
import socket
import time
import math
import requests
import threading
from collections import deque
UDP_PORT_UWB = 44444
UDP_PORT_QT = 55555
BUFFER_SIZE = 1024
SERVER_IP = "192.168.123.43"
API_REPORT_URL = f"http://{SERVER_IP}:8000/dashboard/bot/report"
API_WEIGHT_URL = f"http://{SERVER_IP}:8000/cart/check_weight"
TAG_HEIGHT = 1.85
ANCHOR_Z = 2.37
H_DIFF = abs(ANCHOR_Z - TAG_HEIGHT)
# 고정 오프셋 보정값
FIXED_OFFSETS = {0: -7.00, 1: -7.00, 2: -7.50}
# 앵커 절대 좌표
ANCHORS_POS = {
0: {'x': 0.00, 'y': 0.00},
1: {'x': 6.00, 'y': 0.00},
2: {'x': 3.00, 'y': 15.00}
}
MIN_UPDATE_DIST = 1.2
NAV_UPDATE_INTERVAL = 2.0
class IntegratedRosController(Node):
def __init__(self):
super().__init__('integrated_ros_controller_node')
amcl_qos = QoSProfile(
depth=1,
reliability=ReliabilityPolicy.RELIABLE,
durability=DurabilityPolicy.TRANSIENT_LOCAL,
history=HistoryPolicy.KEEP_LAST
)
self.cmd_vel_pub = self.create_publisher(TwistStamped, '/cmd_vel', 10)
self.battery_sub = self.create_subscription(BatteryState, '/battery_state', self.battery_callback, 10)
self.odom_sub = self.create_subscription(Odometry, '/odom', self.odom_callback, 10)
self.amcl_sub = self.create_subscription(
PoseWithCovarianceStamped,
'/amcl_pose',
self.amcl_callback,
amcl_qos
)
self.nav_to_pose_client = ActionClient(self, NavigateToPose, 'navigate_to_pose')
self.sock_uwb = self.create_udp_socket(UDP_PORT_UWB)
self.sock_qt = self.create_udp_socket(UDP_PORT_QT)
self.current_mode = 0
self.battery_level = 0.0
self.current_speed = 0.0
self.qt_addr = None
self.robot_x = 0.0
self.robot_y = 0.0
self.human_x = 0.0
self.human_y = 0.0
self.x_his = deque(maxlen=15)
self.y_his = deque(maxlen=5)
self.uwb_dists = [0.0, 0.0, 0.0]
self.uwb_last_update = [0.0, 0.0, 0.0]
self.nav_goal_handle = None
self.last_goal_time = 0.0
self.last_target_pos = [0.0, 0.0]
self.is_weight_ok = True
self.last_weight_check_time = 0.0
self.weight_check_interval = 3.0
self.get_logger().info("Waiting for Nav2 Server...")
if not self.nav_to_pose_client.wait_for_server(timeout_sec=5.0):
self.get_logger().warn("Nav2 Server not ready yet.")
self.create_timer(0.05, self.control_loop)
self.create_timer(1.0, self.send_bot_status)
self.get_logger().info("ROS Controller Started")
def create_udp_socket(self, port):
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(('0.0.0.0', port))
sock.setblocking(False)
return sock
# 무게 체크
def trigger_async_weight_check(self):
def _check():
try:
response = requests.get(API_WEIGHT_URL, timeout=0.5)
if response.status_code == 200:
data = response.json()
movable = data.get("movable", True)
self.is_weight_ok = movable
if not movable:
self.send_udp_to_qt("ERROR:WEIGHT_MISMATCH")
except: pass
threading.Thread(target=_check, daemon=True).start()
def send_udp_to_qt(self, message):
if self.qt_addr:
try: self.sock_qt.sendto(message.encode(), self.qt_addr)
except: pass
def battery_callback(self, msg):
self.battery_level = int(msg.percentage) if msg.percentage >= 0 else 0.0
def odom_callback(self, msg):
self.current_speed = msg.twist.twist.linear.x
def amcl_callback(self, msg):
self.robot_x = msg.pose.pose.position.x
self.robot_y = msg.pose.pose.position.y
# 로봇 상태 전송
def send_bot_status(self):
def _post():
try:
payload = {
"status": "ONLINE",
"mode": self.current_mode,
"battery": round(self.battery_level, 1),
"speed": round(self.current_speed, 2),
"x": self.robot_x,
"y": self.robot_y,
"human_x": self.human_x,
"human_y": self.human_y
}
requests.post(API_REPORT_URL, json=payload, timeout=2.0)
except: pass
# 스레드 비동기 전송 후 종료
threading.Thread(target=_post, daemon=True).start()
# 삼변측량 함수
def calculate_trilateration(self, distances):
try:
x1, y1, r1 = ANCHORS_POS[0]['x'], ANCHORS_POS[0]['y'], distances[0]
x2, y2, r2 = ANCHORS_POS[1]['x'], ANCHORS_POS[1]['y'], distances[1]
x3, y3, r3 = ANCHORS_POS[2]['x'], ANCHORS_POS[2]['y'], distances[2]
A, B = 2*(x2-x1), 2*(y2-y1)
C = r1**2 - r2**2 - x1**2 + x2**2 - y1**2 + y2**2
D, E = 2*(x3-x2), 2*(y3-y2)
F = r2**2 - r3**2 - x2**2 + x3**2 - y2**2 + y3**2
den = (A * E) - (B * D)
if abs(den) < 0.0001: return None
return ((C * E) - (B * F)) / den, ((A * F) - (C * D)) / den
except: return None
def transform_to_map(self, ux, uy):
mx = 3.25 + (ux * -0.9725)
my = -7.35 + (uy * 0.5966)
return mx, my
# UWB 데이터 수신
def receive_uwb_data(self):
try:
for _ in range(10):
data, _ = self.sock_uwb.recvfrom(BUFFER_SIZE)
msg = data.decode('utf-8').strip()
idx = -1
if msg.startswith("A1:"): idx = 0
elif msg.startswith("A2:"): idx = 1
elif msg.startswith("A3:"): idx = 2
if idx != -1:
raw_val = float(msg.split(":")[1])
corrected_dist = raw_val - FIXED_OFFSETS[idx]
h_dist = math.sqrt(corrected_dist**2 - H_DIFF**2) if corrected_dist > H_DIFF else 0.1
self.uwb_dists[idx] = h_dist
self.uwb_last_update[idx] = time.time()
except BlockingIOError: pass
except: pass
def receive_qt_command(self):
try:
while True:
data, addr = self.sock_qt.recvfrom(BUFFER_SIZE)
self.qt_addr = addr
text = data.decode('utf-8').strip()
if text.startswith("MODE:"): self.change_mode(int(text.split(":")[1]))
elif text.startswith("GOAL:"):
if self.current_mode != 2: self.change_mode(2)
p = text.split(":")[1].split(",")
self.start_navigation(float(p[0]), float(p[1]))
elif text.startswith("STOP"): self.change_mode(0)
except BlockingIOError: pass
except: pass
def control_loop(self):
self.receive_uwb_data()
self.receive_qt_command()
now = time.time()
if now - self.last_weight_check_time > self.weight_check_interval:
self.trigger_async_weight_check()
self.last_weight_check_time = now
if all(now - self.uwb_last_update[i] < 1.0 for i in range(3)):
pos = self.calculate_trilateration(self.uwb_dists)
if pos:
raw_x = max(0, min(pos[0], 6.0))
raw_y = max(0, min(pos[1], 15.0))
self.x_his.append(raw_x)
self.y_his.append(raw_y)
avg_x = sum(self.x_his) / len(self.x_his)
avg_y = sum(self.y_his) / len(self.y_his)
final_uwb_x = (avg_x / 6.0) * (3.80 - 0.10) + 0.10
final_uwb_y = avg_y
mx, my = self.transform_to_map(final_uwb_x, final_uwb_y)
self.human_x = mx
self.human_y = my
if self.current_mode == 1:
dist_diff = math.sqrt((mx - self.last_target_pos[0])**2 + (my - self.last_target_pos[1])**2)
if self.is_weight_ok:
if (now - self.last_goal_time > NAV_UPDATE_INTERVAL) and (dist_diff > MIN_UPDATE_DIST):
self.get_logger().info(f"Target Moved -> Goal: ({mx:.2f}, {my:.2f})")
self.start_navigation(mx, my)
self.last_goal_time = now
self.last_target_pos = [mx, my]
if self.current_mode == 0:
if self.nav_goal_handle: self.cancel_navigation()
self.stop_robot()
def change_mode(self, new_mode):
if self.current_mode == new_mode: return
self.get_logger().info(f"Mode Switching: {self.current_mode} -> {new_mode}")
self.cancel_navigation()
self.stop_robot()
self.current_mode = new_mode
self.last_target_pos = [0.0, 0.0]
self.trigger_async_weight_check()
# 네비게이션 실행
def start_navigation(self, x, y):
if not self.is_weight_ok: return
if not self.nav_to_pose_client.server_is_ready():
self.get_logger().error("Nav2 Server not ready!")
return
goal_msg = NavigateToPose.Goal()
goal_msg.pose.header.frame_id = 'map'
goal_msg.pose.header.stamp = self.get_clock().now().to_msg()
goal_msg.pose.pose.position.x = x
goal_msg.pose.pose.position.y = y
goal_msg.pose.pose.orientation.w = 1.0
self._send_goal_future = self.nav_to_pose_client.send_goal_async(goal_msg)
self._send_goal_future.add_done_callback(self.goal_response_callback)
def goal_response_callback(self, future):
handle = future.result()
if handle.accepted:
self.nav_goal_handle = handle
self._get_result_future = handle.get_result_async()
self._get_result_future.add_done_callback(self.get_result_callback)
def get_result_callback(self, future):
result = future.result()
if result.status == 4:
self.send_udp_to_qt("STATUS:ARRIVED")
self.nav_goal_handle = None
def cancel_navigation(self):
if self.nav_goal_handle:
self.nav_goal_handle.cancel_goal_async()
self.nav_goal_handle = None
def stop_robot(self):
msg = TwistStamped()
msg.header.stamp = self.get_clock().now().to_msg()
msg.header.frame_id = 'base_link'
self.cmd_vel_pub.publish(msg)
def main():
rclpy.init()
node = IntegratedRosController()
try: rclpy.spin(node)
except KeyboardInterrupt: pass
finally:
node.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()
Binary file not shown.
+253
View File
@@ -0,0 +1,253 @@
body {
background-color: #0f172a;
color: #f1f5f9;
font-family: 'Pretendard', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
margin: 0;
padding: 0;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* 헤더 스타일 */
header {
background-color: #1e293b;
border-bottom: 1px solid #334155;
padding: 0 30px;
height: 60px;
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.logo { font-weight: 800; font-size: 1.3rem; margin-right: 40px; color: #fff; }
nav { display: flex; gap: 10px; flex: 1; }
.nav-btn {
text-decoration: none;
color: #94a3b8;
font-weight: 600;
padding: 8px 16px;
border-radius: 8px;
transition: all 0.2s;
font-size: 0.9rem;
}
.nav-btn:hover { background-color: #334155; color: #fff; }
.nav-btn.active { background-color: #3b82f6; color: #fff; box-shadow: 0 0 10px rgba(59, 130, 246, 0.3); }
/* 상단 상태 배지 */
.status-group { display: flex; gap: 10px; }
.status-badge {
font-size: 0.8rem;
padding: 4px 12px;
border-radius: 99px;
background-color: #334155;
color: #94a3b8;
font-weight: 700;
display: flex; align-items: center; gap: 6px; border: 1px solid #475569;
}
.dot { width: 6px; height: 6px; border-radius: 50%; background-color: #94a3b8; }
.status-badge.on { background-color: #064e3b; color: #34d399; border-color: #059669; }
.status-badge.on .dot { background-color: #34d399; box-shadow: 0 0 4px #34d399; }
.status-badge.off { background-color: #450a0a; color: #f87171; border-color: #b91c1c; }
.status-badge.off .dot { background-color: #f87171; box-shadow: 0 0 4px #f87171; }
/* --- 메인 레이아웃 --- */
main {
padding: 20px;
max-width: 100%;
margin: 0 auto;
width: 100%;
box-sizing: border-box;
flex: 1;
overflow-y: hidden;
display: flex;
flex-direction: column;
gap: 20px;
}
/* 상단 카트 요약 그리드 */
.cart-select-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 15px;
flex-shrink: 0;
}
.cart-summary-card {
background-color: #1e293b;
border: 2px solid #334155;
border-radius: 12px;
padding: 15px;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
flex-direction: column;
gap: 10px;
position: relative;
}
.cart-summary-card:hover { transform: translateY(-2px); border-color: #64748b; }
.cart-summary-card.selected {
border-color: #3b82f6;
background-color: #1e293b;
box-shadow: 0 0 15px rgba(59, 130, 246, 0.2);
}
.summary-header { display: flex; justify-content: space-between; align-items: center; }
.summary-name { font-weight: 700; color: #fff; font-size: 1rem; }
.summary-status-dot { width: 8px; height: 8px; border-radius: 50%; background-color: #475569; }
.summary-status-dot.online { background-color: #10b981; box-shadow: 0 0 5px #10b981; }
.summary-status-dot.warning { background-color: #e45d0f; box-shadow: 0 0 5px #e45d0f; }
.summary-status-dot.offline { background-color: #fa0303; box-shadow: 0 0 5px #e45d0f;}
.summary-row { display: flex; justify-content: space-between; font-size: 0.85rem; color: #94a3b8; }
.summary-val { color: #f1f5f9; font-weight: 600; }
/* 하단 상세 패널 */
.cart-panel {
flex: 1;
background-color: #1e293b;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0,0,0,0.2);
border: 2px solid #334155;
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 0;
}
.panel-header {
background-color: #0f172a;
padding: 15px 25px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #334155;
flex-shrink: 0;
}
.cart-info { display: flex; align-items: center; gap: 10px; }
.cart-icon { font-size: 1.5rem; }
.cart-name { font-size: 1.2rem; font-weight: 800; color: #fff; }
.panel-status { font-weight: 700; font-size: 0.95rem; color: #64748b; }
/* --- 패널 내부 비율 (4:6) --- */
.panel-body {
display: flex;
flex: 1;
overflow: hidden;
flex-direction: row;
}
/* 왼쪽 영역 (정보) */
.panel-left {
flex: 4;
display: flex;
flex-direction: column;
border-right: 1px solid #334155;
min-width: 320px;
overflow-y: auto;
}
/* 오른쪽 영역 (지도) */
.panel-right {
flex: 6;
background-color: #0f172a;
display: flex;
flex-direction: column;
padding: 20px;
min-width: 0;
}
/* 센서 그리드 */
.sensor-grid {
padding: 25px;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 15px;
border-bottom: 1px solid #334155;
flex-shrink: 0;
}
.data-box {
background-color: #28364d;
padding: 20px;
border-radius: 12px;
text-align: center;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
display: flex; flex-direction: column; justify-content: center;
}
.data-label { font-size: 0.9rem; color: #94a3b8; margin-bottom: 5px; font-weight: 600; }
.data-value { font-size: 1.8rem; font-weight: 800; color: #fff; }
.unit { font-size: 0.9rem; color: #64748b; font-weight: 500; margin-left: 3px; }
.val-normal { color: #38bdf8; }
.val-warning { color: #f87171; }
.val-success { color: #34d399; }
.val-offline { color: #64748b; }
/* 아이템 리스트 */
.items-section {
padding: 25px;
background-color: #1e293b;
flex: 1;
display: flex;
flex-direction: column;
}
.items-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; }
.items-title { font-size: 1.1rem; font-weight: 700; color: #e2e8f0; }
.items-summary { font-size: 0.95rem; color: #94a3b8; font-weight: 600; }
.total-price { color: #34d399; font-weight: 800; font-size: 1.1rem; margin-left: 8px; }
.table-scroll-box {
flex: 1; overflow-y: auto;
border: 1px solid #334155; border-radius: 8px;
background-color: #1e293b;
min-height: 150px;
}
.table-scroll-box::-webkit-scrollbar { width: 8px; }
.table-scroll-box::-webkit-scrollbar-track { background: #1e293b; }
.table-scroll-box::-webkit-scrollbar-thumb { background: #475569; border-radius: 4px; }
.items-table { width: 100%; border-collapse: collapse; font-size: 0.95rem; }
.items-table th {
text-align: left; color: #94a3b8; padding: 12px 20px; background-color: #0f172a;
position: sticky; top: 0; z-index: 1; border-bottom: 1px solid #334155; font-size: 0.85rem; font-weight: 700;
}
.items-table td { padding: 12px 20px; border-bottom: 1px solid #334155; color: #f1f5f9; }
.items-table tr:hover td { background-color: #334155; }
/* 지도 헤더 */
.map-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
flex-shrink: 0;
}
.map-title { font-size: 1.1rem; font-weight: 700; color: #e2e8f0; }
.map-coords { font-family: monospace; color: #34d399; font-size: 0.9rem; }
/* 캔버스 컨테이너 */
.canvas-container {
flex: 1;
background-color: #020617;
border: 1px solid #334155;
border-radius: 8px;
position: relative;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
}
canvas {
background-color: transparent;
width: 100%;
height: 100%;
object-fit: contain;
}
+130
View File
@@ -0,0 +1,130 @@
/* --- 다크 모드 기본 스타일 --- */
body {
background-color: #0f172a;
color: #f1f5f9;
font-family: 'Pretendard', sans-serif;
margin: 0;
padding: 0;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* --- 헤더 --- */
header {
background-color: #1e293b;
color: #fff;
border-bottom: 1px solid #334155;
padding: 0 30px;
height: 60px;
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.logo { font-weight: 800; font-size: 1.3rem; margin-right: 40px; }
nav { display: flex; gap: 10px; flex: 1; }
.nav-btn { text-decoration: none; color: #94a3b8; font-weight: 600; padding: 8px 16px; border-radius: 8px; transition: all 0.2s; font-size: 0.9rem; }
.nav-btn:hover { background-color: #334155; color: #fff; }
.nav-btn.active { background-color: #3b82f6; color: #fff; box-shadow: 0 0 10px rgba(59, 130, 246, 0.3); }
.container { display: flex; flex-direction: column; flex: 1; min-height: 0; padding: 20px; gap: 20px; box-sizing: border-box; }
/* 다크 모드 섹션 스타일 */
.inventory-section {
flex: 1;
background: #1e293b;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0,0,0,0.2);
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid #334155;
}
.section-header {
padding: 15px 20px;
border-bottom: 1px solid #334155;
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 700;
font-size: 1.1rem;
color: #e2e8f0;
background-color: #0f172a;
}
.table-wrapper { flex: 1; overflow-y: auto; }
/* 다크 모드 테이블 */
table { width: 100%; border-collapse: collapse; font-size: 0.95rem; }
th {
position: sticky; top: 0;
background-color: #0f172a;
color: #94a3b8;
padding: 12px 20px;
text-align: left;
border-bottom: 2px solid #334155;
font-size: 0.85rem;
font-weight: 700;
z-index: 10;
}
td {
padding: 12px 20px;
border-bottom: 1px solid #334155;
color: #f1f5f9;
vertical-align: middle;
}
tr:hover td { background-color: #334155; }
.sortable { cursor: pointer; user-select: none; transition: color 0.2s; }
.sortable:hover { color: #3b82f6; background-color: #1e293b; }
/* 다크 모드 배지 스타일 */
.badge { padding: 4px 10px; border-radius: 6px; font-size: 0.8rem; font-weight: 700; display: inline-block; min-width: 80px; text-align: center; }
.expiry-expired { background-color: #450a0a; color: #f87171; border: 1px solid #b91c1c; } /* 만료 */
.expiry-warning { background-color: #422006; color: #fbbf24; border: 1px solid #b45309; } /* 임박 */
.expiry-good { background-color: #064e3b; color: #34d399; border: 1px solid #059669; } /* 안전 */
.stock-badge { padding: 4px 10px; border-radius: 6px; font-size: 0.8rem; font-weight: 700; display: inline-block; text-align: center; }
.stock-low { background-color: #450a0a; color: #f87171; border: 1px solid #b91c1c; }
.stock-normal { background-color: #334155; color: #94a3b8; border: 1px solid #475569; }
/* 매출 섹션 */
.sales-section { flex: 1; display: flex; gap: 20px; min-height: 0; }
.kpi-container { display: flex; flex-direction: column; gap: 15px; width: 250px; flex-shrink: 0; }
.kpi-card {
background: #1e293b;
border-radius: 12px;
padding: 20px;
border: 1px solid #334155;
box-shadow: 0 4px 6px rgba(0,0,0,0.2);
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
}
.kpi-title { font-size: 0.9rem; color: #94a3b8; margin-bottom: 5px; font-weight: 600; }
.kpi-value { font-size: 1.8rem; font-weight: 800; color: #fff; }
.kpi-icon { font-size: 2rem; margin-bottom: 10px; }
.chart-container {
flex: 1;
background: #1e293b;
border-radius: 12px;
border: 1px solid #334155;
box-shadow: 0 4px 6px rgba(0,0,0,0.2);
padding: 20px;
position: relative;
display: flex;
flex-direction: column;
}
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: #0f172a; }
::-webkit-scrollbar-thumb { background: #475569; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #64748b; }
+341
View File
@@ -0,0 +1,341 @@
let currentCartId = 1;
const mapImg = new Image();
mapImg.src = "/static/map601.png";
const MAP_BOUNDS = {
LEFT_X: -0.67,
RIGHT_X: 3.34,
TOP_Y: 1.66,
BOTTOM_Y: -7.4
};
const allCartsData = {
1: { id: 1, name: "QTCART 1호차", isOnline: false, battery: 0, speed: 0, weight: 0, expected: 0, items: [], status: "OFFLINE", x: 0.0, y: 0.0, human_x: 0.0, human_y: 0.0 },
2: { id: 2, name: "QTCART 2호차", isOnline: true, battery: 92, speed: 50, weight: 710, expected: 700, items: generateMockItems(3), status: "ONLINE", x: 1.5, y: 1.0 },
3: { id: 3, name: "QTCART 3호차", isOnline: true, battery: 34, speed: 0, weight: 0, expected: 0, items: [], status: "ONLINE", x: -0.67, y: 1.66 },
4: { id: 4, name: "QTCART 4호차", isOnline: true, battery: 65, speed: 0, weight: 0, expected: 0, items: [], status: "ONLINE", x: 3.34, y: -7.4 }
};
function generateMockItems(count) {
const mockDb = [
{name: "코카콜라 500ml", price: 2000, weight: 500},
{name: "포카칩 오리지널", price: 1500, weight: 100},
{name: "제주 삼다수 2L", price: 1000, weight: 2000},
{name: "신라면 멀티팩", price: 4500, weight: 600}
];
let items = [];
for(let i=0; i<count; i++) {
const rand = mockDb[Math.floor(Math.random() * mockDb.length)];
items.push({...rand, id: 100+i});
}
return items;
}
// 주기적으로 데이터 갱신
async function updateDashboard() {
const apiBadge = document.getElementById('apiBadge');
try {
// 로봇 상태 조회
const botRes = await fetch('/dashboard/bot/check');
const botData = await botRes.json();
// 카트 상태 조회
let cartData = null;
try {
const cRes = await fetch('/cart/status');
if(cRes.ok) cartData = await cRes.json();
} catch(e) {}
apiBadge.className = 'status-badge on';
apiBadge.innerHTML = '<span class="dot"></span> 서버 연결됨';
const c1 = allCartsData[1];
if(botData.bot_status === 'ONLINE') {
c1.isOnline = true;
c1.status = "ONLINE";
const batVal = parseFloat(botData.battery);
c1.battery = isNaN(batVal) ? 0 : Math.round(batVal);
c1.speed = Math.abs(Math.round(botData.speed * 100));
// 좌표 업데이트
c1.x = botData.x !== undefined ? botData.x : 0.0;
c1.y = botData.y !== undefined ? botData.y : 0.0;
c1.human_x = botData.human_x !== undefined ? botData.human_x : 0.0;
c1.human_y = botData.human_y !== undefined ? botData.human_y : 0.0;
// 카트 데이터 업데이트
if(cartData) {
c1.weight = cartData.real_weight;
c1.expected = cartData.expected_weight;
c1.items = cartData.cart_items || [];
if(cartData.system_status === "WARNING_WEIGHT_MISMATCH") {
c1.status = "WARNING";
} else {
c1.status = "ONLINE";
}
}
} else {
c1.isOnline = false;
c1.status = "OFFLINE";
c1.speed = 0;
}
} catch (err) {
apiBadge.className = 'status-badge off';
apiBadge.innerHTML = '<span class="dot"></span> 서버 끊김';
allCartsData[1].isOnline = false;
allCartsData[1].status = "OFFLINE";
}
updateMockData(2);
updateMockData(3);
updateMockData(4);
renderGrid();
renderDetailPanel();
drawMap();
}
function updateMockData(id) {
const c = allCartsData[id];
if(!c.isOnline) return;
if(Math.random() > 0.98) c.battery = Math.max(0, c.battery - 1);
if(Math.random() > 0.7) c.speed = Math.floor(Math.random() * 80);
c.x += (Math.random() - 0.5) * 0.1;
c.y += (Math.random() - 0.5) * 0.1;
}
function renderGrid() {
const grid = document.getElementById('cartGrid');
grid.innerHTML = '';
for(let i=1; i<=4; i++) {
const data = allCartsData[i];
const isSelected = (i === currentCartId) ? 'selected' : '';
let dotClass = 'offline';
let statusColor = '#64748b';
if(data.isOnline) {
if(data.status === 'WARNING') {
dotClass = 'warning';
statusColor = '#fbbf24';
} else {
dotClass = 'online';
statusColor = '#34d399';
}
}
const speedDisplay = data.isOnline ? `${data.speed} cm/s` : '--';
const html = `
<div class="cart-summary-card ${isSelected}" onclick="selectCart(${i})">
<div class="summary-header">
<div class="summary-name">${data.name}</div>
<div class="summary-status-dot ${dotClass}" style="${dotClass === 'warning' ? 'background-color:#f97316; box-shadow:0 0 5px #f97316;' : ''}"></div>
</div>
<div class="summary-row" style="margin-top:5px;">
<span>배터리</span>
<span class="summary-val" style="color:${statusColor}">${data.isOnline ? data.battery+'%' : '-'}</span>
</div>
<div class="summary-row">
<span>현재 속도</span> <span class="summary-val">${speedDisplay}</span> </div>
</div>`;
grid.innerHTML += html;
}
}
function renderDetailPanel() {
const data = allCartsData[currentCartId];
const statusText = document.getElementById('detailStatusText');
const panel = document.getElementById('detailPanel');
document.getElementById('detailName').innerText = data.name;
if(data.isOnline) {
if(data.status === 'WARNING') {
statusText.innerText = '● 무게 불일치 경고';
statusText.style.color = '#fbbf24';
panel.style.borderColor = '#fbbf24';
} else {
statusText.innerText = '● 온라인';
statusText.style.color = '#34d399';
panel.style.borderColor = '#334155';
}
} else {
statusText.innerText = '● 오프라인';
statusText.style.color = '#f87171';
panel.style.borderColor = '#ef4444';
}
const elReal = document.getElementById('detailReal');
const elExp = document.getElementById('detailExpected');
const elBat = document.getElementById('detailBattery');
const elSpd = document.getElementById('detailSpeed');
if(data.isOnline) {
elReal.innerText = typeof data.weight === 'number' ? data.weight.toFixed(1) : data.weight;
elExp.innerText = typeof data.expected === 'number' ? data.expected.toFixed(1) : data.expected;
elBat.innerText = data.battery;
elSpd.innerText = data.speed;
elBat.parentElement.className = data.battery < 20 ? "data-value val-warning" : "data-value val-success";
if(data.status === 'WARNING') elReal.parentElement.className = "data-value val-warning";
else elReal.parentElement.className = "data-value val-normal";
} else {
elReal.innerText = '--';
elExp.innerText = '--';
elBat.innerText = '--';
elSpd.innerText = '--';
elReal.parentElement.className = "data-value val-offline";
elBat.parentElement.className = "data-value val-offline";
}
const tbody = document.getElementById('detailListBody');
const countEl = document.getElementById('detailCount');
const priceEl = document.getElementById('detailPrice');
if(!data.isOnline || data.items.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" style="text-align: center; color: #64748b; padding: 30px;">데이터 없음</td></tr>';
countEl.innerText = '0';
priceEl.innerText = '0원';
} else {
const agg = {};
let totalP = 0;
let totalC = 0;
data.items.forEach(item => {
if(agg[item.name]) agg[item.name].qty++;
else agg[item.name] = {...item, qty: 1};
totalP += item.price;
totalC++;
});
let html = '';
Object.values(agg).forEach(item => {
html += `<tr>
<td style="font-weight:600;">${item.name}</td>
<td style="text-align: right;">${item.price.toLocaleString()}원</td>
<td style="text-align: right;">${item.weight}g</td>
<td style="text-align: right; font-weight: bold; color: #38bdf8;">${item.qty}</td>
</tr>`;
});
tbody.innerHTML = html;
countEl.innerText = totalC;
priceEl.innerText = totalP.toLocaleString() + '원';
}
}
function drawMap() {
const canvas = document.getElementById('robotMap');
if(!canvas) return;
const ctx = canvas.getContext('2d');
const data = allCartsData[currentCartId];
const container = canvas.parentElement;
if(canvas.width !== container.clientWidth || canvas.height !== container.clientHeight) {
canvas.width = container.clientWidth;
canvas.height = container.clientHeight;
}
const cvsW = canvas.width;
const cvsH = canvas.height;
ctx.clearRect(0, 0, cvsW, cvsH);
// 이미지 로딩 체크
if (!mapImg.complete || mapImg.naturalWidth === 0) {
ctx.fillStyle = '#94a3b8';
ctx.font = '14px sans-serif';
ctx.fillText("Loading Map...", 20, 30);
return;
}
const imgW = mapImg.naturalWidth;
const imgH = mapImg.naturalHeight;
const imgRatio = imgW / imgH;
const cvsRatio = cvsW / cvsH;
let drawW, drawH, drawX, drawY;
if (cvsRatio > imgRatio) {
drawH = cvsH;
drawW = cvsH * imgRatio;
drawX = (cvsW - drawW) / 2;
drawY = 0;
} else {
drawW = cvsW;
drawH = cvsW / imgRatio;
drawX = 0;
drawY = (cvsH - drawH) / 2;
}
ctx.drawImage(mapImg, drawX, drawY, drawW, drawH);
const toPixel = (rosX, rosY) => {
const xRange = MAP_BOUNDS.RIGHT_X - MAP_BOUNDS.LEFT_X;
let ratioX = (rosX - MAP_BOUNDS.LEFT_X) / xRange;
const yRange = MAP_BOUNDS.BOTTOM_Y - MAP_BOUNDS.TOP_Y;
let ratioY = (rosY - MAP_BOUNDS.TOP_Y) / yRange;
let finalX = drawX + (ratioX * drawW);
let finalY = drawY + (ratioY * drawH);
return { x: finalX, y: finalY };
};
if (data.isOnline) {
console.log(`Robot(${data.x}, ${data.y})`);
const botPos = toPixel(data.x, data.y);
ctx.beginPath();
ctx.arc(botPos.x, botPos.y, 6, 0, Math.PI * 2);
let color = (currentCartId === 1) ? '#ef4444' : (data.status === 'WARNING' ? '#fbbf24' : '#34d399');
ctx.fillStyle = color;
ctx.fill();
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2;
ctx.stroke();
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 12px sans-serif';
ctx.fillText("BOT", botPos.x + 10, botPos.y + 4);
if (currentCartId === 1) {
const humanPos = toPixel(data.human_x, data.human_y);
ctx.beginPath();
ctx.arc(humanPos.x, humanPos.y, 6, 0, Math.PI * 2);
ctx.fillStyle = '#3b82f6';
ctx.fill();
ctx.strokeStyle = '#ffffff';
ctx.stroke();
ctx.fillStyle = '#3b82f6';
ctx.fillText("ME", humanPos.x + 10, humanPos.y + 4);
}
const coordEl = document.getElementById('mapCoords');
if(coordEl) coordEl.innerText = `Robot: (${data.x.toFixed(2)}, ${data.y.toFixed(2)})`;
} else {
const coordEl = document.getElementById('mapCoords');
if(coordEl) coordEl.innerText = "OFFLINE";
}
}
function selectCart(id) {
currentCartId = id;
renderGrid();
renderDetailPanel();
drawMap();
}
// 1초마다 갱신
setInterval(updateDashboard, 1000);
updateDashboard();
+151
View File
@@ -0,0 +1,151 @@
let currentItems = [];
function refreshAll() {
fetchInventory();
fetchSalesStats();
}
async function fetchInventory() {
try {
const res = await fetch('/items');
if (res.ok) {
currentItems = await res.json();
renderTable('expiry');
}
} catch (e) { console.error(e); }
}
function renderTable(sortKey) {
const tbody = document.getElementById('inventoryBody');
if (sortKey === 'expiry') {
currentItems.sort((a, b) => new Date(a.expiry_date) - new Date(b.expiry_date));
} else if (sortKey === 'stock') {
currentItems.sort((a, b) => a.stock - b.stock);
}
const today = new Date();
today.setHours(0,0,0,0);
let html = currentItems.map(item => {
const expiryDate = new Date(item.expiry_date);
const diffTime = expiryDate - today;
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
let dateClass = 'expiry-good';
let dateText = `D-${diffDays}`;
if (diffDays < 0) {
dateClass = 'expiry-expired';
dateText = '만료됨';
} else if (diffDays === 0) {
dateClass = 'expiry-warning';
dateText = '오늘 만료';
} else if (diffDays <= 3) {
dateClass = 'expiry-warning';
dateText = `${diffDays}일 남음`;
}
let stockHtml = '';
if (item.stock === 0) {
stockHtml = `<span class="stock-badge stock-low">부족 (${item.stock})</span>`;
} else {
stockHtml = `<span class="stock-badge stock-normal">${item.stock}개</span>`;
}
return `<tr>
<td style="color:#94a3b8;">#${item.id}</td>
<td style="font-weight: bold;">${item.name}</td>
<td>${item.price.toLocaleString()}원</td>
<td>
<span class="badge ${dateClass}">${item.expiry_date} (${dateText})</span>
</td>
<td>${stockHtml}</td>
</tr>`;
}).join('');
tbody.innerHTML = html || '<tr><td colspan="5" style="text-align:center; padding:30px; color:#64748b;">등록된 상품이 없습니다.</td></tr>';
}
let myChart = null;
// ----- 결제 정보 -----
async function fetchSalesStats() {
try {
const res = await fetch('/payments');
if (!res.ok) return;
const payments = await res.json();
const today = new Date();
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(today.getDate() - 30);
let monthTotal = 0;
let monthCount = 0;
const dailySales = {};
for (let i = 0; i < 30; i++) {
const d = new Date();
d.setDate(today.getDate() - i);
dailySales[d.toISOString().split('T')[0]] = 0;
}
payments.forEach(pay => {
const payDate = new Date(pay.created_at);
if (payDate >= thirtyDaysAgo) {
monthTotal += pay.total_price;
monthCount += 1;
const dateStr = pay.created_at.split('T')[0];
if (dailySales[dateStr] !== undefined) dailySales[dateStr] += pay.total_price;
}
});
document.getElementById('monthTotal').innerText = monthTotal.toLocaleString() + '원';
document.getElementById('monthCount').innerText = monthCount + '건';
updateChart(dailySales);
} catch (e) { console.error(e); }
}
function updateChart(dailyData) {
const sortedDates = Object.keys(dailyData).sort();
const dataValues = sortedDates.map(date => dailyData[date]);
const labels = sortedDates.map(date => date.slice(5));
const ctx = document.getElementById('salesChart').getContext('2d');
if (myChart) myChart.destroy();
myChart = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: '매출 (원)',
data: dataValues,
borderColor: '#8b5cf6',
backgroundColor: 'rgba(139, 92, 246, 0.2)',
borderWidth: 2,
fill: true,
tension: 0.3
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
y: {
beginAtZero: true,
grid: { borderDash: [5, 5], color: '#334155' },
ticks: { color: '#94a3b8' }
},
x: {
grid: { display: false },
ticks: { color: '#94a3b8' }
}
}
}
});
}
refreshAll();
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+98
View File
@@ -0,0 +1,98 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>QTCART 관제 시스템</title>
<link rel="stylesheet" href="/static/css/dashboard.css">
</head>
<body>
<header>
<div class="logo">🛒 QT-CART</div>
<nav>
<a href="/dashboard" class="nav-btn active">관제 모니터링</a>
<a href="/inventory" class="nav-btn">통합 관리</a>
</nav>
<div class="status-group">
<div id="apiBadge" class="status-badge off"><span class="dot"></span> 서버 연결</div>
</div>
</header>
<main>
<div class="cart-select-grid" id="cartGrid">
</div>
<div id="detailPanel" class="cart-panel">
<div class="panel-header">
<div class="cart-info">
<div class="cart-icon">🛒</div>
<div class="cart-name" id="detailName">QTCART 1호차</div>
</div>
<div id="detailStatusText" class="panel-status">● 데이터 로딩중</div>
</div>
<div class="panel-body">
<div class="panel-left">
<div class="sensor-grid">
<div class="data-box">
<div class="data-label">배터리 잔량</div>
<div class="data-value"><span id="detailBattery">--</span><span class="unit">%</span></div>
</div>
<div class="data-box">
<div class="data-label">현재 속도</div>
<div class="data-value"><span id="detailSpeed">--</span><span class="unit">cm/s</span></div>
</div>
<div class="data-box">
<div class="data-label">기대 무게</div>
<div class="data-value" style="color:#fbbf24;"><span id="detailExpected">0</span><span class="unit">g</span></div>
</div>
<div class="data-box">
<div class="data-label">실제 무게</div>
<div class="data-value"><span id="detailReal">--</span><span class="unit">g</span></div>
</div>
</div>
<div class="items-section">
<div class="items-header">
<div class="items-title">📦 담긴 상품 목록</div>
<div class="items-summary">
총 <span id="detailCount">0</span>개
<span class="total-price" id="detailPrice">0원</span>
</div>
</div>
<div class="table-scroll-box">
<table class="items-table">
<thead>
<tr>
<th>상품명</th>
<th style="text-align: right;">단가</th>
<th style="text-align: right;">무게</th>
<th style="text-align: right;">수량</th>
</tr>
</thead>
<tbody id="detailListBody">
<tr><td colspan="4" style="text-align: center; color: #64748b; padding: 30px;">데이터 없음</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="panel-right">
<div class="map-header">
<div class="map-title">🗺️ 실시간 위치</div>
<div class="map-coords" id="mapCoords">X: 0.0, Y: 0.0</div>
</div>
<div class="canvas-container">
<canvas id="robotMap" width="500" height="400"></canvas>
</div>
</div>
</div>
</div>
</main>
<script src="/static/js/dashboard.js"></script>
</body>
</html>
+66
View File
@@ -0,0 +1,66 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>QTCART 통합 관리</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<link rel="stylesheet" href="/static/css/inventory.css">
</head>
<body>
<header>
<div class="logo">🛒 QT-CART</div>
<nav>
<a href="/dashboard" class="nav-btn">관제 모니터링</a>
<a href="/inventory" class="nav-btn active">통합 관리</a>
</nav>
</header>
<div class="container">
<div class="inventory-section">
<div class="section-header">
📦 재고 및 유통기한 관리
<button onclick="refreshAll()" style="background:none; border:none; color:#3b82f6; cursor:pointer; font-weight:600;">🔄 새로고침</button>
</div>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th width="10%">ID</th>
<th width="30%">상품명</th>
<th width="20%">가격</th>
<th width="25%" class="sortable" onclick="renderTable('expiry')">유통기한 ▼</th>
<th width="15%" class="sortable" onclick="renderTable('stock')">재고량 ▼</th>
</tr>
</thead>
<tbody id="inventoryBody"></tbody>
</table>
</div>
</div>
<div class="sales-section">
<div class="kpi-container">
<div class="kpi-card">
<div class="kpi-icon">💰</div>
<div class="kpi-title">이번 달 총 매출</div>
<div class="kpi-value" id="monthTotal">0원</div>
</div>
<div class="kpi-card">
<div class="kpi-icon">🧾</div>
<div class="kpi-title">이번 달 거래 수</div>
<div class="kpi-value" id="monthCount">0건</div>
</div>
</div>
<div class="chart-container">
<div style="font-weight:700; color:#e2e8f0; margin-bottom:10px;">📉 최근 30일 매출 추이</div>
<div style="flex:1; position: relative;">
<canvas id="salesChart"></canvas>
</div>
</div>
</div>
</div>
<script src="/static/js/inventory.js"></script>
</body>
</html>