package com.bizcare.agent;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.*;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;

@RestController
@RequestMapping("/api/agent/homes")
@RequiredArgsConstructor
class CareHomesController {
    private final CrudService crud;
    private final EntityManager em;
    private final PasswordEncoder encoder;
    private final ObjectMapper mapper;

    // ---- Dashboard ----
    @GetMapping("/dashboard")
    ApiResponse dashboard(@AuthenticationPrincipal JwtUser u,
                          @RequestParam(defaultValue = "0") int offset,
                          @RequestParam(defaultValue = "day") String period) {
        return ApiResponse.ok(buildHomesDashboard(u, offset, period));
    }

    @GetMapping("/dashboard/postits")
    ApiResponse homesPostitsGet(@AuthenticationPrincipal JwtUser u) {
        return ApiResponse.ok(Map.of("list", loadHomesPostits(u.getGroupId())));
    }

    @PutMapping("/dashboard/postits/{slot}")
    @Transactional
    ApiResponse homesPostitPut(@PathVariable int slot, @AuthenticationPrincipal JwtUser u,
                               @RequestBody Map<String, Object> d) {
        if (slot < 1 || slot > 8) return ApiResponse.fail("슬롯 번호가 올바르지 않습니다.");
        List<Map<String, Object>> list = loadHomesPostits(u.getGroupId());
        Map<String, Object> note = list.get(slot - 1);
        if (d.get("contents") != null) note.put("contents", String.valueOf(d.get("contents")));
        if (d.get("authorName") != null) note.put("authorName", String.valueOf(d.get("authorName")));
        note.put("noteDate", LocalDate.now(ZoneId.of("Asia/Seoul")).format(java.time.format.DateTimeFormatter.ofPattern("yy-MM-dd")));
        note.put("title", "포스트잇 #" + slot);
        saveHomesPostits(u.getGroupId(), list);
        return ApiResponse.ok(note);
    }

    private Map<String, Object> buildHomesDashboard(JwtUser u, int offset, String period) {
        String g = u.getGroupId();
        ZoneId zone = ZoneId.of("Asia/Seoul");
        LocalDate today = LocalDate.now(zone);
        YearMonth ym = YearMonth.from(today).minusMonths(Math.max(0, offset));
        LocalDate from = ym.atDay(1);
        LocalDate to = ym.atEndOfMonth();
        String label = ym.getYear() + "년 " + String.format("%02d", ym.getMonthValue()) + "월";

        long contracts = count(HomesContract.class, g);
        long customers = count(HomesCustomer.class, g);
        long pendingToday = em.createQuery(
                "select count(p) from HomesPending p where p.groupId=:g and p.pendingDate=:d", Long.class)
                .setParameter("g", g).setParameter("d", today).getSingleResult();
        long pendingOpen = em.createQuery(
                "select count(p) from HomesPending p where p.groupId=:g and (p.status is null or p.status not in ('해결','완료','취소'))", Long.class)
                .setParameter("g", g).getSingleResult();
        long bookingOpen = em.createQuery(
                "select count(b) from HomesBooking b where b.groupId=:g and b.status='접수'", Long.class)
                .setParameter("g", g).getSingleResult();
        long bookingTotal = count(HomesBooking.class, g);
        BigDecimal abookMonth = em.createQuery(
                "select coalesce(sum(a.amount),0) from HomesAbook a where a.groupId=:g and a.entryDate between :f and :t and a.entryType in ('입금','수입','카드입금')", BigDecimal.class)
                .setParameter("g", g).setParameter("f", from).setParameter("t", to).getSingleResult();
        BigDecimal settleMonth = em.createQuery(
                "select coalesce(sum(c.settleAmount),0) from HomesContract c where c.groupId=:g and c.contractDate between :f and :t", BigDecimal.class)
                .setParameter("g", g).setParameter("f", from).setParameter("t", to).getSingleResult();

        List<HomesContract> monthContracts = em.createQuery(
                "select c from HomesContract c where c.groupId=:g and c.contractDate between :f and :t", HomesContract.class)
                .setParameter("g", g).setParameter("f", from).setParameter("t", to).getResultList();
        List<HomesContract> internetMonth = monthContracts.stream().filter(c -> !"HOME".equals(c.getKind())).toList();
        List<HomesContract> homeMonth = monthContracts.stream().filter(c -> "HOME".equals(c.getKind())).toList();

        Map<String, Object> internet = contractPanel("인터넷접수", label, offset, internetMonth, from, to, period, false);
        Map<String, Object> home = contractPanel("홈렌탈접수", label, offset, homeMonth, from, to, period, true);

        Map<String, Long> internetCats = categoryCounts(internetMonth, false);
        Map<String, Long> homeCats = categoryCounts(homeMonth, true);

        List<HomesNotice> notices = em.createQuery(
                "select n from HomesNotice n where n.groupId=:g order by n.id desc", HomesNotice.class)
                .setParameter("g", g).setMaxResults(5).getResultList();
        List<HomesQuestion> questions = em.createQuery(
                "select q from HomesQuestion q where q.groupId=:g order by q.id desc", HomesQuestion.class)
                .setParameter("g", g).setMaxResults(5).getResultList();

        Map<String, Object> data = new LinkedHashMap<>();
        data.put("pending", Map.of(
                "open", pendingOpen, "today", pendingToday, "total", Math.max(pendingOpen, pendingToday),
                "spark", List.of(2, 4, 3, 5, 2, 6, 4, 3, 5, 4, 3, 4)));
        data.put("booking", Map.of(
                "open", bookingOpen, "total", Math.max(bookingTotal, bookingOpen),
                "spark", List.of(1, 2, 2, 3, 1, 4, 2, 3, 2, 1, 3, 2)));
        data.put("customer", Map.of(
                "total", customers, "monthNew", customers > 0 ? Math.min(customers, 3) : 0,
                "spark", List.of(3, 5, 4, 6, 5, 7, 4, 6, 5, 8, 6, 7)));
        data.put("abook", Map.of(
                "monthAmount", abookMonth, "settleMonth", settleMonth,
                "spark", List.of(4, 3, 5, 2, 6, 4, 5, 3, 4, 5, 3, 4)));
        data.put("contractTotal", contracts);
        data.put("customerTotal", customers);
        data.put("postits", loadHomesPostits(g).stream().limit(6).toList());
        data.put("internet", internet);
        data.put("home", home);
        data.put("internetStatus", Map.of("total", internetCats.values().stream().mapToLong(Long::longValue).sum(), "items", internetCats));
        data.put("homeStatus", Map.of("total", homeCats.values().stream().mapToLong(Long::longValue).sum(), "items", homeCats));
        data.put("notices", notices.stream().map(this::noticeMap).toList());
        data.put("questions", questions.stream().map(q -> {
            Map<String, Object> m = new LinkedHashMap<>();
            m.put("id", q.getId());
            m.put("title", q.getTitle());
            m.put("createdDate", q.getCreatedAt() == null ? null : LocalDate.ofInstant(q.getCreatedAt(), zone).toString());
            return m;
        }).toList());
        data.put("csPhone", "0000-0000");
        return data;
    }

    private Map<String, Object> contractPanel(String title, String label, int offset, List<HomesContract> list,
                                              LocalDate from, LocalDate to, String period, boolean home) {
        long apply = list.stream().filter(c -> "신청".equals(c.getStatus())).count();
        long accept = list.stream().filter(c -> "접수".equals(c.getStatus())).count();
        long done = list.stream().filter(c -> "완료".equals(c.getStatus())).count();
        long cancel = list.stream().filter(c -> "철회".equals(c.getStatus()) || "취소".equals(c.getStatus())).count();
        BigDecimal settle = list.stream().map(c -> c.getSettleAmount() == null ? BigDecimal.ZERO : c.getSettleAmount())
                .reduce(BigDecimal.ZERO, BigDecimal::add);

        List<Map<String, Object>> daily = new ArrayList<>();
        int days = to.getDayOfMonth();
        Map<Integer, Long> byDay = new LinkedHashMap<>();
        for (int d = 1; d <= days; d++) byDay.put(d, 0L);
        for (HomesContract c : list) {
            if (c.getContractDate() == null) continue;
            int d = c.getContractDate().getDayOfMonth();
            byDay.put(d, byDay.getOrDefault(d, 0L) + 1);
        }
        for (Map.Entry<Integer, Long> e : byDay.entrySet()) {
            daily.add(Map.of("day", e.getKey(), "value", e.getValue()));
        }
        // decorative demo when empty
        if (list.isEmpty()) {
            int[] demo = home
                    ? new int[]{0, 0, 1, 0, 2, 1, 0, 1, 0, 0, 1, 2, 0, 1, 0, 0, 1, 0, 2, 1, 0, 0, 1, 0, 1, 0, 0, 1, 0, 0, 1}
                    : new int[]{0, 1, 0, 2, 1, 0, 0, 1, 0, 2, 0, 1, 0, 0, 1, 2, 0, 1, 0, 0, 1, 0, 2, 1, 0, 0, 1, 0, 0, 0, 1};
            daily.clear();
            for (int d = 1; d <= days; d++) {
                daily.add(Map.of("day", d, "value", demo[Math.min(d - 1, demo.length - 1)]));
            }
        }

        Map<String, Object> m = new LinkedHashMap<>();
        m.put("title", title);
        m.put("label", label);
        m.put("offset", offset);
        m.put("period", period);
        m.put("apply", apply);
        m.put("accept", accept);
        m.put("done", done);
        m.put("cancel", cancel);
        m.put("settleAmount", settle);
        m.put("count", list.size());
        m.put("daily", daily);
        return m;
    }

    private Map<String, Long> categoryCounts(List<HomesContract> list, boolean home) {
        String[] keys = home
                ? new String[]{"정수기", "공기청정기", "비데", "매트리스", "가전", "기타"}
                : new String[]{"인터넷", "TV", "전화", "와이파이", "스마트홈", "기타"};
        Map<String, Long> map = new LinkedHashMap<>();
        for (String k : keys) map.put(k, 0L);
        for (HomesContract c : list) {
            String products = c.getProducts();
            if (products == null || products.isBlank()) {
                map.put("기타", map.get("기타") + 1);
                continue;
            }
            boolean matched = false;
            for (String k : keys) {
                if (!"기타".equals(k) && products.contains(k)) {
                    map.put(k, map.get(k) + 1);
                    matched = true;
                }
            }
            if (!matched) map.put("기타", map.get("기타") + 1);
        }
        // demo filler when all zero
        long sum = map.values().stream().mapToLong(Long::longValue).sum();
        if (sum == 0) {
            if (home) {
                map.put("정수기", 2L); map.put("공기청정기", 1L); map.put("비데", 1L);
                map.put("매트리스", 1L); map.put("가전", 0L); map.put("기타", 1L);
            } else {
                map.put("인터넷", 3L); map.put("TV", 2L); map.put("전화", 1L);
                map.put("와이파이", 1L); map.put("스마트홈", 0L); map.put("기타", 1L);
            }
        }
        return map;
    }

    private List<Map<String, Object>> loadHomesPostits(String groupId) {
        List<Map<String, Object>> defaults = defaultHomesPostits();
        List<Setting> rows = em.createQuery(
                "select e from Setting e where e.groupId=:g and e.settingKey='postits'", Setting.class)
                .setParameter("g", groupId).getResultList();
        if (rows.isEmpty() || rows.getFirst().getValue() == null || rows.getFirst().getValue().isBlank()) return defaults;
        try {
            @SuppressWarnings("unchecked")
            List<Map<String, Object>> parsed = mapper.readValue(rows.getFirst().getValue(), List.class);
            for (int i = 0; i < Math.min(8, parsed.size()); i++) {
                Map<String, Object> d = defaults.get(i);
                d.putAll(parsed.get(i));
                d.put("slot", i + 1);
                d.put("title", "포스트잇 #" + (i + 1));
            }
        } catch (Exception ignored) {}
        return defaults;
    }

    private void saveHomesPostits(String groupId, List<Map<String, Object>> list) {
        String json;
        try { json = mapper.writeValueAsString(list); } catch (Exception e) { json = "[]"; }
        List<Setting> rows = em.createQuery(
                "select e from Setting e where e.groupId=:g and e.settingKey='postits'", Setting.class)
                .setParameter("g", groupId).getResultList();
        if (rows.isEmpty()) {
            Setting s = new Setting();
            s.setGroupId(groupId);
            s.setSettingKey("postits");
            s.setValue(json);
            em.persist(s);
        } else {
            rows.getFirst().setValue(json);
        }
    }

    private List<Map<String, Object>> defaultHomesPostits() {
        Object[][] samples = {
                {"신규 인터넷 접수 확인", "홍길동", "26-07-08"},
                {"홈렌탈 설치 일정 조율", "홍길동", "26-07-07"},
                {"약속 미결 처리 요청", "김대표", "26-07-05"},
                {"정산서 마감 체크", "홍길동", "26-06-28"},
                {"고객 분류 정리", "홍총괄", "26-06-20"},
                {"예약 접수 현황 점검", "홍길동", "26-06-12"},
                {"고객센터 휴무 안내", "김대표", "26-05-29"},
                {"월간 리포트 작성", "홍총괄", "26-05-15"},
        };
        List<Map<String, Object>> list = new ArrayList<>();
        for (int i = 0; i < 8; i++) {
            Map<String, Object> m = new LinkedHashMap<>();
            m.put("slot", i + 1);
            m.put("title", "포스트잇 #" + (i + 1));
            m.put("contents", samples[i][0]);
            m.put("authorName", samples[i][1]);
            m.put("noteDate", samples[i][2]);
            list.add(m);
        }
        return list;
    }

    // ---- Products ----
    @GetMapping("/products")
    ApiResponse products(@AuthenticationPrincipal JwtUser u, @RequestParam Map<String, String> f,
                         @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "15") int size) {
        List<HomesProduct> all = listAll(HomesProduct.class, u);
        String kind = blankToNull(f.get("kind"));
        String category = blankToNull(f.get("category"));
        if ("ALL".equalsIgnoreCase(category)) category = null;
        String company = blankToNull(f.get("company"));
        String q = qOf(f);
        String cat = category;
        List<HomesProduct> base = all.stream().filter(p -> kind == null || kind.equals(p.getKind())).toList();
        List<HomesProduct> filtered = base.stream().filter(p -> {
            if (cat != null && !cat.equals(p.getCategory())) return false;
            if (company != null && !company.equals(p.getCompany())) return false;
            if (q != null) {
                String hay = (nullToEmpty(p.getName()) + " " + nullToEmpty(p.getModel()) + " " + nullToEmpty(p.getCompany())).toLowerCase();
                if (!hay.contains(q.toLowerCase())) return false;
            }
            return true;
        }).toList();
        Map<String, Long> counts = new LinkedHashMap<>();
        counts.put("ALL", (long) base.size());
        base.stream().collect(Collectors.groupingBy(p -> nullToEmpty(p.getCategory()), Collectors.counting()))
                .forEach(counts::put);
        return pageOk(filtered, page, size, this::productMap, counts);
    }

    @GetMapping("/products/{id}") ApiResponse productGet(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) {
        return ApiResponse.ok(productMap(crud.get(HomesProduct.class, id, u)));
    }
    @PostMapping("/products") @Transactional ApiResponse productPost(@RequestBody Map<String, Object> d, @AuthenticationPrincipal JwtUser u) {
        d.putIfAbsent("policyCount", 0);
        HomesProduct p = crud.create(HomesProduct.class, d, u);
        writeProductLog(u, p, "등록");
        return ApiResponse.ok(productMap(p));
    }

    @PostMapping("/products/sync") @Transactional
    ApiResponse productSync(@AuthenticationPrincipal JwtUser u, @RequestBody(required = false) Map<String, Object> body) {
        String kind = body != null && body.get("kind") != null ? String.valueOf(body.get("kind")) : "INTERNET";
        List<HomesProduct> existing = listAll(HomesProduct.class, u).stream()
                .filter(p -> kind.equals(p.getKind())).toList();
        Set<String> keys = existing.stream()
                .map(p -> p.getCompany() + "|" + p.getCategory() + "|" + p.getName() + "|" + nullToEmpty(p.getModel()))
                .collect(Collectors.toSet());
        int added = 0;
        for (Object[] row : baseProductCatalog()) {
            if (!kind.equals(String.valueOf(row[0]))) continue;
            String model = row[4] == null ? "" : String.valueOf(row[4]);
            String key = row[2] + "|" + row[1] + "|" + row[3] + "|" + model;
            if (keys.contains(key)) continue;
            HomesProduct p = new HomesProduct();
            p.setGroupId(u.getGroupId());
            p.setKind(String.valueOf(row[0]));
            p.setCategory(String.valueOf(row[1]));
            p.setCompany(String.valueOf(row[2]));
            p.setName(String.valueOf(row[3]));
            p.setModel(model.isBlank() ? null : model);
            p.setMonths((Integer) row[5]);
            p.setPolicyAmount(row[6] == null ? null : new BigDecimal(String.valueOf(row[6])));
            p.setPolicyCount(row[7] == null ? 0 : (Integer) row[7]);
            em.persist(p);
            added++;
        }
        String kindLabel = "HOME".equals(kind) ? "홈렌탈상품" : "인터넷상품";
        writeLog(u, "PRODUCT", "기본상품-동기화", kindLabel + " 기본상품 " + added + "건을 동기화하였습니다.");
        return ApiResponse.ok(Map.of("added", added));
    }

    @PutMapping("/products/{id}") @Transactional ApiResponse productPut(@PathVariable Long id, @RequestBody Map<String, Object> d, @AuthenticationPrincipal JwtUser u) {
        HomesProduct p = crud.update(HomesProduct.class, id, d, u);
        writeProductLog(u, p, "수정");
        return ApiResponse.ok(productMap(p));
    }
    @DeleteMapping("/products/{id}") @Transactional ApiResponse productDel(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) {
        HomesProduct p = crud.get(HomesProduct.class, id, u);
        em.createQuery("delete from HomesProductPolicy pol where pol.productId=:id and pol.groupId=:g")
                .setParameter("id", id).setParameter("g", u.getGroupId()).executeUpdate();
        writeProductLog(u, p, "삭제");
        crud.delete(HomesProduct.class, id, u);
        return ApiResponse.ok(null);
    }

    @GetMapping("/products/{id}/policies")
    ApiResponse productPolicies(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) {
        HomesProduct p = crud.get(HomesProduct.class, id, u);
        List<HomesProductPolicy> list = em.createQuery(
                        "select pol from HomesProductPolicy pol where pol.groupId=:g and pol.productId=:id order by pol.id",
                        HomesProductPolicy.class)
                .setParameter("g", u.getGroupId()).setParameter("id", id).getResultList();
        return ApiResponse.ok(Map.of("product", productMap(p), "list", list.stream().map(this::productPolicyMap).toList()));
    }

    @PostMapping("/products/{id}/policies") @Transactional
    ApiResponse productPolicyPost(@PathVariable Long id, @RequestBody Map<String, Object> d, @AuthenticationPrincipal JwtUser u) {
        HomesProduct p = crud.get(HomesProduct.class, id, u);
        d.put("productId", id);
        HomesProductPolicy pol = crud.create(HomesProductPolicy.class, d, u);
        refreshProductPolicyCount(p);
        String kindLabel = productKindLabel(p);
        String policyName = d.get("name") == null ? p.getName() : String.valueOf(d.get("name"));
        writeLog(u, "PRODUCT", kindLabel + "-정책-등록", kindLabel + " 정책 [" + policyName + "] 등록하였습니다.");
        return ApiResponse.ok(productPolicyMap(pol));
    }

    @DeleteMapping("/products/policies/{policyId}") @Transactional
    ApiResponse productPolicyDel(@PathVariable Long policyId, @AuthenticationPrincipal JwtUser u) {
        HomesProductPolicy pol = crud.get(HomesProductPolicy.class, policyId, u);
        Long productId = pol.getProductId();
        crud.delete(HomesProductPolicy.class, policyId, u);
        HomesProduct p = em.find(HomesProduct.class, productId);
        if (p != null && Objects.equals(p.getGroupId(), u.getGroupId())) refreshProductPolicyCount(p);
        return ApiResponse.ok(null);
    }

    private void refreshProductPolicyCount(HomesProduct p) {
        long c = em.createQuery("select count(pol) from HomesProductPolicy pol where pol.productId=:id", Long.class)
                .setParameter("id", p.getId()).getSingleResult();
        p.setPolicyCount((int) c);
        if (c > 0) {
            BigDecimal amt = em.createQuery(
                            "select pol.amount from HomesProductPolicy pol where pol.productId=:id order by pol.id", BigDecimal.class)
                    .setParameter("id", p.getId()).setMaxResults(1).getResultList().stream().findFirst().orElse(p.getPolicyAmount());
            p.setPolicyAmount(amt);
        }
    }

    private static Object[][] baseProductCatalog() {
        return new Object[][]{
                // kind, category, company, name, model, months, policyAmount, policyCount
                {"INTERNET", "인터넷", "KT", "안심 인터넷 베이직 와이드", null, 36, "80000", 3},
                {"INTERNET", "인터넷", "KT", "인터넷 슬림", null, 36, "50000", 1},
                {"INTERNET", "인터넷", "KT", "기가인터넷 500M", null, 36, "80000", 0},
                {"INTERNET", "인터넷", "KT", "기가인터넷 1G", null, 36, "90000", 2},
                {"INTERNET", "인터넷", "KT", "인터넷 베이직 와이파이", null, 36, "60000", 1},
                {"INTERNET", "인터넷", "KT", "인터넷 에센스", null, 36, "70000", 0},
                {"INTERNET", "인터넷", "KT", "인터넷 베이직", null, 36, "55000", 0},
                {"INTERNET", "인터넷", "KT", "인터넷 프리미엄", null, 36, "100000", 3},
                {"INTERNET", "인터넷", "SKB", "B tv 인터넷", null, 36, "75000", 1},
                {"INTERNET", "인터넷", "LGU+", "U+ 인터넷", null, 36, "70000", 0},
                {"INTERNET", "TV", "KT", "올레tv 베이직", null, 36, "30000", 1},
                {"INTERNET", "TV", "KT", "올레tv 슬림", null, 36, "25000", 0},
                {"INTERNET", "TV", "KT", "지니 TV 프리미엄", null, 36, "40000", 2},
                {"INTERNET", "전화", "KT", "집전화 기본", null, 24, "10000", 0},
                {"INTERNET", "전화", "KT", "집전화 스마트", null, 24, "15000", 1},
                {"INTERNET", "와이파이", "KT", "와이파이 공유기", null, 24, "15000", 0},
                {"INTERNET", "와이파이", "SKB", "와이파이 메시", null, 24, "20000", 1},
                {"INTERNET", "스마트홈", "LGU+", "스마트홈 패키지", null, 36, "20000", 0},
                {"INTERNET", "스마트홈", "KT", "GiGA IoT 홈캠", null, 36, "25000", 1},
                {"INTERNET", "기타", "KT", "기타 부가상품", null, 12, "5000", 0},
                {"HOME", "정수기", "청호나이스", "450 (냉온정)", "WP-40C9500M", 60, "120000", 1},
                {"HOME", "정수기", "청호나이스", "550 (얼음냉온정)", "WI-55S9500M", 60, "150000", 0},
                {"HOME", "정수기", "청호나이스", "Compact (정)", "WP-CP1000", 60, "90000", 0},
                {"HOME", "정수기", "청호나이스", "700 (얼음냉온정)", "WI-70S9500M", 60, "180000", 1},
                {"HOME", "정수기", "코웨이", "아이콘 정수기", "CHP-7210N", 60, "130000", 0},
                {"HOME", "정수기", "코웨이", "냉온정수기", "CHP-5610L", 36, "110000", 1},
                {"HOME", "정수기", "SK매직", "스스로살균 정수기", "WPU-A110C", 60, "100000", 0},
                {"HOME", "정수기", "LG전자", "퓨리케어 오브제", "WD503AS", 60, "140000", 0},
                {"HOME", "공기청정기", "LG전자", "퓨리케어 360", "AS180DWFA", 36, "90000", 0},
                {"HOME", "공기청정기", "코웨이", "에어메가", "AP-2021A", 36, "85000", 1},
                {"HOME", "공기청정기", "삼성", "비스포크 큐브", "AX60A", 36, "95000", 0},
                {"HOME", "비데", "SK매직", "비데 기본형", "BIDET-01", 36, "50000", 0},
                {"HOME", "비데", "코웨이", "스스로케어 비데", "BAU-20", 36, "60000", 1},
                {"HOME", "비데", "청호나이스", "방수비데", "BA-17", 0, "45000", 0},
                {"HOME", "매트리스", "코웨이", "매트리스 퀸", "MT-Q", 60, "150000", 0},
                {"HOME", "매트리스", "코웨이", "매트리스 킹", "MT-K", 60, "180000", 1},
                {"HOME", "매트리스", "시몬스", "뷰티레스트", "BR-Q", 0, "200000", 0},
                {"HOME", "가전", "삼성", "김치냉장고", "RQ33A74", 36, "80000", 0},
                {"HOME", "가전", "LG전자", "디오스 냉장고", "M872GBB", 36, "100000", 0},
                {"HOME", "가전", "위닉스", "제습기", "DXAE100-KEK", 24, "40000", 1},
                {"HOME", "기타", "기타", "렌탈 기타상품", "ETC-01", 12, "30000", 0},
        };
    }

    // ---- Contracts ----
    @GetMapping("/contracts")
    ApiResponse contracts(@AuthenticationPrincipal JwtUser u, @RequestParam Map<String, String> f,
                          @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "15") int size) {
        List<HomesContract> all = listAll(HomesContract.class, u);
        String kind = blankToNull(f.get("kind"));
        if ("ALL".equalsIgnoreCase(kind)) kind = null;
        String status = blankToNull(f.get("status"));
        if ("ALL".equalsIgnoreCase(status)) status = null;
        LocalDate from = parseDate(f.get("dateFrom"));
        LocalDate to = parseDate(f.get("dateTo"));
        String agency = blankToNull(f.get("agency"));
        String employee = blankToNull(f.get("employee"));
        String searchType = blankToNull(f.get("searchType"));
        String q = qOf(f);
        String k = kind; String st = status;
        List<HomesContract> base = all.stream().filter(c -> k == null || k.equals(c.getKind())).toList();
        List<HomesContract> filtered = base.stream().filter(c -> {
            if (st != null && !st.equals(c.getStatus())) return false;
            if (from != null && (c.getContractDate() == null || c.getContractDate().isBefore(from))) return false;
            if (to != null && (c.getContractDate() == null || c.getContractDate().isAfter(to))) return false;
            if (agency != null && !agency.equals(c.getAgency())) return false;
            if (employee != null && !employee.equals(c.getEmployee())) return false;
            if (q != null) {
                String hay;
                if ("거래처".equals(searchType)) hay = nullToEmpty(c.getAgency());
                else if ("직원".equals(searchType)) hay = nullToEmpty(c.getEmployee());
                else if ("연락처".equals(searchType)) hay = nullToEmpty(c.getPhone());
                else if ("가입상품".equals(searchType)) hay = nullToEmpty(c.getProducts());
                else hay = nullToEmpty(c.getCustomerName()) + " " + nullToEmpty(c.getPhone()) + " " + nullToEmpty(c.getProducts());
                if (!hay.toLowerCase().contains(q.toLowerCase())) return false;
            }
            return true;
        }).toList();
        Map<String, Long> counts = new LinkedHashMap<>();
        counts.put("ALL", (long) base.size());
        for (String s : List.of("접수", "신청", "완료", "철회")) {
            counts.put(s, base.stream().filter(c -> s.equals(c.getStatus())).count());
        }
        Map<String, Object> data = new LinkedHashMap<>();
        int fromIdx = Math.max(0, page) * Math.max(1, size);
        data.put("total", filtered.size());
        data.put("list", filtered.stream().skip(fromIdx).limit(Math.min(Math.max(size, 1), 200)).map(this::contractMap).toList());
        data.put("counts", counts);
        data.put("agencies", base.stream().map(HomesContract::getAgency).filter(Objects::nonNull).filter(s -> !s.isBlank()).distinct().sorted().toList());
        data.put("employees", base.stream().map(HomesContract::getEmployee).filter(Objects::nonNull).filter(s -> !s.isBlank()).distinct().sorted().toList());
        BigDecimal settleSum = filtered.stream().map(HomesContract::getSettleAmount).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add);
        BigDecimal marginSum = filtered.stream().map(HomesContract::getMargin).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add);
        data.put("settleSum", settleSum);
        data.put("marginSum", marginSum);
        return ApiResponse.ok(data);
    }

    @GetMapping("/contracts/adjust")
    ApiResponse contractsAdjust(@AuthenticationPrincipal JwtUser u, @RequestParam Map<String, String> f) {
        LocalDate from = parseDate(f.get("dateFrom"));
        LocalDate to = parseDate(f.get("dateTo"));
        if (from == null || to == null) return ApiResponse.fail("계약일을 선택하세요.");
        if (to.isBefore(from)) return ApiResponse.fail("날짜 범위가 올바르지 않습니다.");
        if (java.time.temporal.ChronoUnit.DAYS.between(from, to) > 30) return ApiResponse.fail("최대 31일까지 조회할 수 있습니다.");
        String kind = blankToNull(f.get("kind"));
        String category = blankToNull(f.get("category"));
        String product = blankToNull(f.get("product"));
        String months = blankToNull(f.get("months"));
        String status = blankToNull(f.get("status"));
        String employee = blankToNull(f.get("employee"));
        List<HomesContract> list = listAll(HomesContract.class, u).stream().filter(c -> {
            if (c.getContractDate() == null || c.getContractDate().isBefore(from) || c.getContractDate().isAfter(to)) return false;
            if (kind != null && !kind.equals(c.getKind())) return false;
            if (status != null && !status.equals(c.getStatus())) return false;
            if (employee != null && !employee.equals(c.getEmployee())) return false;
            if (months != null) {
                Integer m = c.getMonths();
                if (m == null || !months.equals(String.valueOf(m))) return false;
            }
            String products = nullToEmpty(c.getProducts());
            if (product != null && !products.toLowerCase().contains(product.toLowerCase())) return false;
            if (category != null) {
                if (!products.contains(category) && !nullToEmpty(c.getMemo()).contains(category)) return false;
            }
            if (products.matches("(?is).*\\bx\\s*([2-9]|\\d{2,})\\b.*")) return false;
            return true;
        }).toList();
        Set<String> employees = listAll(HomesContract.class, u).stream()
                .map(HomesContract::getEmployee).filter(Objects::nonNull).filter(s -> !s.isBlank()).collect(Collectors.toCollection(TreeSet::new));
        Set<String> products = listAll(HomesContract.class, u).stream()
                .map(HomesContract::getProducts).filter(Objects::nonNull).flatMap(p -> Arrays.stream(p.split("[,/]")))
                .map(String::trim).filter(s -> !s.isBlank()).collect(Collectors.toCollection(TreeSet::new));
        Map<String, Object> data = new LinkedHashMap<>();
        data.put("total", list.size());
        data.put("list", list.stream().map(this::contractMap).toList());
        data.put("employees", employees);
        data.put("products", products);
        return ApiResponse.ok(data);
    }

    @PostMapping("/contracts/adjust/batch") @Transactional
    ApiResponse contractsAdjustBatch(@RequestBody Map<String, Object> body, @AuthenticationPrincipal JwtUser u) {
        Object raw = body.get("items");
        if (!(raw instanceof List<?> items) || items.isEmpty()) return ApiResponse.fail("변경할 항목이 없습니다.");
        int updated = 0;
        for (Object item : items) {
            if (!(item instanceof Map<?, ?> m)) continue;
            Object idObj = m.get("id");
            if (idObj == null) continue;
            HomesContract c = em.find(HomesContract.class, Long.valueOf(String.valueOf(idObj)));
            if (c == null || !Objects.equals(c.getGroupId(), u.getGroupId())) continue;
            if (m.containsKey("policySum") && m.get("policySum") != null) c.setPolicySum(new BigDecimal(String.valueOf(m.get("policySum"))));
            if (m.containsKey("settleAmount") && m.get("settleAmount") != null) c.setSettleAmount(new BigDecimal(String.valueOf(m.get("settleAmount"))));
            if (m.containsKey("margin") && m.get("margin") != null) c.setMargin(new BigDecimal(String.valueOf(m.get("margin"))));
            writeLog(u, "CONTRACT",
                    ("HOME".equals(c.getKind()) ? "홈렌탈접수" : "인터넷접수") + "-정책",
                    ("HOME".equals(c.getKind()) ? "홈렌탈접수" : "인터넷접수") + " [" + nullToEmpty(c.getCustomerName()) + "] 정책금액을 변경하였습니다.");
            updated++;
        }
        return ApiResponse.ok(Map.of("updated", updated));
    }

    @GetMapping("/contracts/{id}") ApiResponse contractGet(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) {
        return ApiResponse.ok(contractMap(crud.get(HomesContract.class, id, u)));
    }
    @PostMapping("/contracts") @Transactional ApiResponse contractPost(@RequestBody Map<String, Object> d, @AuthenticationPrincipal JwtUser u) {
        d.putIfAbsent("status", "접수");
        HomesContract c = crud.create(HomesContract.class, d, u);
        writeContractLog(u, c, "등록", null);
        return ApiResponse.ok(contractMap(c));
    }
    @PutMapping("/contracts/{id}") @Transactional ApiResponse contractPut(@PathVariable Long id, @RequestBody Map<String, Object> d, @AuthenticationPrincipal JwtUser u) {
        HomesContract c = crud.update(HomesContract.class, id, d, u);
        writeContractLog(u, c, "수정", null);
        return ApiResponse.ok(contractMap(c));
    }
    @DeleteMapping("/contracts/{id}") @Transactional ApiResponse contractDel(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) {
        HomesContract c = crud.get(HomesContract.class, id, u);
        writeContractLog(u, c, "삭제", null);
        crud.delete(HomesContract.class, id, u);
        return ApiResponse.ok(null);
    }
    @PostMapping("/contracts/delete") @Transactional
    ApiResponse contractsBulkDelete(@RequestBody Map<String, Object> body, @AuthenticationPrincipal JwtUser u) {
        Object raw = body.get("ids");
        if (!(raw instanceof List<?> list) || list.isEmpty()) return ApiResponse.fail("삭제할 계약을 선택하세요.");
        int deleted = 0;
        for (Object o : list) {
            Long id = Long.valueOf(String.valueOf(o));
            HomesContract c = em.find(HomesContract.class, id);
            if (c == null || !Objects.equals(c.getGroupId(), u.getGroupId())) continue;
            writeContractLog(u, c, "삭제", null);
            em.remove(c);
            deleted++;
        }
        return ApiResponse.ok(Map.of("deleted", deleted));
    }
    @PostMapping("/contracts/status") @Transactional
    ApiResponse contractsBulkStatus(@RequestBody Map<String, Object> body, @AuthenticationPrincipal JwtUser u) {
        Object raw = body.get("ids");
        String status = body.get("status") == null ? null : String.valueOf(body.get("status"));
        if (!(raw instanceof List<?> list) || list.isEmpty()) return ApiResponse.fail("계약을 선택하세요.");
        if (status == null || status.isBlank()) return ApiResponse.fail("상태를 선택하세요.");
        int updated = 0;
        for (Object o : list) {
            Long id = Long.valueOf(String.valueOf(o));
            HomesContract c = em.find(HomesContract.class, id);
            if (c == null || !Objects.equals(c.getGroupId(), u.getGroupId())) continue;
            c.setStatus(status);
            writeContractLog(u, c, "상태", status);
            updated++;
        }
        return ApiResponse.ok(Map.of("updated", updated));
    }

    // ---- Balances ----
    @GetMapping("/balances")
    ApiResponse balances(@AuthenticationPrincipal JwtUser u, @RequestParam Map<String, String> f,
                         @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "10") int size) {
        List<HomesBalance> filtered = filterBalances(u, f);
        List<HomesBalance> all = listAll(HomesBalance.class, u);
        Map<String, Long> counts = new LinkedHashMap<>();
        counts.put("ALL", (long) all.size());
        counts.put("정산차감", all.stream().filter(b -> "정산차감".equals(normalizeBalanceType(b.getBalanceType()))).count());
        counts.put("정산추가", all.stream().filter(b -> "정산추가".equals(normalizeBalanceType(b.getBalanceType()))).count());
        Set<String> employees = all.stream().filter(b -> "직원".equals(resolveTargetType(b)))
                .map(this::resolveTargetName).filter(s -> !s.isBlank()).collect(Collectors.toCollection(TreeSet::new));
        Set<String> agencies = all.stream().filter(b -> "하부점".equals(resolveTargetType(b)))
                .map(this::resolveTargetName).filter(s -> !s.isBlank()).collect(Collectors.toCollection(TreeSet::new));
        Set<String> reasons = all.stream().map(HomesBalance::getReason).filter(Objects::nonNull).filter(s -> !s.isBlank())
                .collect(Collectors.toCollection(TreeSet::new));
        // 옵션 보강: 멤버/거래처
        listAll(HomesAgency.class, u).stream().map(HomesAgency::getName).filter(Objects::nonNull).forEach(agencies::add);
        em.createQuery("select m from Member m where m.groupId=:g and m.role='AGENT'", Member.class)
                .setParameter("g", u.getGroupId()).getResultList().stream()
                .map(Member::getName).filter(Objects::nonNull).filter(s -> !s.isBlank()).forEach(employees::add);
        int fromIdx = Math.max(0, page) * Math.max(1, size);
        List<Map<String, Object>> list = filtered.stream().skip(fromIdx).limit(Math.max(size, 1)).map(this::balanceMap).toList();
        Map<String, Object> data = new LinkedHashMap<>();
        data.put("total", filtered.size());
        data.put("list", list);
        data.put("counts", counts);
        data.put("employees", employees);
        data.put("agencies", agencies);
        data.put("reasons", reasons);
        return ApiResponse.ok(data);
    }
    @PostMapping("/balances") @Transactional ApiResponse balancePost(@RequestBody Map<String, Object> d, @AuthenticationPrincipal JwtUser u) {
        normalizeBalancePayload(d);
        return ApiResponse.ok(balanceMap(crud.create(HomesBalance.class, d, u)));
    }
    @PutMapping("/balances/{id}") @Transactional ApiResponse balancePut(@PathVariable Long id, @RequestBody Map<String, Object> d, @AuthenticationPrincipal JwtUser u) {
        normalizeBalancePayload(d);
        return ApiResponse.ok(balanceMap(crud.update(HomesBalance.class, id, d, u)));
    }
    @DeleteMapping("/balances/{id}") @Transactional ApiResponse balanceDel(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) {
        crud.delete(HomesBalance.class, id, u); return ApiResponse.ok(null);
    }
    @PostMapping("/balances/delete") @Transactional
    ApiResponse balancesBulkDelete(@RequestBody Map<String, Object> body, @AuthenticationPrincipal JwtUser u) {
        Object raw = body.get("ids");
        if (!(raw instanceof List<?> list) || list.isEmpty()) return ApiResponse.fail("삭제할 항목을 선택하세요.");
        int deleted = 0;
        for (Object o : list) {
            Long id = Long.valueOf(String.valueOf(o));
            HomesBalance b = em.find(HomesBalance.class, id);
            if (b == null || !Objects.equals(b.getGroupId(), u.getGroupId())) continue;
            em.remove(b);
            deleted++;
        }
        return ApiResponse.ok(Map.of("deleted", deleted));
    }
    @GetMapping(value = "/balances/export", produces = "text/csv;charset=UTF-8")
    org.springframework.http.ResponseEntity<String> balancesExport(@AuthenticationPrincipal JwtUser u, @RequestParam Map<String, String> f) {
        List<HomesBalance> filtered = filterBalances(u, f);
        StringBuilder csv = new StringBuilder("\uFEFF후정산일,대상,대상명,구분,사유,정산금액,비고\n");
        for (HomesBalance b : filtered) {
            Map<String, Object> row = balanceMap(b);
            csv.append(csvCell(row.get("baseDate"))).append(',')
                    .append(csvCell(row.get("targetType"))).append(',')
                    .append(csvCell(row.get("targetName"))).append(',')
                    .append(csvCell(row.get("balanceType"))).append(',')
                    .append(csvCell(row.get("reason"))).append(',')
                    .append(csvCell(row.get("amount"))).append(',')
                    .append(csvCell(row.get("memo"))).append('\n');
        }
        return org.springframework.http.ResponseEntity.ok()
                .header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=contract-balances.csv")
                .body(csv.toString());
    }

    private List<HomesBalance> filterBalances(JwtUser u, Map<String, String> f) {
        List<HomesBalance> all = listAll(HomesBalance.class, u);
        String tab = blankToNull(f.get("tab"));
        if (tab == null) tab = blankToNull(f.get("balanceType"));
        if ("ALL".equalsIgnoreCase(tab)) tab = null;
        LocalDate from = parseDate(f.get("dateFrom"));
        LocalDate to = parseDate(f.get("dateTo"));
        String targetType = blankToNull(f.get("targetType"));
        String targetName = blankToNull(f.get("targetName"));
        String employee = blankToNull(f.get("employee"));
        String agency = blankToNull(f.get("agency"));
        String reason = blankToNull(f.get("reason"));
        String q = qOf(f);
        String t = tab;
        return all.stream().filter(b -> {
            String bt = normalizeBalanceType(b.getBalanceType());
            if (t != null && !t.equals(bt)) return false;
            if (from != null && (b.getBaseDate() == null || b.getBaseDate().isBefore(from))) return false;
            if (to != null && (b.getBaseDate() == null || b.getBaseDate().isAfter(to))) return false;
            String tt = resolveTargetType(b);
            String tn = resolveTargetName(b);
            if (targetType != null && !targetType.equals(tt)) return false;
            if (targetName != null && !targetName.equals(tn)) return false;
            if (employee != null && (!"직원".equals(tt) || !employee.equals(tn))) return false;
            if (agency != null && (!"하부점".equals(tt) || !agency.equals(tn))) return false;
            if (reason != null && !reason.equals(nullToEmpty(b.getReason()))) return false;
            if (q != null) {
                String hay = (tn + " " + nullToEmpty(b.getReason()) + " " + nullToEmpty(b.getMemo())
                        + " " + nullToEmpty(b.getCustomerName()) + " " + nullToEmpty(b.getAgency())).toLowerCase();
                if (!hay.contains(q.toLowerCase())) return false;
            }
            return true;
        }).sorted(Comparator.comparing(HomesBalance::getBaseDate, Comparator.nullsLast(Comparator.reverseOrder()))
                .thenComparing(HomesBalance::getId, Comparator.reverseOrder())).toList();
    }

    private void normalizeBalancePayload(Map<String, Object> d) {
        if (d.get("balanceType") != null) d.put("balanceType", normalizeBalanceType(String.valueOf(d.get("balanceType"))));
        if (d.get("targetType") != null) {
            String tt = String.valueOf(d.get("targetType"));
            d.put("targetType", tt);
            if ("하부점".equals(tt) && d.get("targetName") != null) d.put("agency", d.get("targetName"));
            if ("직원".equals(tt) && d.get("targetName") != null) d.put("customerName", d.get("targetName"));
        }
        if (d.get("amount") != null) {
            BigDecimal amt = new BigDecimal(String.valueOf(d.get("amount")));
            String bt = normalizeBalanceType(d.get("balanceType") == null ? null : String.valueOf(d.get("balanceType")));
            if ("정산차감".equals(bt) && amt.signum() > 0) amt = amt.negate();
            if ("정산추가".equals(bt) && amt.signum() < 0) amt = amt.abs();
            d.put("amount", amt);
        }
    }

    private static String normalizeBalanceType(String t) {
        if (t == null || t.isBlank()) return t;
        if ("차감".equals(t) || "정산차감".equals(t)) return "정산차감";
        if ("추가".equals(t) || "정산추가".equals(t)) return "정산추가";
        return t;
    }

    private String resolveTargetType(HomesBalance b) {
        if (b.getTargetType() != null && !b.getTargetType().isBlank()) return b.getTargetType();
        if (b.getAgency() != null && !b.getAgency().isBlank() && (b.getCustomerName() == null || b.getCustomerName().isBlank())) return "하부점";
        return "직원";
    }

    private String resolveTargetName(HomesBalance b) {
        if (b.getTargetName() != null && !b.getTargetName().isBlank()) return b.getTargetName();
        if ("하부점".equals(resolveTargetType(b))) return nullToEmpty(b.getAgency());
        if (b.getCustomerName() != null && !b.getCustomerName().isBlank()) return b.getCustomerName();
        return nullToEmpty(b.getAgency());
    }

    private static String csvCell(Object value) {
        String s = String.valueOf(value == null ? "" : value);
        return "\"" + s.replace("\"", "\"\"") + "\"";
    }

    // ---- Invoices ----
    @GetMapping("/invoices")
    ApiResponse invoices(@AuthenticationPrincipal JwtUser u, @RequestParam Map<String, String> f,
                         @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "15") int size) {
        List<HomesInvoice> all = listAll(HomesInvoice.class, u);
        String scope = blankToNull(f.get("scope"));
        String ym = blankToNull(f.get("yearMonth"));
        String q = qOf(f);
        List<HomesInvoice> filtered = all.stream().filter(i -> {
            if (scope != null && !scope.equals(i.getScope())) return false;
            if (ym != null && !ym.equals(i.getYearMonth())) return false;
            if ("MY".equals(scope)) {
                YearMonth limit = YearMonth.now().minusMonths(11);
                if (i.getYearMonth() != null) {
                    try {
                        if (YearMonth.parse(i.getYearMonth()).isBefore(limit)) return false;
                    } catch (Exception ignored) {}
                }
            }
            if (q != null) {
                String hay = (nullToEmpty(i.getEmployee()) + " " + nullToEmpty(i.getAgency()) + " " + nullToEmpty(i.getYearMonth())).toLowerCase();
                if (!hay.contains(q.toLowerCase())) return false;
            }
            return true;
        }).toList();
        return pageOk(filtered, page, size, this::invoiceMap, null);
    }

    @GetMapping("/invoices/my")
    ApiResponse invoicesMy(@AuthenticationPrincipal JwtUser u) {
        String myName = resolveMemberName(u);
        YearMonth limit = YearMonth.now().minusMonths(11);
        List<HomesInvoice> list = listAll(HomesInvoice.class, u).stream()
                .filter(i -> Boolean.TRUE.equals(i.getIssued()) || "발행".equals(i.getStatus()) || "확정".equals(i.getStatus()))
                .filter(i -> {
                    if (i.getYearMonth() == null || i.getYearMonth().isBlank()) return false;
                    try { return !YearMonth.parse(i.getYearMonth()).isBefore(limit); }
                    catch (Exception e) { return false; }
                })
                .filter(i -> myName == null || myName.isBlank() || myName.equals(i.getEmployee()))
                .sorted(Comparator.comparing(HomesInvoice::getYearMonth, Comparator.nullsLast(Comparator.reverseOrder()))
                        .thenComparing(HomesInvoice::getId, Comparator.reverseOrder()))
                .toList();
        Map<String, Object> data = new LinkedHashMap<>();
        data.put("total", list.size());
        data.put("employee", myName);
        data.put("list", list.stream().map(this::invoiceMap).toList());
        return ApiResponse.ok(data);
    }

    @GetMapping(value = "/invoices/{id}/export", produces = "text/csv;charset=UTF-8")
    org.springframework.http.ResponseEntity<String> invoiceExport(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) {
        HomesInvoice inv = crud.get(HomesInvoice.class, id, u);
        Map<String, Object> row = invoiceMap(inv);
        StringBuilder csv = new StringBuilder("\uFEFF기준월,직원,인터넷,인터넷금액,홈렌탈,홈렌탈금액,후정산,후정산금액,정산금액,상태\n");
        csv.append(csvCell(row.get("yearMonth"))).append(',')
                .append(csvCell(row.get("employee"))).append(',')
                .append(csvCell(row.get("internetCount"))).append(',')
                .append(csvCell(row.get("internetAmount"))).append(',')
                .append(csvCell(row.get("homeCount"))).append(',')
                .append(csvCell(row.get("homeAmount"))).append(',')
                .append(csvCell(row.get("balanceCount"))).append(',')
                .append(csvCell(row.get("balanceAmount"))).append(',')
                .append(csvCell(row.get("settleAmount"))).append(',')
                .append(csvCell(row.get("status"))).append('\n');
        return org.springframework.http.ResponseEntity.ok()
                .header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION,
                        "attachment; filename=invoice-" + nullToEmpty(inv.getYearMonth()) + ".csv")
                .body(csv.toString());
    }

    private String resolveMemberName(JwtUser u) {
        List<Member> members = em.createQuery("select m from Member m where m.groupId=:g and m.userid=:uid", Member.class)
                .setParameter("g", u.getGroupId()).setParameter("uid", u.getUserid()).getResultList();
        if (members.isEmpty()) return null;
        return members.getFirst().getName();
    }

    @GetMapping("/invoices/publish")
    ApiResponse invoicesPublish(@AuthenticationPrincipal JwtUser u, @RequestParam Map<String, String> f) {
        String ym = blankToNull(f.get("yearMonth"));
        if (ym == null) ym = YearMonth.now().toString();
        final String yearMonthKey = ym;
        YearMonth yearMonth;
        try { yearMonth = YearMonth.parse(yearMonthKey); }
        catch (Exception e) { return ApiResponse.fail("정산월이 올바르지 않습니다."); }
        LocalDate from = yearMonth.atDay(1);
        LocalDate to = yearMonth.atEndOfMonth();

        // 완료 계약만 집계
        Map<String, List<HomesContract>> byEmp = listAll(HomesContract.class, u).stream()
                .filter(c -> "완료".equals(c.getStatus()))
                .filter(c -> c.getContractDate() != null && !c.getContractDate().isBefore(from) && !c.getContractDate().isAfter(to))
                .filter(c -> c.getEmployee() != null && !c.getEmployee().isBlank())
                .collect(Collectors.groupingBy(HomesContract::getEmployee, LinkedHashMap::new, Collectors.toList()));

        Map<String, List<HomesBalance>> balByEmp = listAll(HomesBalance.class, u).stream()
                .filter(b -> b.getBaseDate() != null && !b.getBaseDate().isBefore(from) && !b.getBaseDate().isAfter(to))
                .collect(Collectors.groupingBy(b -> {
                    String tn = resolveTargetName(b);
                    return tn.isBlank() ? "미지정" : tn;
                }, LinkedHashMap::new, Collectors.toList()));

        Map<String, HomesInvoice> issuedMap = listAll(HomesInvoice.class, u).stream()
                .filter(i -> "PUBLISH".equals(i.getScope()) && yearMonthKey.equals(i.getYearMonth()))
                .filter(i -> i.getEmployee() != null)
                .collect(Collectors.toMap(HomesInvoice::getEmployee, i -> i, (a, b) -> a, LinkedHashMap::new));

        Set<String> names = new TreeSet<>();
        names.addAll(byEmp.keySet());
        // 후정산만 있는 직원도 포함
        for (String n : balByEmp.keySet()) {
            if (byEmp.containsKey(n) || issuedMap.containsKey(n)) names.add(n);
        }
        names.addAll(issuedMap.keySet());

        List<Map<String, Object>> list = new ArrayList<>();
        for (String emp : names) {
            List<HomesContract> contracts = byEmp.getOrDefault(emp, List.of());
            List<HomesBalance> bals = balByEmp.getOrDefault(emp, List.of());
            int internetCount = (int) contracts.stream().filter(c -> "INTERNET".equals(c.getKind())).count();
            int homeCount = (int) contracts.stream().filter(c -> "HOME".equals(c.getKind())).count();
            BigDecimal internetAmount = contracts.stream().filter(c -> "INTERNET".equals(c.getKind()))
                    .map(c -> bd(c.getSettleAmount())).reduce(BigDecimal.ZERO, BigDecimal::add);
            BigDecimal homeAmount = contracts.stream().filter(c -> "HOME".equals(c.getKind()))
                    .map(c -> bd(c.getSettleAmount())).reduce(BigDecimal.ZERO, BigDecimal::add);
            int balanceCount = bals.size();
            BigDecimal balanceAmount = bals.stream().map(b -> bd(b.getAmount())).reduce(BigDecimal.ZERO, BigDecimal::add);
            BigDecimal settleAmount = internetAmount.add(homeAmount).add(balanceAmount);
            if (internetCount + homeCount + balanceCount == 0 && !issuedMap.containsKey(emp)) continue;

            HomesInvoice inv = issuedMap.get(emp);
            Map<String, Object> row = new LinkedHashMap<>();
            row.put("employee", emp);
            row.put("yearMonth", yearMonthKey);
            row.put("internetCount", internetCount);
            row.put("internetAmount", internetAmount);
            row.put("homeCount", homeCount);
            row.put("homeAmount", homeAmount);
            row.put("balanceCount", balanceCount);
            row.put("balanceAmount", balanceAmount);
            row.put("settleAmount", settleAmount);
            row.put("contractCount", internetCount + homeCount);
            if (inv != null) {
                row.put("id", inv.getId());
                row.put("issued", Boolean.TRUE.equals(inv.getIssued()) || "발행".equals(inv.getStatus()) || "확정".equals(inv.getStatus()));
                row.put("status", inv.getStatus());
                row.put("consent", inv.getConsent() == null || inv.getConsent().isBlank() ? "-" : inv.getConsent());
                row.put("taxInvoice", inv.getTaxInvoice() == null || inv.getTaxInvoice().isBlank() ? "-" : inv.getTaxInvoice());
                row.put("withdrawStatus", inv.getWithdrawStatus() == null || inv.getWithdrawStatus().isBlank() ? "-" : inv.getWithdrawStatus());
                BigDecimal snap = inv.getSettleAmount() != null ? inv.getSettleAmount() : bd(inv.getAmount());
                row.put("mismatched", snap.compareTo(settleAmount) != 0);
            } else {
                row.put("id", null);
                row.put("issued", false);
                row.put("status", "미발행");
                row.put("consent", "-");
                row.put("taxInvoice", "-");
                row.put("withdrawStatus", "-");
                row.put("mismatched", false);
            }
            list.add(row);
        }

        Map<String, Object> data = new LinkedHashMap<>();
        data.put("yearMonth", yearMonthKey);
        data.put("total", list.size());
        data.put("list", list);
        return ApiResponse.ok(data);
    }

    @PostMapping("/invoices/publish/issue") @Transactional
    ApiResponse invoicesPublishIssue(@RequestBody Map<String, Object> body, @AuthenticationPrincipal JwtUser u) {
        String ym = body.get("yearMonth") == null ? null : String.valueOf(body.get("yearMonth"));
        String employee = body.get("employee") == null ? null : String.valueOf(body.get("employee"));
        if (ym == null || ym.isBlank() || employee == null || employee.isBlank()) {
            return ApiResponse.fail("정산월과 직원을 확인하세요.");
        }
        // 최신 집계 재계산
        Map<String, String> f = Map.of("yearMonth", ym);
        ApiResponse pub = invoicesPublish(u, f);
        @SuppressWarnings("unchecked")
        Map<String, Object> data = (Map<String, Object>) pub.data();
        @SuppressWarnings("unchecked")
        List<Map<String, Object>> rows = data == null ? List.of() : (List<Map<String, Object>>) data.getOrDefault("list", List.of());
        Map<String, Object> src = rows.stream().filter(r -> employee.equals(String.valueOf(r.get("employee")))).findFirst().orElse(null);
        if (src == null) return ApiResponse.fail("발행할 정산 자료가 없습니다. (완료 계약만 발행 가능)");

        HomesInvoice inv = listAll(HomesInvoice.class, u).stream()
                .filter(i -> "PUBLISH".equals(i.getScope()) && ym.equals(i.getYearMonth()) && employee.equals(i.getEmployee()))
                .findFirst().orElse(null);
        boolean created = false;
        if (inv == null) {
            inv = new HomesInvoice();
            inv.setGroupId(u.getGroupId());
            inv.setScope("PUBLISH");
            inv.setYearMonth(ym);
            inv.setEmployee(employee);
            created = true;
        }
        boolean reissue = !created && (Boolean.TRUE.equals(inv.getIssued()) || "발행".equals(inv.getStatus()) || "확정".equals(inv.getStatus()));
        inv.setInternetCount(((Number) src.getOrDefault("internetCount", 0)).intValue());
        inv.setHomeCount(((Number) src.getOrDefault("homeCount", 0)).intValue());
        inv.setBalanceCount(((Number) src.getOrDefault("balanceCount", 0)).intValue());
        inv.setInternetAmount(bd(src.get("internetAmount")));
        inv.setHomeAmount(bd(src.get("homeAmount")));
        inv.setBalanceAmount(bd(src.get("balanceAmount")));
        inv.setSettleAmount(bd(src.get("settleAmount")));
        inv.setContractCount(((Number) src.getOrDefault("contractCount", 0)).intValue());
        inv.setAmount(bd(src.get("settleAmount")));
        inv.setIssued(true);
        inv.setStatus("발행");
        if (inv.getConsent() == null || inv.getConsent().isBlank()) inv.setConsent("대기");
        if (inv.getTaxInvoice() == null || inv.getTaxInvoice().isBlank()) inv.setTaxInvoice("미발행");
        if (inv.getWithdrawStatus() == null || inv.getWithdrawStatus().isBlank()) inv.setWithdrawStatus("대기");
        if (created) em.persist(inv);
        writeLog(u, "INVOICE", reissue ? "정산서-재발행" : "정산서-발행",
                "정산서 [" + employee + "] " + (reissue ? "재발행" : "발행") + "하였습니다.");
        return ApiResponse.ok(invoiceMap(inv));
    }

    @GetMapping(value = "/invoices/publish/export", produces = "text/csv;charset=UTF-8")
    org.springframework.http.ResponseEntity<String> invoicesPublishExport(@AuthenticationPrincipal JwtUser u, @RequestParam Map<String, String> f) {
        ApiResponse res = invoicesPublish(u, f);
        @SuppressWarnings("unchecked")
        Map<String, Object> data = (Map<String, Object>) res.data();
        @SuppressWarnings("unchecked")
        List<Map<String, Object>> list = data == null ? List.of() : (List<Map<String, Object>>) data.getOrDefault("list", List.of());
        StringBuilder csv = new StringBuilder("\uFEFF직원,인터넷,인터넷금액,홈렌탈,홈렌탈금액,후정산,후정산금액,정산금액,정산서발행,동의,계산서,출금상태\n");
        for (Map<String, Object> row : list) {
            csv.append(csvCell(row.get("employee"))).append(',')
                    .append(csvCell(row.get("internetCount"))).append(',')
                    .append(csvCell(row.get("internetAmount"))).append(',')
                    .append(csvCell(row.get("homeCount"))).append(',')
                    .append(csvCell(row.get("homeAmount"))).append(',')
                    .append(csvCell(row.get("balanceCount"))).append(',')
                    .append(csvCell(row.get("balanceAmount"))).append(',')
                    .append(csvCell(row.get("settleAmount"))).append(',')
                    .append(csvCell(Boolean.TRUE.equals(row.get("issued")) ? "발행" : "미발행")).append(',')
                    .append(csvCell(row.get("consent"))).append(',')
                    .append(csvCell(row.get("taxInvoice"))).append(',')
                    .append(csvCell(row.get("withdrawStatus"))).append('\n');
        }
        return org.springframework.http.ResponseEntity.ok()
                .header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=invoice-publish.csv")
                .body(csv.toString());
    }

    @PostMapping("/invoices") @Transactional ApiResponse invoicePost(@RequestBody Map<String, Object> d, @AuthenticationPrincipal JwtUser u) {
        HomesInvoice inv = crud.create(HomesInvoice.class, d, u);
        writeLog(u, "INVOICE", "정산서-등록",
                "정산서 [" + nullToEmpty(inv.getEmployee()) + "] 등록하였습니다.");
        return ApiResponse.ok(invoiceMap(inv));
    }
    @PutMapping("/invoices/{id}") @Transactional ApiResponse invoicePut(@PathVariable Long id, @RequestBody Map<String, Object> d, @AuthenticationPrincipal JwtUser u) {
        HomesInvoice inv = crud.update(HomesInvoice.class, id, d, u);
        writeLog(u, "INVOICE", "정산서-수정",
                "정산서 [" + nullToEmpty(inv.getEmployee()) + "] 수정하였습니다.");
        return ApiResponse.ok(invoiceMap(inv));
    }
    @DeleteMapping("/invoices/{id}") @Transactional ApiResponse invoiceDel(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) {
        HomesInvoice inv = crud.get(HomesInvoice.class, id, u);
        writeLog(u, "INVOICE", "정산서-삭제",
                "정산서 [" + nullToEmpty(inv.getEmployee()) + "] 삭제하였습니다.");
        crud.delete(HomesInvoice.class, id, u); return ApiResponse.ok(null);
    }

    // ---- Pendings ----
    @GetMapping("/pendings")
    ApiResponse pendings(@AuthenticationPrincipal JwtUser u, @RequestParam Map<String, String> f,
                         @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "10") int size) {
        List<HomesPending> all = listAll(HomesPending.class, u);
        String type = blankToNull(f.get("pendingType"));
        if (type == null) type = blankToNull(f.get("tab"));
        if ("ALL".equalsIgnoreCase(type)) type = null;
        String status = blankToNull(f.get("status"));
        if ("ALL".equalsIgnoreCase(status)) status = null;
        String receiveEmployee = blankToNull(f.get("receiveEmployee"));
        String processEmployee = blankToNull(f.get("processEmployee"));
        String phoneTail = blankToNull(f.get("phoneTail"));
        LocalDate from = parseDate(f.get("dateFrom"));
        LocalDate to = parseDate(f.get("dateTo"));
        String q = qOf(f);
        String t = type;
        String st = status;
        List<HomesPending> filtered = all.stream().filter(p -> {
            if (t != null && !t.equals(p.getPendingType())) return false;
            if (st != null && !normalizePendingStatus(p.getStatus()).equals(st)) return false;
            String recv = resolveReceiveEmployee(p);
            String proc = nullToEmpty(p.getProcessEmployee());
            if (receiveEmployee != null && !receiveEmployee.equals(recv)) return false;
            if (processEmployee != null && !processEmployee.equals(proc)) return false;
            if (phoneTail != null) {
                String digits = nullToEmpty(p.getPhone()).replaceAll("\\D", "");
                if (!digits.endsWith(phoneTail)) return false;
            }
            if (from != null && (p.getPendingDate() == null || p.getPendingDate().isBefore(from))) return false;
            if (to != null && (p.getPendingDate() == null || p.getPendingDate().isAfter(to))) return false;
            if (q != null) {
                String hay = (nullToEmpty(p.getCustomerName()) + " " + nullToEmpty(p.getContents()) + " " + recv + " " + proc).toLowerCase();
                if (!hay.contains(q.toLowerCase())) return false;
            }
            return true;
        }).sorted(Comparator.comparing(HomesPending::getPendingDate, Comparator.nullsLast(Comparator.reverseOrder()))
                .thenComparing(HomesPending::getId, Comparator.reverseOrder())).toList();

        Map<String, Long> counts = new LinkedHashMap<>();
        counts.put("ALL", (long) all.size());
        List<String> typeList = pendingTypeList(u);
        for (String s : typeList) {
            counts.put(s, all.stream().filter(p -> s.equals(p.getPendingType())).count());
        }
        Set<String> receiveEmployees = all.stream().map(this::resolveReceiveEmployee).filter(s -> !s.isBlank())
                .collect(Collectors.toCollection(TreeSet::new));
        Set<String> processEmployees = all.stream().map(HomesPending::getProcessEmployee).filter(Objects::nonNull).filter(s -> !s.isBlank())
                .collect(Collectors.toCollection(TreeSet::new));
        em.createQuery("select m from Member m where m.groupId=:g and m.role='AGENT'", Member.class)
                .setParameter("g", u.getGroupId()).getResultList().stream()
                .map(Member::getName).filter(Objects::nonNull).filter(s -> !s.isBlank())
                .forEach(n -> { receiveEmployees.add(n); processEmployees.add(n); });

        Set<String> statuses = all.stream().map(p -> normalizePendingStatus(p.getStatus())).filter(s -> !s.isBlank())
                .collect(Collectors.toCollection(TreeSet::new));
        statuses.add("접수"); statuses.add("해결");

        Map<String, Object> data = new LinkedHashMap<>();
        int fromIdx = Math.max(0, page) * Math.max(1, size);
        data.put("total", filtered.size());
        data.put("list", filtered.stream().skip(fromIdx).limit(Math.max(size, 1)).map(this::pendingMap).toList());
        data.put("counts", counts);
        data.put("types", typeList);
        data.put("statuses", statuses);
        data.put("receiveEmployees", receiveEmployees);
        data.put("processEmployees", processEmployees);
        return ApiResponse.ok(data);
    }

    @GetMapping("/pendings/types")
    ApiResponse pendingTypesGet(@AuthenticationPrincipal JwtUser u) {
        return ApiResponse.ok(Map.of("types", pendingTypeList(u)));
    }

    @PutMapping("/pendings/types") @Transactional
    ApiResponse pendingTypesPut(@RequestBody Map<String, Object> body, @AuthenticationPrincipal JwtUser u) {
        Object raw = body.get("types");
        List<String> types = new ArrayList<>();
        if (raw instanceof List<?> list) {
            for (Object o : list) {
                if (o == null) continue;
                String s = String.valueOf(o).trim();
                if (!s.isBlank() && !types.contains(s)) types.add(s);
            }
        }
        if (types.isEmpty()) types = new ArrayList<>(List.of("일반", "약속1", "약속2"));
        saveJsonSetting(u.getGroupId(), "homes.pendingTypes", Map.of("types", types));
        return ApiResponse.ok(Map.of("types", types));
    }

    @PostMapping("/pendings") @Transactional ApiResponse pendingPost(@RequestBody Map<String, Object> d, @AuthenticationPrincipal JwtUser u) {
        normalizePendingPayload(d, u);
        HomesPending p = crud.create(HomesPending.class, d, u);
        writeLog(u, "PENDING", "약속-등록", "약속 [" + nullToEmpty(p.getPendingType()) + "] 등록하였습니다.");
        return ApiResponse.ok(pendingMap(p));
    }
    @PutMapping("/pendings/{id}") @Transactional ApiResponse pendingPut(@PathVariable Long id, @RequestBody Map<String, Object> d, @AuthenticationPrincipal JwtUser u) {
        normalizePendingPayload(d, u);
        HomesPending p = crud.update(HomesPending.class, id, d, u);
        writeLog(u, "PENDING", "약속-수정", "약속 [" + nullToEmpty(p.getPendingType()) + "] 수정하였습니다.");
        return ApiResponse.ok(pendingMap(p));
    }
    @DeleteMapping("/pendings/{id}") @Transactional ApiResponse pendingDel(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) {
        HomesPending p = crud.get(HomesPending.class, id, u);
        writeLog(u, "PENDING", "약속-삭제", "약속 [" + nullToEmpty(p.getPendingType()) + "] 삭제하였습니다.");
        crud.delete(HomesPending.class, id, u); return ApiResponse.ok(null);
    }

    @PostMapping("/pendings/process") @Transactional
    ApiResponse pendingsProcess(@RequestBody Map<String, Object> body, @AuthenticationPrincipal JwtUser u) {
        Object raw = body.get("ids");
        if (!(raw instanceof List<?> list) || list.isEmpty()) return ApiResponse.fail("처리할 약속을 선택하세요.");
        String processEmployee = body.get("processEmployee") == null ? resolveMemberName(u) : String.valueOf(body.get("processEmployee"));
        int updated = 0;
        for (Object o : list) {
            Long id = Long.valueOf(String.valueOf(o));
            HomesPending p = em.find(HomesPending.class, id);
            if (p == null || !Objects.equals(p.getGroupId(), u.getGroupId())) continue;
            p.setStatus("해결");
            p.setProcessEmployee(processEmployee);
            p.setProcessDate(LocalDate.now());
            writeLog(u, "PENDING", "약속-해결", "약속 [" + nullToEmpty(p.getPendingType()) + "] 해결완료하였습니다.");
            updated++;
        }
        return ApiResponse.ok(Map.of("updated", updated));
    }

    @PostMapping("/pendings/delete") @Transactional
    ApiResponse pendingsBulkDelete(@RequestBody Map<String, Object> body, @AuthenticationPrincipal JwtUser u) {
        Object raw = body.get("ids");
        if (!(raw instanceof List<?> list) || list.isEmpty()) return ApiResponse.fail("삭제할 약속을 선택하세요.");
        int deleted = 0;
        for (Object o : list) {
            Long id = Long.valueOf(String.valueOf(o));
            HomesPending p = em.find(HomesPending.class, id);
            if (p == null || !Objects.equals(p.getGroupId(), u.getGroupId())) continue;
            writeLog(u, "PENDING", "약속-삭제", "약속 [" + nullToEmpty(p.getPendingType()) + "] 삭제하였습니다.");
            em.remove(p);
            deleted++;
        }
        return ApiResponse.ok(Map.of("deleted", deleted));
    }

    @GetMapping(value = "/pendings/export", produces = "text/csv;charset=UTF-8")
    org.springframework.http.ResponseEntity<String> pendingsExport(@AuthenticationPrincipal JwtUser u, @RequestParam Map<String, String> f) {
        ApiResponse res = pendings(u, f, 0, 10000);
        @SuppressWarnings("unchecked")
        Map<String, Object> data = (Map<String, Object>) res.data();
        @SuppressWarnings("unchecked")
        List<Map<String, Object>> list = data == null ? List.of() : (List<Map<String, Object>>) data.getOrDefault("list", List.of());
        StringBuilder csv = new StringBuilder("\uFEFF상태,일자,과목,내용,고객명,연락처,접수직원,처리일,처리직원,비고\n");
        for (Map<String, Object> row : list) {
            csv.append(csvCell(row.get("status"))).append(',')
                    .append(csvCell(row.get("pendingDate"))).append(',')
                    .append(csvCell(row.get("pendingType"))).append(',')
                    .append(csvCell(row.get("contents"))).append(',')
                    .append(csvCell(row.get("customerName"))).append(',')
                    .append(csvCell(row.get("phone"))).append(',')
                    .append(csvCell(row.get("receiveEmployee"))).append(',')
                    .append(csvCell(row.get("processDate"))).append(',')
                    .append(csvCell(row.get("processEmployee"))).append(',')
                    .append(csvCell(row.get("memo"))).append('\n');
        }
        return org.springframework.http.ResponseEntity.ok()
                .header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=pendings.csv")
                .body(csv.toString());
    }

    private List<String> pendingTypeList(JwtUser u) {
        Map<String, Object> saved = loadJsonSetting(u.getGroupId(), "homes.pendingTypes", Map.of());
        Object raw = saved.get("types");
        List<String> types = new ArrayList<>();
        if (raw instanceof List<?> list) {
            for (Object o : list) {
                if (o == null) continue;
                String s = String.valueOf(o).trim();
                if (!s.isBlank() && !types.contains(s)) types.add(s);
            }
        }
        if (types.isEmpty()) types.addAll(List.of("일반", "약속1", "약속2"));
        return types;
    }

    private void normalizePendingPayload(Map<String, Object> d, JwtUser u) {
        if (d.get("status") != null) d.put("status", normalizePendingStatus(String.valueOf(d.get("status"))));
        if (d.get("receiveEmployee") != null) {
            d.put("employee", d.get("receiveEmployee"));
        } else if (d.get("employee") == null) {
            String name = resolveMemberName(u);
            if (name != null) {
                d.put("receiveEmployee", name);
                d.put("employee", name);
            }
        }
        if (d.containsKey("phone1") || d.containsKey("phone2") || d.containsKey("phone3")) {
            String p1 = d.get("phone1") == null ? "" : String.valueOf(d.get("phone1")).trim();
            String p2 = d.get("phone2") == null ? "" : String.valueOf(d.get("phone2")).trim();
            String p3 = d.get("phone3") == null ? "" : String.valueOf(d.get("phone3")).trim();
            List<String> parts = new ArrayList<>();
            if (!p1.isBlank()) parts.add(p1);
            if (!p2.isBlank()) parts.add(p2);
            if (!p3.isBlank()) parts.add(p3);
            if (!parts.isEmpty()) d.put("phone", String.join("-", parts));
        }
    }

    private static String normalizePendingStatus(String s) {
        if (s == null || s.isBlank()) return "접수";
        if ("예정".equals(s) || "접수".equals(s)) return "접수";
        if ("완료".equals(s) || "해결".equals(s)) return "해결";
        if ("보류".equals(s)) return "보류";
        if ("취소".equals(s)) return "취소";
        return s;
    }

    private String resolveReceiveEmployee(HomesPending p) {
        if (p.getReceiveEmployee() != null && !p.getReceiveEmployee().isBlank()) return p.getReceiveEmployee();
        return nullToEmpty(p.getEmployee());
    }

    // ---- Bookings ----
    private static final List<String> BOOKING_INTERNET_OPTIONS = List.of("인터넷", "TV", "전화", "와이파이", "스마트홈", "기타");
    private static final List<String> BOOKING_RENTAL_OPTIONS = List.of("정수기", "공기청정기", "비데", "매트리스", "가전", "기타");

    @GetMapping("/bookings")
    ApiResponse bookings(@AuthenticationPrincipal JwtUser u, @RequestParam Map<String, String> f,
                         @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "15") int size) {
        List<HomesBooking> all = listAll(HomesBooking.class, u);
        String status = blankToNull(f.get("status"));
        if ("ALL".equalsIgnoreCase(status)) status = null;
        if (status == null) status = blankToNull(f.get("tab"));
        if ("ALL".equalsIgnoreCase(status)) status = null;
        LocalDate from = parseDate(f.get("dateFrom"));
        LocalDate to = parseDate(f.get("dateTo"));
        String employee = blankToNull(f.get("employee"));
        if (employee == null) employee = blankToNull(f.get("receiveEmployee"));
        String internetProduct = blankToNull(f.get("internetProduct"));
        String rentalProduct = blankToNull(f.get("rentalProduct"));
        String phoneTail = blankToNull(f.get("phoneTail"));
        String q = qOf(f);
        String st = status;
        String emp = employee;
        List<HomesBooking> filtered = all.stream().filter(b -> {
            if (st != null && !st.equals(b.getStatus())) return false;
            if (from != null && (b.getBookingDate() == null || b.getBookingDate().isBefore(from))) return false;
            if (to != null && (b.getBookingDate() == null || b.getBookingDate().isAfter(to))) return false;
            if (emp != null && !emp.equals(b.getEmployee())) return false;
            if (internetProduct != null && !containsProductToken(resolveInternetProducts(b), internetProduct)) return false;
            if (rentalProduct != null && !containsProductToken(resolveRentalProducts(b), rentalProduct)) return false;
            if (phoneTail != null) {
                String digits = nullToEmpty(b.getPhone()).replaceAll("\\D", "");
                if (digits.length() < 4 || !digits.endsWith(phoneTail)) return false;
            }
            if (q != null) {
                String hay = (nullToEmpty(b.getCustomerName()) + " " + nullToEmpty(b.getPhone()) + " "
                        + nullToEmpty(b.getProducts()) + " " + nullToEmpty(b.getInternetProducts()) + " "
                        + nullToEmpty(b.getRentalProducts()) + " " + nullToEmpty(b.getAddress()) + " "
                        + nullToEmpty(b.getMemo())).toLowerCase();
                if (!hay.contains(q.toLowerCase())) return false;
            }
            return true;
        }).toList();
        Map<String, Long> counts = new LinkedHashMap<>();
        counts.put("ALL", (long) all.size());
        for (String s : List.of("접수", "완료", "보류")) {
            counts.put(s, all.stream().filter(b -> s.equals(b.getStatus())).count());
        }
        List<HomesBooking> sorted = filtered.stream()
                .sorted(Comparator.comparing(HomesBooking::getBookingDate, Comparator.nullsLast(Comparator.reverseOrder()))
                        .thenComparing(HomesBooking::getId, Comparator.reverseOrder()))
                .toList();
        ApiResponse res = pageOk(sorted, page, size, this::bookingMap, counts);
        @SuppressWarnings("unchecked")
        Map<String, Object> body = (Map<String, Object>) res.data();
        if (body != null) {
            body.put("employees", all.stream().map(HomesBooking::getEmployee).filter(Objects::nonNull)
                    .map(String::trim).filter(s -> !s.isBlank()).distinct().sorted().toList());
            body.put("internetOptions", BOOKING_INTERNET_OPTIONS);
            body.put("rentalOptions", BOOKING_RENTAL_OPTIONS);
        }
        return res;
    }

    @PostMapping("/bookings") @Transactional ApiResponse bookingPost(@RequestBody Map<String, Object> d, @AuthenticationPrincipal JwtUser u) {
        normalizeBookingPayload(d, u);
        HomesBooking b = crud.create(HomesBooking.class, d, u);
        ensureBookingCustomer(b, u);
        writeLog(u, "BOOKING", "예약-등록", "예약 [" + nullToEmpty(b.getCustomerName()) + "] 등록하였습니다.");
        return ApiResponse.ok(bookingMap(b));
    }
    @PutMapping("/bookings/{id}") @Transactional ApiResponse bookingPut(@PathVariable Long id, @RequestBody Map<String, Object> d, @AuthenticationPrincipal JwtUser u) {
        HomesBooking before = crud.get(HomesBooking.class, id, u);
        String prevStatus = before.getStatus();
        normalizeBookingPayload(d, u);
        HomesBooking b = crud.update(HomesBooking.class, id, d, u);
        String nextStatus = b.getStatus();
        if (nextStatus != null && !nextStatus.equals(prevStatus)) {
            writeLog(u, "BOOKING", "예약-상태",
                    "예약 [" + nullToEmpty(b.getCustomerName()) + "] " + nextStatus + "(으)로 상태변경하였습니다.");
        } else {
            writeLog(u, "BOOKING", "예약-수정", "예약 [" + nullToEmpty(b.getCustomerName()) + "] 수정하였습니다.");
        }
        return ApiResponse.ok(bookingMap(b));
    }
    @DeleteMapping("/bookings/{id}") @Transactional ApiResponse bookingDel(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) {
        HomesBooking b = crud.get(HomesBooking.class, id, u);
        writeLog(u, "BOOKING", "예약-삭제", "예약 [" + nullToEmpty(b.getCustomerName()) + "] 삭제하였습니다.");
        crud.delete(HomesBooking.class, id, u); return ApiResponse.ok(null);
    }

    @PostMapping("/bookings/delete") @Transactional
    ApiResponse bookingsBulkDelete(@RequestBody Map<String, Object> body, @AuthenticationPrincipal JwtUser u) {
        Object raw = body.get("ids");
        if (!(raw instanceof List<?> list) || list.isEmpty()) return ApiResponse.fail("삭제할 예약을 선택하세요.");
        int deleted = 0;
        for (Object o : list) {
            Long id = Long.valueOf(String.valueOf(o));
            HomesBooking b = em.find(HomesBooking.class, id);
            if (b == null || !Objects.equals(b.getGroupId(), u.getGroupId())) continue;
            writeLog(u, "BOOKING", "예약-삭제", "예약 [" + nullToEmpty(b.getCustomerName()) + "] 삭제하였습니다.");
            em.remove(b);
            deleted++;
        }
        return ApiResponse.ok(Map.of("deleted", deleted));
    }

    @GetMapping(value = "/bookings/export", produces = "text/csv;charset=UTF-8")
    org.springframework.http.ResponseEntity<String> bookingsExport(@AuthenticationPrincipal JwtUser u, @RequestParam Map<String, String> f) {
        ApiResponse res = bookings(u, f, 0, 10000);
        @SuppressWarnings("unchecked")
        Map<String, Object> data = (Map<String, Object>) res.data();
        @SuppressWarnings("unchecked")
        List<Map<String, Object>> list = data == null ? List.of() : (List<Map<String, Object>>) data.getOrDefault("list", List.of());
        StringBuilder csv = new StringBuilder("\uFEFF접수일,상태,고객명,연락처,인터넷상품,홈렌탈상품,접수직원,주소,비고\n");
        for (Map<String, Object> row : list) {
            csv.append(csvCell(row.get("bookingDate"))).append(',')
                    .append(csvCell(row.get("status"))).append(',')
                    .append(csvCell(row.get("customerName"))).append(',')
                    .append(csvCell(row.get("phone"))).append(',')
                    .append(csvCell(row.get("internetProducts"))).append(',')
                    .append(csvCell(row.get("rentalProducts"))).append(',')
                    .append(csvCell(row.get("employee"))).append(',')
                    .append(csvCell(row.get("address"))).append(',')
                    .append(csvCell(row.get("memo"))).append('\n');
        }
        return org.springframework.http.ResponseEntity.ok()
                .header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=bookings.csv")
                .body(csv.toString());
    }

    // ---- Customers ----
    private static final List<String> DEFAULT_CUSTOMER_GRADES = List.of("단골", "단골1", "단골2", "단골3", "단골4");

    @GetMapping("/customers")
    ApiResponse customers(@AuthenticationPrincipal JwtUser u, @RequestParam Map<String, String> f,
                          @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "15") int size) {
        List<HomesCustomer> all = listAll(HomesCustomer.class, u);
        String grade = blankToNull(f.get("grade"));
        if (grade == null) grade = blankToNull(f.get("tab"));
        if ("ALL".equalsIgnoreCase(grade)) grade = null;
        String employee = blankToNull(f.get("employee"));
        if (employee == null) employee = blankToNull(f.get("receiveEmployee"));
        String customerType = blankToNull(f.get("customerType"));
        String history = blankToNull(f.get("history"));
        String searchType = blankToNull(f.get("searchType"));
        String phoneTail = blankToNull(f.get("phoneTail"));
        LocalDate from = parseDate(f.get("dateFrom"));
        LocalDate to = parseDate(f.get("dateTo"));
        String q = qOf(f);
        String gr = grade;
        String emp = employee;
        Map<String, Long> contractCounts = countByCustomerPhone(u, HomesContract.class);
        Map<String, Long> bookingCounts = countByCustomerPhone(u, HomesBooking.class);

        List<HomesCustomer> filtered = all.stream().filter(c -> {
            if (gr != null && !gr.equals(normalizeCustomerGrade(c.getGrade()))) return false;
            if (emp != null && !emp.equals(c.getEmployee())) return false;
            if (customerType != null && !customerType.equals(nullToEmpty(c.getCustomerType()).isBlank() ? "개인" : c.getCustomerType())) return false;
            LocalDate reg = resolveCustomerRegisteredDate(c);
            if (from != null && (reg == null || reg.isBefore(from))) return false;
            if (to != null && (reg == null || reg.isAfter(to))) return false;
            String digits = nullToEmpty(c.getPhone()).replaceAll("\\D", "");
            long contracts = contractCounts.getOrDefault(digits, 0L);
            long bookings = bookingCounts.getOrDefault(digits, 0L);
            if ("계약".equals(history) && contracts <= 0) return false;
            if ("예약".equals(history) && bookings <= 0) return false;
            if ("없음".equals(history) && (contracts > 0 || bookings > 0)) return false;
            if (phoneTail != null) {
                if (digits.length() < 4 || !digits.endsWith(phoneTail)) return false;
            }
            if (q != null) {
                String needle = q.toLowerCase();
                if ("name".equals(searchType) || "고객명".equals(searchType)) {
                    if (!nullToEmpty(c.getName()).toLowerCase().contains(needle)) return false;
                } else if ("phone".equals(searchType) || "연락처".equals(searchType) || "연락처(뒷4자리)".equals(searchType)) {
                    if (!digits.contains(q.replaceAll("\\D", "")) && !nullToEmpty(c.getPhone()).toLowerCase().contains(needle)) return false;
                } else if ("address".equals(searchType) || "주소".equals(searchType)) {
                    if (!nullToEmpty(c.getAddress()).toLowerCase().contains(needle)
                            && !nullToEmpty(c.getAddressDetail()).toLowerCase().contains(needle)) return false;
                } else {
                    String hay = (nullToEmpty(c.getName()) + " " + nullToEmpty(c.getPhone()) + " "
                            + nullToEmpty(c.getPhoneAlt()) + " " + nullToEmpty(c.getAddress()) + " "
                            + nullToEmpty(c.getMemo())).toLowerCase();
                    if (!hay.contains(needle)) return false;
                }
            }
            return true;
        }).sorted(Comparator.comparing(this::resolveCustomerRegisteredDate, Comparator.nullsLast(Comparator.reverseOrder()))
                .thenComparing(HomesCustomer::getId, Comparator.reverseOrder())).toList();

        List<String> grades = customerGradeList(u);
        Map<String, Long> counts = new LinkedHashMap<>();
        counts.put("ALL", (long) all.size());
        for (String s : grades) {
            counts.put(s, all.stream().filter(c -> s.equals(normalizeCustomerGrade(c.getGrade()))).count());
        }
        ApiResponse res = pageOk(filtered, page, size, c -> customerMap(c, contractCounts, bookingCounts), counts);
        @SuppressWarnings("unchecked")
        Map<String, Object> body = (Map<String, Object>) res.data();
        if (body != null) {
            body.put("grades", grades);
            body.put("employees", all.stream().map(HomesCustomer::getEmployee).filter(Objects::nonNull)
                    .map(String::trim).filter(s -> !s.isBlank()).distinct().sorted().toList());
        }
        return res;
    }

    @GetMapping("/customers/grades")
    ApiResponse customerGradesGet(@AuthenticationPrincipal JwtUser u) {
        return ApiResponse.ok(Map.of("grades", customerGradeList(u)));
    }

    @PutMapping("/customers/grades") @Transactional
    ApiResponse customerGradesPut(@RequestBody Map<String, Object> body, @AuthenticationPrincipal JwtUser u) {
        Object raw = body.get("grades");
        List<String> grades = new ArrayList<>();
        if (raw instanceof List<?> list) {
            for (Object o : list) {
                if (o == null) continue;
                String s = String.valueOf(o).trim();
                if (!s.isBlank() && !grades.contains(s) && !"분류없음".equals(s) && !"ALL".equalsIgnoreCase(s)) grades.add(s);
            }
        }
        if (grades.isEmpty()) grades = new ArrayList<>(DEFAULT_CUSTOMER_GRADES);
        saveJsonSetting(u.getGroupId(), "homes.customerGrades", Map.of("grades", grades));
        return ApiResponse.ok(Map.of("grades", grades));
    }

    @PostMapping("/customers") @Transactional ApiResponse customerPost(@RequestBody Map<String, Object> d, @AuthenticationPrincipal JwtUser u) {
        normalizeCustomerPayload(d, u);
        HomesCustomer c = crud.create(HomesCustomer.class, d, u);
        writeLog(u, "CUSTOMER", "고객-등록", "고객 [" + nullToEmpty(c.getName()) + "] 등록하였습니다.");
        return ApiResponse.ok(customerMap(c, Map.of(), Map.of()));
    }
    @PutMapping("/customers/{id}") @Transactional ApiResponse customerPut(@PathVariable Long id, @RequestBody Map<String, Object> d, @AuthenticationPrincipal JwtUser u) {
        normalizeCustomerPayload(d, u);
        HomesCustomer c = crud.update(HomesCustomer.class, id, d, u);
        writeLog(u, "CUSTOMER", "고객-수정", "고객 [" + nullToEmpty(c.getName()) + "] 수정하였습니다.");
        return ApiResponse.ok(customerMap(c, Map.of(), Map.of()));
    }
    @DeleteMapping("/customers/{id}") @Transactional ApiResponse customerDel(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) {
        HomesCustomer c = crud.get(HomesCustomer.class, id, u);
        writeLog(u, "CUSTOMER", "고객-삭제", "고객 [" + nullToEmpty(c.getName()) + "] 삭제하였습니다.");
        crud.delete(HomesCustomer.class, id, u); return ApiResponse.ok(null);
    }

    @PostMapping("/customers/delete") @Transactional
    ApiResponse customersBulkDelete(@RequestBody Map<String, Object> body, @AuthenticationPrincipal JwtUser u) {
        Object raw = body.get("ids");
        if (!(raw instanceof List<?> list) || list.isEmpty()) return ApiResponse.fail("삭제할 고객을 선택하세요.");
        int deleted = 0;
        for (Object o : list) {
            Long id = Long.valueOf(String.valueOf(o));
            HomesCustomer c = em.find(HomesCustomer.class, id);
            if (c == null || !Objects.equals(c.getGroupId(), u.getGroupId())) continue;
            writeLog(u, "CUSTOMER", "고객-삭제", "고객 [" + nullToEmpty(c.getName()) + "] 삭제하였습니다.");
            em.remove(c);
            deleted++;
        }
        return ApiResponse.ok(Map.of("deleted", deleted));
    }

    @PostMapping("/customers/grade") @Transactional
    ApiResponse customersChangeGrade(@RequestBody Map<String, Object> body, @AuthenticationPrincipal JwtUser u) {
        Object raw = body.get("ids");
        if (!(raw instanceof List<?> list) || list.isEmpty()) return ApiResponse.fail("분류를 변경할 고객을 선택하세요.");
        String grade = body.get("grade") == null ? "분류없음" : String.valueOf(body.get("grade")).trim();
        if (grade.isBlank()) grade = "분류없음";
        int updated = 0;
        for (Object o : list) {
            Long id = Long.valueOf(String.valueOf(o));
            HomesCustomer c = em.find(HomesCustomer.class, id);
            if (c == null || !Objects.equals(c.getGroupId(), u.getGroupId())) continue;
            c.setGrade(grade);
            writeLog(u, "CUSTOMER", "고객-분류", "고객 [" + nullToEmpty(c.getName()) + "] 분류수정하였습니다.");
            updated++;
        }
        return ApiResponse.ok(Map.of("updated", updated));
    }

    @GetMapping(value = "/customers/export", produces = "text/csv;charset=UTF-8")
    org.springframework.http.ResponseEntity<String> customersExport(@AuthenticationPrincipal JwtUser u, @RequestParam Map<String, String> f) {
        ApiResponse res = customers(u, f, 0, 10000);
        @SuppressWarnings("unchecked")
        Map<String, Object> data = (Map<String, Object>) res.data();
        @SuppressWarnings("unchecked")
        List<Map<String, Object>> list = data == null ? List.of() : (List<Map<String, Object>>) data.getOrDefault("list", List.of());
        StringBuilder csv = new StringBuilder("\uFEFF등록일,분류,구분,고객명,연락처,보조연락처,주소,메모,계약,예약,등록직원\n");
        for (Map<String, Object> row : list) {
            csv.append(csvCell(row.get("registeredDate"))).append(',')
                    .append(csvCell(row.get("grade"))).append(',')
                    .append(csvCell(row.get("customerType"))).append(',')
                    .append(csvCell(row.get("name"))).append(',')
                    .append(csvCell(row.get("phone"))).append(',')
                    .append(csvCell(row.get("phoneAlt"))).append(',')
                    .append(csvCell(row.get("address"))).append(',')
                    .append(csvCell(row.get("memo"))).append(',')
                    .append(csvCell(row.get("contractCount"))).append(',')
                    .append(csvCell(row.get("bookingCount"))).append(',')
                    .append(csvCell(row.get("employee"))).append('\n');
        }
        return org.springframework.http.ResponseEntity.ok()
                .header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=customers.csv")
                .body(csv.toString());
    }

    // ---- Abooks ----
    private static final List<String> DEFAULT_ABOOK_CATEGORIES = List.of("일반", "123");
    private static final List<String> ABOOK_ENTRY_TYPES = List.of("입금", "출금", "카드입금", "카드출금");

    @GetMapping("/abooks")
    ApiResponse abooks(@AuthenticationPrincipal JwtUser u, @RequestParam Map<String, String> f,
                       @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "15") int size) {
        List<HomesAbook> all = listAll(HomesAbook.class, u);
        String entryType = blankToNull(f.get("entryType"));
        if (entryType == null) entryType = blankToNull(f.get("tab"));
        if ("ALL".equalsIgnoreCase(entryType)) entryType = null;
        String category = blankToNull(f.get("category"));
        if (category == null) category = blankToNull(f.get("subject"));
        String employee = blankToNull(f.get("employee"));
        String item = blankToNull(f.get("item"));
        String yearMonth = blankToNull(f.get("yearMonth"));
        Integer day = f.get("day") == null || f.get("day").isBlank() || "ALL".equalsIgnoreCase(f.get("day"))
                ? null : parseInt(f.get("day"), 0);
        if (day != null && day <= 0) day = null;
        LocalDate from = parseDate(f.get("dateFrom"));
        LocalDate to = parseDate(f.get("dateTo"));
        if (yearMonth != null && from == null && to == null) {
            try {
                YearMonth ym = YearMonth.parse(yearMonth);
                from = ym.atDay(1);
                to = ym.atEndOfMonth();
            } catch (Exception ignored) {}
        }
        String q = qOf(f);
        String et = entryType;
        String cat = category;
        LocalDate fromF = from;
        LocalDate toF = to;
        Integer dayF = day;

        List<HomesAbook> filtered = all.stream().filter(a -> {
            if (et != null && !et.equals(normalizeAbookEntryType(a.getEntryType()))) return false;
            if (cat != null && !cat.equals(a.getCategory())) return false;
            if (employee != null && !employee.equals(a.getEmployee())) return false;
            if (item != null && !item.equals(nullToEmpty(a.getItem()).isBlank() ? a.getAgency() : a.getItem())) return false;
            if (fromF != null && (a.getEntryDate() == null || a.getEntryDate().isBefore(fromF))) return false;
            if (toF != null && (a.getEntryDate() == null || a.getEntryDate().isAfter(toF))) return false;
            if (dayF != null && (a.getEntryDate() == null || a.getEntryDate().getDayOfMonth() != dayF)) return false;
            if (q != null) {
                String hay = (nullToEmpty(a.getCategory()) + " " + nullToEmpty(a.getItem()) + " "
                        + nullToEmpty(a.getAgency()) + " " + nullToEmpty(a.getMemo()) + " "
                        + nullToEmpty(a.getEmployee())).toLowerCase();
                if (!hay.contains(q.toLowerCase())) return false;
            }
            return true;
        }).sorted(Comparator.comparing(HomesAbook::getEntryDate, Comparator.nullsLast(Comparator.reverseOrder()))
                .thenComparing(HomesAbook::getId, Comparator.reverseOrder())).toList();

        List<String> categories = abookCategoryList(u);
        Map<String, Long> counts = new LinkedHashMap<>();
        // detailed tabs by entry type (within date range / month, ignoring entryType tab)
        List<HomesAbook> baseForCounts = all.stream().filter(a -> {
            if (fromF != null && (a.getEntryDate() == null || a.getEntryDate().isBefore(fromF))) return false;
            if (toF != null && (a.getEntryDate() == null || a.getEntryDate().isAfter(toF))) return false;
            if (dayF != null && (a.getEntryDate() == null || a.getEntryDate().getDayOfMonth() != dayF)) return false;
            return true;
        }).toList();
        counts.put("ALL", (long) baseForCounts.size());
        for (String t : ABOOK_ENTRY_TYPES) {
            counts.put(t, baseForCounts.stream().filter(a -> t.equals(normalizeAbookEntryType(a.getEntryType()))).count());
        }
        Map<String, Long> categoryCounts = new LinkedHashMap<>();
        categoryCounts.put("ALL", (long) baseForCounts.size());
        for (String c : categories) {
            categoryCounts.put(c, baseForCounts.stream().filter(a -> c.equals(a.getCategory())).count());
        }

        // cash summary for month view
        LocalDate carryCutoff = fromF;
        if (carryCutoff == null) {
            try {
                if (yearMonth != null) carryCutoff = YearMonth.parse(yearMonth).atDay(1);
            } catch (Exception ignored) {}
        }
        if (carryCutoff == null) carryCutoff = LocalDate.now().withDayOfMonth(1);
        BigDecimal carryOver = BigDecimal.ZERO;
        BigDecimal deposit = BigDecimal.ZERO;
        BigDecimal withdraw = BigDecimal.ZERO;
        for (HomesAbook a : all) {
            if (a.getEntryDate() == null || a.getAmount() == null) continue;
            String type = normalizeAbookEntryType(a.getEntryType());
            boolean inPeriod = (fromF == null || !a.getEntryDate().isBefore(fromF))
                    && (toF == null || !a.getEntryDate().isAfter(toF))
                    && (dayF == null || a.getEntryDate().getDayOfMonth() == dayF)
                    && (cat == null || cat.equals(a.getCategory()));
            if (a.getEntryDate().isBefore(carryCutoff) && ("입금".equals(type) || "출금".equals(type))) {
                if ("입금".equals(type)) carryOver = carryOver.add(a.getAmount());
                else carryOver = carryOver.subtract(a.getAmount());
            }
            if (inPeriod) {
                if ("입금".equals(type)) deposit = deposit.add(a.getAmount());
                else if ("출금".equals(type)) withdraw = withdraw.add(a.getAmount());
            }
        }
        BigDecimal cashTotal = carryOver.add(deposit).subtract(withdraw);

        ApiResponse res = pageOk(filtered, page, size, this::abookMap, counts);
        @SuppressWarnings("unchecked")
        Map<String, Object> body = (Map<String, Object>) res.data();
        if (body != null) {
            body.put("categories", categories);
            body.put("categoryCounts", categoryCounts);
            body.put("employees", all.stream().map(HomesAbook::getEmployee).filter(Objects::nonNull)
                    .map(String::trim).filter(s -> !s.isBlank()).distinct().sorted().toList());
            body.put("items", all.stream().map(a -> {
                String it = a.getItem();
                if (it == null || it.isBlank()) it = a.getAgency();
                return it;
            }).filter(Objects::nonNull).map(String::trim).filter(s -> !s.isBlank()).distinct().sorted().toList());
            body.put("summary", Map.of(
                    "carryOver", carryOver,
                    "deposit", deposit,
                    "withdraw", withdraw,
                    "total", cashTotal
            ));
        }
        return res;
    }

    @GetMapping("/abooks/categories")
    ApiResponse abookCategoriesGet(@AuthenticationPrincipal JwtUser u) {
        return ApiResponse.ok(Map.of("categories", abookCategoryList(u)));
    }

    @PutMapping("/abooks/categories") @Transactional
    ApiResponse abookCategoriesPut(@RequestBody Map<String, Object> body, @AuthenticationPrincipal JwtUser u) {
        Object raw = body.get("categories");
        List<String> categories = new ArrayList<>();
        if (raw instanceof List<?> list) {
            for (Object o : list) {
                if (o == null) continue;
                String s = String.valueOf(o).trim();
                if (!s.isBlank() && !categories.contains(s)) categories.add(s);
            }
        }
        if (categories.isEmpty()) categories = new ArrayList<>(DEFAULT_ABOOK_CATEGORIES);
        saveJsonSetting(u.getGroupId(), "homes.abookCategories", Map.of("categories", categories));
        return ApiResponse.ok(Map.of("categories", categories));
    }

    @PostMapping("/abooks") @Transactional ApiResponse abookPost(@RequestBody Map<String, Object> d, @AuthenticationPrincipal JwtUser u) {
        normalizeAbookPayload(d, u);
        HomesAbook a = crud.create(HomesAbook.class, d, u);
        writeLog(u, "ABOOKS", "간편장부-등록", abookLogContent(a, "등록하였습니다."));
        return ApiResponse.ok(abookMap(a));
    }
    @PutMapping("/abooks/{id}") @Transactional ApiResponse abookPut(@PathVariable Long id, @RequestBody Map<String, Object> d, @AuthenticationPrincipal JwtUser u) {
        normalizeAbookPayload(d, u);
        HomesAbook a = crud.update(HomesAbook.class, id, d, u);
        writeLog(u, "ABOOKS", "간편장부-수정", abookLogContent(a, "수정하였습니다."));
        return ApiResponse.ok(abookMap(a));
    }
    @DeleteMapping("/abooks/{id}") @Transactional ApiResponse abookDel(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) {
        HomesAbook a = crud.get(HomesAbook.class, id, u);
        writeLog(u, "ABOOKS", "간편장부-삭제", abookLogContent(a, "삭제하였습니다."));
        crud.delete(HomesAbook.class, id, u); return ApiResponse.ok(null);
    }

    @PostMapping("/abooks/delete") @Transactional
    ApiResponse abooksBulkDelete(@RequestBody Map<String, Object> body, @AuthenticationPrincipal JwtUser u) {
        Object raw = body.get("ids");
        if (!(raw instanceof List<?> list) || list.isEmpty()) return ApiResponse.fail("삭제할 장부를 선택하세요.");
        int deleted = 0;
        for (Object o : list) {
            Long id = Long.valueOf(String.valueOf(o));
            HomesAbook a = em.find(HomesAbook.class, id);
            if (a == null || !Objects.equals(a.getGroupId(), u.getGroupId())) continue;
            writeLog(u, "ABOOKS", "간편장부-삭제", abookLogContent(a, "삭제하였습니다."));
            em.remove(a);
            deleted++;
        }
        return ApiResponse.ok(Map.of("deleted", deleted));
    }

    @GetMapping(value = "/abooks/export", produces = "text/csv;charset=UTF-8")
    org.springframework.http.ResponseEntity<String> abooksExport(@AuthenticationPrincipal JwtUser u, @RequestParam Map<String, String> f) {
        ApiResponse res = abooks(u, f, 0, 10000);
        @SuppressWarnings("unchecked")
        Map<String, Object> data = (Map<String, Object>) res.data();
        @SuppressWarnings("unchecked")
        List<Map<String, Object>> list = data == null ? List.of() : (List<Map<String, Object>>) data.getOrDefault("list", List.of());
        StringBuilder csv = new StringBuilder("\uFEFF거래일,구분,과목,품목,금액,비고,직원\n");
        for (Map<String, Object> row : list) {
            csv.append(csvCell(row.get("entryDate"))).append(',')
                    .append(csvCell(row.get("entryType"))).append(',')
                    .append(csvCell(row.get("category"))).append(',')
                    .append(csvCell(row.get("item"))).append(',')
                    .append(csvCell(row.get("amount"))).append(',')
                    .append(csvCell(row.get("memo"))).append(',')
                    .append(csvCell(row.get("employee"))).append('\n');
        }
        return org.springframework.http.ResponseEntity.ok()
                .header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=abooks.csv")
                .body(csv.toString());
    }

    // ---- Agencies / Partners / Members ----
    @GetMapping("/agencies") ApiResponse agencies(@AuthenticationPrincipal JwtUser u, @RequestParam Map<String, String> f,
                                                  @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "15") int size) {
        String q = qOf(f);
        String status = blankToNull(f.get("status"));
        if ("ALL".equalsIgnoreCase(status)) status = null;
        String st = status;
        List<HomesAgency> all = listAll(HomesAgency.class, u);
        List<HomesAgency> filtered = all.stream().filter(a -> {
            if ("사용".equals(st) && !a.isActive()) return false;
            if ("정지".equals(st) && a.isActive()) return false;
            if (q != null) {
                String hay = (nullToEmpty(a.getName()) + " " + nullToEmpty(a.getManagerName())
                        + " " + nullToEmpty(a.getPhone()) + " " + nullToEmpty(a.getMobile())).toLowerCase();
                if (!hay.contains(q.toLowerCase())) return false;
            }
            return true;
        }).sorted(Comparator.comparing(HomesAgency::getName, Comparator.nullsLast(String::compareTo))
                .thenComparing(HomesAgency::getId, Comparator.reverseOrder())).toList();
        Map<String, Long> counts = new LinkedHashMap<>();
        counts.put("ALL", (long) all.size());
        counts.put("사용", all.stream().filter(HomesAgency::isActive).count());
        counts.put("정지", all.stream().filter(a -> !a.isActive()).count());
        return pageOk(filtered, page, size, this::agencyMap, counts);
    }
    @PostMapping("/agencies") @Transactional ApiResponse agencyPost(@RequestBody Map<String, Object> d, @AuthenticationPrincipal JwtUser u) {
        normalizeAgencyPayload(d);
        HomesAgency a = crud.create(HomesAgency.class, d, u);
        if (a.getRegisteredDate() == null) a.setRegisteredDate(LocalDate.now());
        writeLog(u, "AGENCY", "거래처-등록", "거래처 [" + nullToEmpty(a.getName()) + "] 등록하였습니다.");
        return ApiResponse.ok(agencyMap(a));
    }
    @PutMapping("/agencies/{id}") @Transactional ApiResponse agencyPut(@PathVariable Long id, @RequestBody Map<String, Object> d, @AuthenticationPrincipal JwtUser u) {
        normalizeAgencyPayload(d);
        HomesAgency a = crud.update(HomesAgency.class, id, d, u);
        writeLog(u, "AGENCY", "거래처-수정", "거래처 [" + nullToEmpty(a.getName()) + "] 수정하였습니다.");
        return ApiResponse.ok(agencyMap(a));
    }
    @DeleteMapping("/agencies/{id}") @Transactional ApiResponse agencyDel(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) {
        HomesAgency a = crud.get(HomesAgency.class, id, u);
        writeLog(u, "AGENCY", "거래처-삭제", "거래처 [" + nullToEmpty(a.getName()) + "] 삭제하였습니다.");
        crud.delete(HomesAgency.class, id, u); return ApiResponse.ok(null);
    }

    @PostMapping("/agencies/status") @Transactional
    ApiResponse agenciesStatus(@RequestBody Map<String, Object> body, @AuthenticationPrincipal JwtUser u) {
        Object raw = body.get("ids");
        if (!(raw instanceof List<?> list) || list.isEmpty()) return ApiResponse.fail("거래처를 선택하세요.");
        boolean active = !"정지".equals(String.valueOf(body.getOrDefault("status", "사용")));
        int updated = 0;
        for (Object o : list) {
            Long id = Long.valueOf(String.valueOf(o));
            HomesAgency a = em.find(HomesAgency.class, id);
            if (a == null || !Objects.equals(a.getGroupId(), u.getGroupId())) continue;
            a.setActive(active);
            writeLog(u, "AGENCY", "거래처-상태",
                    "거래처 [" + nullToEmpty(a.getName()) + "] " + (active ? "사용" : "정지") + "(으)로 변경하였습니다.");
            updated++;
        }
        return ApiResponse.ok(Map.of("updated", updated));
    }

    @GetMapping(value = "/agencies/export", produces = "text/csv;charset=UTF-8")
    org.springframework.http.ResponseEntity<String> agenciesExport(@AuthenticationPrincipal JwtUser u, @RequestParam Map<String, String> f) {
        ApiResponse res = agencies(u, f, 0, 10000);
        @SuppressWarnings("unchecked")
        Map<String, Object> data = (Map<String, Object>) res.data();
        @SuppressWarnings("unchecked")
        List<Map<String, Object>> list = data == null ? List.of() : (List<Map<String, Object>>) data.getOrDefault("list", List.of());
        StringBuilder csv = new StringBuilder("\uFEFF상태,업체명,전화,팩스,담당자,휴대폰,등록일,비고\n");
        for (Map<String, Object> row : list) {
            csv.append(csvCell(row.get("status"))).append(',')
                    .append(csvCell(row.get("name"))).append(',')
                    .append(csvCell(row.get("phone"))).append(',')
                    .append(csvCell(row.get("fax"))).append(',')
                    .append(csvCell(row.get("managerName"))).append(',')
                    .append(csvCell(row.get("mobile"))).append(',')
                    .append(csvCell(row.get("registeredDate"))).append(',')
                    .append(csvCell(row.get("memo"))).append('\n');
        }
        return org.springframework.http.ResponseEntity.ok()
                .header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=agencies.csv")
                .body(csv.toString());
    }

    @GetMapping("/partners") ApiResponse partners(@AuthenticationPrincipal JwtUser u, @RequestParam Map<String, String> f,
                                                  @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "15") int size) {
        String level = blankToNull(f.get("partnerLevel"));
        String status = blankToNull(f.get("status"));
        if ("ALL".equalsIgnoreCase(status)) status = null;
        String q = qOf(f);
        String st = status;
        List<HomesPartner> filtered = listAll(HomesPartner.class, u).stream().filter(p -> {
            if (level != null && !level.equals(p.getPartnerLevel())) return false;
            if (st != null && !st.equals(nullToEmpty(p.getStatus()).isBlank() ? "대기" : p.getStatus())) return false;
            if (q != null) {
                String hay = (nullToEmpty(p.getName()) + " " + nullToEmpty(p.getUserid())
                        + " " + nullToEmpty(p.getManagerName()) + " " + nullToEmpty(p.getParentName())).toLowerCase();
                if (!hay.contains(q.toLowerCase())) return false;
            }
            return true;
        }).sorted(Comparator.comparing(HomesPartner::getId, Comparator.reverseOrder())).toList();
        return pageOk(filtered, page, size, this::partnerMap, null);
    }
    @PostMapping("/partners") @Transactional ApiResponse partnerPost(@RequestBody Map<String, Object> d, @AuthenticationPrincipal JwtUser u) {
        normalizePartnerPayload(d);
        HomesPartner p = crud.create(HomesPartner.class, d, u);
        if (p.getRegisteredDate() == null) p.setRegisteredDate(LocalDate.now());
        if (p.getStatus() == null || p.getStatus().isBlank()) p.setStatus("대기");
        writeLog(u, "PARTNER", "상부점".equals(levelLabel(p)) ? "상부점-등록" : "하부점-등록",
                levelLabel(p) + " [" + nullToEmpty(p.getName()) + "] 등록하였습니다.");
        return ApiResponse.ok(partnerMap(p));
    }
    @PutMapping("/partners/{id}") @Transactional ApiResponse partnerPut(@PathVariable Long id, @RequestBody Map<String, Object> d, @AuthenticationPrincipal JwtUser u) {
        normalizePartnerPayload(d);
        HomesPartner p = crud.update(HomesPartner.class, id, d, u);
        writeLog(u, "PARTNER", "상부점".equals(levelLabel(p)) ? "상부점-수정" : "하부점-수정",
                levelLabel(p) + " [" + nullToEmpty(p.getName()) + "] 수정하였습니다.");
        return ApiResponse.ok(partnerMap(p));
    }
    @DeleteMapping("/partners/{id}") @Transactional ApiResponse partnerDel(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) {
        HomesPartner p = crud.get(HomesPartner.class, id, u);
        writeLog(u, "PARTNER", "상부점".equals(levelLabel(p)) ? "상부점-삭제" : "하부점-삭제",
                levelLabel(p) + " [" + nullToEmpty(p.getName()) + "] 삭제하였습니다.");
        crud.delete(HomesPartner.class, id, u);
        return ApiResponse.ok(null);
    }

    private String levelLabel(HomesPartner p) {
        return "2".equals(p.getPartnerLevel()) ? "하부점" : "상부점";
    }

    private void normalizePartnerPayload(Map<String, Object> d) {
        if (d.get("partnerLevel") == null || String.valueOf(d.get("partnerLevel")).isBlank()) {
            d.put("partnerLevel", "1");
        }
        if (d.get("status") == null || String.valueOf(d.get("status")).isBlank()) {
            d.put("status", "대기");
        }
        if (d.get("registeredDate") == null || String.valueOf(d.get("registeredDate")).isBlank()) {
            d.put("registeredDate", LocalDate.now().toString());
        }
    }

    @GetMapping("/members") ApiResponse members(@AuthenticationPrincipal JwtUser u, @RequestParam Map<String, String> f,
                                                @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "15") int size) {
        String q = qOf(f);
        if (q == null) q = blankToNull(f.get("keyword"));
        String status = blankToNull(f.get("status"));
        if ("ALL".equalsIgnoreCase(status)) status = null;
        String st = status;
        String qLower = q == null ? null : q.toLowerCase();
        List<Member> all = em.createQuery("select m from Member m where m.groupId=:g and m.role='AGENT'", Member.class)
                .setParameter("g", u.getGroupId()).getResultList();
        List<Member> filtered = all.stream().filter(m -> {
            if ("사용".equals(st) && !m.isActive()) return false;
            if (("중지".equals(st) || "정지".equals(st)) && m.isActive()) return false;
            if (qLower != null) {
                String hay = (nullToEmpty(m.getUserid()) + " " + nullToEmpty(m.getName())).toLowerCase();
                if (!hay.contains(qLower)) return false;
            }
            return true;
        }).sorted(memberComparator(f.get("sort"))).toList();
        Map<String, Long> counts = new LinkedHashMap<>();
        counts.put("ALL", (long) all.size());
        counts.put("사용", all.stream().filter(Member::isActive).count());
        counts.put("중지", all.stream().filter(m -> !m.isActive()).count());
        return pageOk(filtered, page, size, this::memberMap, counts);
    }
    @PostMapping("/members") @Transactional ApiResponse memberPost(@RequestBody Map<String, Object> d, @AuthenticationPrincipal JwtUser u) {
        String userid = d.get("userid") == null ? "" : String.valueOf(d.get("userid")).trim();
        if (userid.isBlank()) return ApiResponse.fail("아이디를 입력하세요.");
        if (!em.createQuery("select m from Member m where m.userid=:id", Member.class).setParameter("id", userid).getResultList().isEmpty()) {
            return ApiResponse.fail("이미 사용 중인 아이디입니다.");
        }
        Member m = new Member();
        m.setGroupId(u.getGroupId());
        m.setUserid(userid);
        m.setName(String.valueOf(d.getOrDefault("name", "")).trim());
        m.setPhone(blankToNull(d.get("phone") == null ? null : String.valueOf(d.get("phone"))));
        m.setAddress(blankToNull(d.get("address") == null ? null : String.valueOf(d.get("address"))));
        m.setAccessLimit(blankToNull(d.get("accessLimit") == null ? null : String.valueOf(d.get("accessLimit"))));
        m.setMemo(blankToNull(d.get("memo") == null ? null : String.valueOf(d.get("memo"))));
        m.setStaffPermission(String.valueOf(d.getOrDefault("staffPermission", "사원")));
        m.setRole("AGENT");
        m.setActive(!"중지".equals(String.valueOf(d.getOrDefault("status", "사용"))) && !"정지".equals(String.valueOf(d.getOrDefault("status", ""))));
        m.setLoginCount(0);
        m.setMenuFlags(d.get("menuFlags") == null ? null : String.valueOf(d.get("menuFlags")));
        String pw = d.get("password") == null || String.valueOf(d.get("password")).isBlank() ? "demo123" : String.valueOf(d.get("password"));
        m.setPassword(encoder.encode(pw));
        m.setPlainPassword(pw);
        em.persist(m);
        writeLog(u, "MEMBER", "직원-등록", "직원 [" + nullToEmpty(m.getName()) + "] 등록하였습니다.");
        return ApiResponse.ok(memberMap(m));
    }
    @PutMapping("/members/{id}") @Transactional ApiResponse memberPut(@PathVariable Long id, @RequestBody Map<String, Object> d, @AuthenticationPrincipal JwtUser u) {
        Member m = crud.get(Member.class, id, u);
        if (d.get("name") != null) m.setName(String.valueOf(d.get("name")).trim());
        if (d.get("phone") != null) m.setPhone(blankToNull(String.valueOf(d.get("phone"))));
        if (d.get("address") != null) m.setAddress(blankToNull(String.valueOf(d.get("address"))));
        if (d.get("accessLimit") != null) m.setAccessLimit(blankToNull(String.valueOf(d.get("accessLimit"))));
        if (d.get("memo") != null) m.setMemo(blankToNull(String.valueOf(d.get("memo"))));
        if (d.get("staffPermission") != null && !String.valueOf(d.get("staffPermission")).isBlank()) {
            m.setStaffPermission(String.valueOf(d.get("staffPermission")));
        }
        if (d.get("status") != null) {
            String st = String.valueOf(d.get("status"));
            m.setActive(!"중지".equals(st) && !"정지".equals(st) && !"false".equalsIgnoreCase(st));
        }
        if (d.containsKey("menuFlags")) {
            m.setMenuFlags(d.get("menuFlags") == null || String.valueOf(d.get("menuFlags")).isBlank()
                    ? null : String.valueOf(d.get("menuFlags")));
        }
        if (d.get("password") != null && !String.valueOf(d.get("password")).isBlank()) {
            String pw = String.valueOf(d.get("password"));
            m.setPassword(encoder.encode(pw));
            m.setPlainPassword(pw);
        }
        writeLog(u, "MEMBER", "직원-수정", "직원 [" + nullToEmpty(m.getName()) + "] 수정하였습니다.");
        return ApiResponse.ok(memberMap(m));
    }
    @DeleteMapping("/members/{id}") @Transactional ApiResponse memberDel(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) {
        Member m = crud.get(Member.class, id, u);
        if ("demo".equals(m.getUserid()) || "대표".equals(m.getStaffPermission())) {
            return ApiResponse.fail("대표 계정은 삭제할 수 없습니다.");
        }
        writeLog(u, "MEMBER", "직원-삭제", "직원 [" + nullToEmpty(m.getName()) + "] 삭제하였습니다.");
        em.remove(m);
        return ApiResponse.ok(null);
    }

    @PostMapping("/members/status") @Transactional
    ApiResponse membersStatus(@RequestBody Map<String, Object> body, @AuthenticationPrincipal JwtUser u) {
        Object raw = body.get("ids");
        if (!(raw instanceof List<?> list) || list.isEmpty()) return ApiResponse.fail("직원을 선택하세요.");
        String status = String.valueOf(body.getOrDefault("status", "사용"));
        boolean active = !"중지".equals(status) && !"정지".equals(status);
        int updated = 0;
        for (Object o : list) {
            Long id = Long.valueOf(String.valueOf(o));
            Member m = em.find(Member.class, id);
            if (m == null || !Objects.equals(m.getGroupId(), u.getGroupId()) || !"AGENT".equals(m.getRole())) continue;
            if ("demo".equals(m.getUserid()) || "대표".equals(m.getStaffPermission())) continue;
            m.setActive(active);
            writeLog(u, "MEMBER", "직원-상태",
                    "직원 [" + nullToEmpty(m.getName()) + "] " + (active ? "사용" : "중지") + "(으)로 변경하였습니다.");
            updated++;
        }
        return ApiResponse.ok(Map.of("updated", updated));
    }

    @GetMapping(value = "/members/export", produces = "text/csv;charset=UTF-8")
    org.springframework.http.ResponseEntity<String> membersExport(@AuthenticationPrincipal JwtUser u, @RequestParam Map<String, String> f) {
        ApiResponse res = members(u, f, 0, 10000);
        @SuppressWarnings("unchecked")
        Map<String, Object> data = (Map<String, Object>) res.data();
        @SuppressWarnings("unchecked")
        List<Map<String, Object>> list = data == null ? List.of() : (List<Map<String, Object>>) data.getOrDefault("list", List.of());
        StringBuilder csv = new StringBuilder("\uFEFF상태,권한,아이디,이름,휴대폰,주소,접속제한,접속수,최종접속,등록일,비고\n");
        for (Map<String, Object> row : list) {
            csv.append(csvCell(row.get("status"))).append(',')
                    .append(csvCell(row.get("staffPermission"))).append(',')
                    .append(csvCell(row.get("userid"))).append(',')
                    .append(csvCell(row.get("name"))).append(',')
                    .append(csvCell(row.get("phone"))).append(',')
                    .append(csvCell(row.get("address"))).append(',')
                    .append(csvCell(row.get("accessLimit"))).append(',')
                    .append(csvCell(row.get("loginCount"))).append(',')
                    .append(csvCell(row.get("lastLoginAt"))).append(',')
                    .append(csvCell(row.get("registeredDate"))).append(',')
                    .append(csvCell(row.get("memo"))).append('\n');
        }
        return org.springframework.http.ResponseEntity.ok()
                .header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=members.csv")
                .body(csv.toString());
    }

    // ---- Preference / Levels ----
    @GetMapping("/preference") ApiResponse preferenceGet(@AuthenticationPrincipal JwtUser u) {
        return ApiResponse.ok(loadHomesPreference(u.getGroupId()));
    }
    @PutMapping("/preference") @Transactional ApiResponse preferencePut(@RequestBody Map<String, Object> d, @AuthenticationPrincipal JwtUser u) {
        Map<String, Object> current = loadHomesPreference(u.getGroupId());
        mergePreference(current, d);
        saveJsonSetting(u.getGroupId(), "homes_preference", current);
        writeLog(u, "SETTING", "기본설정-저장", "기본설정을 저장하였습니다.");
        return ApiResponse.ok(current);
    }
    /** 상단 계정 팝오버용 프로필 요약 */
    @GetMapping("/account-profile")
    ApiResponse accountProfile(@AuthenticationPrincipal JwtUser u) {
        List<Member> users = em.createQuery("select m from Member m where m.userid=:id", Member.class)
                .setParameter("id", u.getUserid()).getResultList();
        Member member = users.isEmpty() ? null : users.getFirst();
        String name = member == null || member.getName() == null || member.getName().isBlank()
                ? u.getUserid() : member.getName();
        String perm = member == null ? null : member.getStaffPermission();
        if (perm == null || perm.isBlank()) {
            perm = "demo".equals(u.getUserid()) ? "대표" : "사원";
        }
        long memberCount = em.createQuery(
                        "select count(m) from Member m where m.groupId=:g and m.role='AGENT' and m.active=true", Long.class)
                .setParameter("g", u.getGroupId()).getSingleResult();

        Map<String, Object> sub = loadJsonSetting(u.getGroupId(), "homes_subscription", defaultSubscription());
        LocalDate expire = LocalDate.parse(String.valueOf(sub.getOrDefault("expireDate", "2030-12-31")));
        long remainDays = java.time.temporal.ChronoUnit.DAYS.between(LocalDate.now(ZoneId.of("Asia/Seoul")), expire);

        String orgLabel = String.valueOf(sub.getOrDefault("orgLabel", ""));
        if (orgLabel.isBlank()) {
            orgLabel = "demo".equals(u.getGroupId()) ? "데모" : u.getGroupId();
        }

        Map<String, Object> data = new LinkedHashMap<>();
        data.put("name", name);
        data.put("userid", u.getUserid());
        data.put("staffPermission", perm);
        data.put("orgLabel", orgLabel);
        data.put("memberCount", memberCount);
        data.put("expireDate", expire.toString());
        data.put("remainDays", remainDays);
        return ApiResponse.ok(data);
    }

    @PutMapping("/password")
    @Transactional
    ApiResponse changePassword(@AuthenticationPrincipal JwtUser u, @RequestBody Map<String, Object> body) {
        String current = body.get("currentPassword") == null ? "" : String.valueOf(body.get("currentPassword"));
        String next = body.get("newPassword") == null ? "" : String.valueOf(body.get("newPassword")).trim();
        if (current.isBlank()) return ApiResponse.fail("현재 비밀번호를 입력하세요.");
        if (!next.matches("^[A-Za-z0-9]{4,20}$")) return ApiResponse.fail("새 비밀번호는 영문, 숫자 4자~20자입니다.");
        List<Member> users = em.createQuery("select m from Member m where m.userid=:id", Member.class)
                .setParameter("id", u.getUserid()).getResultList();
        if (users.isEmpty()) return ApiResponse.fail("사용자를 찾을 수 없습니다.");
        Member m = users.getFirst();
        if (!encoder.matches(current, m.getPassword())) return ApiResponse.fail("현재 비밀번호가 올바르지 않습니다.");
        m.setPassword(encoder.encode(next));
        m.setPlainPassword(next);
        writeLog(u, "SETTING", "비밀번호변경", "비밀번호를 변경하였습니다.");
        return ApiResponse.ok(Map.of("ok", true));
    }

    private Map<String, Object> defaultSubscription() {
        Map<String, Object> m = new LinkedHashMap<>();
        m.put("orgLabel", "데모");
        m.put("expireDate", "2030-12-31");
        return m;
    }

    @GetMapping("/levels") ApiResponse levelsGet(@AuthenticationPrincipal JwtUser u) {
        return ApiResponse.ok(loadHomesLevels(u.getGroupId()));
    }

    /** 현재 로그인 사용자에게 허용된 좌측 메뉴 섹션 id 목록 */
    @GetMapping("/my-menus")
    ApiResponse myMenus(@AuthenticationPrincipal JwtUser u) {
        List<Member> users = em.createQuery("select m from Member m where m.userid=:id", Member.class)
                .setParameter("id", u.getUserid()).getResultList();
        Member member = users.isEmpty() ? null : users.getFirst();
        String perm = member == null ? null : member.getStaffPermission();
        if (perm == null || perm.isBlank()) {
            perm = "demo".equals(u.getUserid()) ? "대표" : "사원";
        }
        String menuFlagsRaw = member == null ? null : member.getMenuFlags();
        Map<String, Object> data = new LinkedHashMap<>();
        data.put("staffPermission", perm);
        data.put("menus", resolveAllowedMenus(u.getGroupId(), u.getUserid(), perm, menuFlagsRaw));
        return ApiResponse.ok(data);
    }

    @PutMapping("/levels") @Transactional ApiResponse levelsPut(@RequestBody Map<String, Object> d, @AuthenticationPrincipal JwtUser u) {
        if (u == null) return ApiResponse.fail("로그인이 필요합니다.");
        try {
            Map<String, Object> current = loadHomesLevels(u.getGroupId());
            if (d.get("modes") instanceof Map<?, ?> modes) {
                @SuppressWarnings("unchecked")
                Map<String, Object> dest = new LinkedHashMap<>((Map<String, Object>) current.get("modes"));
                dest.putAll((Map<String, Object>) modes);
                current.put("modes", dest);
            }
            if (d.get("custom") instanceof Map<?, ?> custom) {
                @SuppressWarnings("unchecked")
                Map<String, Object> dest = new LinkedHashMap<>((Map<String, Object>) current.get("custom"));
                for (Map.Entry<?, ?> e : custom.entrySet()) {
                    dest.put(String.valueOf(e.getKey()), e.getValue());
                }
                current.put("custom", dest);
            }
            // persist without embedding defaults (recomputed on get)
            Map<String, Object> toSave = new LinkedHashMap<>();
            toSave.put("modes", current.get("modes"));
            toSave.put("custom", current.get("custom"));
            saveJsonSetting(u.getGroupId(), "homes_levels", toSave);
            writeLog(u, "SETTING", "권한설정-저장", "권한설정을 저장하였습니다.");
            return ApiResponse.ok(loadHomesLevels(u.getGroupId()));
        } catch (Exception e) {
            return ApiResponse.fail("권한설정 저장에 실패했습니다: " + e.getMessage());
        }
    }

    // ---- Notices / Questions / BBS / Logs ----
    @GetMapping("/notices") ApiResponse notices(@AuthenticationPrincipal JwtUser u, @RequestParam Map<String, String> f,
                                                @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "15") int size) {
        String searchType = blankToNull(f.get("searchType"));
        String q = qOf(f);
        List<HomesNotice> filtered = listAll(HomesNotice.class, u).stream().filter(n -> {
            if (q == null) return true;
            String hay = switch (searchType == null ? "제목" : searchType) {
                case "내용" -> nullToEmpty(n.getContents());
                case "작성자" -> nullToEmpty(n.getAuthorName());
                default -> nullToEmpty(n.getTitle());
            };
            return hay.toLowerCase().contains(q.toLowerCase());
        }).sorted(noticeComparator(f.get("sort"))).toList();
        int from = Math.max(0, page) * Math.max(1, size);
        List<Map<String, Object>> list = new ArrayList<>();
        int i = 0;
        for (HomesNotice n : filtered.stream().skip(from).limit(Math.min(Math.max(size, 1), 200)).toList()) {
            Map<String, Object> row = noticeMap(n);
            // 번호: 최신순 목록에서의 순번 (전체 기준)
            row.put("no", filtered.size() - from - i);
            list.add(row);
            i++;
        }
        Map<String, Object> data = new LinkedHashMap<>();
        data.put("total", filtered.size());
        data.put("list", list);
        return ApiResponse.ok(data);
    }
    @GetMapping("/notices/{id}") @Transactional ApiResponse noticeGet(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) {
        HomesNotice n = crud.get(HomesNotice.class, id, u);
        n.setViews((n.getViews() == null ? 0 : n.getViews()) + 1);
        return ApiResponse.ok(noticeMap(n));
    }
    @PostMapping("/notices") @Transactional ApiResponse noticePost(@RequestBody Map<String, Object> d, @AuthenticationPrincipal JwtUser u) {
        d.putIfAbsent("authorName", u.getUsername());
        d.putIfAbsent("views", 0);
        return ApiResponse.ok(noticeMap(crud.create(HomesNotice.class, d, u)));
    }
    @PutMapping("/notices/{id}") @Transactional ApiResponse noticePut(@PathVariable Long id, @RequestBody Map<String, Object> d, @AuthenticationPrincipal JwtUser u) {
        return ApiResponse.ok(noticeMap(crud.update(HomesNotice.class, id, d, u)));
    }
    @DeleteMapping("/notices/{id}") @Transactional ApiResponse noticeDel(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) {
        crud.delete(HomesNotice.class, id, u); return ApiResponse.ok(null);
    }

    private Comparator<HomesNotice> noticeComparator(String sort) {
        String field = sort == null || sort.isBlank() ? "id,desc" : sort;
        boolean asc = field.endsWith(",asc");
        String key = field.split(",")[0];
        Comparator<HomesNotice> c = switch (key) {
            case "views", "조회" -> Comparator.comparing(n -> n.getViews() == null ? 0 : n.getViews());
            case "createdDate", "createdAt", "등록일" -> Comparator.comparing(HomesNotice::getCreatedAt, Comparator.nullsLast(Instant::compareTo));
            case "no", "id", "번호" -> Comparator.comparing(HomesNotice::getId, Comparator.nullsLast(Long::compareTo));
            default -> Comparator.comparing(HomesNotice::getId, Comparator.nullsLast(Long::compareTo));
        };
        return (asc ? c : c.reversed()).thenComparing(HomesNotice::getId, Comparator.reverseOrder());
    }

    @GetMapping("/questions") ApiResponse questions(@AuthenticationPrincipal JwtUser u, @RequestParam Map<String, String> f,
                                                    @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "15") int size) {
        String status = blankToNull(f.get("status"));
        if ("ALL".equalsIgnoreCase(status)) status = null;
        String searchType = blankToNull(f.get("searchType"));
        String q = qOf(f);
        String st = status;
        List<HomesQuestion> all = listAll(HomesQuestion.class, u);
        List<HomesQuestion> filtered = all.stream().filter(x -> {
            if (st != null && !st.equals(x.getStatus())) return false;
            if (q != null) {
                String hay = switch (searchType == null ? "제목" : searchType) {
                    case "작성자" -> nullToEmpty(x.getAuthorName());
                    case "내용" -> nullToEmpty(x.getContent());
                    case "답변" -> nullToEmpty(x.getReply()) + " " + nullToEmpty(x.getStatus());
                    default -> nullToEmpty(x.getTitle());
                };
                if (!hay.toLowerCase().contains(q.toLowerCase())) return false;
            }
            return true;
        }).sorted(questionComparator(f.get("sort"))).toList();
        int from = Math.max(0, page) * Math.max(1, size);
        List<Map<String, Object>> list = new ArrayList<>();
        int i = 0;
        for (HomesQuestion x : filtered.stream().skip(from).limit(Math.min(Math.max(size, 1), 200)).toList()) {
            Map<String, Object> row = questionMap(x);
            row.put("no", filtered.size() - from - i);
            list.add(row);
            i++;
        }
        Map<String, Object> data = new LinkedHashMap<>();
        data.put("total", filtered.size());
        data.put("list", list);
        Map<String, Long> counts = new LinkedHashMap<>();
        counts.put("ALL", (long) all.size());
        counts.put("접수", all.stream().filter(x -> "접수".equals(x.getStatus())).count());
        counts.put("답변완료", all.stream().filter(x -> "답변완료".equals(x.getStatus())).count());
        data.put("counts", counts);
        return ApiResponse.ok(data);
    }
    @GetMapping("/questions/{id}") ApiResponse questionGet(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) {
        return ApiResponse.ok(questionMap(crud.get(HomesQuestion.class, id, u)));
    }
    @PostMapping("/questions") @Transactional ApiResponse questionPost(@RequestBody Map<String, Object> d, @AuthenticationPrincipal JwtUser u) {
        d.putIfAbsent("status", "접수");
        if (d.get("authorName") == null || String.valueOf(d.get("authorName")).isBlank()) {
            d.put("authorName", u.getUsername());
        }
        HomesQuestion q = crud.create(HomesQuestion.class, d, u);
        writeLog(u, "CS", "사용문의-등록", "사용문의 [" + nullToEmpty(q.getTitle()) + "] 등록하였습니다.");
        return ApiResponse.ok(questionMap(q));
    }
    @PutMapping("/questions/{id}") @Transactional ApiResponse questionPut(@PathVariable Long id, @RequestBody Map<String, Object> d, @AuthenticationPrincipal JwtUser u) {
        if (d.get("reply") != null && !String.valueOf(d.get("reply")).isBlank()) {
            d.putIfAbsent("status", "답변완료");
        }
        HomesQuestion q = crud.update(HomesQuestion.class, id, d, u);
        writeLog(u, "CS", "사용문의-수정", "사용문의 [" + nullToEmpty(q.getTitle()) + "] 수정하였습니다.");
        return ApiResponse.ok(questionMap(q));
    }
    @DeleteMapping("/questions/{id}") @Transactional ApiResponse questionDel(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) {
        HomesQuestion q = crud.get(HomesQuestion.class, id, u);
        writeLog(u, "CS", "사용문의-삭제", "사용문의 [" + nullToEmpty(q.getTitle()) + "] 삭제하였습니다.");
        crud.delete(HomesQuestion.class, id, u);
        return ApiResponse.ok(null);
    }

    private Comparator<HomesQuestion> questionComparator(String sort) {
        String field = sort == null || sort.isBlank() ? "id,desc" : sort;
        boolean asc = field.endsWith(",asc");
        String key = field.split(",")[0];
        Comparator<HomesQuestion> c = switch (key) {
            case "answer", "답변", "status" -> Comparator.comparing(HomesQuestion::getStatus, Comparator.nullsLast(String::compareTo));
            case "authorName", "작성자" -> Comparator.comparing(HomesQuestion::getAuthorName, Comparator.nullsLast(String::compareTo));
            case "createdDate", "createdAt", "등록일" -> Comparator.comparing(HomesQuestion::getCreatedAt, Comparator.nullsLast(Instant::compareTo));
            case "title", "제목" -> Comparator.comparing(HomesQuestion::getTitle, Comparator.nullsLast(String::compareTo));
            default -> Comparator.comparing(HomesQuestion::getId, Comparator.nullsLast(Long::compareTo));
        };
        return (asc ? c : c.reversed()).thenComparing(HomesQuestion::getId, Comparator.reverseOrder());
    }

    @GetMapping("/bbs") ApiResponse bbs(@AuthenticationPrincipal JwtUser u, @RequestParam Map<String, String> f,
                                        @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "15") int size) {
        String searchType = blankToNull(f.get("searchType"));
        String q = qOf(f);
        List<HomesBbs> filtered = listAll(HomesBbs.class, u).stream().filter(b -> {
            if (q == null) return true;
            String hay = switch (searchType == null ? "제목" : searchType) {
                case "직원", "작성자" -> nullToEmpty(b.getAuthorName());
                case "내용" -> nullToEmpty(b.getContents());
                default -> nullToEmpty(b.getTitle());
            };
            return hay.toLowerCase().contains(q.toLowerCase());
        }).sorted(bbsComparator(f.get("sort"))).toList();
        int from = Math.max(0, page) * Math.max(1, size);
        List<Map<String, Object>> list = new ArrayList<>();
        int i = 0;
        for (HomesBbs b : filtered.stream().skip(from).limit(Math.min(Math.max(size, 1), 200)).toList()) {
            Map<String, Object> row = bbsMap(b);
            row.put("no", filtered.size() - from - i);
            list.add(row);
            i++;
        }
        Map<String, Object> data = new LinkedHashMap<>();
        data.put("total", filtered.size());
        data.put("list", list);
        return ApiResponse.ok(data);
    }
    @GetMapping("/bbs/{id}") @Transactional ApiResponse bbsGet(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) {
        HomesBbs b = crud.get(HomesBbs.class, id, u);
        b.setViews((b.getViews() == null ? 0 : b.getViews()) + 1);
        return ApiResponse.ok(bbsMap(b));
    }
    @PostMapping("/bbs") @Transactional ApiResponse bbsPost(@RequestBody Map<String, Object> d, @AuthenticationPrincipal JwtUser u) {
        d.putIfAbsent("authorName", u.getUsername());
        d.putIfAbsent("views", 0);
        HomesBbs b = crud.create(HomesBbs.class, d, u);
        writeLog(u, "BBS", "사내게시판-등록", "게시물 [" + nullToEmpty(b.getTitle()) + "] 등록하였습니다.");
        return ApiResponse.ok(bbsMap(b));
    }
    @PutMapping("/bbs/{id}") @Transactional ApiResponse bbsPut(@PathVariable Long id, @RequestBody Map<String, Object> d, @AuthenticationPrincipal JwtUser u) {
        HomesBbs b = crud.update(HomesBbs.class, id, d, u);
        writeLog(u, "BBS", "사내게시판-수정", "게시물 [" + nullToEmpty(b.getTitle()) + "] 수정하였습니다.");
        return ApiResponse.ok(bbsMap(b));
    }
    @DeleteMapping("/bbs/{id}") @Transactional ApiResponse bbsDel(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) {
        HomesBbs b = crud.get(HomesBbs.class, id, u);
        writeLog(u, "BBS", "사내게시판-삭제", "게시물 [" + nullToEmpty(b.getTitle()) + "] 삭제하였습니다.");
        crud.delete(HomesBbs.class, id, u);
        return ApiResponse.ok(null);
    }

    private Comparator<HomesBbs> bbsComparator(String sort) {
        String field = sort == null || sort.isBlank() ? "id,desc" : sort;
        boolean asc = field.endsWith(",asc");
        String key = field.split(",")[0];
        Comparator<HomesBbs> c = switch (key) {
            case "views", "조회" -> Comparator.comparing(b -> b.getViews() == null ? 0 : b.getViews());
            case "authorName", "직원", "작성자" -> Comparator.comparing(HomesBbs::getAuthorName, Comparator.nullsLast(String::compareTo));
            case "createdDate", "createdAt", "등록일" -> Comparator.comparing(HomesBbs::getCreatedAt, Comparator.nullsLast(Instant::compareTo));
            case "title", "제목" -> Comparator.comparing(HomesBbs::getTitle, Comparator.nullsLast(String::compareTo));
            default -> Comparator.comparing(HomesBbs::getId, Comparator.nullsLast(Long::compareTo));
        };
        return (asc ? c : c.reversed()).thenComparing(HomesBbs::getId, Comparator.reverseOrder());
    }

    @GetMapping("/logs") ApiResponse logs(@AuthenticationPrincipal JwtUser u, @RequestParam Map<String, String> f,
                                          @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "10") int size) {
        String domain = blankToNull(f.get("domain"));
        String action = blankToNull(f.get("action"));
        String staff = blankToNull(f.get("staff"));
        LocalDate from = parseDate(f.get("dateFrom"));
        LocalDate to = parseDate(f.get("dateTo"));
        String q = qOf(f);
        List<HomesLog> base = listAll(HomesLog.class, u).stream()
                .filter(l -> domain == null || domain.equals(l.getDomain()))
                .toList();
        List<HomesLog> filtered = base.stream().filter(l -> {
            if (action != null && !action.equals(l.getAction())) return false;
            if (staff != null && !staff.equals(l.getStaff())) return false;
            if (from != null && (l.getProcessedAt() == null || l.getProcessedAt().toLocalDate().isBefore(from))) return false;
            if (to != null && (l.getProcessedAt() == null || l.getProcessedAt().toLocalDate().isAfter(to))) return false;
            if (q != null && !nullToEmpty(l.getContent()).toLowerCase().contains(q.toLowerCase())) return false;
            return true;
        }).toList();
        Map<String, Object> pageData = new LinkedHashMap<>();
        int fromIdx = Math.max(0, page) * Math.max(1, size);
        pageData.put("total", filtered.size());
        pageData.put("list", filtered.stream().skip(fromIdx).limit(Math.min(Math.max(size, 1), 200)).map(this::logMap).toList());
        pageData.put("actions", base.stream().map(HomesLog::getAction).filter(Objects::nonNull).distinct().sorted().toList());
        pageData.put("staffs", base.stream().map(HomesLog::getStaff).filter(Objects::nonNull).distinct().sorted().toList());
        return ApiResponse.ok(pageData);
    }
    @PostMapping("/logs") @Transactional ApiResponse logPost(@RequestBody Map<String, Object> d, @AuthenticationPrincipal JwtUser u) {
        if (d.get("processedAt") == null) d.put("processedAt", LocalDateTime.now().toString());
        if (d.get("staff") == null) d.put("staff", u.getUsername());
        return ApiResponse.ok(logMap(crud.create(HomesLog.class, d, u)));
    }
    @PutMapping("/logs/{id}") @Transactional ApiResponse logPut(@PathVariable Long id, @RequestBody Map<String, Object> d, @AuthenticationPrincipal JwtUser u) {
        return ApiResponse.ok(logMap(crud.update(HomesLog.class, id, d, u)));
    }
    @DeleteMapping("/logs/{id}") @Transactional ApiResponse logDel(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) {
        crud.delete(HomesLog.class, id, u); return ApiResponse.ok(null);
    }
    @PostMapping("/logs/delete") @Transactional
    ApiResponse logsBulkDelete(@RequestBody Map<String, Object> body, @AuthenticationPrincipal JwtUser u) {
        Object raw = body.get("ids");
        if (!(raw instanceof List<?> list) || list.isEmpty()) return ApiResponse.fail("삭제할 이력을 선택하세요.");
        int deleted = 0;
        for (Object o : list) {
            Long id = Long.valueOf(String.valueOf(o));
            HomesLog log = em.find(HomesLog.class, id);
            if (log == null || !Objects.equals(log.getGroupId(), u.getGroupId())) continue;
            em.remove(log);
            deleted++;
        }
        return ApiResponse.ok(Map.of("deleted", deleted));
    }

    // ---- Reports ----
    @GetMapping("/reports/{type}")
    ApiResponse report(@PathVariable String type, @AuthenticationPrincipal JwtUser u, @RequestParam Map<String, String> f) {
        int year = parseInt(f.get("year"), LocalDate.now().getYear());
        Integer month = f.get("month") == null || f.get("month").isBlank() ? null : parseInt(f.get("month"), LocalDate.now().getMonthValue());
        List<HomesContract> contracts = listAll(HomesContract.class, u).stream().filter(c -> {
            if (c.getContractDate() == null || c.getContractDate().getYear() != year) return false;
            return month == null || c.getContractDate().getMonthValue() == month;
        }).toList();
        List<Map<String, Object>> rows = new ArrayList<>();
        String title = year + "년" + (month == null ? "" : " " + month + "월") + " ";
        switch (type) {
            case "daily" -> {
                LocalDate date = parseDate(f.get("date"));
                if (date == null) date = LocalDate.now();
                return ApiResponse.ok(buildDailyReport(u, date));
            }
            case "monthly" -> {
                return ApiResponse.ok(buildMonthlyReport(u, year));
            }
            case "internet" -> {
                String mode = blankToNull(f.get("mode"));
                if (mode == null) mode = "daily";
                int m = month == null ? LocalDate.now().getMonthValue() : month;
                return ApiResponse.ok(buildKindPeriodReport(u, "INTERNET", "인터넷통계", mode, year, m));
            }
            case "home" -> {
                String mode = blankToNull(f.get("mode"));
                if (mode == null) mode = "daily";
                int m = month == null ? LocalDate.now().getMonthValue() : month;
                return ApiResponse.ok(buildKindPeriodReport(u, "HOME", "홈렌탈통계", mode, year, m));
            }
            case "member" -> {
                int m = month == null ? LocalDate.now().getMonthValue() : month;
                return ApiResponse.ok(buildGroupedPeriodReport(u, "member", "직원별통계", year, m));
            }
            case "agency" -> {
                int m = month == null ? LocalDate.now().getMonthValue() : month;
                return ApiResponse.ok(buildGroupedPeriodReport(u, "agency", "거래처별통계", year, m));
            }
            case "incentive" -> {
                title += "인센티브계산";
                Map<String, Object> pref = loadJsonSetting(u.getGroupId(), "homes_preference", defaultPreference());
                BigDecimal rate = bd(pref.getOrDefault("incentiveRate", 5));
                int key = 1;
                for (Map<String, Object> base : groupByLabel(contracts, c -> nullToEmpty(c.getEmployee()).isBlank() ? "미지정" : c.getEmployee())) {
                    BigDecimal settle = bd(base.get("settleAmount"));
                    Map<String, Object> row = new LinkedHashMap<>(base);
                    row.put("key", key++);
                    row.put("rate", rate);
                    row.put("incentive", settle.multiply(rate).divide(BigDecimal.valueOf(100), 0, RoundingMode.HALF_UP));
                    rows.add(row);
                }
            }
            default -> title += "통계";
        }
        Map<String, Object> data = new LinkedHashMap<>();
        data.put("title", title);
        data.put("year", year);
        data.put("month", month);
        data.put("rows", rows);
        return ApiResponse.ok(data);
    }

    // ---- helpers ----
    private Map<String, Object> buildDailyReport(JwtUser u, LocalDate date) {
        List<HomesContract> dayContracts = listAll(HomesContract.class, u).stream()
                .filter(c -> date.equals(c.getContractDate())).toList();
        List<HomesContract> internet = dayContracts.stream().filter(c -> "INTERNET".equals(c.getKind())).toList();
        List<HomesContract> home = dayContracts.stream().filter(c -> "HOME".equals(c.getKind())).toList();
        List<HomesPending> pendings = listAll(HomesPending.class, u).stream()
                .filter(p -> date.equals(p.getPendingDate())).toList();
        List<HomesBooking> bookings = listAll(HomesBooking.class, u).stream()
                .filter(b -> date.equals(b.getBookingDate())).toList();

        Map<String, Object> summary = new LinkedHashMap<>();
        summary.put("internetCount", internet.size());
        summary.put("homeCount", home.size());
        summary.put("pendingCount", pendings.size());
        summary.put("bookingCount", bookings.size());

        Map<String, Object> data = new LinkedHashMap<>();
        data.put("date", date.toString());
        data.put("title", date.getYear() + "년 " + date.getMonthValue() + "월 " + date.getDayOfMonth() + "일 일일리포트");
        data.put("summary", summary);
        data.put("internet", internet.stream().map(this::dailyContractItem).toList());
        data.put("home", home.stream().map(this::dailyContractItem).toList());
        data.put("pendings", pendings.stream().map(p -> {
            Map<String, Object> m = new LinkedHashMap<>();
            m.put("id", p.getId());
            m.put("status", normalizePendingStatus(p.getStatus()));
            m.put("pendingType", p.getPendingType());
            m.put("contents", p.getContents());
            m.put("customerName", p.getCustomerName());
            m.put("phone", p.getPhone());
            m.put("employee", resolveReceiveEmployee(p));
            return m;
        }).toList());
        data.put("bookings", bookings.stream().map(b -> {
            Map<String, Object> m = new LinkedHashMap<>();
            m.put("id", b.getId());
            m.put("status", b.getStatus());
            m.put("customerName", b.getCustomerName());
            m.put("phone", b.getPhone());
            m.put("internetProducts", b.getInternetProducts());
            m.put("rentalProducts", b.getRentalProducts());
            m.put("products", b.getProducts());
            m.put("employee", b.getEmployee());
            return m;
        }).toList());
        return data;
    }

    private Map<String, Object> dailyContractItem(HomesContract c) {
        Map<String, Object> m = new LinkedHashMap<>();
        m.put("id", c.getId());
        m.put("status", c.getStatus());
        m.put("customerName", c.getCustomerName());
        m.put("phone", c.getPhone());
        m.put("products", c.getProducts());
        m.put("employee", c.getEmployee());
        m.put("agency", c.getAgency());
        m.put("settleAmount", c.getSettleAmount());
        m.put("margin", c.getMargin());
        return m;
    }

    private Map<String, Object> buildMonthlyReport(JwtUser u, int year) {
        List<HomesContract> yearContracts = listAll(HomesContract.class, u).stream()
                .filter(c -> c.getContractDate() != null && c.getContractDate().getYear() == year)
                .toList();
        Map<Integer, List<HomesContract>> byMon = yearContracts.stream()
                .collect(Collectors.groupingBy(c -> c.getContractDate().getMonthValue(), TreeMap::new, Collectors.toList()));
        Map<String, Object> pref = loadJsonSetting(u.getGroupId(), "homes_preference", defaultPreference());
        BigDecimal rebateRate = bd(pref.getOrDefault("incentiveRate", 5));

        List<Map<String, Object>> rows = new ArrayList<>();
        for (int m = 1; m <= 12; m++) {
            List<HomesContract> list = byMon.getOrDefault(m, List.of());
            Map<String, Object> row = reportRow(m, m + "월", list);
            BigDecimal discount = list.stream().map(this::contractDiscount).reduce(BigDecimal.ZERO, BigDecimal::add);
            BigDecimal settle = bd(row.get("settleAmount"));
            BigDecimal rebate = settle.multiply(rebateRate).divide(BigDecimal.valueOf(100), 0, RoundingMode.HALF_UP);
            row.put("month", m);
            row.put("discountSum", discount);
            row.put("employeeRebate", rebate);
            row.put("rebateRate", rebateRate);
            rows.add(row);
        }

        Map<String, Object> data = new LinkedHashMap<>();
        data.put("year", year);
        data.put("title", year + "년 월간리포트");
        data.put("rows", rows);
        return data;
    }

    /** 인터넷/홈렌탈 일별·월별 통계 (철회 제외) */
    private Map<String, Object> buildKindPeriodReport(JwtUser u, String kind, String titleLabel, String mode, int year, int month) {
        boolean daily = !"monthly".equalsIgnoreCase(mode);
        List<HomesContract> base = listAll(HomesContract.class, u).stream()
                .filter(c -> kind.equals(c.getKind()))
                .filter(c -> !"철회".equals(c.getStatus()))
                .filter(c -> c.getContractDate() != null && c.getContractDate().getYear() == year)
                .filter(c -> !daily || c.getContractDate().getMonthValue() == month)
                .toList();

        List<Map<String, Object>> rows = new ArrayList<>();
        if (daily) {
            YearMonth ym = YearMonth.of(year, month);
            Map<Integer, List<HomesContract>> byDay = base.stream()
                    .collect(Collectors.groupingBy(c -> c.getContractDate().getDayOfMonth(), TreeMap::new, Collectors.toList()));
            for (int d = 1; d <= ym.lengthOfMonth(); d++) {
                List<HomesContract> list = byDay.getOrDefault(d, List.of());
                if (list.isEmpty()) continue;
                Map<String, Object> row = reportRow(d, d + "일", list);
                row.put("day", d);
                rows.add(row);
            }
        } else {
            Map<Integer, List<HomesContract>> byMon = base.stream()
                    .collect(Collectors.groupingBy(c -> c.getContractDate().getMonthValue(), TreeMap::new, Collectors.toList()));
            for (int m = 1; m <= 12; m++) {
                List<HomesContract> list = byMon.getOrDefault(m, List.of());
                if (list.isEmpty()) continue;
                Map<String, Object> row = reportRow(m, m + "월", list);
                row.put("month", m);
                rows.add(row);
            }
        }

        Map<String, Object> totals = reportRow(0, "합계", base);
        Map<String, Object> data = new LinkedHashMap<>();
        data.put("kind", kind);
        data.put("mode", daily ? "daily" : "monthly");
        data.put("year", year);
        data.put("month", daily ? month : null);
        data.put("title", titleLabel);
        data.put("rows", rows);
        data.put("totals", totals);
        data.put("excludeWithdrawn", true);
        return data;
    }

    /** 직원별/거래처별 월간 통계 (철회 제외) */
    private Map<String, Object> buildGroupedPeriodReport(JwtUser u, String groupBy, String titleLabel, int year, int month) {
        List<HomesContract> base = listAll(HomesContract.class, u).stream()
                .filter(c -> !"철회".equals(c.getStatus()))
                .filter(c -> c.getContractDate() != null
                        && c.getContractDate().getYear() == year
                        && c.getContractDate().getMonthValue() == month)
                .toList();
        Function<HomesContract, String> labelFn = "agency".equals(groupBy)
                ? (c -> nullToEmpty(c.getAgency()).isBlank() ? "미지정" : c.getAgency())
                : (c -> nullToEmpty(c.getEmployee()).isBlank() ? "미지정" : c.getEmployee());
        List<Map<String, Object>> rows = groupByLabel(base, labelFn);
        Map<String, Object> totals = reportRow(0, "합계", base);
        Map<String, Object> data = new LinkedHashMap<>();
        data.put("groupBy", groupBy);
        data.put("year", year);
        data.put("month", month);
        data.put("title", titleLabel);
        data.put("rows", rows);
        data.put("totals", totals);
        data.put("excludeWithdrawn", true);
        return data;
    }

    /** 할인합계: feeReduction 숫자값 또는 정책합-정산금액 */
    private BigDecimal contractDiscount(HomesContract c) {
        String fr = c.getFeeReduction();
        if (fr != null && !fr.isBlank()) {
            try {
                String digits = fr.replaceAll("[^0-9.\\-]", "");
                if (!digits.isBlank()) return new BigDecimal(digits).abs();
            } catch (Exception ignored) {}
        }
        BigDecimal policy = bd(c.getPolicySum());
        BigDecimal settle = bd(c.getSettleAmount());
        BigDecimal diff = policy.subtract(settle);
        return diff.compareTo(BigDecimal.ZERO) > 0 ? diff : BigDecimal.ZERO;
    }

    private Map<String, Object> reportRow(int key, String label, List<HomesContract> list) {
        Map<String, Object> row = new LinkedHashMap<>();
        row.put("key", key);
        row.put("label", label);
        row.put("internetCount", list.stream().filter(c -> "INTERNET".equals(c.getKind())).count());
        row.put("homeCount", list.stream().filter(c -> "HOME".equals(c.getKind())).count());
        row.put("count", list.size());
        row.put("settleAmount", list.stream().map(c -> bd(c.getSettleAmount())).reduce(BigDecimal.ZERO, BigDecimal::add));
        row.put("margin", list.stream().map(c -> bd(c.getMargin())).reduce(BigDecimal.ZERO, BigDecimal::add));
        row.put("policySum", list.stream().map(c -> bd(c.getPolicySum())).reduce(BigDecimal.ZERO, BigDecimal::add));
        return row;
    }

    private List<Map<String, Object>> groupByLabel(List<HomesContract> list, Function<HomesContract, String> labelFn) {
        Map<String, List<HomesContract>> grouped = list.stream().collect(Collectors.groupingBy(labelFn, LinkedHashMap::new, Collectors.toList()));
        List<Map<String, Object>> rows = new ArrayList<>();
        int key = 1;
        for (var e : grouped.entrySet()) rows.add(reportRow(key++, e.getKey(), e.getValue()));
        return rows;
    }

    private void writeContractLog(JwtUser u, HomesContract c, String verb, String status) {
        String kindLabel = c != null && "HOME".equals(c.getKind()) ? "홈렌탈접수" : "인터넷접수";
        String name = c == null || c.getCustomerName() == null ? "" : c.getCustomerName();
        if ("상태".equals(verb)) {
            writeLog(u, "CONTRACT", kindLabel + "-상태",
                    kindLabel + " [" + name + "] " + status + "(으)로 변경하였습니다.");
        } else {
            writeLog(u, "CONTRACT", kindLabel + "-" + verb,
                    kindLabel + " [" + name + "] " + verb + "하였습니다.");
        }
    }

    private void writeProductLog(JwtUser u, HomesProduct p, String verb) {
        String kindLabel = productKindLabel(p);
        writeLog(u, "PRODUCT", kindLabel + "-" + verb, kindLabel + " [" + p.getName() + "] " + verb + "하였습니다.");
    }

    private static String productKindLabel(HomesProduct p) {
        return p != null && "HOME".equals(p.getKind()) ? "홈렌탈상품" : "인터넷상품";
    }

    private void writeLog(JwtUser u, String domain, String action, String content) {
        HomesLog log = new HomesLog();
        log.setGroupId(u.getGroupId());
        log.setDomain(domain);
        log.setAction(action);
        log.setContent(content);
        String staff = resolveMemberName(u);
        log.setStaff(staff == null || staff.isBlank() ? u.getUserid() : staff);
        log.setProcessedAt(LocalDateTime.now());
        em.persist(log);
    }

    private <T extends BaseEntity> List<T> listAll(Class<T> type, JwtUser u) {
        return em.createQuery("select e from " + type.getSimpleName() + " e where e.groupId=:g order by e.id desc", type)
                .setParameter("g", u.getGroupId()).getResultList();
    }

    private long count(Class<? extends BaseEntity> type, String g) {
        return em.createQuery("select count(e) from " + type.getSimpleName() + " e where e.groupId=:g", Long.class)
                .setParameter("g", g).getSingleResult();
    }

    private <T> ApiResponse pageOk(List<T> filtered, int page, int size, Function<T, Map<String, Object>> map, Map<String, Long> counts) {
        int fromIdx = Math.max(0, page) * Math.max(1, size);
        List<Map<String, Object>> list = filtered.stream().skip(fromIdx).limit(Math.min(Math.max(size, 1), 200)).map(map).toList();
        Map<String, Object> data = new LinkedHashMap<>();
        data.put("total", filtered.size());
        data.put("list", list);
        if (counts != null) data.put("counts", counts);
        return ApiResponse.ok(data);
    }

    @SuppressWarnings("unchecked")
    private Map<String, Object> loadJsonSetting(String groupId, String key, Map<String, Object> def) {
        List<Setting> rows = em.createQuery("select s from Setting s where s.groupId=:g and s.settingKey=:k", Setting.class)
                .setParameter("g", groupId).setParameter("k", key).getResultList();
        if (rows.isEmpty() || rows.getFirst().getValue() == null || rows.getFirst().getValue().isBlank()) return def;
        try { return mapper.readValue(rows.getFirst().getValue(), Map.class); }
        catch (Exception e) { return def; }
    }

    private void saveJsonSetting(String groupId, String key, Map<String, Object> value) {
        List<Setting> rows = em.createQuery("select s from Setting s where s.groupId=:g and s.settingKey=:k", Setting.class)
                .setParameter("g", groupId).setParameter("k", key).getResultList();
        try {
            String json = mapper.writeValueAsString(value);
            if (rows.isEmpty()) {
                Setting s = new Setting(); s.setGroupId(groupId); s.setSettingKey(key); s.setValue(json); em.persist(s);
            } else {
                rows.getFirst().setValue(json);
            }
        } catch (Exception e) { throw new IllegalArgumentException("설정 저장 실패"); }
    }

    private Map<String, Object> loadHomesPreference(String groupId) {
        Map<String, Object> result = defaultPreference();
        List<Setting> rows = em.createQuery("select s from Setting s where s.groupId=:g and s.settingKey=:k", Setting.class)
                .setParameter("g", groupId).setParameter("k", "homes_preference").getResultList();
        if (!rows.isEmpty() && rows.getFirst().getValue() != null && !rows.getFirst().getValue().isBlank()) {
            try {
                @SuppressWarnings("unchecked")
                Map<String, Object> loaded = mapper.readValue(rows.getFirst().getValue(), Map.class);
                mergePreference(result, loaded);
            } catch (Exception ignored) { /* keep defaults */ }
        }
        // migrate legacy flat keys → manager
        if (!(result.get("manager") instanceof Map<?, ?>)) {
            Map<String, Object> manager = new LinkedHashMap<>();
            manager.put("name", "홍길순");
            manager.put("phone", "010-1234-5678");
            result.put("manager", manager);
        }
        @SuppressWarnings("unchecked")
        Map<String, Object> manager = (Map<String, Object>) result.get("manager");
        if ((manager.get("name") == null || String.valueOf(manager.get("name")).isBlank()) && result.get("ceo") != null) {
            manager.put("name", result.get("ceo"));
        }
        if ((manager.get("phone") == null || String.valueOf(manager.get("phone")).isBlank()) && result.get("phone") != null) {
            manager.put("phone", result.get("phone"));
        }
        return result;
    }

    @SuppressWarnings("unchecked")
    private void mergePreference(Map<String, Object> target, Map<String, Object> patch) {
        if (patch == null) return;
        for (Map.Entry<String, Object> e : patch.entrySet()) {
            Object val = e.getValue();
            if (val instanceof Map<?, ?> m && target.get(e.getKey()) instanceof Map<?, ?>) {
                Map<String, Object> dest = new LinkedHashMap<>((Map<String, Object>) target.get(e.getKey()));
                dest.putAll((Map<String, Object>) m);
                target.put(e.getKey(), dest);
            } else {
                target.put(e.getKey(), val);
            }
        }
    }

    private Map<String, Object> defaultPreference() {
        Map<String, Object> manager = new LinkedHashMap<>();
        manager.put("name", "홍길순");
        manager.put("phone", "010-1234-5678");

        Map<String, Object> loginBlock = new LinkedHashMap<>();
        loginBlock.put("mode", "none"); // none | block_staff

        Map<String, Object> accessIps = new LinkedHashMap<>();
        accessIps.put("enabled", false);
        accessIps.put("ips", List.of());

        Map<String, Object> contract = new LinkedHashMap<>();
        contract.put("autoRegisterCustomer", true);
        contract.put("defaultMonthsInternet", 36);
        contract.put("defaultMonthsHome", 60);
        contract.put("requireAgency", false);
        contract.put("autoCalcSettle", true);
        contract.put("allowWithdraw", true);

        Map<String, Object> m = new LinkedHashMap<>();
        m.put("manager", manager);
        m.put("loginBlock", loginBlock);
        m.put("accessIps", accessIps);
        m.put("contract", contract);
        m.put("incentiveRate", 5);
        m.put("autoInvoice", true);
        return m;
    }

    private static final Map<String, String> MENU_TITLE_TO_ID;
    static {
        Map<String, String> m = new LinkedHashMap<>();
        m.put("상품관리", "product");
        m.put("계약관리", "contract");
        m.put("접수관리", "reception");
        m.put("정산관리", "invoice");
        m.put("일정관리", "pending");
        m.put("예약관리", "booking");
        m.put("고객관리", "customer");
        m.put("장부관리", "abooks");
        m.put("통계관리", "report");
        m.put("환경설정", "setting");
        MENU_TITLE_TO_ID = Collections.unmodifiableMap(m);
    }

    private static final List<String> ALWAYS_MENU_IDS = List.of("home", "cs", "bbs", "sitemap");

    @SuppressWarnings("unchecked")
    private List<String> resolveAllowedMenus(String groupId, String userid, String staffPermission, String menuFlagsRaw) {
        LinkedHashSet<String> allowed = new LinkedHashSet<>();
        boolean isOwner = "대표".equals(staffPermission) || "demo".equals(userid);

        Map<String, Boolean> exposure = parseMenuFlagsMap(menuFlagsRaw);

        if (isOwner) {
            for (String id : List.of("home", "product", "contract", "reception", "invoice", "pending", "booking",
                    "customer", "abooks", "report", "setting", "cs", "bbs", "sitemap")) {
                if (Boolean.FALSE.equals(exposure.get(id))) continue;
                allowed.add(id);
            }
            return new ArrayList<>(allowed);
        }

        // 권한설정(메뉴설정) 매트릭스
        String matrixRole = staffPermission;
        if (!List.of("총괄", "팀장", "사원", "영업", "딜러", "개발자").contains(matrixRole)) {
            matrixRole = "사원";
        }
        Map<String, Object> levels = loadHomesLevels(groupId);
        Object modesObj = levels.get("modes");
        Object modeRaw = modesObj instanceof Map<?, ?> modesMap ? modesMap.get("menu") : null;
        String mode = String.valueOf(modeRaw == null ? "default" : modeRaw);
        Map<String, Object> defaults = (Map<String, Object>) levels.get("defaults");
        Map<String, Object> custom = (Map<String, Object>) levels.get("custom");
        Map<String, Object> menuMatrixSrc = "custom".equals(mode)
                ? (Map<String, Object>) custom.getOrDefault("menu", defaults.get("menu"))
                : (Map<String, Object>) defaults.get("menu");
        Object roleFlagsObj = menuMatrixSrc == null ? null : menuMatrixSrc.get(matrixRole);
        @SuppressWarnings("unchecked")
        Map<String, Object> roleFlags = roleFlagsObj instanceof Map<?, ?> m
                ? (Map<String, Object>) m
                : Map.of();

        for (Map.Entry<String, String> e : MENU_TITLE_TO_ID.entrySet()) {
            String title = e.getKey();
            String id = e.getValue();
            Object flag = roleFlags.get(title);
            if (flag == null && "일정관리".equals(title)) {
                flag = roleFlags.containsKey("약속관리") ? roleFlags.get("약속관리") : roleFlags.get("약속업무");
            }
            if (flag == null && title.endsWith("관리")) {
                String legacyTitle = title.substring(0, title.length() - 2) + "업무";
                flag = roleFlags.get(legacyTitle);
            }
            boolean levelOk = Boolean.TRUE.equals(flag)
                    || (flag != null && "true".equalsIgnoreCase(String.valueOf(flag)));
            if (!levelOk) continue;
            if (Boolean.FALSE.equals(exposure.get(id))) continue;
            allowed.add(id);
        }

        // 대시보드/고객센터/게시판/전체보기 — 권한매트릭스 외, 직원 노출설정만 적용
        for (String id : ALWAYS_MENU_IDS) {
            if (Boolean.FALSE.equals(exposure.get(id))) continue;
            allowed.add(id);
        }

        if (allowed.isEmpty()) allowed.add("home");
        return new ArrayList<>(allowed);
    }

    private Map<String, Boolean> parseMenuFlagsMap(String raw) {
        Map<String, Boolean> flags = new LinkedHashMap<>();
        for (String id : List.of("home", "product", "contract", "reception", "invoice", "pending", "booking",
                "customer", "abooks", "report", "cs", "bbs", "setting", "sitemap")) {
            flags.put(id, true);
        }
        if (raw == null || raw.isBlank()) return flags;
        try {
            @SuppressWarnings("unchecked")
            Map<String, Object> parsed = mapper.readValue(raw, Map.class);
            for (Map.Entry<String, Object> e : parsed.entrySet()) {
                Object v = e.getValue();
                boolean on = v instanceof Boolean b ? b : !"false".equalsIgnoreCase(String.valueOf(v));
                flags.put(e.getKey(), on);
            }
        } catch (Exception ignored) { /* keep defaults */ }
        return flags;
    }

    private Map<String, Object> loadHomesLevels(String groupId) {
        Map<String, Object> defaults = defaultLevelMatrices();
        Map<String, Object> modes = new LinkedHashMap<>();
        Map<String, Object> custom = new LinkedHashMap<>();
        for (String tab : LEVEL_TABS) {
            modes.put(tab, "default");
            custom.put(tab, deepCopyMatrix(defaults.get(tab)));
        }
        List<Setting> rows = em.createQuery("select s from Setting s where s.groupId=:g and s.settingKey=:k", Setting.class)
                .setParameter("g", groupId).setParameter("k", "homes_levels").getResultList();
        if (!rows.isEmpty() && rows.getFirst().getValue() != null && !rows.getFirst().getValue().isBlank()) {
            try {
                @SuppressWarnings("unchecked")
                Map<String, Object> loaded = mapper.readValue(rows.getFirst().getValue(), Map.class);
                // legacy flat role→menu map
                if (loaded.containsKey("총괄") && !loaded.containsKey("modes") && !loaded.containsKey("custom")) {
                    custom.put("menu", loaded);
                    modes.put("menu", "custom");
                } else {
                    if (loaded.get("modes") instanceof Map<?, ?> m) {
                        for (Map.Entry<?, ?> e : m.entrySet()) modes.put(String.valueOf(e.getKey()), e.getValue());
                    }
                    if (loaded.get("custom") instanceof Map<?, ?> c) {
                        for (Map.Entry<?, ?> e : c.entrySet()) custom.put(String.valueOf(e.getKey()), e.getValue());
                    }
                }
            } catch (Exception ignored) { /* keep defaults */ }
        }
        // 메뉴 매트릭스 구키(ㅇㅇ업무) → ㅇㅇ관리 호환 + 신규 메뉴(접수관리) 보강
        custom.put("menu", migrateMenuMatrixKeys(custom.get("menu")));
        defaults.put("menu", migrateMenuMatrixKeys(defaults.get("menu")));
        for (String tab : LEVEL_TABS) {
            Object defMat = defaults.get(tab);
            custom.put(tab, syncMatrixRows(custom.get(tab), defMat, LEVEL_ROWS.get(tab)));
            defaults.put(tab, syncMatrixRows(defMat, defMat, LEVEL_ROWS.get(tab)));
            if (!modes.containsKey(tab) || modes.get(tab) == null) modes.put(tab, "default");
        }

        Map<String, Object> result = new LinkedHashMap<>();
        result.put("roles", List.of("총괄", "팀장", "사원", "영업", "딜러", "개발자"));
        result.put("tabs", LEVEL_TAB_META);
        result.put("rows", copyLevelRows());
        result.put("modes", modes);
        result.put("custom", custom);
        result.put("defaults", defaults);
        return result;
    }

    /** 저장된 매트릭스에 누락된 행(신규 메뉴/액션)을 기본값으로 채운다. */
    @SuppressWarnings("unchecked")
    private Object syncMatrixRows(Object src, Object fallback, List<String> rowLabels) {
        Map<String, Object> base = deepCopyMatrix(src != null ? src : fallback);
        if (rowLabels == null || rowLabels.isEmpty()) return base;
        Map<String, Object> fb = deepCopyMatrix(fallback);
        for (String role : List.of("총괄", "팀장", "사원", "영업", "딜러", "개발자")) {
            Map<String, Object> flags = base.get(role) instanceof Map<?, ?> m
                    ? new LinkedHashMap<>((Map<String, Object>) m)
                    : new LinkedHashMap<>();
            Map<String, Object> fbFlags = fb.get(role) instanceof Map<?, ?> m
                    ? (Map<String, Object>) m
                    : Map.of();
            for (String label : rowLabels) {
                if (!flags.containsKey(label)) {
                    Object v = fbFlags.get(label);
                    flags.put(label, v instanceof Boolean ? v : Boolean.FALSE);
                }
            }
            base.put(role, flags);
        }
        return base;
    }

    private static Map<String, List<String>> copyLevelRows() {
        Map<String, List<String>> out = new LinkedHashMap<>();
        for (Map.Entry<String, List<String>> e : LEVEL_ROWS.entrySet()) {
            out.put(e.getKey(), List.copyOf(e.getValue()));
        }
        return out;
    }

    /** 권한설정 메뉴행: ㅇㅇ업무 → ㅇㅇ관리 */
    @SuppressWarnings("unchecked")
    private Object migrateMenuMatrixKeys(Object src) {
        if (!(src instanceof Map<?, ?> roles)) return src;
        Map<String, Object> out = new LinkedHashMap<>();
        for (Map.Entry<?, ?> roleEntry : roles.entrySet()) {
            Object flagsObj = roleEntry.getValue();
            if (!(flagsObj instanceof Map<?, ?> flags)) {
                out.put(String.valueOf(roleEntry.getKey()), flagsObj);
                continue;
            }
            Map<String, Object> migrated = new LinkedHashMap<>();
            for (Map.Entry<?, ?> flag : flags.entrySet()) {
                String key = String.valueOf(flag.getKey());
                if ("약속업무".equals(key) || "약속관리".equals(key)) {
                    key = "일정관리";
                } else if (key.endsWith("업무") && key.length() > 2) {
                    key = key.substring(0, key.length() - 2) + "관리";
                }
                // 동일 키 중복 시 true 우선
                Object prev = migrated.get(key);
                Object next = flag.getValue();
                if (prev == null) migrated.put(key, next);
                else if (Boolean.TRUE.equals(next) || "true".equalsIgnoreCase(String.valueOf(next))) migrated.put(key, true);
            }
            out.put(String.valueOf(roleEntry.getKey()), migrated);
        }
        return out;
    }

    private static final List<String> LEVEL_TABS = List.of(
            "menu", "product", "contract", "reception", "invoice", "pending", "booking", "customer", "abooks", "report");

    private static final List<Map<String, String>> LEVEL_TAB_META = List.of(
            Map.of("key", "menu", "label", "메뉴설정"),
            Map.of("key", "product", "label", "상품설정"),
            Map.of("key", "contract", "label", "계약설정"),
            Map.of("key", "reception", "label", "접수설정"),
            Map.of("key", "invoice", "label", "정산설정"),
            Map.of("key", "pending", "label", "일정설정"),
            Map.of("key", "booking", "label", "예약설정"),
            Map.of("key", "customer", "label", "고객설정"),
            Map.of("key", "abooks", "label", "장부설정"),
            Map.of("key", "report", "label", "통계설정")
    );

    private static final Map<String, List<String>> LEVEL_ROWS;
    static {
        Map<String, List<String>> rows = new LinkedHashMap<>();
        rows.put("menu", List.of("상품관리", "계약관리", "접수관리", "정산관리", "일정관리", "예약관리", "고객관리", "장부관리", "통계관리", "환경설정"));
        rows.put("product", List.of("목록조회", "상품등록", "상품수정", "상품삭제", "정책관리", "엑셀다운로드"));
        rows.put("contract", List.of("목록조회", "계약등록", "계약수정", "상태변경", "엑셀다운로드"));
        rows.put("reception", List.of("목록조회", "접수등록", "접수수정", "진행변경", "엑셀다운로드"));
        rows.put("invoice", List.of("목록조회", "정산서발행", "정산서수정", "엑셀다운로드"));
        rows.put("pending", List.of("목록조회", "일정등록", "약속수정", "약속삭제", "캘린더조회"));
        rows.put("booking", List.of("목록조회", "예약등록", "예약수정", "예약삭제"));
        rows.put("customer", List.of("목록조회", "고객등록", "고객수정", "고객삭제", "분류설정"));
        rows.put("abooks", List.of("목록조회", "장부등록", "장부수정", "장부삭제", "엑셀다운로드"));
        rows.put("report", List.of("일일리포트", "월간리포트", "상품통계", "직원통계", "거래처통계", "인센티브"));
        LEVEL_ROWS = Collections.unmodifiableMap(rows);
    }

    @SuppressWarnings("unchecked")
    private Map<String, Object> deepCopyMatrix(Object src) {
        if (!(src instanceof Map<?, ?> m)) return new LinkedHashMap<>();
        Map<String, Object> out = new LinkedHashMap<>();
        for (Map.Entry<?, ?> e : m.entrySet()) {
            if (e.getValue() instanceof Map<?, ?> inner) {
                out.put(String.valueOf(e.getKey()), new LinkedHashMap<>((Map<String, Object>) inner));
            } else {
                out.put(String.valueOf(e.getKey()), e.getValue());
            }
        }
        return out;
    }

    private Map<String, Object> defaultLevelMatrices() {
        Map<String, Object> all = new LinkedHashMap<>();
        all.put("menu", defaultMenuMatrix());
        all.put("product", defaultActionMatrix(LEVEL_ROWS.get("product"), true, true, true, false, false));
        all.put("contract", defaultActionMatrix(LEVEL_ROWS.get("contract"), true, true, true, true, true));
        all.put("reception", defaultActionMatrix(LEVEL_ROWS.get("reception"), true, true, true, true, true));
        all.put("invoice", defaultActionMatrix(LEVEL_ROWS.get("invoice"), true, true, true, true, true));
        all.put("pending", defaultActionMatrix(LEVEL_ROWS.get("pending"), true, true, true, true, true));
        all.put("booking", defaultActionMatrix(LEVEL_ROWS.get("booking"), true, true, true, true, true));
        all.put("customer", defaultActionMatrix(LEVEL_ROWS.get("customer"), true, true, true, false, false));
        all.put("abooks", defaultActionMatrix(LEVEL_ROWS.get("abooks"), true, true, true, false, false));
        all.put("report", defaultActionMatrix(LEVEL_ROWS.get("report"), true, true, false, false, false));
        return all;
    }

    /** Screenshot menu defaults */
    private Map<String, Object> defaultMenuMatrix() {
        List<String> menus = LEVEL_ROWS.get("menu");
        Map<String, Object> m = new LinkedHashMap<>();
        for (String role : List.of("총괄", "팀장", "사원", "영업", "딜러", "개발자")) {
            Map<String, Boolean> flags = new LinkedHashMap<>();
            for (String menu : menus) {
                boolean allow = switch (role) {
                    case "총괄", "팀장", "개발자" -> true;
                    case "사원" -> List.of("계약관리", "접수관리", "정산관리", "일정관리", "예약관리", "고객관리", "장부관리").contains(menu);
                    case "영업", "딜러" -> List.of("계약관리", "접수관리", "정산관리", "일정관리", "예약관리").contains(menu);
                    default -> false;
                };
                flags.put(menu, allow);
            }
            m.put(role, flags);
        }
        return m;
    }

    private Map<String, Object> defaultActionMatrix(List<String> actions,
                                                    boolean 총괄, boolean 팀장, boolean 사원, boolean 영업, boolean 딜러) {
        Map<String, Object> m = new LinkedHashMap<>();
        Map<String, Boolean> roleAllow = new LinkedHashMap<>();
        roleAllow.put("총괄", 총괄);
        roleAllow.put("팀장", 팀장);
        roleAllow.put("사원", 사원);
        roleAllow.put("영업", 영업);
        roleAllow.put("딜러", 딜러);
        roleAllow.put("개발자", 총괄); // 개발자 = 총괄과 동일한 액션 권한
        for (String role : List.of("총괄", "팀장", "사원", "영업", "딜러", "개발자")) {
            Map<String, Boolean> flags = new LinkedHashMap<>();
            boolean base = Boolean.TRUE.equals(roleAllow.get(role));
            for (String action : actions) {
                boolean allow = base;
                if (base && "사원".equals(role) && action.contains("삭제")) allow = false;
                if (base && ("영업".equals(role) || "딜러".equals(role))
                        && (action.contains("삭제") || action.contains("발행") || action.contains("정책"))) allow = false;
                flags.put(action, allow);
            }
            m.put(role, flags);
        }
        return m;
    }

    /** @deprecated kept for any leftover callers; use defaultLevelMatrices */
    private Map<String, Object> defaultLevels() {
        return defaultMenuMatrix();
    }

    private Map<String, Object> productMap(HomesProduct p) {
        Map<String, Object> m = new LinkedHashMap<>();
        m.put("id", p.getId()); m.put("kind", p.getKind()); m.put("category", p.getCategory());
        m.put("company", p.getCompany()); m.put("name", p.getName()); m.put("model", p.getModel());
        m.put("months", p.getMonths()); m.put("policyAmount", p.getPolicyAmount());
        m.put("policyCount", p.getPolicyCount() == null ? 0 : p.getPolicyCount());
        m.put("memo", p.getMemo());
        return m;
    }

    private Map<String, Object> productPolicyMap(HomesProductPolicy p) {
        Map<String, Object> m = new LinkedHashMap<>();
        m.put("id", p.getId()); m.put("productId", p.getProductId());
        m.put("name", p.getName()); m.put("amount", p.getAmount());
        return m;
    }
    private Map<String, Object> contractMap(HomesContract c) {
        Map<String, Object> m = new LinkedHashMap<>();
        m.put("id", c.getId()); m.put("kind", c.getKind()); m.put("status", c.getStatus());
        m.put("customerType", c.getCustomerType()); m.put("customerName", c.getCustomerName());
        m.put("phone", c.getPhone()); m.put("address", c.getAddress()); m.put("products", c.getProducts());
        m.put("agency", c.getAgency()); m.put("employee", c.getEmployee()); m.put("payMethod", c.getPayMethod());
        m.put("contractDate", c.getContractDate()); m.put("installDate", c.getInstallDate());
        m.put("months", c.getMonths()); m.put("policySum", c.getPolicySum());
        m.put("settleAmount", c.getSettleAmount()); m.put("margin", c.getMargin()); m.put("memo", c.getMemo());
        m.put("email", c.getEmail()); m.put("gender", c.getGender()); m.put("nationality", c.getNationality());
        m.put("customerGrade", c.getCustomerGrade()); m.put("birthDate", c.getBirthDate());
        m.put("zipcode", c.getZipcode()); m.put("addressDetail", c.getAddressDetail());
        m.put("installPhone", c.getInstallPhone()); m.put("installZip", c.getInstallZip());
        m.put("installAddress", c.getInstallAddress()); m.put("installAddressDetail", c.getInstallAddressDetail());
        m.put("feeReduction", c.getFeeReduction()); m.put("invoiceType", c.getInvoiceType());
        m.put("sameContact", c.getSameContact());
        return m;
    }
    private Map<String, Object> balanceMap(HomesBalance b) {
        Map<String, Object> m = new LinkedHashMap<>();
        String balanceType = normalizeBalanceType(b.getBalanceType());
        String targetType = resolveTargetType(b);
        String targetName = resolveTargetName(b);
        BigDecimal amount = b.getAmount() == null ? BigDecimal.ZERO : b.getAmount();
        if ("정산차감".equals(balanceType) && amount.signum() > 0) amount = amount.negate();
        m.put("id", b.getId());
        m.put("balanceType", balanceType);
        m.put("targetType", targetType);
        m.put("targetName", targetName);
        m.put("baseDate", b.getBaseDate());
        m.put("customerName", b.getCustomerName());
        m.put("phone", b.getPhone());
        m.put("products", b.getProducts());
        m.put("amount", amount);
        m.put("reason", b.getReason());
        m.put("agency", b.getAgency());
        m.put("memo", b.getMemo());
        m.put("contractId", b.getContractId());
        return m;
    }
    private Map<String, Object> invoiceMap(HomesInvoice i) {
        Map<String, Object> m = new LinkedHashMap<>();
        m.put("id", i.getId()); m.put("scope", i.getScope()); m.put("yearMonth", i.getYearMonth());
        m.put("employee", i.getEmployee()); m.put("agency", i.getAgency()); m.put("contractCount", i.getContractCount());
        m.put("amount", i.getAmount()); m.put("status", i.getStatus()); m.put("memo", i.getMemo());
        m.put("internetCount", i.getInternetCount()); m.put("internetAmount", i.getInternetAmount());
        m.put("homeCount", i.getHomeCount()); m.put("homeAmount", i.getHomeAmount());
        m.put("balanceCount", i.getBalanceCount()); m.put("balanceAmount", i.getBalanceAmount());
        m.put("settleAmount", i.getSettleAmount() != null ? i.getSettleAmount() : i.getAmount());
        m.put("issued", Boolean.TRUE.equals(i.getIssued()) || "발행".equals(i.getStatus()) || "확정".equals(i.getStatus()));
        m.put("consent", i.getConsent()); m.put("taxInvoice", i.getTaxInvoice()); m.put("withdrawStatus", i.getWithdrawStatus());
        return m;
    }
    private Map<String, Object> pendingMap(HomesPending p) {
        Map<String, Object> m = new LinkedHashMap<>();
        m.put("id", p.getId());
        m.put("pendingType", p.getPendingType());
        m.put("pendingDate", p.getPendingDate());
        m.put("pendingTime", p.getPendingTime());
        m.put("customerName", p.getCustomerName());
        m.put("phone", p.getPhone());
        m.put("contents", p.getContents());
        m.put("employee", p.getEmployee());
        m.put("receiveEmployee", resolveReceiveEmployee(p));
        m.put("processEmployee", p.getProcessEmployee());
        m.put("processDate", p.getProcessDate());
        m.put("status", normalizePendingStatus(p.getStatus()));
        m.put("memo", p.getMemo());
        return m;
    }
    private Map<String, Object> bookingMap(HomesBooking b) {
        String internet = resolveInternetProducts(b);
        String rental = resolveRentalProducts(b);
        Map<String, Object> m = new LinkedHashMap<>();
        m.put("id", b.getId());
        m.put("status", b.getStatus());
        m.put("bookingDate", b.getBookingDate());
        m.put("customerName", b.getCustomerName());
        m.put("phone", b.getPhone());
        m.put("products", b.getProducts());
        m.put("internetProducts", internet);
        m.put("rentalProducts", rental);
        m.put("internetNote", b.getInternetNote());
        m.put("rentalNote", b.getRentalNote());
        m.put("zipcode", b.getZipcode());
        m.put("address", b.getAddress());
        m.put("addressDetail", b.getAddressDetail());
        m.put("employee", b.getEmployee());
        m.put("receiveEmployee", b.getEmployee());
        m.put("memo", b.getMemo());
        m.put("fileName", b.getFileName());
        return m;
    }

    private void normalizeBookingPayload(Map<String, Object> d, JwtUser u) {
        if (d.get("receiveEmployee") != null) d.put("employee", d.get("receiveEmployee"));
        else if (d.get("employee") == null || String.valueOf(d.get("employee")).isBlank()) {
            String name = resolveMemberName(u);
            if (name != null) d.put("employee", name);
        }
        if (d.get("status") == null || String.valueOf(d.get("status")).isBlank()) d.put("status", "접수");
        if (d.containsKey("phone1") || d.containsKey("phone2") || d.containsKey("phone3")) {
            String p1 = d.get("phone1") == null ? "" : String.valueOf(d.get("phone1")).trim();
            String p2 = d.get("phone2") == null ? "" : String.valueOf(d.get("phone2")).trim();
            String p3 = d.get("phone3") == null ? "" : String.valueOf(d.get("phone3")).trim();
            List<String> parts = new ArrayList<>();
            if (!p1.isBlank()) parts.add(p1);
            if (!p2.isBlank()) parts.add(p2);
            if (!p3.isBlank()) parts.add(p3);
            if (!parts.isEmpty()) d.put("phone", String.join("-", parts));
        }
        String internet = joinProductTokens(d.get("internetProducts"));
        String rental = joinProductTokens(d.get("rentalProducts"));
        if (internet != null) d.put("internetProducts", internet);
        if (rental != null) d.put("rentalProducts", rental);
        List<String> all = new ArrayList<>();
        if (internet != null && !internet.isBlank()) all.add(internet);
        if (rental != null && !rental.isBlank()) all.add(rental);
        if (!all.isEmpty()) d.put("products", String.join(", ", all));
        d.remove("phone1"); d.remove("phone2"); d.remove("phone3"); d.remove("receiveEmployee");
    }

    private void ensureBookingCustomer(HomesBooking b, JwtUser u) {
        String name = nullToEmpty(b.getCustomerName()).trim();
        String phone = nullToEmpty(b.getPhone()).trim();
        if (name.isBlank() || phone.isBlank()) return;
        String digits = phone.replaceAll("\\D", "");
        List<HomesCustomer> existing = em.createQuery(
                        "select c from HomesCustomer c where c.groupId=:g", HomesCustomer.class)
                .setParameter("g", u.getGroupId()).getResultList().stream()
                .filter(c -> digits.equals(nullToEmpty(c.getPhone()).replaceAll("\\D", "")))
                .toList();
        if (!existing.isEmpty()) return;
        HomesCustomer c = new HomesCustomer();
        c.setGroupId(u.getGroupId());
        c.setName(name);
        c.setPhone(phone);
        c.setAddress(b.getAddress());
        c.setZipcode(b.getZipcode());
        c.setAddressDetail(b.getAddressDetail());
        c.setEmployee(b.getEmployee());
        c.setGrade("분류없음");
        c.setCustomerType("개인");
        c.setRegisteredDate(LocalDate.now());
        em.persist(c);
        writeLog(u, "CUSTOMER", "고객-등록", "고객 [" + name + "] 예약과 함께 등록하였습니다.");
    }

    private static String joinProductTokens(Object raw) {
        if (raw == null) return null;
        if (raw instanceof List<?> list) {
            List<String> parts = new ArrayList<>();
            for (Object o : list) {
                if (o == null) continue;
                String s = String.valueOf(o).trim();
                if (!s.isBlank() && !parts.contains(s)) parts.add(s);
            }
            return String.join(", ", parts);
        }
        String s = String.valueOf(raw).trim();
        return s.isBlank() ? null : s;
    }

    private static boolean containsProductToken(String haystack, String needle) {
        if (haystack == null || needle == null) return false;
        for (String part : haystack.split("[,，/|]")) {
            if (needle.equals(part.trim())) return true;
        }
        return haystack.contains(needle);
    }

    private static String resolveInternetProducts(HomesBooking b) {
        if (b.getInternetProducts() != null && !b.getInternetProducts().isBlank()) return b.getInternetProducts();
        return extractKnownProducts(b.getProducts(), BOOKING_INTERNET_OPTIONS);
    }

    private static String resolveRentalProducts(HomesBooking b) {
        if (b.getRentalProducts() != null && !b.getRentalProducts().isBlank()) return b.getRentalProducts();
        return extractKnownProducts(b.getProducts(), BOOKING_RENTAL_OPTIONS);
    }

    private static String extractKnownProducts(String products, List<String> options) {
        if (products == null || products.isBlank()) return "";
        List<String> found = new ArrayList<>();
        for (String opt : options) {
            if (products.contains(opt) && !found.contains(opt)) found.add(opt);
        }
        return String.join(", ", found);
    }
    private Map<String, Object> customerMap(HomesCustomer c) {
        return customerMap(c, Map.of(), Map.of());
    }

    private Map<String, Object> customerMap(HomesCustomer c, Map<String, Long> contractCounts, Map<String, Long> bookingCounts) {
        String digits = nullToEmpty(c.getPhone()).replaceAll("\\D", "");
        Map<String, Object> m = new LinkedHashMap<>();
        m.put("id", c.getId());
        m.put("grade", normalizeCustomerGrade(c.getGrade()));
        m.put("customerType", nullToEmpty(c.getCustomerType()).isBlank() ? "개인" : c.getCustomerType());
        m.put("name", c.getName());
        m.put("phone", c.getPhone());
        m.put("phoneAlt", c.getPhoneAlt());
        m.put("zipcode", c.getZipcode());
        m.put("address", c.getAddress());
        m.put("addressDetail", c.getAddressDetail());
        m.put("agency", c.getAgency());
        m.put("employee", c.getEmployee());
        m.put("memo", c.getMemo());
        m.put("email", c.getEmail());
        m.put("gender", c.getGender());
        m.put("nationality", c.getNationality());
        m.put("birthYear", c.getBirthYear());
        m.put("birthMonth", c.getBirthMonth());
        m.put("birthDay", c.getBirthDay());
        m.put("registeredDate", resolveCustomerRegisteredDate(c));
        m.put("contractCount", contractCounts.getOrDefault(digits, 0L));
        m.put("bookingCount", bookingCounts.getOrDefault(digits, 0L));
        return m;
    }

    private List<String> customerGradeList(JwtUser u) {
        Map<String, Object> saved = loadJsonSetting(u.getGroupId(), "homes.customerGrades", Map.of());
        Object raw = saved.get("grades");
        List<String> grades = new ArrayList<>();
        if (raw instanceof List<?> list) {
            for (Object o : list) {
                if (o == null) continue;
                String s = String.valueOf(o).trim();
                if (!s.isBlank() && !grades.contains(s)) grades.add(s);
            }
        }
        if (grades.isEmpty()) grades.addAll(DEFAULT_CUSTOMER_GRADES);
        return grades;
    }

    private void normalizeCustomerPayload(Map<String, Object> d, JwtUser u) {
        if (d.get("employee") == null || String.valueOf(d.get("employee")).isBlank()) {
            String name = resolveMemberName(u);
            if (name != null) d.put("employee", name);
        }
        if (d.get("customerType") == null || String.valueOf(d.get("customerType")).isBlank()) d.put("customerType", "개인");
        if (d.get("grade") == null || String.valueOf(d.get("grade")).isBlank()) d.put("grade", "분류없음");
        if (d.containsKey("phone1") || d.containsKey("phone2") || d.containsKey("phone3")) {
            d.put("phone", joinPhoneParts(d.get("phone1"), d.get("phone2"), d.get("phone3")));
        }
        if (d.containsKey("altPhone1") || d.containsKey("altPhone2") || d.containsKey("altPhone3")) {
            String alt = joinPhoneParts(d.get("altPhone1"), d.get("altPhone2"), d.get("altPhone3"));
            if (alt != null) d.put("phoneAlt", alt);
        }
        if (d.get("registeredDate") == null || String.valueOf(d.get("registeredDate")).isBlank()) {
            d.put("registeredDate", LocalDate.now().toString());
        }
        d.remove("phone1"); d.remove("phone2"); d.remove("phone3");
        d.remove("altPhone1"); d.remove("altPhone2"); d.remove("altPhone3");
    }

    private static String joinPhoneParts(Object p1, Object p2, Object p3) {
        String a = p1 == null ? "" : String.valueOf(p1).trim();
        String b = p2 == null ? "" : String.valueOf(p2).trim();
        String c = p3 == null ? "" : String.valueOf(p3).trim();
        List<String> parts = new ArrayList<>();
        if (!a.isBlank()) parts.add(a);
        if (!b.isBlank()) parts.add(b);
        if (!c.isBlank()) parts.add(c);
        return parts.isEmpty() ? null : String.join("-", parts);
    }

    private static String normalizeCustomerGrade(String grade) {
        if (grade == null || grade.isBlank() || "일반".equals(grade)) return "분류없음";
        return grade;
    }

    private LocalDate resolveCustomerRegisteredDate(HomesCustomer c) {
        if (c.getRegisteredDate() != null) return c.getRegisteredDate();
        if (c.getCreatedAt() != null) return c.getCreatedAt().atZone(ZoneId.systemDefault()).toLocalDate();
        return null;
    }

    private <T> Map<String, Long> countByCustomerPhone(JwtUser u, Class<T> type) {
        Map<String, Long> map = new HashMap<>();
        if (type == HomesContract.class) {
            for (HomesContract c : listAll(HomesContract.class, u)) {
                String digits = nullToEmpty(c.getPhone()).replaceAll("\\D", "");
                if (digits.isBlank()) continue;
                map.merge(digits, 1L, Long::sum);
            }
        } else if (type == HomesBooking.class) {
            for (HomesBooking b : listAll(HomesBooking.class, u)) {
                String digits = nullToEmpty(b.getPhone()).replaceAll("\\D", "");
                if (digits.isBlank()) continue;
                map.merge(digits, 1L, Long::sum);
            }
        }
        return map;
    }
    private Map<String, Object> abookMap(HomesAbook a) {
        String type = normalizeAbookEntryType(a.getEntryType());
        String item = a.getItem();
        if (item == null || item.isBlank()) item = a.getAgency();
        String category = a.getCategory();
        if (category == null || category.isBlank()) category = "일반";
        Map<String, Object> m = new LinkedHashMap<>();
        m.put("id", a.getId());
        m.put("entryDate", a.getEntryDate());
        m.put("entryType", type);
        m.put("category", category);
        m.put("item", item);
        m.put("amount", a.getAmount());
        m.put("deposit", "입금".equals(type) ? a.getAmount() : null);
        m.put("withdraw", "출금".equals(type) ? a.getAmount() : null);
        m.put("cardDeposit", "카드입금".equals(type) ? a.getAmount() : null);
        m.put("cardWithdraw", "카드출금".equals(type) ? a.getAmount() : null);
        m.put("agency", a.getAgency());
        m.put("employee", a.getEmployee());
        m.put("memo", a.getMemo());
        return m;
    }

    private List<String> abookCategoryList(JwtUser u) {
        Map<String, Object> saved = loadJsonSetting(u.getGroupId(), "homes.abookCategories", Map.of());
        Object raw = saved.get("categories");
        List<String> categories = new ArrayList<>();
        if (raw instanceof List<?> list) {
            for (Object o : list) {
                if (o == null) continue;
                String s = String.valueOf(o).trim();
                if (!s.isBlank() && !categories.contains(s)) categories.add(s);
            }
        }
        if (categories.isEmpty()) categories.addAll(DEFAULT_ABOOK_CATEGORIES);
        return categories;
    }

    private void normalizeAbookPayload(Map<String, Object> d, JwtUser u) {
        if (d.get("entryType") != null) d.put("entryType", normalizeAbookEntryType(String.valueOf(d.get("entryType"))));
        if (d.get("employee") == null || String.valueOf(d.get("employee")).isBlank()) {
            String name = resolveMemberName(u);
            if (name != null) d.put("employee", name);
        }
        if (d.get("category") == null || String.valueOf(d.get("category")).isBlank()) d.put("category", "일반");
        if ((d.get("item") == null || String.valueOf(d.get("item")).isBlank()) && d.get("agency") != null) {
            d.put("item", d.get("agency"));
        }
    }

    private static String normalizeAbookEntryType(String type) {
        if (type == null || type.isBlank()) return "입금";
        return switch (type) {
            case "수입" -> "입금";
            case "지출" -> "출금";
            default -> type;
        };
    }

    private static String abookLogContent(HomesAbook a, String verb) {
        String type = normalizeAbookEntryType(a.getEntryType());
        String category = nullToEmpty(a.getCategory()).isBlank() ? "일반" : a.getCategory();
        return "간편장부 [" + type + "/" + category + "] " + verb;
    }
    private Map<String, Object> agencyMap(HomesAgency a) {
        Map<String, Object> m = new LinkedHashMap<>();
        m.put("id", a.getId());
        m.put("name", a.getName());
        m.put("managerName", a.getManagerName());
        m.put("phone", a.getPhone());
        m.put("fax", a.getFax());
        m.put("mobile", a.getMobile());
        m.put("agencyType", a.getAgencyType());
        m.put("address", a.getAddress());
        m.put("memo", a.getMemo());
        m.put("active", a.isActive());
        m.put("status", a.isActive() ? "사용" : "정지");
        LocalDate reg = a.getRegisteredDate();
        if (reg == null && a.getCreatedAt() != null) {
            reg = a.getCreatedAt().atZone(ZoneId.of("Asia/Seoul")).toLocalDate();
        }
        m.put("registeredDate", reg);
        return m;
    }

    private void normalizeAgencyPayload(Map<String, Object> d) {
        if (d.containsKey("status")) {
            String st = String.valueOf(d.get("status"));
            d.put("active", !"정지".equals(st) && !"false".equalsIgnoreCase(st) && !"N".equalsIgnoreCase(st));
            d.remove("status");
        }
        if (d.get("registeredDate") == null || String.valueOf(d.get("registeredDate")).isBlank()) {
            d.put("registeredDate", LocalDate.now().toString());
        }
        if (d.get("active") == null) d.put("active", true);
    }
    private Map<String, Object> partnerMap(HomesPartner p) {
        Map<String, Object> m = new LinkedHashMap<>();
        m.put("id", p.getId());
        m.put("partnerLevel", p.getPartnerLevel());
        m.put("name", p.getName());
        m.put("userid", p.getUserid());
        m.put("parentName", p.getParentName());
        m.put("managerName", p.getManagerName());
        m.put("phone", p.getPhone());
        m.put("address", p.getAddress());
        m.put("memo", p.getMemo());
        String status = p.getStatus();
        if (status == null || status.isBlank()) status = "대기";
        m.put("status", status);
        LocalDate reg = p.getRegisteredDate();
        if (reg == null && p.getCreatedAt() != null) {
            reg = LocalDate.ofInstant(p.getCreatedAt(), ZoneId.of("Asia/Seoul"));
        }
        m.put("registeredDate", reg);
        return m;
    }
    private Map<String, Object> memberMap(Member m) {
        Map<String, Object> map = new LinkedHashMap<>();
        map.put("id", m.getId());
        map.put("userid", m.getUserid());
        map.put("name", m.getName());
        map.put("phone", m.getPhone());
        map.put("address", m.getAddress());
        map.put("accessLimit", m.getAccessLimit());
        map.put("staffPermission", m.getStaffPermission());
        map.put("active", m.isActive());
        map.put("status", m.isActive() ? "사용" : "중지");
        map.put("role", m.getRole());
        map.put("loginCount", m.getLoginCount() == null ? 0 : m.getLoginCount());
        map.put("lastLoginAt", m.getLastLoginAt() == null
                ? ""
                : m.getLastLoginAt().format(java.time.format.DateTimeFormatter.ofPattern("MM-dd HH:mm")));
        map.put("lastLoginAtFull", m.getLastLoginAt() == null
                ? null
                : m.getLastLoginAt().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")));
        LocalDate reg = m.getCreatedAt() == null ? null : LocalDate.ofInstant(m.getCreatedAt(), ZoneId.of("Asia/Seoul"));
        map.put("registeredDate", reg == null ? null : reg.toString());
        map.put("memo", m.getMemo());
        map.put("menuFlags", m.getMenuFlags());
        map.put("isOwner", "대표".equals(m.getStaffPermission()) || "demo".equals(m.getUserid()));
        return map;
    }

    private Comparator<Member> memberComparator(String sort) {
        String field = sort == null || sort.isBlank() ? "id,asc" : sort;
        boolean asc = !field.endsWith(",desc");
        String key = field.split(",")[0];
        Comparator<Member> c = switch (key) {
            case "staffPermission", "권한" -> Comparator.comparing(Member::getStaffPermission, Comparator.nullsLast(String::compareTo));
            case "userid", "아이디" -> Comparator.comparing(Member::getUserid, Comparator.nullsLast(String::compareTo));
            case "name", "이름" -> Comparator.comparing(Member::getName, Comparator.nullsLast(String::compareTo));
            case "loginCount", "접속수" -> Comparator.comparing(m -> m.getLoginCount() == null ? 0 : m.getLoginCount());
            case "lastLoginAt", "최종접속" -> Comparator.comparing(Member::getLastLoginAt, Comparator.nullsLast(LocalDateTime::compareTo));
            case "registeredDate", "createdAt", "등록일" -> Comparator.comparing(Member::getCreatedAt, Comparator.nullsLast(Instant::compareTo));
            default -> Comparator.comparing(Member::getId, Comparator.nullsLast(Long::compareTo));
        };
        return (asc ? c : c.reversed()).thenComparing(Member::getId);
    }
    private Map<String, Object> noticeMap(HomesNotice n) {
        Map<String, Object> m = new LinkedHashMap<>();
        m.put("id", n.getId()); m.put("title", n.getTitle()); m.put("authorName", n.getAuthorName());
        m.put("contents", n.getContents()); m.put("views", n.getViews());
        m.put("createdDate", n.getCreatedAt() == null ? null : LocalDate.ofInstant(n.getCreatedAt(), ZoneId.of("Asia/Seoul")).toString());
        return m;
    }
    private Map<String, Object> questionMap(HomesQuestion q) {
        Map<String, Object> m = new LinkedHashMap<>();
        m.put("id", q.getId());
        m.put("title", q.getTitle());
        m.put("authorName", q.getAuthorName());
        m.put("content", q.getContent());
        m.put("reply", q.getReply());
        m.put("status", q.getStatus());
        boolean answered = "답변완료".equals(q.getStatus()) || (q.getReply() != null && !q.getReply().isBlank());
        m.put("answer", answered ? "답변완료" : "");
        m.put("createdDate", q.getCreatedAt() == null ? null : LocalDate.ofInstant(q.getCreatedAt(), ZoneId.of("Asia/Seoul")).toString());
        return m;
    }
    private Map<String, Object> bbsMap(HomesBbs b) {
        Map<String, Object> m = new LinkedHashMap<>();
        m.put("id", b.getId()); m.put("title", b.getTitle()); m.put("authorName", b.getAuthorName());
        m.put("contents", b.getContents()); m.put("views", b.getViews());
        m.put("createdDate", b.getCreatedAt() == null ? null : LocalDate.ofInstant(b.getCreatedAt(), ZoneId.of("Asia/Seoul")).toString());
        return m;
    }
    private Map<String, Object> logMap(HomesLog l) {
        Map<String, Object> m = new LinkedHashMap<>();
        m.put("id", l.getId()); m.put("domain", l.getDomain()); m.put("action", l.getAction());
        m.put("content", l.getContent()); m.put("staff", l.getStaff());
        m.put("processedAt", l.getProcessedAt() == null ? null
                : l.getProcessedAt().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")));
        return m;
    }

    private static String qOf(Map<String, String> f) {
        String q = blankToNull(f.get("q"));
        if (q == null) q = blankToNull(f.get("keyword"));
        if (q == null) q = blankToNull(f.get("name"));
        return q;
    }
    private static LocalDate parseDate(String v) {
        if (v == null || v.isBlank()) return null;
        try { return LocalDate.parse(v); } catch (Exception e) { return null; }
    }
    private static int parseInt(String v, int def) {
        try { return Integer.parseInt(v); } catch (Exception e) { return def; }
    }
    private static String blankToNull(String v) { return v == null || v.isBlank() ? null : v; }
    private static String nullToEmpty(String v) { return v == null ? "" : v; }
    private static BigDecimal bd(Object v) {
        if (v == null) return BigDecimal.ZERO;
        if (v instanceof BigDecimal b) return b;
        try { return new BigDecimal(String.valueOf(v)); } catch (Exception e) { return BigDecimal.ZERO; }
    }
}
