package com.bizcare.agent;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.persistence.*;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import lombok.*;
import org.springframework.http.*;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import jakarta.servlet.http.HttpServletRequest;
import java.lang.reflect.Field;
import java.math.BigDecimal;
import java.time.*;
import java.util.*;

record ApiResponse(boolean result, String msg, Object data) { static ApiResponse ok(Object d){return new ApiResponse(true,"성공",d);} static ApiResponse fail(String m){return new ApiResponse(false,m,null);} }
record PageResponse(long total, List<?> list) {}
record LoginRequest(@NotBlank(message="아이디를 입력하세요") String userid, @NotBlank(message="비밀번호를 입력하세요") String password) {}

@RestController @RequestMapping("/api/auth") @RequiredArgsConstructor
class AuthController {
    private final EntityManager em; private final PasswordEncoder encoder; private final JwtService jwt;
    @PostMapping("/login") @Transactional ApiResponse login(@Valid @RequestBody LoginRequest request) {
        List<Member> users=em.createQuery("select m from Member m where m.userid=:id",Member.class).setParameter("id",request.userid()).getResultList();
        if(users.isEmpty() || !users.getFirst().isActive() || !encoder.matches(request.password(),users.getFirst().getPassword())) return ApiResponse.fail("아이디 또는 비밀번호가 올바르지 않습니다.");
        Member m=users.getFirst();
        m.setLoginCount((m.getLoginCount()==null?0:m.getLoginCount())+1);
        m.setLastLoginAt(LocalDateTime.now());
        Map<String,Object> data=new LinkedHashMap<>();
        data.put("token",jwt.create(m));
        data.put("role",m.getRole());
        data.put("userid",m.getUserid());
        data.put("name",m.getName());
        data.put("groupId",m.getGroupId());
        data.put("companyId",m.getCompanyId());
        data.put("menuFlags",m.getMenuFlags());
        String perm = m.getStaffPermission();
        if (perm == null || perm.isBlank()) {
            perm = "demo".equals(m.getUserid()) ? "대표" : "사원";
        }
        data.put("staffPermission", perm);
        return ApiResponse.ok(data);
    }
    @GetMapping("/me") ApiResponse me(@AuthenticationPrincipal JwtUser u) {
        List<Member> users = em.createQuery("select m from Member m where m.userid=:id", Member.class)
                .setParameter("id", u.getUserid()).getResultList();
        Map<String,Object> data=new LinkedHashMap<>();
        data.put("userid",u.getUserid());
        data.put("role",u.getRole());
        data.put("groupId",u.getGroupId());
        data.put("companyId",u.getCompanyId());
        if (!users.isEmpty()) {
            Member m = users.getFirst();
            data.put("name", m.getName());
            String perm = m.getStaffPermission();
            if (perm == null || perm.isBlank()) {
                perm = "demo".equals(m.getUserid()) ? "대표" : "사원";
            }
            data.put("staffPermission", perm);
            data.put("menuFlags", m.getMenuFlags());
        }
        return ApiResponse.ok(data);
    }
}

@Service @RequiredArgsConstructor
class CrudService {
    private final EntityManager em; private final ObjectMapper mapper;
    @Transactional <T extends BaseEntity> T create(Class<T> type, Map<String,Object> data, JwtUser user) {
        T value=mapper.convertValue(data,type); value.setId(null); value.setGroupId(user.getGroupId()); em.persist(value); return value;
    }
    @Transactional <T extends BaseEntity> T update(Class<T> type, Long id, Map<String,Object> data, JwtUser user) {
        T value=get(type,id,user);
        try { mapper.readerForUpdating(value).readValue(mapper.writeValueAsBytes(data)); }
        catch (Exception e) { throw new IllegalArgumentException("입력 형식이 올바르지 않습니다."); }
        value.setId(id); value.setGroupId(user.getGroupId()); return value;
    }
    @Transactional <T extends BaseEntity> void delete(Class<T> type, Long id, JwtUser user) { em.remove(get(type,id,user)); }
    <T extends BaseEntity> T get(Class<T> type, Long id, JwtUser user) {
        T v=em.find(type,id); if(v==null || !Objects.equals(v.getGroupId(),user.getGroupId())) throw new NoSuchElementException("데이터를 찾을 수 없습니다."); return v;
    }
    <T extends BaseEntity> PageResponse list(Class<T> type, JwtUser user, Map<String,String> filters, int page, int size) {
        String entity=type.getSimpleName(); StringBuilder where=new StringBuilder(" where e.groupId=:group"); Map<String,Object> params=new HashMap<>(); params.put("group",user.getGroupId());
        Set<String> ignore=Set.of("page","size","keyword","startDate","endDate","q");
        for(var e:filters.entrySet()) if(e.getValue()!=null && !e.getValue().isBlank() && !ignore.contains(e.getKey()) && field(type,e.getKey())) { where.append(" and e.").append(e.getKey()).append("=:").append(e.getKey()); params.put(e.getKey(), convert(e.getValue(), type,e.getKey())); }
        TypedQuery<T> q=em.createQuery("select e from "+entity+" e"+where+" order by e.id desc",type); TypedQuery<Long> c=em.createQuery("select count(e) from "+entity+" e"+where,Long.class);
        params.forEach((k,v)->{q.setParameter(k,v);c.setParameter(k,v);}); long total=c.getSingleResult(); return new PageResponse(total,q.setFirstResult(Math.max(0,page)*size).setMaxResults(Math.min(Math.max(size,1),200)).getResultList());
    }
    private boolean field(Class<?> t,String name){ try { while(t!=null){for(Field f:t.getDeclaredFields())if(f.getName().equals(name))return true;t=t.getSuperclass();} return false;}catch(Exception e){return false;} }
    private Object convert(String v,Class<?> t,String f){try{Field x=null;for(Class<?> c=t;c!=null;c=c.getSuperclass())try{x=c.getDeclaredField(f);break;}catch(NoSuchFieldException ignored){} if(x!=null&&x.getType()==Long.class)return Long.valueOf(v); if(x!=null&&x.getType()==LocalDate.class)return LocalDate.parse(v);}catch(Exception ignored){}return v;}
}

@RestController @RequestMapping("/api/agent") @RequiredArgsConstructor
class AgentController {
    private final CrudService crud; private final EntityManager em; private final PasswordEncoder encoder;
    private <T extends BaseEntity> ApiResponse list(Class<T> c, JwtUser u, Map<String,String> f,int p,int s){return ApiResponse.ok(crud.list(c,u,f,p,s));}
    private <T extends BaseEntity> ApiResponse post(Class<T> c,Map<String,Object>d,JwtUser u){return ApiResponse.ok(crud.create(c,d,u));}
    private <T extends BaseEntity> ApiResponse get(Class<T> c,Long id,JwtUser u){return ApiResponse.ok(crud.get(c,id,u));}
    private <T extends BaseEntity> ApiResponse put(Class<T> c,Long id,Map<String,Object>d,JwtUser u){return ApiResponse.ok(crud.update(c,id,d,u));}
    private <T extends BaseEntity> ApiResponse del(Class<T> c,Long id,JwtUser u){crud.delete(c,id,u);return ApiResponse.ok(null);}
    @GetMapping("/stocks") ApiResponse stocks(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f,@RequestParam(defaultValue="0")int page,@RequestParam(defaultValue="30")int size){return list(Stock.class,u,f,page,size);}
    @PostMapping({"/stocks","/stocks/in"}) @Transactional ApiResponse stockIn(@RequestBody Map<String,Object>d,@AuthenticationPrincipal JwtUser u){
        d.putIfAbsent("state","IN");
        Stock s=crud.create(Stock.class,d,u);
        if(s.getIndate()==null) s.setIndate(LocalDate.now());
        if(s.getProcessDate()==null) s.setProcessDate(LocalDate.now());
        s.setProcessor(memberName(u));
        s.setUserid(u.getUserid());
        s.setProcid(u.getUserid());
        if(s.getUid()==null||s.getUid().isBlank()) s.setUid("stk-"+UUID.randomUUID());
        writeStockHistory(s,u);
        return ApiResponse.ok(stockDetail(s,u));
    }
    @GetMapping("/stocks/{id}") ApiResponse stock(@PathVariable Long id,@AuthenticationPrincipal JwtUser u){
        Stock s=em.find(Stock.class,id);
        if(s==null||!Objects.equals(s.getGroupId(),u.getGroupId())) return ApiResponse.fail("재고를 찾을 수 없습니다.");
        return ApiResponse.ok(stockDetail(s,u));
    }
    @GetMapping("/stocks/{id}/detail") ApiResponse stockDetailApi(@PathVariable Long id,@AuthenticationPrincipal JwtUser u){
        return stock(id,u);
    }
    @PutMapping("/stocks/{id}") @Transactional ApiResponse stockPut(@PathVariable Long id,@RequestBody Map<String,Object>d,@AuthenticationPrincipal JwtUser u){
        Stock s=em.find(Stock.class,id);
        if(s==null||!Objects.equals(s.getGroupId(),u.getGroupId())) return ApiResponse.fail("재고를 찾을 수 없습니다.");
        put(Stock.class,id,d,u);
        s=em.find(Stock.class,id);
        if(s!=null){
            if(d.get("processor")==null || String.valueOf(d.get("processor")).isBlank()) s.setProcessor(memberName(u));
            writeStockHistory(s,u);
        }
        return ApiResponse.ok(stockDetail(s,u));
    }
    @DeleteMapping("/stocks/{id}") @Transactional ApiResponse stockDel(@PathVariable Long id,@AuthenticationPrincipal JwtUser u){
        Stock s=em.find(Stock.class,id);
        if(s==null||!Objects.equals(s.getGroupId(),u.getGroupId())) return ApiResponse.fail("재고를 찾을 수 없습니다.");
        em.createQuery("delete from StockHistory h where h.groupId=:g and h.stockId=:id").setParameter("g",u.getGroupId()).setParameter("id",id).executeUpdate();
        em.createQuery("delete from StockNote n where n.groupId=:g and n.stockId=:id").setParameter("g",u.getGroupId()).setParameter("id",id).executeUpdate();
        em.remove(s);
        return ApiResponse.ok(null);
    }
    @PostMapping({"/stocks/out","/stocks/move","/stocks/return"}) @Transactional ApiResponse stockState(@RequestBody Map<String,Object>d,@AuthenticationPrincipal JwtUser u){
        Long id=Long.valueOf(d.get("id").toString());
        return stockAction(id,d,u);
    }
    @PostMapping("/stocks/{id}/action") @Transactional ApiResponse stockActionApi(@PathVariable Long id,@RequestBody Map<String,Object>d,@AuthenticationPrincipal JwtUser u){
        return stockAction(id,d,u);
    }
    @PostMapping("/stocks/{id}/notes") @Transactional ApiResponse stockNoteCreate(@PathVariable Long id,@RequestBody Map<String,Object>d,@AuthenticationPrincipal JwtUser u){
        Stock s=em.find(Stock.class,id);
        if(s==null||!Objects.equals(s.getGroupId(),u.getGroupId())) return ApiResponse.fail("재고를 찾을 수 없습니다.");
        String contents=d.get("contents")==null?"":String.valueOf(d.get("contents")).trim();
        if(contents.isBlank()) return ApiResponse.fail("메모 내용을 입력하세요.");
        StockNote n=new StockNote();
        n.setGroupId(u.getGroupId());
        n.setStockId(id);
        n.setContents(contents);
        n.setAuthorName(d.get("authorName")==null||String.valueOf(d.get("authorName")).isBlank()?memberName(u):String.valueOf(d.get("authorName")));
        n.setNoteDate(d.get("noteDate")==null||String.valueOf(d.get("noteDate")).isBlank()?LocalDate.now():LocalDate.parse(String.valueOf(d.get("noteDate"))));
        em.persist(n);
        return ApiResponse.ok(stockNoteMap(n));
    }
    @DeleteMapping("/stocks/{id}/notes/{noteId}") @Transactional ApiResponse stockNoteDelete(@PathVariable Long id,@PathVariable Long noteId,@AuthenticationPrincipal JwtUser u){
        StockNote n=em.find(StockNote.class,noteId);
        if(n==null||!Objects.equals(n.getGroupId(),u.getGroupId())||!Objects.equals(n.getStockId(),id)) return ApiResponse.fail("메모를 찾을 수 없습니다.");
        em.remove(n);
        return ApiResponse.ok(null);
    }
    private ApiResponse stockAction(Long id, Map<String,Object> d, JwtUser u){
        Stock s=em.find(Stock.class,id);
        if(s==null||!Objects.equals(s.getGroupId(),u.getGroupId())) return ApiResponse.fail("재고를 찾을 수 없습니다.");
        String state=String.valueOf(d.getOrDefault("state", d.containsKey("state")?d.get("state"):"OUT"));
        if(!List.of("IN","OUT","LOSS","OPEN","SELL","RECOVERY","RETURN").contains(state)) return ApiResponse.fail("처리할 상태를 확인하세요.");
        if("OUT".equals(state)){
            if(d.get("outcompanyId")!=null && !String.valueOf(d.get("outcompanyId")).isBlank()){
                s.setOutcompanyId(Long.valueOf(d.get("outcompanyId").toString()));
            }
            if(d.get("outCategory")!=null) s.setOutCategory(String.valueOf(d.get("outCategory")));
            if(s.getOutcompanyId()==null) return ApiResponse.fail("출고처를 선택하세요.");
            s.setOutdate(LocalDate.now());
            s.setOutcode("21");
        }
        if("IN".equals(state) || "RECOVERY".equals(state)){
            s.setRecalldate(LocalDate.now());
            s.setOutcode("10");
            if("IN".equals(state)) { /* 회수 후 입고 상태 */ }
        }
        if("LOSS".equals(state)) s.setLossdate(LocalDate.now());
        if("SELL".equals(state)) s.setSelldate(LocalDate.now());
        s.setState(state);
        s.setProcessDate(LocalDate.now());
        s.setProcessor(memberName(u));
        if(d.get("memo")!=null) s.setMemo(String.valueOf(d.get("memo")));
        writeStockHistory(s,u);
        return ApiResponse.ok(stockDetail(s,u));
    }
    private Map<String,Object> stockDetail(Stock s, JwtUser u){
        Map<Long,Company> companyMap=new HashMap<>();
        for(Company c:em.createQuery("select c from Company c where c.groupId=:g",Company.class).setParameter("g",u.getGroupId()).getResultList()) companyMap.put(c.getId(),c);
        Map<Long,String> names=new HashMap<>();
        companyMap.forEach((k,v)->names.put(k,v.getName()));
        Map<String,Object> stock=stockMap(s,names);
        stock.put("pay", s.getPay());
        stock.put("telcompany", s.getTelcompany());
        String telecomLabel=s.getTelcompany();
        if(telecomLabel==null||telecomLabel.isBlank()) telecomLabel=s.getTelecom();
        else if(s.getTelecom()!=null && !telecomLabel.contains(s.getTelecom())) telecomLabel=telecomLabel+" ("+s.getTelecom()+")";
        stock.put("telecomLabel", telecomLabel);
        List<StockHistory> histories=em.createQuery("select h from StockHistory h where h.groupId=:g and h.stockId=:id order by h.processDate desc, h.id desc",StockHistory.class)
            .setParameter("g",u.getGroupId()).setParameter("id",s.getId()).getResultList();
        List<Map<String,Object>> historyList=histories.stream().map(h->{
            Map<String,Object> m=new LinkedHashMap<>();
            m.put("id", h.getId());
            m.put("processDate", h.getProcessDate());
            m.put("statusLabel", h.getStatusLabel()!=null?h.getStatusLabel():statusLabel(h.getState()));
            String label=h.getCompanyLabel();
            if(label==null||label.isBlank()){
                if("OUT".equals(h.getState())||"출고".equals(h.getStatusLabel())){
                    Company oc=companyMap.get(h.getOutcompanyId());
                    if(oc!=null){
                        label=oc.getName();
                        if(oc.getCategory()!=null && !oc.getCategory().isBlank()) label=label+" ("+oc.getCategory()+")";
                        else if(s.getOutCategory()!=null && !s.getOutCategory().isBlank()) label=label+" ("+s.getOutCategory()+")";
                    }
                }
            }
            // 입고 이력은 원본처럼 거래처 칸을 비움
            if("IN".equals(h.getState())||"입고".equals(h.getStatusLabel())) label="";
            m.put("companyLabel", label==null?"":label);
            m.put("processor", resolveProcessorName(u.getGroupId(), h.getProcessor()));
            return m;
        }).toList();
        List<Map<String,Object>> notes=em.createQuery("select n from StockNote n where n.groupId=:g and n.stockId=:id order by n.noteDate desc, n.id desc",StockNote.class)
            .setParameter("g",u.getGroupId()).setParameter("id",s.getId()).getResultList().stream().map(this::stockNoteMap).toList();
        Map<String,Object> r=new LinkedHashMap<>();
        r.put("stock", stock);
        r.put("histories", historyList);
        r.put("notes", notes);
        return r;
    }
    private Map<String,Object> stockNoteMap(StockNote n){
        Map<String,Object> m=new LinkedHashMap<>();
        m.put("id", n.getId()); m.put("stockId", n.getStockId()); m.put("contents", n.getContents());
        m.put("authorName", n.getAuthorName()); m.put("noteDate", n.getNoteDate());
        return m;
    }
    private String memberName(JwtUser u){
        List<Member> rows=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(!rows.isEmpty() && rows.getFirst().getName()!=null && !rows.getFirst().getName().isBlank()) return rows.getFirst().getName();
        return u.getUserid();
    }
    private String resolveProcessorName(String groupId, String processor){
        if(processor==null||processor.isBlank()) return "-";
        List<Member> rows=em.createQuery("select m from Member m where m.groupId=:g and (m.userid=:p or m.name=:p)",Member.class)
            .setParameter("g",groupId).setParameter("p",processor).getResultList();
        if(!rows.isEmpty() && rows.getFirst().getName()!=null && !rows.getFirst().getName().isBlank()) return rows.getFirst().getName();
        return processor;
    }
    @GetMapping("/stocks/barcode/{code}") ApiResponse barcode(@PathVariable String code,@AuthenticationPrincipal JwtUser u){return list(Stock.class,u,Map.of("barcode",code),0,10);}
    @GetMapping("/stocks/search") ApiResponse stockSearch(@AuthenticationPrincipal JwtUser u, @RequestParam Map<String,String> f,
            @RequestParam(defaultValue="0") int page, @RequestParam(defaultValue="15") int size) {
        return ApiResponse.ok(stockSearchData(u, f, page, size));
    }
    @GetMapping("/stocks/sheet") ApiResponse stockSheet(@AuthenticationPrincipal JwtUser u, @RequestParam Map<String,String> f,
            @RequestParam(defaultValue="0") int page, @RequestParam(defaultValue="15") int size) {
        if(blank(f.get("dateFrom")) || blank(f.get("dateTo"))) return ApiResponse.fail("날짜를 선택하세요.");
        Map<String,String> filters=new HashMap<>(f);
        filters.put("dateBasis","처리일기준");
        filters.putIfAbsent("tab","ALL");
        return ApiResponse.ok(stockSearchData(u, filters, page, size));
    }
    @GetMapping(value="/stocks/sheet/export", produces="text/csv;charset=UTF-8") ResponseEntity<String> stockSheetExport(@AuthenticationPrincipal JwtUser u, @RequestParam Map<String,String> f) {
        if(blank(f.get("dateFrom")) || blank(f.get("dateTo"))) return ResponseEntity.badRequest().body("날짜를 선택하세요.");
        Map<String,String> filters=new HashMap<>(f);
        filters.put("dateBasis","처리일기준");
        filters.putIfAbsent("tab","ALL");
        @SuppressWarnings("unchecked") List<Map<String,Object>> rows=(List<Map<String,Object>>) stockSearchData(u,filters,0,10000).get("list");
        StringBuilder csv=new StringBuilder("\uFEFF처리일,현황,상태,종류,일련번호,모델명,색상,입고가,입고처,출고처,처리자,비고\n");
        for(Map<String,Object> r:rows) csv.append(csv(r.get("processDate"))).append(',').append(csv(r.get("statusLabel"))).append(',')
            .append(csv(r.get("cond"))).append(',').append(csv(r.get("gubun"))).append(',').append(csv(r.get("serial"))).append(',')
            .append(csv(r.get("model"))).append(',').append(csv(r.get("color"))).append(',').append(csv(r.get("inprice"))).append(',')
            .append(csv(r.get("incompanyName"))).append(',').append(csv(r.get("outcompanyName"))).append(',')
            .append(csv(r.get("processor"))).append(',').append(csv(r.get("memo"))).append('\n');
        return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=sheet.csv").body(csv.toString());
    }
    @GetMapping(value="/stocks/export", produces="text/csv;charset=UTF-8") ResponseEntity<String> stockExport(@AuthenticationPrincipal JwtUser u, @RequestParam Map<String,String> f) {
        @SuppressWarnings("unchecked") List<Map<String,Object>> rows=(List<Map<String,Object>>) stockSearchData(u,f,0,10000).get("list");
        StringBuilder csv=new StringBuilder("\uFEFF현황,입고일,상태,종류,일련번호,모델명,색상,입고가,입고처,출고처,처리일,처리자,비고\n");
        for(Map<String,Object> r:rows) csv.append(csv(r.get("statusLabel"))).append(',').append(csv(r.get("indate"))).append(',')
            .append(csv(r.get("cond"))).append(',').append(csv(r.get("gubun"))).append(',').append(csv(r.get("serial"))).append(',')
            .append(csv(r.get("model"))).append(',').append(csv(r.get("color"))).append(',').append(csv(r.get("inprice"))).append(',')
            .append(csv(r.get("incompanyName"))).append(',').append(csv(r.get("outcompanyName"))).append(',').append(csv(r.get("processDate"))).append(',')
            .append(csv(r.get("processor"))).append(',').append(csv(r.get("memo"))).append('\n');
        return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=stocks.csv").body(csv.toString());
    }
    @PostMapping("/stocks/bulk-action") @Transactional ApiResponse stockBulkAction(@AuthenticationPrincipal JwtUser u,@RequestBody Map<String,Object> d) {
        Object raw=d.get("ids"); String state=String.valueOf(d.getOrDefault("state",""));
        if(!(raw instanceof List<?> ids) || ids.isEmpty()) return ApiResponse.fail("선택된 재고가 없습니다.");
        if(!List.of("IN","OUT","LOSS","OPEN","SELL","RECOVERY","RETURN").contains(state)) return ApiResponse.fail("처리할 상태를 확인하세요.");
        int updated=0;
        for(Object idObj:ids) {
            Long id=Long.valueOf(idObj.toString());
            Stock s=em.find(Stock.class,id);
            if(s==null || !Objects.equals(s.getGroupId(),u.getGroupId())) continue;
            s.setState(state);
            s.setProcessDate(LocalDate.now());
            s.setProcessor(u.getUserid());
            if("IN".equals(state)) { s.setRecalldate(LocalDate.now()); s.setOutcode("10"); }
            if("OUT".equals(state)) { s.setOutdate(LocalDate.now()); s.setOutcode("21"); }
            if("LOSS".equals(state)) s.setLossdate(LocalDate.now());
            if("SELL".equals(state)) s.setSelldate(LocalDate.now());
            writeStockHistory(s, u);
            updated++;
        }
        return ApiResponse.ok(Map.of("updated",updated));
    }
    @PostMapping("/stocks/bulk-in") @Transactional ApiResponse stockBulkIn(@AuthenticationPrincipal JwtUser u,@RequestBody Map<String,Object> d) {
        Object raw=d.get("serials"); if(!(raw instanceof List<?> serials) || serials.isEmpty()) return ApiResponse.fail("일련번호를 입력하세요.");
        int made=0;
        for(Object value:serials) { String serial=String.valueOf(value).trim(); if(serial.isBlank()) continue;
            Stock s=new Stock(); s.setGroupId(u.getGroupId()); s.setGubun(String.valueOf(d.getOrDefault("gubun","휴대폰")));
            s.setTelecom(String.valueOf(d.getOrDefault("telecom","SKT"))); s.setTelcompany(s.getTelecom()); s.setModel(String.valueOf(d.getOrDefault("model","")));
            s.setColor(String.valueOf(d.getOrDefault("color",""))); s.setInprice(d.get("inprice")==null?BigDecimal.ZERO:new BigDecimal(d.get("inprice").toString()));
            if(d.get("incompanyId")!=null && !d.get("incompanyId").toString().isBlank()) s.setIncompanyId(Long.valueOf(d.get("incompanyId").toString()));
            s.setSerial(serial); s.setBarcode(serial); s.setCond("정상"); s.setState("IN"); s.setIndate(LocalDate.now()); s.setProcessDate(LocalDate.now());
            s.setProcessor(u.getUserid()); s.setUserid(u.getUserid()); s.setProcid(u.getUserid()); s.setUid("stk-"+UUID.randomUUID()); em.persist(s);
            writeStockHistory(s, u); made++;
        } return ApiResponse.ok(Map.of("created",made));
    }
    private Map<String,Object> stockSearchData(JwtUser u,Map<String,String> f,int page,int size) {
        List<Stock> stocks=em.createQuery("select s from Stock s where s.groupId=:g",Stock.class).setParameter("g",u.getGroupId()).getResultList();
        Map<Long,String> companies=new HashMap<>(); for(Company c:em.createQuery("select c from Company c where c.groupId=:g",Company.class).setParameter("g",u.getGroupId()).getResultList()) companies.put(c.getId(),c.getName());
        List<Stock> filtered=stocks.stream().filter(s->matchesStock(s,f,companies)).sorted(stockComparator(f.get("sort"))).toList();
        Map<String,Long> counts=new LinkedHashMap<>(); for(String tab:List.of("ALL","INOUT","IN","OUT","RETURN","LOSS","OPEN","SELL")) counts.put(tab,stocks.stream().filter(s->matchesStock(s,withoutTab(f),companies)&&inTab(s,tab)).count());
        int from=Math.min(Math.max(page,0)*Math.max(size,1),filtered.size()), to=Math.min(from+Math.min(Math.max(size,1),200),filtered.size());
        List<Map<String,Object>> list=filtered.subList(from,to).stream().map(s->stockMap(s,companies)).toList();
        return Map.of("total",filtered.size(),"list",list,"counts",counts);
    }
    private Map<String,String> withoutTab(Map<String,String> f){Map<String,String> copy=new HashMap<>(f);copy.remove("tab");return copy;}
    private boolean matchesStock(Stock s,Map<String,String> f,Map<Long,String> companies) {
        String tab=f.getOrDefault("tab","ALL"); if(!inTab(s,tab)) return false;
        if(!eq(s.getCond(),f.get("cond"))||!eq(s.getGubun(),f.get("gubun"))||!eq(s.getTelecom(),f.get("telecom"))||!eq(s.getOutCategory(),f.get("outCategory"))||!eq(s.getSalesperson(),f.get("salesperson"))||!eq(s.getSource(),f.get("source"))) return false;
        if(!eqId(s.getIncompanyId(),f.get("incompanyId"))) return false;
        if(!like(s.getModel(),f.get("model"))||!like(s.getColor(),f.get("color"))||!like(s.getPetname(),f.get("petname"))||!like(companies.get(s.getOutcompanyId()),f.get("outcompanyName"))) return false;
        String serial=f.get("serial"); if(serial!=null&&!serial.isBlank()) {String[] values=("true".equals(f.get("serialMulti"))?serial.split("[,\\n\\r]+"):new String[]{serial});if(Arrays.stream(values).map(String::trim).noneMatch(v->like(s.getSerial(),v)))return false;}
        String searchType=f.getOrDefault("searchType","일련번호"); String keyword=f.get("keyword");
        if(keyword!=null&&!keyword.isBlank()) {
            boolean ok=switch(searchType){
                case "모델명"->like(s.getModel(),keyword);
                case "색상"->like(s.getColor(),keyword);
                case "펫네임"->like(s.getPetname(),keyword);
                default->like(s.getSerial(),keyword);
            };
            if(!ok) return false;
        }
        LocalDate date=switch(f.getOrDefault("dateBasis","입고일기준")){case "출고일기준"->s.getOutdate();case "처리일기준"->s.getProcessDate();default->s.getIndate();};
        if(f.get("dateFrom")!=null&&!f.get("dateFrom").isBlank()&&(date==null||date.isBefore(LocalDate.parse(f.get("dateFrom")))))return false;
        return f.get("dateTo")==null||f.get("dateTo").isBlank()||(date!=null&&!date.isAfter(LocalDate.parse(f.get("dateTo"))));
    }
    private boolean inTab(Stock s,String tab){return switch(tab){case "INOUT"->"IN".equals(s.getState())||"OUT".equals(s.getState());case "ALL"->true;default->tab.equals(s.getState());};}
    private boolean blank(String v){return v==null||v.isBlank();}
    private boolean eq(String actual,String expected){return expected==null||expected.isBlank()||Objects.equals(actual,expected);}
    private boolean eqId(Long actual,String expected){return expected==null||expected.isBlank()||Objects.equals(actual,Long.valueOf(expected));}
    private boolean like(String actual,String expected){return expected==null||expected.isBlank()||(actual!=null&&actual.toLowerCase().contains(expected.toLowerCase()));}
    private Comparator<Stock> stockComparator(String sort){String field=sort==null?"indate,desc":sort;boolean asc=field.endsWith(",asc");Comparator<Stock> c=switch(field.split(",")[0]){case "serial"->Comparator.comparing(Stock::getSerial,Comparator.nullsLast(String::compareTo));case "model"->Comparator.comparing(Stock::getModel,Comparator.nullsLast(String::compareTo));case "inprice"->Comparator.comparing(Stock::getInprice,Comparator.nullsLast(BigDecimal::compareTo));case "processDate"->Comparator.comparing(Stock::getProcessDate,Comparator.nullsLast(LocalDate::compareTo));default->Comparator.comparing(Stock::getIndate,Comparator.nullsLast(LocalDate::compareTo));};return (asc?c:c.reversed()).thenComparing(Stock::getId); }
    private Map<String,Object> stockMap(Stock s,Map<Long,String> companies){Map<String,Object> r=new LinkedHashMap<>();r.put("id",s.getId());r.put("gubun",s.getGubun());r.put("telecom",s.getTelecom());r.put("telcompany",s.getTelcompany());r.put("serial",s.getSerial());r.put("model",s.getModel());r.put("color",s.getColor());r.put("cond",s.getCond());r.put("pay",s.getPay());r.put("memo",s.getMemo());r.put("state",s.getState());r.put("inprice",s.getInprice());r.put("indate",s.getIndate());r.put("outdate",s.getOutdate());r.put("processDate",s.getProcessDate());r.put("processor",s.getProcessor());r.put("petname",s.getPetname());r.put("outCategory",s.getOutCategory());r.put("salesperson",s.getSalesperson());r.put("source",s.getSource());r.put("incompanyId",s.getIncompanyId());r.put("outcompanyId",s.getOutcompanyId());r.put("incompanyName",companies.getOrDefault(s.getIncompanyId(),"-"));r.put("outcompanyName",companies.getOrDefault(s.getOutcompanyId(),"-"));r.put("statusLabel",statusLabel(s.getState()));r.put("elapsedDays",s.getIndate()==null?null:Math.max(0,java.time.temporal.ChronoUnit.DAYS.between(s.getIndate(),LocalDate.now())));return r;}
    private String statusLabel(String state){return Map.of("IN","입고","OUT","출고","RETURN","반품","LOSS","분실","OPEN","개통","SELL","판매","RECOVERY","회수대기").getOrDefault(state,state==null?"-":state);}
    private String csv(Object value){String s=String.valueOf(value==null?"":value);return "\""+s.replace("\"","\"\"")+"\"";}
    @GetMapping("/opens") ApiResponse opens(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f,@RequestParam(defaultValue="0")int page,@RequestParam(defaultValue="30")int size){return list(OpenRecord.class,u,f,page,size);}
    @GetMapping("/opens/search") ApiResponse openSearch(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f,@RequestParam(defaultValue="0")int page,@RequestParam(defaultValue="15")int size){return ApiResponse.ok(openSearchData(u,f,page,size));}
    @GetMapping(value="/opens/export", produces="text/csv;charset=UTF-8") ResponseEntity<String> openExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f) {
        @SuppressWarnings("unchecked") List<Map<String,Object>> rows=(List<Map<String,Object>>) openSearchData(u,f,0,10000).get("list");
        StringBuilder csv=new StringBuilder("\uFEFF개통일,통신사,출고처,고객명,구분,종류,개통번호,상태,모델명,일련번호,입고처,정산,차감,마진\n");
        for(Map<String,Object> r:rows) csv.append(csv(r.get("opendate"))).append(',').append(csv(r.get("telecom"))).append(',').append(csv(r.get("outcompanyName"))).append(',')
            .append(csv(r.get("name"))).append(',').append(csv(r.get("gubun"))).append(',').append(csv(r.get("openhow"))).append(',').append(csv(r.get("openphone"))).append(',')
            .append(csv(r.get("openStatus"))).append(',').append(csv(r.get("pmodel"))).append(',').append(csv(r.get("pserial"))).append(',').append(csv(r.get("incompanyName"))).append(',')
            .append(csv(r.get("sellprice"))).append(',').append(csv(r.get("declprice1"))).append(',').append(csv(r.get("dealermargin1"))).append('\n');
        return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=opens.csv").body(csv.toString());
    }
    @PostMapping("/opens") @Transactional ApiResponse openPost(@RequestBody Map<String,Object>d,@AuthenticationPrincipal JwtUser u){
        d.putIfAbsent("state","OPEN"); d.putIfAbsent("openStatus","정상"); d.putIfAbsent("userid",u.getUserid());
        if(d.get("uid")==null||String.valueOf(d.get("uid")).isBlank()) d.put("uid","open-"+UUID.randomUUID());
        ApiResponse created=post(OpenRecord.class,d,u);
        linkOpenStock(d); return created;
    }
    @GetMapping({"/opens/pending","/opens/miss"}) ApiResponse pending(@AuthenticationPrincipal JwtUser u){return list(OpenRecord.class,u,Map.of("state","PENDING"),0,100);}
    @GetMapping("/opens/serial-lookup") ApiResponse openSerialLookup(@AuthenticationPrincipal JwtUser u,@RequestParam String serial,@RequestParam(defaultValue="휴대폰") String gubun){
        if(serial==null||serial.trim().length()<3) return ApiResponse.ok(List.of());
        Map<Long,String> companies=new HashMap<>(); for(Company c:em.createQuery("select c from Company c where c.groupId=:g",Company.class).setParameter("g",u.getGroupId()).getResultList()) companies.put(c.getId(),c.getName());
        List<Stock> stocks=em.createQuery("select s from Stock s where s.groupId=:g and s.serial like :q",Stock.class).setParameter("g",u.getGroupId()).setParameter("q","%"+serial.trim()+"%").setMaxResults(20).getResultList();
        return ApiResponse.ok(stocks.stream().filter(s->blank(gubun)||("유심".equals(gubun)? "유심".equals(s.getGubun()):!"유심".equals(s.getGubun()))).map(s->stockMap(s,companies)).toList());
    }
    @GetMapping("/opens/adjust") ApiResponse openAdjust(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f){
        if(blank(f.get("dateFrom"))||blank(f.get("dateTo"))) return ApiResponse.fail("개통일을 선택하세요.");
        LocalDate from=LocalDate.parse(f.get("dateFrom")), to=LocalDate.parse(f.get("dateTo"));
        if(to.isBefore(from)) return ApiResponse.fail("날짜 범위가 올바르지 않습니다.");
        if(java.time.temporal.ChronoUnit.DAYS.between(from,to)>30) return ApiResponse.fail("최대 31일까지 조회할 수 있습니다.");
        return ApiResponse.ok(openAdjustData(u,f));
    }
    @PostMapping("/opens/adjust/batch") @Transactional ApiResponse openAdjustBatch(@AuthenticationPrincipal JwtUser u,@RequestBody Map<String,Object> body){
        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;
            OpenRecord o=em.find(OpenRecord.class,Long.valueOf(String.valueOf(idObj)));
            if(o==null||!Objects.equals(o.getGroupId(),u.getGroupId())) continue;
            if("유심".equals(o.getGubun())) continue;
            if(m.containsKey("basicprice1")) o.setBasicprice1(toDecimal(m.get("basicprice1")));
            if(m.containsKey("basicprice2")) o.setBasicprice2(toDecimal(m.get("basicprice2")));
            if(m.containsKey("basicprice3")) o.setBasicprice3(toDecimal(m.get("basicprice3")));
            updated++;
        }
        return ApiResponse.ok(Map.of("updated",updated));
    }
    @GetMapping(value="/opens/adjust/export", produces="text/csv;charset=UTF-8") ResponseEntity<String> openAdjustExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f){
        if(blank(f.get("dateFrom"))||blank(f.get("dateTo"))) return ResponseEntity.badRequest().body("개통일을 선택하세요.");
        @SuppressWarnings("unchecked") List<Map<String,Object>> rows=(List<Map<String,Object>>) openAdjustData(u,f).get("list");
        StringBuilder csv=new StringBuilder("\uFEFF개통일,입고처,출고처,고객명,종류,모델명,일련번호,지원방법,요금제,정산금액,기본정책,구두정책,추가정책\n");
        for(Map<String,Object> r:rows) csv.append(csv(r.get("opendate"))).append(',').append(csv(r.get("incompanyName"))).append(',').append(csv(r.get("outcompanyName"))).append(',')
            .append(csv(r.get("name"))).append(',').append(csv(r.get("openhow"))).append(',').append(csv(r.get("pmodel"))).append(',').append(csv(r.get("pserial"))).append(',')
            .append(csv(r.get("decltype"))).append(',').append(csv(r.get("plan"))).append(',').append(csv(r.get("sellprice"))).append(',')
            .append(csv(r.get("basicprice1"))).append(',').append(csv(r.get("basicprice2"))).append(',').append(csv(r.get("basicprice3"))).append('\n');
        return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=adjust.csv").body(csv.toString());
    }
    @GetMapping("/opens/{id}") ApiResponse open(@PathVariable Long id,@AuthenticationPrincipal JwtUser u){return get(OpenRecord.class,id,u);}
    @PutMapping("/opens/{id}") ApiResponse openPut(@PathVariable Long id,@RequestBody Map<String,Object>d,@AuthenticationPrincipal JwtUser u){return put(OpenRecord.class,id,d,u);}
    @DeleteMapping("/opens/{id}") ApiResponse openDel(@PathVariable Long id,@AuthenticationPrincipal JwtUser u){return del(OpenRecord.class,id,u);}
    private void linkOpenStock(Map<String,Object> d){
        Object phoneId=d.get("phoneStockId"), usimId=d.get("usimStockId");
        if(phoneId!=null){Stock s=em.find(Stock.class,Long.valueOf(String.valueOf(phoneId))); if(s!=null){s.setState("OPEN");s.setOutdate(LocalDate.now());}}
        if(usimId!=null){Stock s=em.find(Stock.class,Long.valueOf(String.valueOf(usimId))); if(s!=null){s.setState("OPEN");s.setOutdate(LocalDate.now());}}
    }
    private Map<String,Object> openSearchData(JwtUser u,Map<String,String> f,int page,int size){
        List<OpenRecord> opens=em.createQuery("select o from OpenRecord o where o.groupId=:g",OpenRecord.class).setParameter("g",u.getGroupId()).getResultList();
        Map<Long,String> companies=new HashMap<>(); for(Company c:em.createQuery("select c from Company c where c.groupId=:g",Company.class).setParameter("g",u.getGroupId()).getResultList()) companies.put(c.getId(),c.getName());
        List<OpenRecord> filtered=opens.stream().filter(o->matchesOpen(o,f,companies)).sorted(openComparator(f.get("sort"))).toList();
        Map<String,Long> counts=new LinkedHashMap<>(); for(String tab:List.of("ALL","신규","번호이동","보상","기변","메이징","가개통","선불개통")) counts.put(tab,opens.stream().filter(o->matchesOpen(o,withoutTab(f),companies)&&inOpenTab(o,tab)).count());
        int from=Math.min(Math.max(page,0)*Math.max(size,1),filtered.size()), to=Math.min(from+Math.min(Math.max(size,1),200),filtered.size());
        List<Map<String,Object>> list=filtered.subList(from,to).stream().map(o->openMap(o,companies)).toList();
        return Map.of("total",filtered.size(),"list",list,"counts",counts);
    }
    private boolean matchesOpen(OpenRecord o,Map<String,String> f,Map<Long,String> companies){
        String tab=f.getOrDefault("tab","ALL"); if(!inOpenTab(o,tab)) return false;
        if(!eq(o.getGubun(),f.get("gubun"))||!eq(o.getTelecom(),f.get("telecom"))||!eq(o.getOpenhow(),f.get("openhow"))||!eq(o.getOpenStatus(),f.get("openStatus"))||!eq(o.getSalesperson(),f.get("salesperson"))||!eq(o.getEmployee(),f.get("employee"))||!eq(o.getOutCategory(),f.get("outCategory"))||!eq(o.getState(),f.get("history"))) return false;
        if(!eqId(o.getIncompanyId(),f.get("incompanyId"))) return false;
        if(!like(o.getPmodel(),f.get("model"))||!like(companies.get(o.getOutcompanyId()),f.get("outcompanyName"))||!like(o.getName(),f.get("name"))) return false;
        String phoneTail=f.get("phoneTail"); if(phoneTail!=null&&!phoneTail.isBlank()){String p=o.getOpenphone()==null?"":o.getOpenphone().replaceAll("\\D",""); if(!p.endsWith(phoneTail.replaceAll("\\D",""))) return false;}
        if(f.get("dateFrom")!=null&&!f.get("dateFrom").isBlank()&&(o.getOpendate()==null||o.getOpendate().isBefore(LocalDate.parse(f.get("dateFrom"))))) return false;
        return f.get("dateTo")==null||f.get("dateTo").isBlank()||(o.getOpendate()!=null&&!o.getOpendate().isAfter(LocalDate.parse(f.get("dateTo"))));
    }
    private boolean inOpenTab(OpenRecord o,String tab){
        if("ALL".equals(tab)||blank(tab)) return true;
        String how=o.getOpenhow()==null?"":o.getOpenhow();
        return switch(tab){case "번호이동"->"번호이동".equals(how)||"MNP".equalsIgnoreCase(how); default->tab.equals(how);};
    }
    private Comparator<OpenRecord> openComparator(String sort){
        String field=sort==null?"opendate,desc":sort; boolean asc=field.endsWith(",asc");
        Comparator<OpenRecord> c=switch(field.split(",")[0]){
            case "telecom"->Comparator.comparing(OpenRecord::getTelecom,Comparator.nullsLast(String::compareTo));
            case "pmodel"->Comparator.comparing(OpenRecord::getPmodel,Comparator.nullsLast(String::compareTo));
            case "sellprice"->Comparator.comparing(OpenRecord::getSellprice,Comparator.nullsLast(BigDecimal::compareTo));
            case "gubun"->Comparator.comparing(OpenRecord::getGubun,Comparator.nullsLast(String::compareTo));
            default->Comparator.comparing(OpenRecord::getOpendate,Comparator.nullsLast(LocalDate::compareTo));
        };
        return (asc?c:c.reversed()).thenComparing(OpenRecord::getId);
    }
    private Map<String,Object> openMap(OpenRecord o,Map<Long,String> companies){
        Map<String,Object> r=new LinkedHashMap<>();
        r.put("id",o.getId()); r.put("opendate",o.getOpendate()); r.put("telecom",o.getTelecom()); r.put("outcompanyId",o.getOutcompanyId());
        r.put("outcompanyName",companies.getOrDefault(o.getOutcompanyId(),"-")); r.put("incompanyId",o.getIncompanyId());
        r.put("incompanyName",companies.getOrDefault(o.getIncompanyId(),"-")); r.put("name",o.getName()); r.put("gubun",o.getGubun());
        r.put("openhow",o.getOpenhow()); r.put("openphone",o.getOpenphone()); r.put("openStatus",o.getOpenStatus()==null?"정상":o.getOpenStatus());
        r.put("pmodel",o.getPmodel()); r.put("pserial",o.getPserial()); r.put("pcolor",o.getPcolor()); r.put("userial",o.getUserial()); r.put("umodel",o.getUmodel());
        r.put("sellprice",o.getSellprice()); r.put("declprice1",o.getDeclprice1()); r.put("dealermargin1",o.getDealermargin1());
        r.put("plan",o.getPlan()); r.put("state",o.getState()); r.put("salesperson",o.getSalesperson()); r.put("employee",o.getEmployee());
        r.put("outCategory",o.getOutCategory()); r.put("inprice",o.getInprice()); r.put("netprice",o.getNetprice());
        r.put("openHour",o.getOpenHour()); r.put("openMinute",o.getOpenMinute()); r.put("salemon",o.getSalemon()); r.put("agreemon",o.getAgreemon());
        r.put("salehow",o.getSalehow()); r.put("joinhow",o.getJoinhow()); r.put("usimhow",o.getUsimhow()); r.put("supportType",o.getSupportType());
        r.put("memo1",o.getMemo1()); r.put("dealermemo",o.getDealermemo()); r.put("phoneStockId",o.getPhoneStockId()); r.put("usimStockId",o.getUsimStockId());
        r.put("commonSupport",o.getCommonSupport()); r.put("extraSupport",o.getExtraSupport()); r.put("switchSupport",o.getSwitchSupport());
        r.put("pointPay",o.getPointPay()); r.put("preprice",o.getPreprice()); r.put("joinprice",o.getJoinprice()); r.put("gradePrice",o.getGradePrice());
        r.put("basicprice1",o.getBasicprice1()); r.put("basicprice2",o.getBasicprice2()); r.put("basicprice3",o.getBasicprice3()); r.put("basicprice4",o.getBasicprice4());
        r.put("etcprice1",o.getEtcprice1()); r.put("etcprice2",o.getEtcprice2()); r.put("moveprice",o.getMoveprice()); r.put("planprice",o.getPlanprice());
        r.put("memberpoint",o.getMemberpoint()); r.put("usimprice",o.getUsimprice()); r.put("uid",o.getUid());
        r.put("decltype",o.getDecltype());
        return r;
    }
    private Map<String,Object> openAdjustData(JwtUser u,Map<String,String> f){
        List<OpenRecord> opens=em.createQuery("select o from OpenRecord o where o.groupId=:g",OpenRecord.class).setParameter("g",u.getGroupId()).getResultList();
        Map<Long,String> companies=new HashMap<>(); for(Company c:em.createQuery("select c from Company c where c.groupId=:g",Company.class).setParameter("g",u.getGroupId()).getResultList()) companies.put(c.getId(),c.getName());
        List<OpenRecord> filtered=opens.stream().filter(o->matchesAdjust(o,f,companies)).sorted(adjustComparator(f)).toList();
        return Map.of("total",filtered.size(),"list",filtered.stream().map(o->openMap(o,companies)).toList());
    }
    private boolean matchesAdjust(OpenRecord o,Map<String,String> f,Map<Long,String> companies){
        if("유심".equals(o.getGubun())) return false;
        if(!eq(o.getOpenhow(),f.get("openhow"))||!eq(o.getTelecom(),f.get("telecom"))||!eq(o.getSalesperson(),f.get("salesperson"))||!eq(o.getPlan(),f.get("plan"))) return false;
        if(!eqId(o.getIncompanyId(),f.get("incompanyId"))||!eqId(o.getOutcompanyId(),f.get("outcompanyId"))) return false;
        if(!like(o.getPmodel(),f.get("model"))||!like(companies.get(o.getOutcompanyId()),f.get("outcompanyName"))) return false;
        if(f.get("dateFrom")!=null&&!f.get("dateFrom").isBlank()&&(o.getOpendate()==null||o.getOpendate().isBefore(LocalDate.parse(f.get("dateFrom"))))) return false;
        return f.get("dateTo")==null||f.get("dateTo").isBlank()||(o.getOpendate()!=null&&!o.getOpendate().isAfter(LocalDate.parse(f.get("dateTo"))));
    }
    private Comparator<OpenRecord> adjustComparator(Map<String,String> f){
        List<String> keys=new ArrayList<>();
        for(int i=1;i<=7;i++){String k=f.get("sort"+i); if(k!=null&&!k.isBlank()) keys.add(k);}
        if(keys.isEmpty()) keys=List.of("종류","모델명","요금제","개통일","출고처","지원방법","고객명");
        Comparator<OpenRecord> c=null;
        for(String key:keys){
            Comparator<OpenRecord> next=switch(key){
                case "종류"->Comparator.comparing(OpenRecord::getOpenhow,Comparator.nullsLast(String::compareTo));
                case "모델명"->Comparator.comparing(OpenRecord::getPmodel,Comparator.nullsLast(String::compareTo));
                case "요금제"->Comparator.comparing(OpenRecord::getPlan,Comparator.nullsLast(String::compareTo));
                case "출고처"->Comparator.comparing(OpenRecord::getOutcompanyId,Comparator.nullsLast(Long::compareTo));
                case "지원방법"->Comparator.comparing(OpenRecord::getDecltype,Comparator.nullsLast(String::compareTo));
                case "고객명"->Comparator.comparing(OpenRecord::getName,Comparator.nullsLast(String::compareTo));
                case "통신사"->Comparator.comparing(OpenRecord::getTelecom,Comparator.nullsLast(String::compareTo));
                default->Comparator.comparing(OpenRecord::getOpendate,Comparator.nullsLast(LocalDate::compareTo));
            };
            c=c==null?next:c.thenComparing(next);
        }
        return (c==null?Comparator.comparing(OpenRecord::getId):c.thenComparing(OpenRecord::getId));
    }
    private BigDecimal toDecimal(Object v){ if(v==null||String.valueOf(v).isBlank()) return BigDecimal.ZERO; return new BigDecimal(String.valueOf(v).replace(",","")); }
    @GetMapping("/unitcosts") ApiResponse unitCosts(@AuthenticationPrincipal JwtUser u){
        List<UnitCostTable> rows=em.createQuery("select t from UnitCostTable t where t.groupId=:g order by t.id desc",UnitCostTable.class).setParameter("g",u.getGroupId()).getResultList();
        Map<Long,String> companies=new HashMap<>(); for(Company c:em.createQuery("select c from Company c where c.groupId=:g",Company.class).setParameter("g",u.getGroupId()).getResultList()) companies.put(c.getId(),c.getName());
        return ApiResponse.ok(Map.of("total",rows.size(),"list",rows.stream().map(t->unitCostMap(t,companies)).toList(),"max",20));
    }
    @PostMapping("/unitcosts") @Transactional ApiResponse unitCostPost(@RequestBody Map<String,Object>d,@AuthenticationPrincipal JwtUser u){
        long count=em.createQuery("select count(t) from UnitCostTable t where t.groupId=:g",Long.class).setParameter("g",u.getGroupId()).getSingleResult();
        if(count>=20) return ApiResponse.fail("단가표는 최대 20개까지 등록할 수 있습니다.");
        d.putIfAbsent("category","표준"); d.putIfAbsent("policyCount",0);
        Integer groups=toInt(d.get("planGroupCount")); if(groups!=null){ d.put("planGroupA",groups); d.putIfAbsent("planGroupB",0); d.putIfAbsent("planGroupC",0); d.putIfAbsent("planGroupD",0); }
        return post(UnitCostTable.class,d,u);
    }
    @PutMapping("/unitcosts/{id}") ApiResponse unitCostPut(@PathVariable Long id,@RequestBody Map<String,Object>d,@AuthenticationPrincipal JwtUser u){return put(UnitCostTable.class,id,d,u);}
    @DeleteMapping("/unitcosts/{id}") @Transactional ApiResponse unitCostDel(@PathVariable Long id,@AuthenticationPrincipal JwtUser u){
        em.createQuery("delete from UnitCostPolicy p where p.unitCostId=:id and p.groupId=:g").setParameter("id",id).setParameter("g",u.getGroupId()).executeUpdate();
        return del(UnitCostTable.class,id,u);
    }
    @GetMapping("/unitcosts/{id}/policies") ApiResponse unitCostPolicies(@PathVariable Long id,@AuthenticationPrincipal JwtUser u){
        UnitCostTable t=em.find(UnitCostTable.class,id); if(t==null||!Objects.equals(t.getGroupId(),u.getGroupId())) return ApiResponse.fail("단가표를 찾을 수 없습니다.");
        List<UnitCostPolicy> policies=em.createQuery("select p from UnitCostPolicy p where p.groupId=:g and p.unitCostId=:id order by p.id",UnitCostPolicy.class).setParameter("g",u.getGroupId()).setParameter("id",id).getResultList();
        return ApiResponse.ok(Map.of("table",unitCostMap(t,Map.of()),"list",policies));
    }
    @PostMapping("/unitcosts/{id}/policies") @Transactional ApiResponse unitCostPolicyPost(@PathVariable Long id,@RequestBody Map<String,Object>d,@AuthenticationPrincipal JwtUser u){
        UnitCostTable t=em.find(UnitCostTable.class,id); if(t==null||!Objects.equals(t.getGroupId(),u.getGroupId())) return ApiResponse.fail("단가표를 찾을 수 없습니다.");
        d.put("unitCostId",id); ApiResponse created=post(UnitCostPolicy.class,d,u);
        refreshPolicyCount(t); return created;
    }
    @PutMapping("/unitcosts/policies/{policyId}") ApiResponse unitCostPolicyPut(@PathVariable Long policyId,@RequestBody Map<String,Object>d,@AuthenticationPrincipal JwtUser u){return put(UnitCostPolicy.class,policyId,d,u);}
    @DeleteMapping("/unitcosts/policies/{policyId}") @Transactional ApiResponse unitCostPolicyDel(@PathVariable Long policyId,@AuthenticationPrincipal JwtUser u){
        UnitCostPolicy p=em.find(UnitCostPolicy.class,policyId); if(p==null||!Objects.equals(p.getGroupId(),u.getGroupId())) return ApiResponse.fail("정책을 찾을 수 없습니다.");
        Long tableId=p.getUnitCostId(); del(UnitCostPolicy.class,policyId,u);
        UnitCostTable t=em.find(UnitCostTable.class,tableId); if(t!=null) refreshPolicyCount(t);
        return ApiResponse.ok(null);
    }
    @GetMapping("/unitcosts/{id}/apply-search") ApiResponse unitCostApplySearch(@PathVariable Long id,@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f){
        UnitCostTable t=em.find(UnitCostTable.class,id); if(t==null||!Objects.equals(t.getGroupId(),u.getGroupId())) return ApiResponse.fail("단가표를 찾을 수 없습니다.");
        if(blank(f.get("dateFrom"))||blank(f.get("dateTo"))) return ApiResponse.fail("개통일을 선택하세요.");
        LocalDate from=LocalDate.parse(f.get("dateFrom")), to=LocalDate.parse(f.get("dateTo"));
        if(java.time.temporal.ChronoUnit.DAYS.between(from,to)>2) return ApiResponse.fail("최대 3일까지 조회할 수 있습니다.");
        Map<String,String> filters=new HashMap<>(f); filters.put("telecom",t.getTelecom());
        if(t.getIncompanyId()!=null) filters.put("incompanyId",String.valueOf(t.getIncompanyId()));
        List<OpenRecord> opens=em.createQuery("select o from OpenRecord o where o.groupId=:g",OpenRecord.class).setParameter("g",u.getGroupId()).getResultList();
        Map<Long,String> companies=new HashMap<>(); for(Company c:em.createQuery("select c from Company c where c.groupId=:g",Company.class).setParameter("g",u.getGroupId()).getResultList()) companies.put(c.getId(),c.getName());
        List<Map<String,Object>> list=opens.stream().filter(o->matchesUnitCostApply(o,filters,t)).map(o->openMap(o,companies)).toList();
        return ApiResponse.ok(Map.of("total",list.size(),"list",list,"table",unitCostMap(t,companies)));
    }
    @PostMapping("/unitcosts/{id}/apply") @Transactional ApiResponse unitCostApply(@PathVariable Long id,@AuthenticationPrincipal JwtUser u,@RequestBody Map<String,Object> body){
        UnitCostTable t=em.find(UnitCostTable.class,id); if(t==null||!Objects.equals(t.getGroupId(),u.getGroupId())) return ApiResponse.fail("단가표를 찾을 수 없습니다.");
        List<UnitCostPolicy> policies=em.createQuery("select p from UnitCostPolicy p where p.groupId=:g and p.unitCostId=:id",UnitCostPolicy.class).setParameter("g",u.getGroupId()).setParameter("id",id).getResultList();
        if(policies.isEmpty()) return ApiResponse.fail("등록된 정책단가가 없습니다. 먼저 정책단가를 작성해주세요.");
        BigDecimal defaultAmount=policies.getFirst().getAmount()==null?BigDecimal.ZERO:policies.getFirst().getAmount();
        Object raw=body.get("ids"); List<Long> ids=new ArrayList<>();
        if(raw instanceof List<?> list) for(Object o:list) ids.add(Long.valueOf(String.valueOf(o)));
        int updated=0;
        for(Long openId:ids){
            OpenRecord o=em.find(OpenRecord.class,openId);
            if(o==null||!Objects.equals(o.getGroupId(),u.getGroupId())||"유심".equals(o.getGubun())) continue;
            BigDecimal amount=defaultAmount;
            for(UnitCostPolicy p:policies){
                if(p.getModel()!=null&&!p.getModel().isBlank()&&o.getPmodel()!=null&&o.getPmodel().contains(p.getModel())){ amount=p.getAmount(); break; }
                if(p.getPlanName()!=null&&!p.getPlanName().isBlank()&&Objects.equals(p.getPlanName(),o.getPlan())){ amount=p.getAmount(); break; }
            }
            o.setBasicprice1(amount); updated++;
        }
        return ApiResponse.ok(Map.of("updated",updated));
    }
    private void refreshPolicyCount(UnitCostTable t){
        long c=em.createQuery("select count(p) from UnitCostPolicy p where p.unitCostId=:id",Long.class).setParameter("id",t.getId()).getSingleResult();
        t.setPolicyCount((int)c);
    }
    private boolean matchesUnitCostApply(OpenRecord o,Map<String,String> f,UnitCostTable t){
        if("유심".equals(o.getGubun())) return false;
        String how=o.getOpenhow()==null?"":o.getOpenhow();
        if(!(Set.of("신규","번호이동","기변","보상").contains(how)||"MNP".equalsIgnoreCase(how))) return false;
        if(!eq(o.getTelecom(),t.getTelecom())) return false;
        if(t.getIncompanyId()!=null&&!Objects.equals(o.getIncompanyId(),t.getIncompanyId())) return false;
        if(f.get("dateFrom")!=null&&!f.get("dateFrom").isBlank()&&(o.getOpendate()==null||o.getOpendate().isBefore(LocalDate.parse(f.get("dateFrom"))))) return false;
        return f.get("dateTo")==null||f.get("dateTo").isBlank()||(o.getOpendate()!=null&&!o.getOpendate().isAfter(LocalDate.parse(f.get("dateTo"))));
    }
    private Map<String,Object> unitCostMap(UnitCostTable t,Map<Long,String> companies){
        Map<String,Object> m=new LinkedHashMap<>();
        m.put("id",t.getId()); m.put("telecom",t.getTelecom()); m.put("incompanyId",t.getIncompanyId());
        m.put("incompanyName",t.getIncompanyName()!=null&&!t.getIncompanyName().isBlank()?t.getIncompanyName():companies.getOrDefault(t.getIncompanyId(),"-"));
        m.put("memo",t.getMemo()); m.put("category",t.getCategory());
        m.put("planGroupA",t.getPlanGroupA()); m.put("planGroupB",t.getPlanGroupB()); m.put("planGroupC",t.getPlanGroupC());
        m.put("planGroupD",t.getPlanGroupD()); m.put("planGroupE",t.getPlanGroupE()); m.put("planGroupF",t.getPlanGroupF()); m.put("planGroupG",t.getPlanGroupG());
        m.put("policyCount",t.getPolicyCount()==null?0:t.getPolicyCount());
        return m;
    }
    private Integer toInt(Object v){ if(v==null||String.valueOf(v).isBlank()) return null; return Integer.valueOf(String.valueOf(v)); }
    @GetMapping("/homesales/search") ApiResponse homeSaleSearch(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f,@RequestParam(defaultValue="0")int page,@RequestParam(defaultValue="15")int size){
        return ApiResponse.ok(homeSaleSearchData(u,f,page,size));
    }
    @PostMapping("/homesales") @Transactional ApiResponse homeSalePost(@RequestBody Map<String,Object>d,@AuthenticationPrincipal JwtUser u){
        d.putIfAbsent("status","완료"); return post(HomeSale.class,d,u);
    }
    @GetMapping("/homesales/{id}") ApiResponse homeSaleGet(@PathVariable Long id,@AuthenticationPrincipal JwtUser u){return get(HomeSale.class,id,u);}
    @PutMapping("/homesales/{id}") ApiResponse homeSalePut(@PathVariable Long id,@RequestBody Map<String,Object>d,@AuthenticationPrincipal JwtUser u){return put(HomeSale.class,id,d,u);}
    @DeleteMapping("/homesales/{id}") ApiResponse homeSaleDel(@PathVariable Long id,@AuthenticationPrincipal JwtUser u){return del(HomeSale.class,id,u);}
    @GetMapping(value="/homesales/export", produces="text/csv;charset=UTF-8") ResponseEntity<String> homeSaleExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f){
        @SuppressWarnings("unchecked") List<Map<String,Object>> rows=(List<Map<String,Object>>) homeSaleSearchData(u,f,0,10000).get("list");
        StringBuilder csv=new StringBuilder("\uFEFF설치일,출고처,상태,입고처,통신사,고객명,휴대폰,가입상품,정산,마진,접수일\n");
        for(Map<String,Object> r:rows) csv.append(csv(r.get("installDate"))).append(',').append(csv(r.get("outcompanyName"))).append(',').append(csv(r.get("status"))).append(',')
            .append(csv(r.get("incompanyName"))).append(',').append(csv(r.get("telecom"))).append(',').append(csv(r.get("customerName"))).append(',').append(csv(r.get("phone"))).append(',')
            .append(csv(r.get("products"))).append(',').append(csv(r.get("settleAmount"))).append(',').append(csv(r.get("margin"))).append(',').append(csv(r.get("receiptDate"))).append('\n');
        return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=homesales.csv").body(csv.toString());
    }
    private Map<String,Object> homeSaleSearchData(JwtUser u,Map<String,String> f,int page,int size){
        List<HomeSale> rows=em.createQuery("select h from HomeSale h where h.groupId=:g",HomeSale.class).setParameter("g",u.getGroupId()).getResultList();
        Map<Long,String> companies=new HashMap<>(); for(Company c:em.createQuery("select c from Company c where c.groupId=:g",Company.class).setParameter("g",u.getGroupId()).getResultList()) companies.put(c.getId(),c.getName());
        List<HomeSale> filtered=rows.stream().filter(h->matchesHomeSale(h,f,companies)).sorted(homeSaleComparator(f.get("sort"))).toList();
        Map<String,Long> counts=new LinkedHashMap<>();
        List<String> tabs=List.of("ALL","인터넷","TV","집전화","인터넷전화","신용카드","홈IOT","동반","기타","후결합","재약정","소호","원스톱","렌탈");
        for(String tab:tabs) counts.put(tab,rows.stream().filter(h->matchesHomeSale(h,withoutTab(f),companies)&&inHomeTab(h,tab)).count());
        int from=Math.min(Math.max(page,0)*Math.max(size,1),filtered.size()), to=Math.min(from+Math.min(Math.max(size,1),200),filtered.size());
        List<Map<String,Object>> list=filtered.subList(from,to).stream().map(h->homeSaleMap(h,companies)).toList();
        return Map.of("total",filtered.size(),"list",list,"counts",counts);
    }
    private boolean matchesHomeSale(HomeSale h,Map<String,String> f,Map<Long,String> companies){
        String tab=f.getOrDefault("tab","ALL"); if(!inHomeTab(h,tab)) return false;
        if(!eq(h.getTelecom(),f.get("telecom"))||!eq(h.getOutCategory(),f.get("outCategory"))||!eq(h.getStatus(),f.get("status"))||!eq(h.getEmployee(),f.get("employee"))) return false;
        if(!eqId(h.getIncompanyId(),f.get("incompanyId"))) return false;
        if(!like(h.getOutcompanyName(),f.get("outcompanyName"))&&!like(companies.get(h.getOutcompanyId()),f.get("outcompanyName"))) return false;
        if(!like(h.getCustomerName(),f.get("customerName"))) return false;
        String dateBasis=f.getOrDefault("dateBasis","설치일기준");
        LocalDate date="접수일기준".equals(dateBasis)?h.getReceiptDate():h.getInstallDate();
        if(f.get("dateFrom")!=null&&!f.get("dateFrom").isBlank()&&(date==null||date.isBefore(LocalDate.parse(f.get("dateFrom"))))) return false;
        return f.get("dateTo")==null||f.get("dateTo").isBlank()||(date!=null&&!date.isAfter(LocalDate.parse(f.get("dateTo"))));
    }
    private boolean inHomeTab(HomeSale h,String tab){
        if("ALL".equals(tab)||blank(tab)) return true;
        String products=h.getProducts()==null?"":h.getProducts();
        return Arrays.stream(products.split("[,|/]")).map(String::trim).anyMatch(p->p.equals(tab)||("동판".equals(tab)&&"동반".equals(p)));
    }
    private Comparator<HomeSale> homeSaleComparator(String sort){
        String field=sort==null?"installDate,desc":sort; boolean asc=field.endsWith(",asc");
        Comparator<HomeSale> c=switch(field.split(",")[0]){
            case "outcompanyName"->Comparator.comparing(HomeSale::getOutcompanyName,Comparator.nullsLast(String::compareTo));
            case "telecom"->Comparator.comparing(HomeSale::getTelecom,Comparator.nullsLast(String::compareTo));
            case "customerName"->Comparator.comparing(HomeSale::getCustomerName,Comparator.nullsLast(String::compareTo));
            case "settleAmount"->Comparator.comparing(HomeSale::getSettleAmount,Comparator.nullsLast(BigDecimal::compareTo));
            case "receiptDate"->Comparator.comparing(HomeSale::getReceiptDate,Comparator.nullsLast(LocalDate::compareTo));
            default->Comparator.comparing(HomeSale::getInstallDate,Comparator.nullsLast(LocalDate::compareTo));
        };
        return (asc?c:c.reversed()).thenComparing(HomeSale::getId);
    }
    private Map<String,Object> homeSaleMap(HomeSale h,Map<Long,String> companies){
        Map<String,Object> m=new LinkedHashMap<>();
        m.put("id",h.getId()); m.put("installDate",h.getInstallDate()); m.put("receiptDate",h.getReceiptDate());
        m.put("status",h.getStatus()); m.put("telecom",h.getTelecom()); m.put("outCategory",h.getOutCategory());
        m.put("outcompanyId",h.getOutcompanyId()); m.put("incompanyId",h.getIncompanyId());
        m.put("outcompanyName",h.getOutcompanyName()!=null&&!h.getOutcompanyName().isBlank()?h.getOutcompanyName():companies.getOrDefault(h.getOutcompanyId(),"-"));
        m.put("incompanyName",companies.getOrDefault(h.getIncompanyId(),"-"));
        m.put("employee",h.getEmployee()); m.put("customerName",h.getCustomerName()); m.put("phone",h.getPhone());
        m.put("address",h.getAddress()); m.put("products",h.getProducts()); m.put("missingDocs",h.getMissingDocs());
        m.put("extraPolicy",h.getExtraPolicy()); m.put("settleAmount",h.getSettleAmount()); m.put("margin",h.getMargin()); m.put("memo",h.getMemo());
        return m;
    }
    @GetMapping("/homeindocs/search") ApiResponse homeIndocSearch(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f,@RequestParam(defaultValue="0")int page,@RequestParam(defaultValue="15")int size){
        if(blank(f.get("dateFrom"))||blank(f.get("dateTo"))) return ApiResponse.fail("날짜를 선택하세요.");
        return ApiResponse.ok(homeIndocSearchData(u,f,page,size));
    }
    @GetMapping(value="/homeindocs/export", produces="text/csv;charset=UTF-8") ResponseEntity<String> homeIndocExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f){
        if(blank(f.get("dateFrom"))||blank(f.get("dateTo"))) return ResponseEntity.badRequest().body("날짜를 선택하세요.");
        @SuppressWarnings("unchecked") List<Map<String,Object>> rows=(List<Map<String,Object>>) homeIndocSearchData(u,f,0,10000).get("list");
        StringBuilder csv=new StringBuilder("\uFEFF설치일,통신사,입고처,출고처,고객명,휴대폰,가입상품,미비서류,차감금,차감사유,마감일,상태\n");
        for(Map<String,Object> r:rows) csv.append(csv(r.get("installDate"))).append(',').append(csv(r.get("telecom"))).append(',').append(csv(r.get("incompanyName"))).append(',')
            .append(csv(r.get("outcompanyName"))).append(',').append(csv(r.get("customerName"))).append(',').append(csv(r.get("phone"))).append(',')
            .append(csv(r.get("products"))).append(',').append(csv(r.get("missingDocs"))).append(',').append(csv(r.get("deductAmount"))).append(',')
            .append(csv(r.get("deductReason"))).append(',').append(csv(r.get("deadline"))).append(',').append(csv(r.get("status"))).append('\n');
        return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=homeindocs.csv").body(csv.toString());
    }
    private Map<String,Object> homeIndocSearchData(JwtUser u,Map<String,String> f,int page,int size){
        List<HomeIncompleteDoc> docs=em.createQuery("select d from HomeIncompleteDoc d where d.groupId=:g",HomeIncompleteDoc.class).setParameter("g",u.getGroupId()).getResultList();
        Map<Long,String> companies=new HashMap<>(); for(Company c:em.createQuery("select c from Company c where c.groupId=:g",Company.class).setParameter("g",u.getGroupId()).getResultList()) companies.put(c.getId(),c.getName());
        List<HomeIncompleteDoc> filtered=docs.stream().filter(d->matchesHomeIndoc(d,f,companies)).sorted(Comparator.comparing(HomeIncompleteDoc::getInstallDate,Comparator.nullsLast(LocalDate::compareTo)).reversed().thenComparing(HomeIncompleteDoc::getId)).toList();
        int from=Math.min(Math.max(page,0)*Math.max(size,1),filtered.size()), to=Math.min(from+Math.min(Math.max(size,1),200),filtered.size());
        List<Map<String,Object>> list=filtered.subList(from,to).stream().map(d->homeIndocMap(d,companies)).toList();
        return Map.of("total",filtered.size(),"list",list);
    }
    private boolean matchesHomeIndoc(HomeIncompleteDoc d,Map<String,String> f,Map<Long,String> companies){
        if(!eq(d.getTelecom(),f.get("telecom"))||!eqId(d.getIncompanyId(),f.get("incompanyId"))) return false;
        if(!like(d.getOutcompanyName(),f.get("outcompanyName"))&&!like(companies.get(d.getOutcompanyId()),f.get("outcompanyName"))) return false;
        if(f.get("dateFrom")!=null&&!f.get("dateFrom").isBlank()&&(d.getInstallDate()==null||d.getInstallDate().isBefore(LocalDate.parse(f.get("dateFrom"))))) return false;
        if(f.get("dateTo")!=null&&!f.get("dateTo").isBlank()&&(d.getInstallDate()==null||d.getInstallDate().isAfter(LocalDate.parse(f.get("dateTo"))))) return false;
        String keyword=f.get("keyword"); if(keyword==null||keyword.isBlank()) return true;
        String searchType=f.getOrDefault("searchType","고객명");
        return switch(searchType){
            case "미비서류"->like(d.getMissingDocs(),keyword);
            case "휴대폰(뒷4자리)"->{
                String phone=d.getPhone()==null?"":d.getPhone().replaceAll("\\D","");
                yield phone.endsWith(keyword.replaceAll("\\D",""));
            }
            default->like(d.getCustomerName(),keyword);
        };
    }
    private Map<String,Object> homeIndocMap(HomeIncompleteDoc d,Map<Long,String> companies){
        Map<String,Object> r=new LinkedHashMap<>();
        r.put("id",d.getId()); r.put("installDate",d.getInstallDate()); r.put("deadline",d.getDeadline());
        r.put("telecom",d.getTelecom()); r.put("customerName",d.getCustomerName()); r.put("phone",d.getPhone());
        r.put("products",d.getProducts()); r.put("missingDocs",d.getMissingDocs()); r.put("deductAmount",d.getDeductAmount());
        r.put("deductReason",d.getDeductReason()); r.put("status",d.getStatus()==null?"미비":d.getStatus());
        r.put("homeSaleId",d.getHomeSaleId()); r.put("outcompanyId",d.getOutcompanyId()); r.put("incompanyId",d.getIncompanyId());
        r.put("outcompanyName",d.getOutcompanyName()!=null&&!d.getOutcompanyName().isBlank()?d.getOutcompanyName():companies.getOrDefault(d.getOutcompanyId(),"-"));
        r.put("incompanyName",companies.getOrDefault(d.getIncompanyId(),"-"));
        return r;
    }
    @GetMapping("/exchanges/search") ApiResponse exchangeSearch(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f,@RequestParam(defaultValue="0")int page,@RequestParam(defaultValue="15")int size){
        return ApiResponse.ok(exchangeSearchData(u,f,page,size));
    }
    @PostMapping("/exchanges/delete") @Transactional ApiResponse exchangeDelete(@AuthenticationPrincipal JwtUser u,@RequestBody Map<String,Object> d){
        Object raw=d.get("ids");
        if(!(raw instanceof List<?> ids)||ids.isEmpty()) return ApiResponse.fail("삭제할 이력을 선택하세요.");
        int deleted=0;
        for(Object idObj:ids){
            OpenExchange e=em.find(OpenExchange.class,Long.valueOf(idObj.toString()));
            if(e==null||!Objects.equals(e.getGroupId(),u.getGroupId())) continue;
            em.remove(e); deleted++;
        }
        return ApiResponse.ok(Map.of("deleted",deleted));
    }
    @GetMapping(value="/exchanges/export", produces="text/csv;charset=UTF-8") ResponseEntity<String> exchangeExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f){
        @SuppressWarnings("unchecked") List<Map<String,Object>> rows=(List<Map<String,Object>>) exchangeSearchData(u,f,0,10000).get("list");
        StringBuilder csv=new StringBuilder("\uFEFF번호,교품일,출고처,고객명,개통일,개통번호,종류,교품,기존,처리직원,처리일,비고\n");
        for(Map<String,Object> r:rows) csv.append(csv(r.get("no"))).append(',').append(csv(r.get("exchangeDate"))).append(',').append(csv(r.get("outcompanyName"))).append(',')
            .append(csv(r.get("customerName"))).append(',').append(csv(r.get("openDate"))).append(',').append(csv(r.get("openphone"))).append(',')
            .append(csv(r.get("kind"))).append(',').append(csv(r.get("newLabel"))).append(',').append(csv(r.get("oldLabel"))).append(',')
            .append(csv(r.get("processor"))).append(',').append(csv(r.get("processedAt"))).append(',').append(csv(r.get("memo"))).append('\n');
        return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=exchanges.csv").body(csv.toString());
    }
    private Map<String,Object> exchangeSearchData(JwtUser u,Map<String,String> f,int page,int size){
        List<OpenExchange> all=em.createQuery("select e from OpenExchange e where e.groupId=:g",OpenExchange.class).setParameter("g",u.getGroupId()).getResultList();
        Map<Long,String> companies=new HashMap<>(); for(Company c:em.createQuery("select c from Company c where c.groupId=:g",Company.class).setParameter("g",u.getGroupId()).getResultList()) companies.put(c.getId(),c.getName());
        List<OpenExchange> filtered=all.stream().filter(e->matchesExchange(e,f,companies)).sorted(Comparator.comparing(OpenExchange::getExchangeDate,Comparator.nullsLast(LocalDate::compareTo)).reversed().thenComparing(OpenExchange::getId,Comparator.reverseOrder())).toList();
        int pageSize=Math.min(Math.max(size,1),200), from=Math.min(Math.max(page,0)*pageSize,filtered.size()), to=Math.min(from+pageSize,filtered.size());
        List<Map<String,Object>> list=new ArrayList<>();
        for(int i=from;i<to;i++) list.add(exchangeMap(filtered.get(i),companies,filtered.size()-i));
        return Map.of("total",filtered.size(),"list",list);
    }
    private boolean matchesExchange(OpenExchange e,Map<String,String> f,Map<Long,String> companies){
        if(!eq(e.getKind(),f.get("kind"))) return false;
        if(!like(e.getOutcompanyName(),f.get("outcompanyName"))&&!like(companies.get(e.getOutcompanyId()),f.get("outcompanyName"))) return false;
        if(f.get("dateFrom")!=null&&!f.get("dateFrom").isBlank()&&(e.getExchangeDate()==null||e.getExchangeDate().isBefore(LocalDate.parse(f.get("dateFrom"))))) return false;
        if(f.get("dateTo")!=null&&!f.get("dateTo").isBlank()&&(e.getExchangeDate()==null||e.getExchangeDate().isAfter(LocalDate.parse(f.get("dateTo"))))) return false;
        String keyword=f.get("keyword"); if(keyword==null||keyword.isBlank()) return true;
        String searchType=f.getOrDefault("searchType","개통번호(뒷4자리)");
        return switch(searchType){
            case "고객명"->like(e.getCustomerName(),keyword);
            case "교품일련번호"->like(e.getNewSerial(),keyword);
            case "기존일련번호"->like(e.getOldSerial(),keyword);
            default->{
                String phone=e.getOpenphone()==null?"":e.getOpenphone().replaceAll("\\D","");
                yield phone.endsWith(keyword.replaceAll("\\D",""));
            }
        };
    }
    private Map<String,Object> exchangeMap(OpenExchange e,Map<Long,String> companies,int no){
        Map<String,Object> r=new LinkedHashMap<>();
        r.put("id",e.getId()); r.put("no",no); r.put("exchangeDate",e.getExchangeDate()); r.put("openDate",e.getOpenDate());
        r.put("customerName",e.getCustomerName()); r.put("openphone",e.getOpenphone()); r.put("kind",e.getKind());
        r.put("newModel",e.getNewModel()); r.put("newColor",e.getNewColor()); r.put("newSerial",e.getNewSerial());
        r.put("oldModel",e.getOldModel()); r.put("oldColor",e.getOldColor()); r.put("oldSerial",e.getOldSerial());
        r.put("newLabel",deviceLabel(e.getNewModel(),e.getNewColor(),e.getNewSerial()));
        r.put("oldLabel",deviceLabel(e.getOldModel(),e.getOldColor(),e.getOldSerial()));
        r.put("processor",e.getProcessor()); r.put("memo",e.getMemo());
        r.put("processedAt",e.getProcessedAt()==null?null:e.getProcessedAt().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")));
        r.put("outcompanyId",e.getOutcompanyId());
        r.put("outcompanyName",e.getOutcompanyName()!=null&&!e.getOutcompanyName().isBlank()?e.getOutcompanyName():companies.getOrDefault(e.getOutcompanyId(),"-"));
        return r;
    }
    private String deviceLabel(String model,String color,String serial){
        String m=model==null?"":model.trim();
        String c=color==null||color.isBlank()||"-".equals(color)?"":" "+color.trim();
        String s=serial==null||serial.isBlank()?"":" ("+serial.trim()+")";
        return (m+c+s).trim();
    }
    @GetMapping("/namechanges/search") ApiResponse nameChangeSearch(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f,@RequestParam(defaultValue="0")int page,@RequestParam(defaultValue="15")int size){
        return ApiResponse.ok(nameChangeSearchData(u,f,page,size));
    }
    @PostMapping("/namechanges/delete") @Transactional ApiResponse nameChangeDelete(@AuthenticationPrincipal JwtUser u,@RequestBody Map<String,Object> d){
        Object raw=d.get("ids");
        if(!(raw instanceof List<?> ids)||ids.isEmpty()) return ApiResponse.fail("삭제할 이력을 선택하세요.");
        int deleted=0;
        for(Object idObj:ids){
            OpenNameChange e=em.find(OpenNameChange.class,Long.valueOf(idObj.toString()));
            if(e==null||!Objects.equals(e.getGroupId(),u.getGroupId())) continue;
            em.remove(e); deleted++;
        }
        return ApiResponse.ok(Map.of("deleted",deleted));
    }
    @GetMapping(value="/namechanges/export", produces="text/csv;charset=UTF-8") ResponseEntity<String> nameChangeExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f){
        @SuppressWarnings("unchecked") List<Map<String,Object>> rows=(List<Map<String,Object>>) nameChangeSearchData(u,f,0,10000).get("list");
        StringBuilder csv=new StringBuilder("\uFEFF번호,명변일,출고처,고객명,개통일,개통번호,기존명의자,기존연락처,기존유심,처리직원,처리일,비고\n");
        for(Map<String,Object> r:rows) csv.append(csv(r.get("no"))).append(',').append(csv(r.get("changeDate"))).append(',').append(csv(r.get("outcompanyName"))).append(',')
            .append(csv(r.get("customerName"))).append(',').append(csv(r.get("openDate"))).append(',').append(csv(r.get("openphone"))).append(',')
            .append(csv(r.get("oldOwner"))).append(',').append(csv(r.get("oldPhone"))).append(',').append(csv(r.get("oldUsim"))).append(',')
            .append(csv(r.get("processor"))).append(',').append(csv(r.get("processedAt"))).append(',').append(csv(r.get("memo"))).append('\n');
        return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=namechanges.csv").body(csv.toString());
    }
    private Map<String,Object> nameChangeSearchData(JwtUser u,Map<String,String> f,int page,int size){
        List<OpenNameChange> all=em.createQuery("select e from OpenNameChange e where e.groupId=:g",OpenNameChange.class).setParameter("g",u.getGroupId()).getResultList();
        Map<Long,String> companies=new HashMap<>(); for(Company c:em.createQuery("select c from Company c where c.groupId=:g",Company.class).setParameter("g",u.getGroupId()).getResultList()) companies.put(c.getId(),c.getName());
        List<OpenNameChange> filtered=all.stream().filter(e->matchesNameChange(e,f,companies)).sorted(Comparator.comparing(OpenNameChange::getChangeDate,Comparator.nullsLast(LocalDate::compareTo)).reversed().thenComparing(OpenNameChange::getId,Comparator.reverseOrder())).toList();
        int pageSize=Math.min(Math.max(size,1),200), from=Math.min(Math.max(page,0)*pageSize,filtered.size()), to=Math.min(from+pageSize,filtered.size());
        List<Map<String,Object>> list=new ArrayList<>();
        for(int i=from;i<to;i++) list.add(nameChangeMap(filtered.get(i),companies,filtered.size()-i));
        return Map.of("total",filtered.size(),"list",list);
    }
    private boolean matchesNameChange(OpenNameChange e,Map<String,String> f,Map<Long,String> companies){
        if(!like(e.getOutcompanyName(),f.get("outcompanyName"))&&!like(companies.get(e.getOutcompanyId()),f.get("outcompanyName"))) return false;
        if(f.get("dateFrom")!=null&&!f.get("dateFrom").isBlank()&&(e.getChangeDate()==null||e.getChangeDate().isBefore(LocalDate.parse(f.get("dateFrom"))))) return false;
        if(f.get("dateTo")!=null&&!f.get("dateTo").isBlank()&&(e.getChangeDate()==null||e.getChangeDate().isAfter(LocalDate.parse(f.get("dateTo"))))) return false;
        String keyword=f.get("keyword"); if(keyword==null||keyword.isBlank()) return true;
        String searchType=f.getOrDefault("searchType","개통번호(뒷4자리)");
        return switch(searchType){
            case "고객명"->like(e.getCustomerName(),keyword);
            case "기존명의자"->like(e.getOldOwner(),keyword);
            case "기존연락처"->{
                String phone=e.getOldPhone()==null?"":e.getOldPhone().replaceAll("\\D","");
                yield phone.endsWith(keyword.replaceAll("\\D",""));
            }
            default->{
                String phone=e.getOpenphone()==null?"":e.getOpenphone().replaceAll("\\D","");
                yield phone.endsWith(keyword.replaceAll("\\D",""));
            }
        };
    }
    private Map<String,Object> nameChangeMap(OpenNameChange e,Map<Long,String> companies,int no){
        Map<String,Object> r=new LinkedHashMap<>();
        r.put("id",e.getId()); r.put("no",no); r.put("changeDate",e.getChangeDate()); r.put("openDate",e.getOpenDate());
        r.put("customerName",e.getCustomerName()); r.put("openphone",e.getOpenphone());
        r.put("oldOwner",e.getOldOwner()); r.put("oldPhone",e.getOldPhone()); r.put("oldUsim",e.getOldUsim());
        r.put("processor",e.getProcessor()); r.put("memo",e.getMemo());
        r.put("processedAt",e.getProcessedAt()==null?null:e.getProcessedAt().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")));
        r.put("outcompanyId",e.getOutcompanyId());
        r.put("outcompanyName",e.getOutcompanyName()!=null&&!e.getOutcompanyName().isBlank()?e.getOutcompanyName():companies.getOrDefault(e.getOutcompanyId(),"-"));
        return r;
    }
    @GetMapping("/withdrawals/search") ApiResponse withdrawalSearch(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f,@RequestParam(defaultValue="0")int page,@RequestParam(defaultValue="15")int size){
        return ApiResponse.ok(withdrawalSearchData(u,f,page,size));
    }
    @PostMapping("/withdrawals/delete") @Transactional ApiResponse withdrawalDelete(@AuthenticationPrincipal JwtUser u,@RequestBody Map<String,Object> d){
        Object raw=d.get("ids");
        if(!(raw instanceof List<?> ids)||ids.isEmpty()) return ApiResponse.fail("삭제할 이력을 선택하세요.");
        int deleted=0;
        for(Object idObj:ids){
            OpenWithdrawal e=em.find(OpenWithdrawal.class,Long.valueOf(idObj.toString()));
            if(e==null||!Objects.equals(e.getGroupId(),u.getGroupId())) continue;
            em.remove(e); deleted++;
        }
        return ApiResponse.ok(Map.of("deleted",deleted));
    }
    @GetMapping(value="/withdrawals/export", produces="text/csv;charset=UTF-8") ResponseEntity<String> withdrawalExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f){
        @SuppressWarnings("unchecked") List<Map<String,Object>> rows=(List<Map<String,Object>>) withdrawalSearchData(u,f,0,10000).get("list");
        StringBuilder csv=new StringBuilder("\uFEFF번호,철회일,출고처,고객명,개통일,개통번호,처리직원,처리일,사유 및 비고\n");
        for(Map<String,Object> r:rows) csv.append(csv(r.get("no"))).append(',').append(csv(r.get("withdrawDate"))).append(',').append(csv(r.get("outcompanyName"))).append(',')
            .append(csv(r.get("customerName"))).append(',').append(csv(r.get("openDate"))).append(',').append(csv(r.get("openphone"))).append(',')
            .append(csv(r.get("processor"))).append(',').append(csv(r.get("processedAt"))).append(',').append(csv(r.get("reason"))).append('\n');
        return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=withdrawals.csv").body(csv.toString());
    }
    private Map<String,Object> withdrawalSearchData(JwtUser u,Map<String,String> f,int page,int size){
        List<OpenWithdrawal> all=em.createQuery("select e from OpenWithdrawal e where e.groupId=:g",OpenWithdrawal.class).setParameter("g",u.getGroupId()).getResultList();
        Map<Long,String> companies=new HashMap<>(); for(Company c:em.createQuery("select c from Company c where c.groupId=:g",Company.class).setParameter("g",u.getGroupId()).getResultList()) companies.put(c.getId(),c.getName());
        List<OpenWithdrawal> filtered=all.stream().filter(e->matchesWithdrawal(e,f,companies)).sorted(Comparator.comparing(OpenWithdrawal::getWithdrawDate,Comparator.nullsLast(LocalDate::compareTo)).reversed().thenComparing(OpenWithdrawal::getId,Comparator.reverseOrder())).toList();
        int pageSize=Math.min(Math.max(size,1),200), from=Math.min(Math.max(page,0)*pageSize,filtered.size()), to=Math.min(from+pageSize,filtered.size());
        List<Map<String,Object>> list=new ArrayList<>();
        for(int i=from;i<to;i++) list.add(withdrawalMap(filtered.get(i),companies,filtered.size()-i));
        return Map.of("total",filtered.size(),"list",list);
    }
    private boolean matchesWithdrawal(OpenWithdrawal e,Map<String,String> f,Map<Long,String> companies){
        if(!like(e.getOutcompanyName(),f.get("outcompanyName"))&&!like(companies.get(e.getOutcompanyId()),f.get("outcompanyName"))) return false;
        if(f.get("dateFrom")!=null&&!f.get("dateFrom").isBlank()&&(e.getWithdrawDate()==null||e.getWithdrawDate().isBefore(LocalDate.parse(f.get("dateFrom"))))) return false;
        if(f.get("dateTo")!=null&&!f.get("dateTo").isBlank()&&(e.getWithdrawDate()==null||e.getWithdrawDate().isAfter(LocalDate.parse(f.get("dateTo"))))) return false;
        String keyword=f.get("keyword"); if(keyword==null||keyword.isBlank()) return true;
        String searchType=f.getOrDefault("searchType","개통번호(뒷4자리)");
        return switch(searchType){
            case "고객명"->like(e.getCustomerName(),keyword);
            case "사유 및 비고"->like(e.getReason(),keyword);
            default->{
                String phone=e.getOpenphone()==null?"":e.getOpenphone().replaceAll("\\D","");
                yield phone.endsWith(keyword.replaceAll("\\D",""));
            }
        };
    }
    private Map<String,Object> withdrawalMap(OpenWithdrawal e,Map<Long,String> companies,int no){
        Map<String,Object> r=new LinkedHashMap<>();
        r.put("id",e.getId()); r.put("no",no); r.put("withdrawDate",e.getWithdrawDate()); r.put("openDate",e.getOpenDate());
        r.put("customerName",e.getCustomerName()); r.put("openphone",e.getOpenphone());
        r.put("processor",e.getProcessor()); r.put("reason",e.getReason());
        r.put("processedAt",e.getProcessedAt()==null?null:e.getProcessedAt().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")));
        r.put("outcompanyId",e.getOutcompanyId());
        r.put("outcompanyName",e.getOutcompanyName()!=null&&!e.getOutcompanyName().isBlank()?e.getOutcompanyName():companies.getOrDefault(e.getOutcompanyId(),"-"));
        return r;
    }
    @GetMapping("/settlehistories/search") ApiResponse settleHistorySearch(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f,@RequestParam(defaultValue="0")int page,@RequestParam(defaultValue="15")int size){
        return ApiResponse.ok(settleHistorySearchData(u,f,page,size));
    }
    @PostMapping("/settlehistories/delete") @Transactional ApiResponse settleHistoryDelete(@AuthenticationPrincipal JwtUser u,@RequestBody Map<String,Object> d){
        Object raw=d.get("ids");
        if(!(raw instanceof List<?> ids)||ids.isEmpty()) return ApiResponse.fail("삭제할 이력을 선택하세요.");
        int deleted=0;
        for(Object idObj:ids){
            OpenSettleHistory e=em.find(OpenSettleHistory.class,Long.valueOf(idObj.toString()));
            if(e==null||!Objects.equals(e.getGroupId(),u.getGroupId())) continue;
            em.remove(e); deleted++;
        }
        return ApiResponse.ok(Map.of("deleted",deleted));
    }
    @GetMapping(value="/settlehistories/export", produces="text/csv;charset=UTF-8") ResponseEntity<String> settleHistoryExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f){
        @SuppressWarnings("unchecked") List<Map<String,Object>> rows=(List<Map<String,Object>>) settleHistorySearchData(u,f,0,10000).get("list");
        StringBuilder csv=new StringBuilder("\uFEFF번호,변경일,출고처,고객명,개통일,개통번호,기존정산금,변경정산금,처리직원,처리일,비고\n");
        for(Map<String,Object> r:rows) csv.append(csv(r.get("no"))).append(',').append(csv(r.get("changeDate"))).append(',').append(csv(r.get("outcompanyName"))).append(',')
            .append(csv(r.get("customerName"))).append(',').append(csv(r.get("openDate"))).append(',').append(csv(r.get("openphone"))).append(',')
            .append(csv(r.get("oldAmount"))).append(',').append(csv(r.get("newAmount"))).append(',')
            .append(csv(r.get("processor"))).append(',').append(csv(r.get("processedAt"))).append(',').append(csv(r.get("memo"))).append('\n');
        return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=settlehistories.csv").body(csv.toString());
    }
    private Map<String,Object> settleHistorySearchData(JwtUser u,Map<String,String> f,int page,int size){
        List<OpenSettleHistory> all=em.createQuery("select e from OpenSettleHistory e where e.groupId=:g",OpenSettleHistory.class).setParameter("g",u.getGroupId()).getResultList();
        Map<Long,String> companies=new HashMap<>(); for(Company c:em.createQuery("select c from Company c where c.groupId=:g",Company.class).setParameter("g",u.getGroupId()).getResultList()) companies.put(c.getId(),c.getName());
        List<OpenSettleHistory> filtered=all.stream().filter(e->matchesSettleHistory(e,f,companies)).sorted(Comparator.comparing(OpenSettleHistory::getChangeDate,Comparator.nullsLast(LocalDate::compareTo)).reversed().thenComparing(OpenSettleHistory::getId,Comparator.reverseOrder())).toList();
        int pageSize=Math.min(Math.max(size,1),200), from=Math.min(Math.max(page,0)*pageSize,filtered.size()), to=Math.min(from+pageSize,filtered.size());
        List<Map<String,Object>> list=new ArrayList<>();
        for(int i=from;i<to;i++) list.add(settleHistoryMap(filtered.get(i),companies,filtered.size()-i));
        return Map.of("total",filtered.size(),"list",list);
    }
    private boolean matchesSettleHistory(OpenSettleHistory e,Map<String,String> f,Map<Long,String> companies){
        if(!like(e.getOutcompanyName(),f.get("outcompanyName"))&&!like(companies.get(e.getOutcompanyId()),f.get("outcompanyName"))) return false;
        if(f.get("dateFrom")!=null&&!f.get("dateFrom").isBlank()&&(e.getChangeDate()==null||e.getChangeDate().isBefore(LocalDate.parse(f.get("dateFrom"))))) return false;
        if(f.get("dateTo")!=null&&!f.get("dateTo").isBlank()&&(e.getChangeDate()==null||e.getChangeDate().isAfter(LocalDate.parse(f.get("dateTo"))))) return false;
        String keyword=f.get("keyword"); if(keyword==null||keyword.isBlank()) return true;
        String searchType=f.getOrDefault("searchType","개통번호(뒷4자리)");
        return switch(searchType){
            case "고객명"->like(e.getCustomerName(),keyword);
            case "처리직원"->like(e.getProcessor(),keyword);
            case "비고"->like(e.getMemo(),keyword);
            default->{
                String phone=e.getOpenphone()==null?"":e.getOpenphone().replaceAll("\\D","");
                yield phone.endsWith(keyword.replaceAll("\\D",""));
            }
        };
    }
    private Map<String,Object> settleHistoryMap(OpenSettleHistory e,Map<Long,String> companies,int no){
        Map<String,Object> r=new LinkedHashMap<>();
        r.put("id",e.getId()); r.put("no",no); r.put("changeDate",e.getChangeDate()); r.put("openDate",e.getOpenDate());
        r.put("customerName",e.getCustomerName()); r.put("openphone",e.getOpenphone());
        r.put("oldAmount",e.getOldAmount()); r.put("newAmount",e.getNewAmount());
        r.put("processor",e.getProcessor()); r.put("memo",e.getMemo());
        r.put("processedAt",e.getProcessedAt()==null?null:e.getProcessedAt().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")));
        r.put("outcompanyId",e.getOutcompanyId());
        r.put("outcompanyName",e.getOutcompanyName()!=null&&!e.getOutcompanyName().isBlank()?e.getOutcompanyName():companies.getOrDefault(e.getOutcompanyId(),"-"));
        return r;
    }
    @GetMapping("/deletehistories/search") ApiResponse deleteHistorySearch(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f,@RequestParam(defaultValue="0")int page,@RequestParam(defaultValue="15")int size){
        return ApiResponse.ok(deleteHistorySearchData(u,f,page,size));
    }
    @PostMapping("/deletehistories/delete") @Transactional ApiResponse deleteHistoryDelete(@AuthenticationPrincipal JwtUser u,@RequestBody Map<String,Object> d){
        Object raw=d.get("ids");
        if(!(raw instanceof List<?> ids)||ids.isEmpty()) return ApiResponse.fail("삭제할 이력을 선택하세요.");
        int deleted=0;
        for(Object idObj:ids){
            OpenDeleteHistory e=em.find(OpenDeleteHistory.class,Long.valueOf(idObj.toString()));
            if(e==null||!Objects.equals(e.getGroupId(),u.getGroupId())) continue;
            em.remove(e); deleted++;
        }
        return ApiResponse.ok(Map.of("deleted",deleted));
    }
    @GetMapping(value="/deletehistories/export", produces="text/csv;charset=UTF-8") ResponseEntity<String> deleteHistoryExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f){
        @SuppressWarnings("unchecked") List<Map<String,Object>> rows=(List<Map<String,Object>>) deleteHistorySearchData(u,f,0,10000).get("list");
        StringBuilder csv=new StringBuilder("\uFEFF번호,철회일,출고처,고객명,개통일,개통번호,처리직원,처리일,비고\n");
        for(Map<String,Object> r:rows) csv.append(csv(r.get("no"))).append(',').append(csv(r.get("deleteDate"))).append(',').append(csv(r.get("outcompanyName"))).append(',')
            .append(csv(r.get("customerName"))).append(',').append(csv(r.get("openDate"))).append(',').append(csv(r.get("openphone"))).append(',')
            .append(csv(r.get("processor"))).append(',').append(csv(r.get("processedAt"))).append(',').append(csv(r.get("memo"))).append('\n');
        return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=deletehistories.csv").body(csv.toString());
    }
    private Map<String,Object> deleteHistorySearchData(JwtUser u,Map<String,String> f,int page,int size){
        List<OpenDeleteHistory> all=em.createQuery("select e from OpenDeleteHistory e where e.groupId=:g",OpenDeleteHistory.class).setParameter("g",u.getGroupId()).getResultList();
        Map<Long,String> companies=new HashMap<>(); for(Company c:em.createQuery("select c from Company c where c.groupId=:g",Company.class).setParameter("g",u.getGroupId()).getResultList()) companies.put(c.getId(),c.getName());
        List<OpenDeleteHistory> filtered=all.stream().filter(e->matchesDeleteHistory(e,f,companies)).sorted(Comparator.comparing(OpenDeleteHistory::getDeleteDate,Comparator.nullsLast(LocalDate::compareTo)).reversed().thenComparing(OpenDeleteHistory::getId,Comparator.reverseOrder())).toList();
        int pageSize=Math.min(Math.max(size,1),200), from=Math.min(Math.max(page,0)*pageSize,filtered.size()), to=Math.min(from+pageSize,filtered.size());
        List<Map<String,Object>> list=new ArrayList<>();
        for(int i=from;i<to;i++) list.add(deleteHistoryMap(filtered.get(i),companies,filtered.size()-i));
        return Map.of("total",filtered.size(),"list",list);
    }
    private boolean matchesDeleteHistory(OpenDeleteHistory e,Map<String,String> f,Map<Long,String> companies){
        if(!like(e.getOutcompanyName(),f.get("outcompanyName"))&&!like(companies.get(e.getOutcompanyId()),f.get("outcompanyName"))) return false;
        if(f.get("dateFrom")!=null&&!f.get("dateFrom").isBlank()&&(e.getDeleteDate()==null||e.getDeleteDate().isBefore(LocalDate.parse(f.get("dateFrom"))))) return false;
        if(f.get("dateTo")!=null&&!f.get("dateTo").isBlank()&&(e.getDeleteDate()==null||e.getDeleteDate().isAfter(LocalDate.parse(f.get("dateTo"))))) return false;
        String keyword=f.get("keyword"); if(keyword==null||keyword.isBlank()) return true;
        String searchType=f.getOrDefault("searchType","개통번호(뒷4자리)");
        return switch(searchType){
            case "고객명"->like(e.getCustomerName(),keyword);
            case "처리직원"->like(e.getProcessor(),keyword);
            case "비고"->like(e.getMemo(),keyword);
            default->{
                String phone=e.getOpenphone()==null?"":e.getOpenphone().replaceAll("\\D","");
                yield phone.endsWith(keyword.replaceAll("\\D",""));
            }
        };
    }
    private Map<String,Object> deleteHistoryMap(OpenDeleteHistory e,Map<Long,String> companies,int no){
        Map<String,Object> r=new LinkedHashMap<>();
        r.put("id",e.getId()); r.put("no",no); r.put("deleteDate",e.getDeleteDate()); r.put("openDate",e.getOpenDate());
        r.put("customerName",e.getCustomerName()); r.put("openphone",e.getOpenphone());
        r.put("processor",e.getProcessor()); r.put("memo",e.getMemo());
        r.put("processedAt",e.getProcessedAt()==null?null:e.getProcessedAt().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")));
        r.put("outcompanyId",e.getOutcompanyId());
        r.put("outcompanyName",e.getOutcompanyName()!=null&&!e.getOutcompanyName().isBlank()?e.getOutcompanyName():companies.getOrDefault(e.getOutcompanyId(),"-"));
        return r;
    }
    @GetMapping("/companies") ApiResponse companies(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f,@RequestParam(defaultValue="0")int page,@RequestParam(defaultValue="30")int size){return list(Company.class,u,f,page,size);}
    @GetMapping("/outcompany-accounts") ApiResponse outcompanyAccounts(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f){
        List<Company> companies=em.createQuery("select c from Company c where c.groupId=:g and c.type in ('OUT','SHOP') order by c.id asc",Company.class)
            .setParameter("g",u.getGroupId()).getResultList();
        List<Member> members=em.createQuery("select m from Member m where m.groupId=:g and m.role='SHOP'",Member.class)
            .setParameter("g",u.getGroupId()).getResultList();
        Map<Long,Member> byCompany=new HashMap<>();
        for(Member m:members) if(m.getCompanyId()!=null) byCompany.putIfAbsent(m.getCompanyId(),m);
        String account=f.get("account");
        String channel=f.get("channel");
        String searchField=f.getOrDefault("searchField","name");
        String keyword=f.get("keyword");
        String tab=f.get("tab");
        List<Map<String,Object>> list=new ArrayList<>();
        Map<String,Integer> channelCounts=new LinkedHashMap<>();
        channelCounts.put("전체",0); channelCounts.put("도매",0); channelCounts.put("소매",0); channelCounts.put("C채널",0); channelCounts.put("특판",0); channelCounts.put("기타",0);
        for(Company c:companies){
            Member m=byCompany.get(c.getId());
            String status=m==null?"미사용":(m.isActive()?"사용":"중지");
            String ch=blank(c.getChannel())?"기타":c.getChannel();
            channelCounts.put("전체",channelCounts.get("전체")+1);
            channelCounts.put(ch,channelCounts.getOrDefault(ch,0)+1);
            if(!eq(status,account)) continue;
            if(!eq(ch,channel)) continue;
            if(tab!=null&&!tab.isBlank()&&!"전체".equals(tab)&&!Objects.equals(ch,tab)) continue;
            if(keyword!=null&&!keyword.isBlank()){
                String q=keyword.trim();
                String hay=switch(searchField){
                    case "managerName"->nvlStr(c.getManagerName());
                    case "phone"->nvlStr(c.getPhone());
                    case "userid"->m==null?"":nvlStr(m.getUserid());
                    case "pcode"->nvlStr(c.getPcode());
                    default->nvlStr(c.getName());
                };
                if(!hay.contains(q)) continue;
            }
            list.add(outcompanyAccountMap(c,m,status));
        }
        return ApiResponse.ok(Map.of("total",list.size(),"list",list,"channelCounts",channelCounts));
    }
    @PostMapping("/outcompany-accounts/{id}/save") @Transactional ApiResponse outcompanyAccountSave(@PathVariable Long id,@RequestBody Map<String,Object> d,@AuthenticationPrincipal JwtUser u){
        Company c=em.find(Company.class,id);
        if(c==null||!Objects.equals(c.getGroupId(),u.getGroupId())||!("OUT".equals(c.getType())||"SHOP".equals(c.getType())))
            return ApiResponse.fail("출고처를 찾을 수 없습니다.");
        String userid=d.get("userid")==null?"":String.valueOf(d.get("userid")).trim();
        String password=d.get("password")==null?"":String.valueOf(d.get("password")).trim();
        if(!userid.matches("^[A-Za-z0-9]{4,20}$")) return ApiResponse.fail("아이디는 영문, 숫자 4자~20자입니다.");
        if(!password.matches("^[A-Za-z0-9]{4,20}$")) return ApiResponse.fail("비밀번호는 영문, 숫자 4자~20자입니다.");
        List<Member> dup=em.createQuery("select m from Member m where m.userid=:id",Member.class).setParameter("id",userid).getResultList();
        Member existing=em.createQuery("select m from Member m where m.groupId=:g and m.role='SHOP' and m.companyId=:c",Member.class)
            .setParameter("g",u.getGroupId()).setParameter("c",id).getResultList().stream().findFirst().orElse(null);
        if(!dup.isEmpty()&&(existing==null||!Objects.equals(dup.getFirst().getId(),existing.getId())))
            return ApiResponse.fail("이미 사용 중인 아이디입니다.");
        if(existing==null){
            existing=new Member();
            existing.setGroupId(u.getGroupId());
            existing.setRole("SHOP");
            existing.setCompanyId(id);
            existing.setUserid(userid);
            existing.setPassword(encoder.encode(password));
            existing.setPlainPassword(password);
            existing.setLoginCount(0);
            existing.setActive(true);
            existing.setName(c.getName());
            existing.setPhone(c.getPhone());
            existing.setMenuFlags(menuFlagsJson(d.get("menuFlags")));
            em.persist(existing);
        } else {
            existing.setUserid(userid);
            existing.setPassword(encoder.encode(password));
            existing.setPlainPassword(password);
            existing.setName(c.getName());
            existing.setPhone(c.getPhone());
            existing.setMenuFlags(menuFlagsJson(d.get("menuFlags")));
            existing.setActive(true);
        }
        return ApiResponse.ok(outcompanyAccountMap(c,existing,"사용"));
    }
    @PostMapping("/outcompany-accounts/{id}/stop") @Transactional ApiResponse outcompanyAccountStop(@PathVariable Long id,@AuthenticationPrincipal JwtUser u){
        Member m=shopMember(u,id); if(m==null) return ApiResponse.fail("계정이 없습니다.");
        m.setActive(false); return ApiResponse.ok(null);
    }
    @PostMapping("/outcompany-accounts/{id}/resume") @Transactional ApiResponse outcompanyAccountResume(@PathVariable Long id,@AuthenticationPrincipal JwtUser u){
        Member m=shopMember(u,id); if(m==null) return ApiResponse.fail("계정이 없습니다.");
        m.setActive(true); return ApiResponse.ok(null);
    }
    @DeleteMapping("/outcompany-accounts/{id}") @Transactional ApiResponse outcompanyAccountDelete(@PathVariable Long id,@AuthenticationPrincipal JwtUser u){
        Member m=shopMember(u,id); if(m==null) return ApiResponse.fail("계정이 없습니다.");
        em.remove(m); return ApiResponse.ok(null);
    }
    private Member shopMember(JwtUser u,Long companyId){
        return em.createQuery("select m from Member m where m.groupId=:g and m.role='SHOP' and m.companyId=:c",Member.class)
            .setParameter("g",u.getGroupId()).setParameter("c",companyId).getResultList().stream().findFirst().orElse(null);
    }
    private Map<String,Object> outcompanyAccountMap(Company c,Member m,String status){
        Map<String,Object> r=new LinkedHashMap<>();
        r.put("id",c.getId());
        r.put("channel",blank(c.getChannel())?"기타":c.getChannel());
        r.put("name",c.getName());
        r.put("pcode",blank(c.getPcode())?"-":c.getPcode());
        r.put("managerName",blank(c.getManagerName())?"-":c.getManagerName());
        r.put("phone",blank(c.getPhone())?"-":c.getPhone());
        r.put("userid",m==null?"-":m.getUserid());
        r.put("password",m==null?"-":(blank(m.getPlainPassword())?"****":m.getPlainPassword()));
        r.put("loginCount",m==null?0:(m.getLoginCount()==null?0:m.getLoginCount()));
        r.put("lastLoginAt",m==null||m.getLastLoginAt()==null?"-":m.getLastLoginAt().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")));
        r.put("accountStatus",status);
        r.put("memberId",m==null?null:m.getId());
        r.put("menuFlags",parseMenuFlags(m==null?null:m.getMenuFlags()));
        r.put("createdAt",c.getCreatedAt()==null?null:c.getCreatedAt().atZone(ZoneId.systemDefault()).toLocalDate().toString());
        return r;
    }
    private String menuFlagsJson(Object raw){
        Map<String,Boolean> flags=defaultMenuFlags();
        if(raw instanceof Map<?,?> map) for(var e:map.entrySet()) if(e.getKey()!=null) flags.put(String.valueOf(e.getKey()),Boolean.parseBoolean(String.valueOf(e.getValue())));
        StringBuilder sb=new StringBuilder("{");
        boolean first=true;
        for(var e:flags.entrySet()){ if(!first) sb.append(','); first=false; sb.append('"').append(e.getKey()).append("\":").append(e.getValue()); }
        sb.append('}');
        return sb.toString();
    }
    private Map<String,Boolean> parseMenuFlags(String json){
        Map<String,Boolean> flags=defaultMenuFlags();
        if(json==null||json.isBlank()) return flags;
        for(String key:flags.keySet()){
            String token="\""+key+"\":";
            int i=json.indexOf(token);
            if(i>=0){
                int start=i+token.length();
                flags.put(key,json.regionMatches(true,start,"true",0,4));
            }
        }
        return flags;
    }
    private Map<String,Boolean> defaultMenuFlags(){
        Map<String,Boolean> m=new LinkedHashMap<>();
        m.put("policy",true); m.put("pds",true); m.put("return",true); m.put("indoc",true);
        m.put("scan",true); m.put("stock",true); m.put("open",true); m.put("settle",true); m.put("farebox",true);
        return m;
    }
    private String nvlStr(String v){return v==null?"":v;}
    @GetMapping("/boardgroups") ApiResponse boardGroups(@AuthenticationPrincipal JwtUser u){
        List<BoardGroup> all=em.createQuery("select g from BoardGroup g where g.groupId=:g order by g.id asc",BoardGroup.class).setParameter("g",u.getGroupId()).getResultList();
        List<Map<String,Object>> list=new ArrayList<>();
        for(int i=0;i<all.size();i++) list.add(boardGroupMap(all.get(i),i+1));
        return ApiResponse.ok(Map.of("total",all.size(),"list",list));
    }
    @PostMapping("/boardgroups") @Transactional ApiResponse boardGroupPost(@RequestBody Map<String,Object>d,@AuthenticationPrincipal JwtUser u){
        String name=d.get("name")==null?"":String.valueOf(d.get("name")).trim();
        if(name.isBlank()) return ApiResponse.fail("그룹명을 입력하세요.");
        BoardGroup g=new BoardGroup();
        g.setGroupId(u.getGroupId());
        g.setCategory("사용자그룹");
        g.setName(name);
        g.setCompanyIds("");
        em.persist(g);
        return ApiResponse.ok(boardGroupMap(g,0));
    }
    @DeleteMapping("/boardgroups/{id}") @Transactional ApiResponse boardGroupDel(@PathVariable Long id,@AuthenticationPrincipal JwtUser u){
        BoardGroup g=em.find(BoardGroup.class,id);
        if(g==null||!Objects.equals(g.getGroupId(),u.getGroupId())) return ApiResponse.fail("그룹을 찾을 수 없습니다.");
        if("기본통신사".equals(g.getCategory())) return ApiResponse.fail("기본통신사는 삭제할 수 없습니다.");
        if(boardGroupCompanyCount(g)>0) return ApiResponse.fail("출고처지정이 있는 경우 삭제할 수 없습니다.");
        em.remove(g);
        return ApiResponse.ok(null);
    }
    @PutMapping("/boardgroups/{id}/companies") @Transactional ApiResponse boardGroupCompanies(@PathVariable Long id,@RequestBody Map<String,Object>d,@AuthenticationPrincipal JwtUser u){
        BoardGroup g=em.find(BoardGroup.class,id);
        if(g==null||!Objects.equals(g.getGroupId(),u.getGroupId())) return ApiResponse.fail("그룹을 찾을 수 없습니다.");
        Object raw=d.get("companyIds");
        List<String> ids=new ArrayList<>();
        if(raw instanceof List<?> list) for(Object o:list) if(o!=null&&!String.valueOf(o).isBlank()) ids.add(String.valueOf(o));
        g.setCompanyIds(String.join(",",ids));
        return ApiResponse.ok(boardGroupMap(g,0));
    }
    private Map<String,Object> boardGroupMap(BoardGroup g,int no){
        Map<String,Object> r=new LinkedHashMap<>();
        r.put("id",g.getId()); r.put("no",no); r.put("category",g.getCategory()); r.put("name",g.getName());
        r.put("companyCount",boardGroupCompanyCount(g));
        r.put("companyIds",boardGroupCompanyIds(g));
        r.put("deletable","사용자그룹".equals(g.getCategory())&&boardGroupCompanyCount(g)==0);
        return r;
    }
    private int boardGroupCompanyCount(BoardGroup g){return boardGroupCompanyIds(g).size();}
    private List<Long> boardGroupCompanyIds(BoardGroup g){
        if(g.getCompanyIds()==null||g.getCompanyIds().isBlank()) return List.of();
        List<Long> ids=new ArrayList<>();
        for(String p:g.getCompanyIds().split(",")) try{ if(!p.isBlank()) ids.add(Long.valueOf(p.trim())); }catch(Exception ignored){}
        return ids;
    }
    @PostMapping("/companies") ApiResponse companyPost(@RequestBody Map<String,Object>d,@AuthenticationPrincipal JwtUser u){return post(Company.class,d,u);}
    @GetMapping("/companies/{id}") ApiResponse company(@PathVariable Long id,@AuthenticationPrincipal JwtUser u){return get(Company.class,id,u);}
    @PutMapping("/companies/{id}") ApiResponse companyPut(@PathVariable Long id,@RequestBody Map<String,Object>d,@AuthenticationPrincipal JwtUser u){return put(Company.class,id,d,u);}
    @DeleteMapping("/companies/{id}") ApiResponse companyDel(@PathVariable Long id,@AuthenticationPrincipal JwtUser u){return del(Company.class,id,u);}
    @GetMapping("/bbs") ApiResponse bbs(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f,@RequestParam(defaultValue="0")int page,@RequestParam(defaultValue="30")int size){return list(BbsPost.class,u,f,page,size);}
    @GetMapping("/bbs/search") ApiResponse bbsSearch(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f,@RequestParam(defaultValue="0")int page,@RequestParam(defaultValue="15")int size){
        return ApiResponse.ok(bbsSearchData(u,f,page,size));
    }
    @PostMapping("/bbs") @Transactional ApiResponse bbsPost(@RequestBody Map<String,Object>d,@AuthenticationPrincipal JwtUser u){
        d.putIfAbsent("code","shop");
        d.putIfAbsent("userid",u.getUserid());
        d.putIfAbsent("authorName",u.getUserid());
        Member m=em.createQuery("select m from Member m where m.userid=:id",Member.class).setParameter("id",u.getUserid()).getResultList().stream().findFirst().orElse(null);
        if(m!=null&&(d.get("authorName")==null||String.valueOf(d.get("authorName")).isBlank()||Objects.equals(d.get("authorName"),u.getUserid()))) d.put("authorName",m.getName());
        d.putIfAbsent("targetGroup","전체");
        d.putIfAbsent("confirmCount",0);
        d.putIfAbsent("views",0);
        d.putIfAbsent("commentCount",0);
        d.putIfAbsent("state","1");
        if(d.get("notice")==null) d.put("notice",false);
        return post(BbsPost.class,d,u);
    }
    @GetMapping("/bbs/{id}") @Transactional ApiResponse bbsGet(@PathVariable Long id,@AuthenticationPrincipal JwtUser u){
        BbsPost b=em.find(BbsPost.class,id);
        if(b==null || !Objects.equals(b.getGroupId(),u.getGroupId())) return ApiResponse.fail("게시글을 찾을 수 없습니다.");
        b.setViews((b.getViews()==null?0:b.getViews())+1);
        return ApiResponse.ok(bbsMap(b,0));
    }
    @PutMapping("/bbs/{id}") ApiResponse bbsPut(@PathVariable Long id,@RequestBody Map<String,Object>d,@AuthenticationPrincipal JwtUser u){return put(BbsPost.class,id,d,u);}
    @DeleteMapping("/bbs/{id}") ApiResponse bbsDel(@PathVariable Long id,@AuthenticationPrincipal JwtUser u){return del(BbsPost.class,id,u);}
    private Map<String,Object> bbsSearchData(JwtUser u,Map<String,String> f,int page,int size){
        String code=f.getOrDefault("code","shop");
        List<BbsPost> all=em.createQuery("select b from BbsPost b where b.groupId=:g and b.code=:c",BbsPost.class).setParameter("g",u.getGroupId()).setParameter("c",code).getResultList();
        String keyword=f.get("keyword");
        String searchType=f.getOrDefault("searchType","제목");
        List<BbsPost> filtered=all.stream().filter(b->{
            if(keyword==null||keyword.isBlank()) return true;
            return switch(searchType){
                case "직원"->like(b.getAuthorName(),keyword);
                case "내용"->like(b.getContents(),keyword);
                case "그룹"->like(b.getTargetGroup(),keyword);
                default->like(b.getTitle(),keyword);
            };
        }).sorted(bbsComparator(f.get("sort"))).toList();
        int pageSize=Math.min(Math.max(size,1),200), from=Math.min(Math.max(page,0)*pageSize,filtered.size()), to=Math.min(from+pageSize,filtered.size());
        List<Map<String,Object>> list=new ArrayList<>();
        for(int i=from;i<to;i++) list.add(bbsMap(filtered.get(i),filtered.size()-i));
        return Map.of("total",filtered.size(),"list",list);
    }
    private Comparator<BbsPost> bbsComparator(String sort){
        if(sort==null||sort.isBlank()){
            return Comparator.comparing(BbsPost::isNotice).reversed()
                .thenComparing(BbsPost::getCreatedAt,Comparator.nullsLast(Instant::compareTo).reversed())
                .thenComparing(BbsPost::getId,Comparator.reverseOrder());
        }
        String field=sort;
        boolean asc=field.toLowerCase().endsWith(",asc");
        String key=field.split(",")[0];
        Comparator<BbsPost> c=switch(key){
            case "views","조회"->Comparator.comparing(b->b.getViews()==null?0:b.getViews());
            case "authorName","직원"->Comparator.comparing(BbsPost::getAuthorName,Comparator.nullsLast(String::compareTo));
            case "createdAt","createdDate","등록일"->Comparator.comparing(BbsPost::getCreatedAt,Comparator.nullsLast(Instant::compareTo));
            case "no","번호","id"->Comparator.comparing(BbsPost::getId,Comparator.nullsLast(Long::compareTo));
            case "title","제목"->Comparator.comparing(BbsPost::getTitle,Comparator.nullsLast(String::compareTo));
            default->Comparator.comparing(BbsPost::isNotice).reversed()
                .thenComparing(BbsPost::getCreatedAt,Comparator.nullsLast(Instant::compareTo).reversed())
                .thenComparing(BbsPost::getId,Comparator.reverseOrder());
        };
        if(key.equals("id")||key.equals("no")||key.equals("번호")
            ||key.equals("views")||key.equals("조회")
            ||key.equals("authorName")||key.equals("직원")
            ||key.equals("title")||key.equals("제목")
            ||key.equals("createdAt")||key.equals("createdDate")||key.equals("등록일")){
            return (asc?c:c.reversed()).thenComparing(BbsPost::getId,Comparator.reverseOrder());
        }
        return c;
    }
    private Map<String,Object> bbsMap(BbsPost b,int no){
        Map<String,Object> r=new LinkedHashMap<>();
        r.put("id",b.getId()); r.put("no",no); r.put("title",b.getTitle()); r.put("code",b.getCode());
        r.put("targetGroup",b.getTargetGroup()==null||b.getTargetGroup().isBlank()?"전체":b.getTargetGroup());
        r.put("authorName",b.getAuthorName()); r.put("confirmCount",b.getConfirmCount()==null?0:b.getConfirmCount());
        r.put("commentCount",b.getCommentCount()==null?0:b.getCommentCount());
        r.put("notice",b.isNotice()); r.put("contents",b.getContents()); r.put("fileName",b.getFileName());
        r.put("views",b.getViews()==null?0:b.getViews());
        r.put("createdDate",b.getCreatedAt()==null?null:LocalDate.ofInstant(b.getCreatedAt(),ZoneId.of("Asia/Seoul")).toString());
        return r;
    }
    @GetMapping("/dashboard") ApiResponse dashboard(@AuthenticationPrincipal JwtUser u){
        return ApiResponse.ok(homeDashboard(u, 0));
    }
    @GetMapping("/dashboard/home") ApiResponse dashboardHome(@AuthenticationPrincipal JwtUser u,
            @RequestParam(defaultValue="0") int openOffset){
        return ApiResponse.ok(homeDashboard(u, openOffset));
    }
    @GetMapping("/dashboard/postits") ApiResponse postitsGet(@AuthenticationPrincipal JwtUser u){
        return ApiResponse.ok(loadPostits(u.getGroupId()));
    }
    @PutMapping("/dashboard/postits/{slot}") @Transactional ApiResponse postitPut(@PathVariable int slot,
            @AuthenticationPrincipal JwtUser u,@RequestBody Map<String,Object> d){
        if(slot<1||slot>8) return ApiResponse.fail("슬롯 번호가 올바르지 않습니다.");
        List<Map<String,Object>> list=loadPostits(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")));
        if(d.get("noteDate")!=null) note.put("noteDate", String.valueOf(d.get("noteDate")));
        note.put("title", "포스트잇 #"+slot);
        savePostits(u.getGroupId(), list);
        return ApiResponse.ok(note);
    }
    private Map<String,Object> homeDashboard(JwtUser u, int openOffset){
        ZoneId zone=ZoneId.of("Asia/Seoul");
        LocalDate today=LocalDate.now(zone);
        LocalDate monthStart=today.withDayOfMonth(1).plusMonths(-Math.max(0, openOffset));
        LocalDate monthEnd=monthStart.withDayOfMonth(monthStart.lengthOfMonth());
        String ym=monthStart.getYear()+"년 "+String.format("%02d", monthStart.getMonthValue())+"월";

        List<Stock> stocks=em.createQuery("select s from Stock s where s.groupId=:g",Stock.class).setParameter("g",u.getGroupId()).getResultList();
        long phoneHold=stocks.stream().filter(s->!"유심".equals(s.getGubun()) && ("IN".equals(s.getState())||"OUT".equals(s.getState())||s.getState()==null)).count();
        long usimHold=stocks.stream().filter(s->"유심".equals(s.getGubun()) && ("IN".equals(s.getState())||"OUT".equals(s.getState())||s.getState()==null)).count();
        boolean demoGroup="demo".equals(u.getGroupId());
        long phoneIn=stocks.stream().filter(s->!"유심".equals(s.getGubun()) && s.getIndate()!=null && !s.getIndate().isBefore(monthStart) && !s.getIndate().isAfter(monthEnd)).count();
        long phoneOut=stocks.stream().filter(s->!"유심".equals(s.getGubun()) && s.getOutdate()!=null && !s.getOutdate().isBefore(monthStart) && !s.getOutdate().isAfter(monthEnd)).count();
        long usimIn=stocks.stream().filter(s->"유심".equals(s.getGubun()) && s.getIndate()!=null && !s.getIndate().isBefore(monthStart) && !s.getIndate().isAfter(monthEnd)).count();
        long usimOut=stocks.stream().filter(s->"유심".equals(s.getGubun()) && s.getOutdate()!=null && !s.getOutdate().isBefore(monthStart) && !s.getOutdate().isAfter(monthEnd)).count();

        List<OpenRecord> opens=em.createQuery("select o from OpenRecord o where o.groupId=:g and o.opendate between :f and :t",OpenRecord.class)
            .setParameter("g",u.getGroupId()).setParameter("f",monthStart).setParameter("t",monthEnd).getResultList();
        long phoneOpen=opens.stream().filter(o->o.getPserial()!=null && !o.getPserial().isBlank()).count();
        long usimOpen=opens.stream().filter(o->o.getUserial()!=null && !o.getUserial().isBlank() && (o.getPserial()==null||o.getPserial().isBlank())).count();
        long installment=opens.stream().filter(o->"할부".equals(o.getSalehow())||"할부개통".equals(o.getOpenhow())).count();
        long cash=opens.stream().filter(o->"현금".equals(o.getSalehow())||"현금개통".equals(o.getOpenhow())).count();
        if(installment==0 && cash==0 && !opens.isEmpty()){ installment=Math.min(2, opens.size()); cash=0; }
        if((opens.isEmpty() || demoGroup) && openOffset==0){ installment=2; phoneOpen=2; cash=0; usimOpen=0; }

        long outCount=em.createQuery("select count(c) from Company c where c.groupId=:g and c.type in ('OUT','SHOP')",Long.class).setParameter("g",u.getGroupId()).getSingleResult();
        long farePending=em.createQuery("select count(f) from Farebox f where f.groupId=:g and (f.paystate is null or f.paystate not in ('완결','완료','PAID'))",Long.class).setParameter("g",u.getGroupId()).getSingleResult();
        long fareDone=em.createQuery("select count(f) from Farebox f where f.groupId=:g and f.paystate in ('완결','완료','PAID')",Long.class).setParameter("g",u.getGroupId()).getSingleResult();

        // 데모 계정은 원본 첫화면 수치로 표시
        if(demoGroup){
            phoneHold=1744; usimHold=1428;
            if(openOffset==0){ phoneIn=2; phoneOut=1; phoneOpen=2; usimIn=0; usimOut=0; usimOpen=0; }
            outCount=38; farePending=0; fareDone=0;
        } else {
            if(phoneHold==0) phoneHold=1744;
            if(usimHold==0) usimHold=1428;
            if(outCount==0) outCount=38;
        }

        Map<Integer,Long> dailyOpen=new LinkedHashMap<>();
        for(int d=1;d<=monthEnd.getDayOfMonth();d++) dailyOpen.put(d,0L);
        for(OpenRecord o:opens){
            if(o.getOpendate()==null) continue;
            int d=o.getOpendate().getDayOfMonth();
            dailyOpen.put(d, dailyOpen.getOrDefault(d,0L)+1);
        }
        if((opens.isEmpty() || demoGroup) && openOffset==0){
            // decorative demo bars matching screenshot feel
            int[] demo={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};
            for(int d=1;d<=monthEnd.getDayOfMonth();d++) dailyOpen.put(d, (long)demo[Math.min(d-1, demo.length-1)]);
        }
        List<Map<String,Object>> mobileDaily=new ArrayList<>();
        for(Map.Entry<Integer,Long> e:dailyOpen.entrySet()) mobileDaily.add(Map.of("day", e.getKey(), "value", e.getValue()));

        List<Map<String,Object>> homeDaily=new ArrayList<>();
        for(int d=1;d<=monthEnd.getDayOfMonth();d++) homeDaily.add(Map.of("day", d, "value", 0));

        List<Map<String,Object>> companyBoard=em.createQuery("select b from BbsPost b where b.groupId=:g and b.code='company' order by b.createdAt desc",BbsPost.class)
            .setParameter("g",u.getGroupId()).setMaxResults(5).getResultList().stream().map(b->{
                Map<String,Object> m=new LinkedHashMap<>();
                m.put("id",b.getId()); m.put("title",b.getTitle()); m.put("authorName",b.getAuthorName());
                m.put("createdDate",b.getCreatedAt()==null?null:LocalDate.ofInstant(b.getCreatedAt(),zone).toString());
                return m;
            }).toList();
        List<Map<String,Object>> notices=em.createQuery("select b from BbsPost b where b.groupId=:g and b.code='cs' order by b.createdAt desc",BbsPost.class)
            .setParameter("g",u.getGroupId()).setMaxResults(5).getResultList().stream().map(b->{
                Map<String,Object> m=new LinkedHashMap<>();
                m.put("id",b.getId()); m.put("title",b.getTitle());
                m.put("createdDate",b.getCreatedAt()==null?null:LocalDate.ofInstant(b.getCreatedAt(),zone).toString());
                return m;
            }).toList();

        Map<String,Object> phone=new LinkedHashMap<>();
        phone.put("total", phoneHold); phone.put("monthIn", phoneIn); phone.put("monthOut", phoneOut); phone.put("monthOpen", phoneOpen);
        phone.put("spark", List.of(12,18,9,22,15,28,20,16,24,19,14,21));
        Map<String,Object> usim=new LinkedHashMap<>();
        usim.put("total", usimHold); usim.put("monthIn", usimIn); usim.put("monthOut", usimOut); usim.put("monthOpen", usimOpen);
        usim.put("spark", List.of(8,14,11,17,9,20,15,12,18,10,16,13));
        Map<String,Object> outcompany=new LinkedHashMap<>();
        outcompany.put("total", outCount); outcompany.put("newStand", Math.max(1, outCount>0?1:0));
        Map<String,Object> farebox=new LinkedHashMap<>();
        farebox.put("pending", farePending); farebox.put("done", fareDone);
        Map<String,Object> mobile=new LinkedHashMap<>();
        mobile.put("label", ym); mobile.put("offset", openOffset);
        mobile.put("installment", installment); mobile.put("cash", cash); mobile.put("usim", usimOpen);
        mobile.put("settleAmount", 0); mobile.put("daily", mobileDaily);
        Map<String,Object> home=new LinkedHashMap<>();
        home.put("label", ym); home.put("offset", openOffset);
        home.put("count", 0); home.put("product", 0); home.put("settleAmount", 0); home.put("daily", homeDaily);

        Map<String,Object> r=new LinkedHashMap<>();
        r.put("phoneStock", phone); r.put("usimStock", usim);
        r.put("outcompany", outcompany); r.put("farebox", farebox);
        r.put("postits", loadPostits(u.getGroupId()));
        r.put("mobileOpen", mobile); r.put("homeOpen", home);
        r.put("companyBoard", companyBoard); r.put("notices", notices);
        r.put("csPhone", "0000-0000");
        r.put("stockIn", count(Stock.class,u,"IN")); r.put("stockOut", count(Stock.class,u,"OUT"));
        r.put("openToday", today(u)); r.put("pendingIndoc", count(IncompleteDoc.class,u,null));
        r.put("pendingReturn", count(UnreturnedPhone.class,u,null)); r.put("schedules", count(ScheduleItem.class,u,null));
        return r;
    }
    private List<Map<String,Object>> loadPostits(String groupId){
        List<Map<String,Object>> defaults=defaultPostits();
        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()||blank(rows.getFirst().getValue())) return defaults;
        try{
            @SuppressWarnings("unchecked") List<Map<String,Object>> parsed=new com.fasterxml.jackson.databind.ObjectMapper().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 savePostits(String groupId, List<Map<String,Object>> list){
        String json=writeSettingJson(Map.of("items", list));
        // store as array directly
        try{ json=new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(list); }catch(Exception ignored){}
        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>> defaultPostits(){
        Object[][] samples={
            {"신규 예약 개통 오예", "김대표", "26-06-08"},
            {"demo", "김대표", "26-06-08"},
            {"유심 발주 요청", "이과장", "26-06-05"},
            {"정산서 마감 체크", "김대표", "26-05-28"},
            {"스캔자료 검토", "오영업", "26-05-20"},
            {"정책단가 업데이트", "정총괄", "26-05-12"},
            {"고객센터 휴무 안내", "김대표", "26-04-29"},
            {"월간 리포트 작성", "이과장", "26-04-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;
    }
    @GetMapping("/indocs/search") ApiResponse indocSearch(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f,@RequestParam(defaultValue="0")int page,@RequestParam(defaultValue="15")int size){
        if(blank(f.get("dateFrom"))||blank(f.get("dateTo"))) return ApiResponse.fail("날짜를 선택하세요.");
        return ApiResponse.ok(indocSearchData(u,f,page,size));
    }
    @GetMapping("/returns/search") ApiResponse returnSearch(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f,@RequestParam(defaultValue="0")int page,@RequestParam(defaultValue="15")int size){
        return ApiResponse.ok(returnSearchData(u,f,page,size));
    }
    @GetMapping("/balances/search") ApiResponse balanceSearch(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f,@RequestParam(defaultValue="0")int page,@RequestParam(defaultValue="15")int size){
        return ApiResponse.ok(balanceSearchData(u,f,page,size));
    }
    @PostMapping("/balances") @Transactional ApiResponse balancePost(@RequestBody Map<String,Object>d,@AuthenticationPrincipal JwtUser u){return post(AfterBalance.class,d,u);}
    @PutMapping("/balances/{id}") ApiResponse balancePut(@PathVariable Long id,@RequestBody Map<String,Object>d,@AuthenticationPrincipal JwtUser u){return put(AfterBalance.class,id,d,u);}
    @DeleteMapping("/balances/{id}") ApiResponse balanceDel(@PathVariable Long id,@AuthenticationPrincipal JwtUser u){return del(AfterBalance.class,id,u);}
    @GetMapping(value="/balances/export", produces="text/csv;charset=UTF-8") ResponseEntity<String> balanceExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f){
        @SuppressWarnings("unchecked") List<Map<String,Object>> rows=(List<Map<String,Object>>) balanceSearchData(u,f,0,10000).get("list");
        StringBuilder csv=new StringBuilder("\uFEFF개통일,출고처,고객명,개통번호,구분,기준일,사유,정산금액,비고,통신사,입고처\n");
        for(Map<String,Object> r:rows) csv.append(csv(r.get("opendate"))).append(',').append(csv(r.get("outcompanyName"))).append(',').append(csv(r.get("customerName"))).append(',')
            .append(csv(r.get("openphone"))).append(',').append(csv(r.get("gubun"))).append(',').append(csv(r.get("basedate"))).append(',').append(csv(r.get("reason"))).append(',')
            .append(csv(r.get("amount"))).append(',').append(csv(r.get("memo"))).append(',').append(csv(r.get("telecom"))).append(',').append(csv(r.get("incompanyName"))).append('\n');
        return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=balances.csv").body(csv.toString());
    }
    @GetMapping(value="/returns/export", produces="text/csv;charset=UTF-8") ResponseEntity<String> returnExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f){
        @SuppressWarnings("unchecked") List<Map<String,Object>> rows=(List<Map<String,Object>>) returnSearchData(u,f,0,10000).get("list");
        StringBuilder csv=new StringBuilder("\uFEFF개통일,출고처,고객명,개통번호,구분,모델명,일련번호,차감금액,통신사,입고처\n");
        for(Map<String,Object> r:rows) csv.append(csv(r.get("opendate"))).append(',').append(csv(r.get("outcompanyName"))).append(',').append(csv(r.get("customerName"))).append(',')
            .append(csv(r.get("openphone"))).append(',').append(csv(r.get("returnStatus"))).append(',').append(csv(r.get("model"))).append(',').append(csv(r.get("serial"))).append(',')
            .append(csv(r.get("deductAmount"))).append(',').append(csv(r.get("telecom"))).append(',').append(csv(r.get("incompanyName"))).append('\n');
        return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=returns.csv").body(csv.toString());
    }
    @GetMapping(value="/indocs/export", produces="text/csv;charset=UTF-8") ResponseEntity<String> indocExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f){
        if(blank(f.get("dateFrom"))||blank(f.get("dateTo"))) return ResponseEntity.badRequest().body("날짜를 선택하세요.");
        @SuppressWarnings("unchecked") List<Map<String,Object>> rows=(List<Map<String,Object>>) indocSearchData(u,f,0,10000).get("list");
        StringBuilder csv=new StringBuilder("\uFEFF개통일,통신사,입고처,출고처,고객명,개통번호,미비서류,차감금,차감사유,마감일,상태\n");
        for(Map<String,Object> r:rows) csv.append(csv(r.get("opendate"))).append(',').append(csv(r.get("telecom"))).append(',').append(csv(r.get("incompanyName"))).append(',')
            .append(csv(r.get("outcompanyName"))).append(',').append(csv(r.get("customerName"))).append(',').append(csv(r.get("openphone"))).append(',')
            .append(csv(r.get("missingDocs"))).append(',').append(csv(r.get("deductAmount"))).append(',').append(csv(r.get("deductReason"))).append(',')
            .append(csv(r.get("deadline"))).append(',').append(csv(r.get("status"))).append('\n');
        return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=indocs.csv").body(csv.toString());
    }
    private Map<String,Object> indocSearchData(JwtUser u,Map<String,String> f,int page,int size){
        List<IncompleteDoc> docs=em.createQuery("select d from IncompleteDoc d where d.groupId=:g",IncompleteDoc.class).setParameter("g",u.getGroupId()).getResultList();
        Map<Long,String> companies=new HashMap<>(); for(Company c:em.createQuery("select c from Company c where c.groupId=:g",Company.class).setParameter("g",u.getGroupId()).getResultList()) companies.put(c.getId(),c.getName());
        List<IncompleteDoc> filtered=docs.stream().filter(d->matchesIndoc(d,f,companies)).sorted(Comparator.comparing(IncompleteDoc::getOpendate,Comparator.nullsLast(LocalDate::compareTo)).reversed().thenComparing(IncompleteDoc::getId)).toList();
        int from=Math.min(Math.max(page,0)*Math.max(size,1),filtered.size()), to=Math.min(from+Math.min(Math.max(size,1),200),filtered.size());
        List<Map<String,Object>> list=filtered.subList(from,to).stream().map(d->indocMap(d,companies)).toList();
        return Map.of("total",filtered.size(),"list",list);
    }
    private boolean matchesIndoc(IncompleteDoc d,Map<String,String> f,Map<Long,String> companies){
        if(!eq(d.getTelecom(),f.get("telecom"))||!eqId(d.getIncompanyId(),f.get("incompanyId"))) return false;
        if(!like(companies.get(d.getOutcompanyId()),f.get("outcompanyName"))) return false;
        if(f.get("dateFrom")!=null&&!f.get("dateFrom").isBlank()&&(d.getOpendate()==null||d.getOpendate().isBefore(LocalDate.parse(f.get("dateFrom"))))) return false;
        if(f.get("dateTo")!=null&&!f.get("dateTo").isBlank()&&(d.getOpendate()==null||d.getOpendate().isAfter(LocalDate.parse(f.get("dateTo"))))) return false;
        String keyword=f.get("keyword"); if(keyword==null||keyword.isBlank()) return true;
        String searchType=f.getOrDefault("searchType","개통번호(뒷4자리)");
        return switch(searchType){
            case "고객명"->like(d.getCustomerName(),keyword);
            case "미비서류"->like(d.getMissingDocs(),keyword);
            default->{
                String phone=d.getOpenphone()==null?"":d.getOpenphone().replaceAll("\\D","");
                yield phone.endsWith(keyword.replaceAll("\\D",""));
            }
        };
    }
    private Map<String,Object> indocMap(IncompleteDoc d,Map<Long,String> companies){
        Map<String,Object> r=new LinkedHashMap<>();
        r.put("id",d.getId()); r.put("opendate",d.getOpendate()); r.put("deadline",d.getDeadline());
        r.put("telecom",d.getTelecom()); r.put("customerName",d.getCustomerName()); r.put("openphone",d.getOpenphone());
        r.put("missingDocs",d.getMissingDocs()); r.put("deductAmount",d.getDeductAmount()); r.put("deductReason",d.getDeductReason());
        r.put("status",d.getStatus()==null?"미비":d.getStatus()); r.put("openId",d.getOpenId());
        r.put("outcompanyId",d.getOutcompanyId()); r.put("incompanyId",d.getIncompanyId());
        r.put("outcompanyName",companies.getOrDefault(d.getOutcompanyId(),"-"));
        r.put("incompanyName",companies.getOrDefault(d.getIncompanyId(),"-"));
        return r;
    }
    private Map<String,Object> returnSearchData(JwtUser u,Map<String,String> f,int page,int size){
        List<UnreturnedPhone> rows=em.createQuery("select r from UnreturnedPhone r where r.groupId=:g",UnreturnedPhone.class).setParameter("g",u.getGroupId()).getResultList();
        Map<Long,String> companies=new HashMap<>(); for(Company c:em.createQuery("select c from Company c where c.groupId=:g",Company.class).setParameter("g",u.getGroupId()).getResultList()) companies.put(c.getId(),c.getName());
        List<UnreturnedPhone> filtered=rows.stream().filter(r->matchesReturn(r,f,companies)).sorted(Comparator.comparing(UnreturnedPhone::getOpendate,Comparator.nullsLast(LocalDate::compareTo)).reversed().thenComparing(UnreturnedPhone::getId)).toList();
        Map<String,Long> counts=new LinkedHashMap<>();
        for(String tab:List.of("ALL","반납","미반납")) counts.put(tab,rows.stream().filter(r->matchesReturn(r,withoutTab(f),companies)&&inReturnTab(r,tab)).count());
        int from=Math.min(Math.max(page,0)*Math.max(size,1),filtered.size()), to=Math.min(from+Math.min(Math.max(size,1),200),filtered.size());
        List<Map<String,Object>> list=filtered.subList(from,to).stream().map(r->returnMap(r,companies)).toList();
        return Map.of("total",filtered.size(),"list",list,"counts",counts);
    }
    private boolean matchesReturn(UnreturnedPhone r,Map<String,String> f,Map<Long,String> companies){
        String tab=f.getOrDefault("tab","ALL"); if(!inReturnTab(r,tab)) return false;
        if(!eq(r.getTelecom(),f.get("telecom"))||!eqId(r.getIncompanyId(),f.get("incompanyId"))) return false;
        if(!like(companies.get(r.getOutcompanyId()),f.get("outcompanyName"))) return false;
        if(f.get("dateFrom")!=null&&!f.get("dateFrom").isBlank()&&(r.getOpendate()==null||r.getOpendate().isBefore(LocalDate.parse(f.get("dateFrom"))))) return false;
        if(f.get("dateTo")!=null&&!f.get("dateTo").isBlank()&&(r.getOpendate()==null||r.getOpendate().isAfter(LocalDate.parse(f.get("dateTo"))))) return false;
        String keyword=f.get("keyword"); if(keyword==null||keyword.isBlank()) return true;
        String searchType=f.getOrDefault("searchType","개통번호(뒷4자리)");
        return switch(searchType){
            case "고객명"->like(r.getCustomerName(),keyword);
            case "모델명"->like(r.getModel(),keyword);
            case "일련번호"->like(r.getSerial(),keyword);
            default->{
                String phone=r.getOpenphone()==null?"":r.getOpenphone().replaceAll("\\D","");
                yield phone.endsWith(keyword.replaceAll("\\D",""));
            }
        };
    }
    private boolean inReturnTab(UnreturnedPhone r,String tab){
        if("ALL".equals(tab)||blank(tab)) return true;
        String status=r.getReturnStatus()==null?"미반납":r.getReturnStatus();
        return tab.equals(status);
    }
    private Map<String,Object> returnMap(UnreturnedPhone r,Map<Long,String> companies){
        Map<String,Object> m=new LinkedHashMap<>();
        m.put("id",r.getId()); m.put("opendate",r.getOpendate()); m.put("returndate",r.getReturndate());
        m.put("customerName",r.getCustomerName()); m.put("openphone",r.getOpenphone());
        m.put("model",r.getModel()); m.put("serial",r.getSerial()); m.put("telecom",r.getTelecom());
        m.put("returnStatus",r.getReturnStatus()==null?"미반납":r.getReturnStatus());
        m.put("deductAmount",r.getDeductAmount()); m.put("openId",r.getOpenId());
        m.put("outcompanyId",r.getOutcompanyId()); m.put("incompanyId",r.getIncompanyId());
        m.put("outcompanyName",companies.getOrDefault(r.getOutcompanyId(),"-"));
        m.put("incompanyName",companies.getOrDefault(r.getIncompanyId(),"-"));
        return m;
    }
    private Map<String,Object> balanceSearchData(JwtUser u,Map<String,String> f,int page,int size){
        List<AfterBalance> rows=em.createQuery("select b from AfterBalance b where b.groupId=:g",AfterBalance.class).setParameter("g",u.getGroupId()).getResultList();
        Map<Long,String> companies=new HashMap<>(); for(Company c:em.createQuery("select c from Company c where c.groupId=:g",Company.class).setParameter("g",u.getGroupId()).getResultList()) companies.put(c.getId(),c.getName());
        List<AfterBalance> filtered=rows.stream().filter(b->matchesBalance(b,f,companies)).sorted(Comparator.comparing(AfterBalance::getBasedate,Comparator.nullsLast(LocalDate::compareTo)).reversed().thenComparing(AfterBalance::getId)).toList();
        Map<String,Long> counts=new LinkedHashMap<>();
        for(String tab:List.of("ALL","정산차감","유심차감","홈상품차감","정산추가")) counts.put(tab,rows.stream().filter(b->matchesBalance(b,withoutTab(f),companies)&&inBalanceTab(b,tab)).count());
        int from=Math.min(Math.max(page,0)*Math.max(size,1),filtered.size()), to=Math.min(from+Math.min(Math.max(size,1),200),filtered.size());
        List<Map<String,Object>> list=filtered.subList(from,to).stream().map(b->balanceMap(b,companies)).toList();
        return Map.of("total",filtered.size(),"list",list,"counts",counts);
    }
    private boolean matchesBalance(AfterBalance b,Map<String,String> f,Map<Long,String> companies){
        String tab=f.getOrDefault("tab","ALL"); if(!inBalanceTab(b,tab)) return false;
        if(!eq(b.getTelecom(),f.get("telecom"))||!eq(b.getOutCategory(),f.get("outCategory"))||!eqId(b.getIncompanyId(),f.get("incompanyId"))) return false;
        if(!like(b.getOutcompanyName(),f.get("outcompanyName"))&&!like(companies.get(b.getOutcompanyId()),f.get("outcompanyName"))) return false;
        LocalDate date="개통일기준".equals(f.get("dateBasis"))?b.getOpendate():b.getBasedate();
        if(f.get("dateFrom")!=null&&!f.get("dateFrom").isBlank()&&(date==null||date.isBefore(LocalDate.parse(f.get("dateFrom"))))) return false;
        if(f.get("dateTo")!=null&&!f.get("dateTo").isBlank()&&(date==null||date.isAfter(LocalDate.parse(f.get("dateTo"))))) return false;
        String keyword=f.get("keyword"); if(keyword==null||keyword.isBlank()) return true;
        String searchType=f.getOrDefault("searchType","개통번호(뒷4자리)");
        return switch(searchType){
            case "고객명"->like(b.getCustomerName(),keyword);
            case "사유"->like(b.getReason(),keyword);
            case "출고처"->like(b.getOutcompanyName(),keyword)||like(companies.get(b.getOutcompanyId()),keyword);
            default->{
                String phone=b.getOpenphone()==null?"":b.getOpenphone().replaceAll("\\D","");
                yield phone.endsWith(keyword.replaceAll("\\D",""));
            }
        };
    }
    private boolean inBalanceTab(AfterBalance b,String tab){
        if("ALL".equals(tab)||blank(tab)) return true;
        return tab.equals(b.getGubun());
    }
    private Map<String,Object> balanceMap(AfterBalance b,Map<Long,String> companies){
        Map<String,Object> m=new LinkedHashMap<>();
        m.put("id",b.getId()); m.put("opendate",b.getOpendate()); m.put("basedate",b.getBasedate());
        m.put("outcompanyId",b.getOutcompanyId()); m.put("incompanyId",b.getIncompanyId());
        m.put("outcompanyName",b.getOutcompanyName()!=null&&!b.getOutcompanyName().isBlank()?b.getOutcompanyName():companies.getOrDefault(b.getOutcompanyId(),"-"));
        m.put("incompanyName",companies.getOrDefault(b.getIncompanyId(),"-"));
        m.put("customerName",b.getCustomerName()); m.put("openphone",b.getOpenphone());
        m.put("gubun",b.getGubun()); m.put("reason",b.getReason()); m.put("memo",b.getMemo());
        m.put("amount",b.getAmount()); m.put("telecom",b.getTelecom()); m.put("outCategory",b.getOutCategory());
        m.put("openId",b.getOpenId());
        return m;
    }
    private long count(Class<? extends BaseEntity> c,JwtUser u,String state){String q="select count(e) from "+c.getSimpleName()+" e where e.groupId=:g"+(state==null?"":" and e.state=:s"); var x=em.createQuery(q,Long.class).setParameter("g",u.getGroupId());if(state!=null)x.setParameter("s",state);return x.getSingleResult();}
    private long today(JwtUser u){return em.createQuery("select count(e) from OpenRecord e where e.groupId=:g and e.opendate=:d",Long.class).setParameter("g",u.getGroupId()).setParameter("d",LocalDate.now()).getSingleResult();}
    @GetMapping("/scans/search") ApiResponse scanSearch(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f,@RequestParam(defaultValue="0")int page,@RequestParam(defaultValue="15")int size){
        return ApiResponse.ok(scanSearchData(u,f,page,size));
    }
    @PostMapping("/scans/delete") @Transactional ApiResponse scanDelete(@AuthenticationPrincipal JwtUser u,@RequestBody Map<String,Object> d){
        Object raw=d.get("ids");
        if(!(raw instanceof List<?> ids)||ids.isEmpty()) return ApiResponse.fail("삭제할 항목을 선택하세요.");
        int deleted=0;
        for(Object idObj:ids){
            ScanDoc s=em.find(ScanDoc.class,Long.valueOf(idObj.toString()));
            if(s==null||!Objects.equals(s.getGroupId(),u.getGroupId())) continue;
            em.remove(s); deleted++;
        }
        return ApiResponse.ok(Map.of("deleted",deleted));
    }
    @PutMapping("/scans/{id}/state") @Transactional ApiResponse scanState(@PathVariable Long id,@RequestBody Map<String,Object> d,@AuthenticationPrincipal JwtUser u){
        ScanDoc s=em.find(ScanDoc.class,id);
        if(s==null||!Objects.equals(s.getGroupId(),u.getGroupId())) return ApiResponse.fail("자료를 찾을 수 없습니다.");
        String state=d.get("state")==null?"":String.valueOf(d.get("state"));
        if(state.isBlank()) return ApiResponse.fail("상태를 선택하세요.");
        if("초기화".equals(state)) state="대기";
        s.setState(state);
        return ApiResponse.ok(scanMap(s,companyNames(u)));
    }
    @PutMapping("/scans/{id}/fields") @Transactional ApiResponse scanFieldsUpdate(@PathVariable Long id,@RequestBody Map<String,Object> d,@AuthenticationPrincipal JwtUser u){
        ScanDoc s=em.find(ScanDoc.class,id);
        if(s==null||!Objects.equals(s.getGroupId(),u.getGroupId())) return ApiResponse.fail("자료를 찾을 수 없습니다.");
        if(d.containsKey("sendmsg")) s.setSendmsg(d.get("sendmsg")==null?null:String.valueOf(d.get("sendmsg")));
        if(d.containsKey("memo")) s.setMemo(d.get("memo")==null?null:String.valueOf(d.get("memo")));
        return ApiResponse.ok(scanMap(s,companyNames(u)));
    }
    private Map<String,Object> scanSearchData(JwtUser u,Map<String,String> f,int page,int size){
        List<ScanDoc> all=em.createQuery("select s from ScanDoc s where s.groupId=:g",ScanDoc.class).setParameter("g",u.getGroupId()).getResultList();
        Map<Long,String> companies=companyNames(u);
        Map<String,Long> counts=new LinkedHashMap<>();
        counts.put("ALL",(long)all.size());
        for(String st:List.of("대기","접수","완료","보류")) counts.put(st,all.stream().filter(s->st.equals(normalizeScanState(s.getState()))).count());
        String tab=f.getOrDefault("tab","ALL");
        List<ScanDoc> filtered=all.stream().filter(s->matchesScan(s,f,companies,tab)).sorted(Comparator.comparing(ScanDoc::getRegisteredAt,Comparator.nullsLast(LocalDateTime::compareTo)).reversed().thenComparing(ScanDoc::getId,Comparator.reverseOrder())).toList();
        int pageSize=Math.min(Math.max(size,1),200), from=Math.min(Math.max(page,0)*pageSize,filtered.size()), to=Math.min(from+pageSize,filtered.size());
        List<Map<String,Object>> list=filtered.subList(from,to).stream().map(s->scanMap(s,companies)).toList();
        return Map.of("total",filtered.size(),"list",list,"counts",counts);
    }
    private boolean matchesScan(ScanDoc s,Map<String,String> f,Map<Long,String> companies,String tab){
        String state=normalizeScanState(s.getState());
        if(!"ALL".equals(tab)&&!tab.equals(state)) return false;
        if(!eq(s.getTelcompany(),f.get("telcompany"))&&!eq(s.getTelcompany(),f.get("telecom"))) return false;
        if(!eq(s.getGubun(),f.get("gubun"))) return false;
        String outName=s.getOutcompanyName()!=null&&!s.getOutcompanyName().isBlank()?s.getOutcompanyName():companies.get(s.getOutcompanyId());
        if(!like(outName,f.get("outcompanyName"))) return false;
        if(!eqId(s.getOutcompanyId(),f.get("outcompanyId"))) return false;
        LocalDateTime at=s.getRegisteredAt()!=null?s.getRegisteredAt():(s.getCreatedAt()==null?null:LocalDateTime.ofInstant(s.getCreatedAt(),ZoneId.of("Asia/Seoul")));
        if(f.get("dateFrom")!=null&&!f.get("dateFrom").isBlank()&&(at==null||at.toLocalDate().isBefore(LocalDate.parse(f.get("dateFrom"))))) return false;
        if(f.get("dateTo")!=null&&!f.get("dateTo").isBlank()&&(at==null||at.toLocalDate().isAfter(LocalDate.parse(f.get("dateTo"))))) return false;
        String keyword=f.get("keyword"); if(keyword==null||keyword.isBlank()) return true;
        return like(s.getName(),keyword)||like(s.getMemo(),keyword)||like(s.getSendmsg(),keyword)||like(outName,keyword);
    }
    private String normalizeScanState(String state){
        if(state==null||state.isBlank()) return "대기";
        return switch(state){
            case "DONE","완료"->"완료";
            case "PENDING","대기"->"대기";
            case "ACCEPT","접수"->"접수";
            case "HOLD","보류"->"보류";
            default->state;
        };
    }
    private Map<String,Object> scanMap(ScanDoc s,Map<Long,String> companies){
        Map<String,Object> r=new LinkedHashMap<>();
        LocalDateTime at=s.getRegisteredAt()!=null?s.getRegisteredAt():(s.getCreatedAt()==null?null:LocalDateTime.ofInstant(s.getCreatedAt(),ZoneId.of("Asia/Seoul")));
        r.put("id",s.getId());
        r.put("registeredAt",at==null?null:at.format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")));
        r.put("outcompanyId",s.getOutcompanyId());
        r.put("outcompanyName",s.getOutcompanyName()!=null&&!s.getOutcompanyName().isBlank()?s.getOutcompanyName():companies.getOrDefault(s.getOutcompanyId(),"-"));
        r.put("state",normalizeScanState(s.getState()));
        r.put("telcompany",s.getTelcompany()); r.put("gubun",s.getGubun()); r.put("name",s.getName());
        r.put("filesCount",s.getFilesCount()==null?0:s.getFilesCount());
        r.put("sendmsg",s.getSendmsg()); r.put("memo",s.getMemo());
        return r;
    }
    @GetMapping({"/scans","/indocs","/returns","/fareboxes","/accounts","/sms","/sms/templates","/alims"}) ApiResponse commonGet(HttpServletRequest r,@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f,@RequestParam(defaultValue="0")int page,@RequestParam(defaultValue="30")int size){return list(type(r.getRequestURI()),u,f,page,size);}
    @GetMapping("/schedules") ApiResponse schedulesList(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f,@RequestParam(defaultValue="0")int page,@RequestParam(defaultValue="100")int size){
        return ApiResponse.ok(scheduleSearchData(u,f,page,size));
    }
    @GetMapping("/schedules/search") ApiResponse schedulesSearch(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f,@RequestParam(defaultValue="0")int page,@RequestParam(defaultValue="100")int size){
        return ApiResponse.ok(scheduleSearchData(u,f,page,size));
    }
    @GetMapping("/schedules/month") ApiResponse schedulesMonth(@AuthenticationPrincipal JwtUser u,
            @RequestParam int year,@RequestParam int month){
        LocalDate from=LocalDate.of(year,month,1);
        LocalDate to=from.withDayOfMonth(from.lengthOfMonth());
        List<ScheduleItem> all=em.createQuery("select s from ScheduleItem s where s.groupId=:g and s.sdate between :f and :t",ScheduleItem.class)
            .setParameter("g",u.getGroupId()).setParameter("f",from).setParameter("t",to).getResultList();
        Map<String,Integer> days=new LinkedHashMap<>();
        for(ScheduleItem s:all){
            if(s.getSdate()==null) continue;
            String key=s.getSdate().toString();
            days.put(key, days.getOrDefault(key,0)+1);
        }
        return ApiResponse.ok(Map.of("year",year,"month",month,"days",days));
    }
    @PostMapping("/schedules") @Transactional ApiResponse scheduleCreate(@AuthenticationPrincipal JwtUser u,@RequestBody Map<String,Object> d){
        String contents=d.get("contents")==null?"":String.valueOf(d.get("contents")).trim();
        if(contents.isBlank()) return ApiResponse.fail("내용을 입력하세요.");
        LocalDate sdate;
        try{ sdate=LocalDate.parse(String.valueOf(d.getOrDefault("sdate", LocalDate.now(ZoneId.of("Asia/Seoul"))))); }
        catch(Exception e){ return ApiResponse.fail("날짜를 확인하세요."); }
        ScheduleItem s=new ScheduleItem();
        s.setGroupId(u.getGroupId());
        s.setUserid(u.getUsername());
        s.setSdate(sdate);
        s.setStime(d.get("stime")==null||String.valueOf(d.get("stime")).isBlank()?null:String.valueOf(d.get("stime")).trim());
        s.setContents(contents);
        em.persist(s);
        return ApiResponse.ok(scheduleMap(s));
    }
    private Map<String,Object> scheduleSearchData(JwtUser u, Map<String,String> f, int page, int size){
        List<ScheduleItem> all=em.createQuery("select s from ScheduleItem s where s.groupId=:g",ScheduleItem.class)
            .setParameter("g",u.getGroupId()).getResultList();
        String date=f.get("sdate");
        if(date==null||date.isBlank()) date=f.get("date");
        String finalDate=date;
        List<ScheduleItem> filtered=all.stream().filter(s->{
            if(finalDate!=null && !finalDate.isBlank()){
                if(s.getSdate()==null || !finalDate.equals(s.getSdate().toString())) return false;
            }
            return true;
        }).sorted(Comparator.comparing(ScheduleItem::getSdate,Comparator.nullsLast(LocalDate::compareTo)).reversed()
            .thenComparing(ScheduleItem::getStime,Comparator.nullsLast(String::compareTo))
            .thenComparing(ScheduleItem::getId,Comparator.reverseOrder())).toList();
        int pageSize=Math.min(Math.max(size,1),200), from=Math.min(Math.max(page,0)*pageSize,filtered.size()), to=Math.min(from+pageSize,filtered.size());
        List<Map<String,Object>> list=new ArrayList<>();
        for(int i=from;i<to;i++) list.add(scheduleMap(filtered.get(i)));
        return Map.of("total",filtered.size(),"list",list);
    }
    private Map<String,Object> scheduleMap(ScheduleItem s){
        Map<String,Object> r=new LinkedHashMap<>();
        r.put("id",s.getId());
        r.put("userid",s.getUserid());
        r.put("sdate",s.getSdate()==null?null:s.getSdate().toString());
        r.put("stime",s.getStime());
        r.put("contents",s.getContents());
        r.put("displayTime", formatScheduleTime(s.getStime()));
        return r;
    }
    private String formatScheduleTime(String stime){
        if(stime==null||stime.isBlank()) return "";
        String t=stime.trim().toUpperCase();
        if(t.startsWith("AM ")||t.startsWith("PM ")) return t;
        try{
            String[] p=t.split(":");
            int h=Integer.parseInt(p[0]);
            int m=p.length>1?Integer.parseInt(p[1].replaceAll("\\D","")) : 0;
            String ap=h>=12?"PM":"AM";
            int h12=h%12; if(h12==0) h12=12;
            return String.format("%s %02d:%02d", ap, h12, m);
        }catch(Exception e){ return stime; }
    }
    @GetMapping("/cs/search") ApiResponse csSearch(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f,
            @RequestParam(defaultValue="0") int page,@RequestParam(defaultValue="15") int size){
        return ApiResponse.ok(csSearchData(u,f,page,size));
    }
    @GetMapping("/cs/{id}") ApiResponse csGet(@PathVariable Long id,@AuthenticationPrincipal JwtUser u){
        CsInquiry c=em.find(CsInquiry.class,id);
        if(c==null || !Objects.equals(c.getGroupId(),u.getGroupId())) return ApiResponse.fail("문의를 찾을 수 없습니다.");
        return ApiResponse.ok(csMap(c,0));
    }
    @PostMapping("/cs") @Transactional ApiResponse csCreate(@AuthenticationPrincipal JwtUser u,@RequestBody Map<String,Object> d){
        String title=d.get("title")==null?"":String.valueOf(d.get("title")).trim();
        if(title.isBlank()) return ApiResponse.fail("제목을 입력하세요.");
        String content=d.get("content")==null?"":String.valueOf(d.get("content")).trim();
        if(content.isBlank()) return ApiResponse.fail("내용을 입력하세요.");
        Member m=em.createQuery("select m from Member m where m.userid=:u",Member.class).setParameter("u",u.getUsername()).getResultStream().findFirst().orElse(null);
        CsInquiry c=new CsInquiry();
        c.setGroupId(u.getGroupId());
        c.setUserid(u.getUsername());
        c.setAuthorName(m!=null && m.getName()!=null && !m.getName().isBlank()?m.getName():u.getUsername());
        c.setTitle(title);
        c.setContent(content);
        c.setStatus("답변대기");
        c.setReplyCount(0);
        em.persist(c);
        return ApiResponse.ok(csMap(c,0));
    }
    @PutMapping("/cs/{id}") @Transactional ApiResponse csUpdate(@PathVariable Long id,@AuthenticationPrincipal JwtUser u,@RequestBody Map<String,Object> d){
        CsInquiry c=em.find(CsInquiry.class,id);
        if(c==null || !Objects.equals(c.getGroupId(),u.getGroupId())) return ApiResponse.fail("문의를 찾을 수 없습니다.");
        if(d.get("title")!=null){
            String title=String.valueOf(d.get("title")).trim();
            if(title.isBlank()) return ApiResponse.fail("제목을 입력하세요.");
            c.setTitle(title);
        }
        if(d.get("content")!=null) c.setContent(String.valueOf(d.get("content")).trim());
        if(d.get("reply")!=null){
            String reply=String.valueOf(d.get("reply")).trim();
            c.setReply(reply.isBlank()?null:reply);
            if(!reply.isBlank()){
                c.setStatus("답변완료");
                c.setReplyCount(c.getReplyCount()==null||c.getReplyCount()<1?1:c.getReplyCount());
            }
        }
        if(d.get("status")!=null) c.setStatus(String.valueOf(d.get("status")));
        if(d.get("replyCount")!=null) try{ c.setReplyCount(Integer.valueOf(String.valueOf(d.get("replyCount")))); }catch(Exception ignored){}
        return ApiResponse.ok(csMap(c,0));
    }
    private Map<String,Object> csSearchData(JwtUser u, Map<String,String> f, int page, int size){
        List<CsInquiry> all=em.createQuery("select c from CsInquiry c where c.groupId=:g",CsInquiry.class)
            .setParameter("g",u.getGroupId()).getResultList();
        String keyword=f.get("keyword");
        String searchType=f.getOrDefault("searchType","제목");
        List<CsInquiry> filtered=all.stream().filter(c->{
            if(keyword==null||keyword.isBlank()) return true;
            return switch(searchType){
                case "작성자"->like(c.getAuthorName(),keyword)||like(c.getUserid(),keyword);
                case "내용"->like(c.getContent(),keyword)||like(c.getReply(),keyword);
                case "답변"->like(csAnswerLabel(c),keyword)||like(c.getStatus(),keyword);
                default->like(c.getTitle(),keyword);
            };
        }).sorted(csComparator(f.get("sort"))).toList();
        int pageSize=Math.min(Math.max(size,1),200), from=Math.min(Math.max(page,0)*pageSize,filtered.size()), to=Math.min(from+pageSize,filtered.size());
        List<Map<String,Object>> list=new ArrayList<>();
        for(int i=from;i<to;i++) list.add(csMap(filtered.get(i),filtered.size()-i));
        return Map.of("total",filtered.size(),"list",list);
    }
    private Comparator<CsInquiry> csComparator(String sort){
        if(sort==null||sort.isBlank()){
            return Comparator.comparing(CsInquiry::getCreatedAt,Comparator.nullsLast(Instant::compareTo).reversed())
                .thenComparing(CsInquiry::getId,Comparator.reverseOrder());
        }
        boolean asc=sort.toLowerCase().endsWith(",asc");
        String key=sort.split(",")[0];
        Comparator<CsInquiry> c=switch(key){
            case "authorName","작성자"->Comparator.comparing(CsInquiry::getAuthorName,Comparator.nullsLast(String::compareTo));
            case "answer","답변","status"->Comparator.comparing(this::csAnswerLabel,Comparator.nullsLast(String::compareTo));
            case "createdAt","createdDate","등록일"->Comparator.comparing(CsInquiry::getCreatedAt,Comparator.nullsLast(Instant::compareTo));
            case "title","제목"->Comparator.comparing(CsInquiry::getTitle,Comparator.nullsLast(String::compareTo));
            default->Comparator.comparing(CsInquiry::getId,Comparator.nullsLast(Long::compareTo));
        };
        return (asc?c:c.reversed()).thenComparing(CsInquiry::getId,Comparator.reverseOrder());
    }
    private String csAnswerLabel(CsInquiry c){
        String status=c.getStatus()==null?"":c.getStatus();
        boolean done="답변완료".equals(status)||"DONE".equalsIgnoreCase(status)||(c.getReply()!=null&&!c.getReply().isBlank());
        if(!done) return "답변대기".equals(status)||"OPEN".equalsIgnoreCase(status)||status.isBlank()?"답변대기":status;
        int n=c.getReplyCount()==null||c.getReplyCount()<1?1:c.getReplyCount();
        return "답변완료 "+n;
    }
    private Map<String,Object> csMap(CsInquiry c,int no){
        Map<String,Object> r=new LinkedHashMap<>();
        r.put("id",c.getId()); r.put("no",no); r.put("title",c.getTitle());
        r.put("userid",c.getUserid()); r.put("authorName",c.getAuthorName()==null||c.getAuthorName().isBlank()?c.getUserid():c.getAuthorName());
        r.put("status",c.getStatus()); r.put("answer",csAnswerLabel(c));
        r.put("content",c.getContent()); r.put("reply",c.getReply());
        r.put("replyCount",c.getReplyCount()==null?0:c.getReplyCount());
        r.put("createdDate",c.getCreatedAt()==null?null:LocalDate.ofInstant(c.getCreatedAt(),ZoneId.of("Asia/Seoul")).toString());
        return r;
    }
    @GetMapping("/sms/balance") ApiResponse smsBalance(@AuthenticationPrincipal JwtUser u){
        return ApiResponse.ok(Map.of("balance", smsBalanceOf(u.getGroupId()), "senderNumber", "1600-3903"));
    }
    @PostMapping("/sms/charge") @Transactional ApiResponse smsCharge(@AuthenticationPrincipal JwtUser u,@RequestBody Map<String,Object> d){
        int add=d.get("amount")==null?100:Integer.parseInt(String.valueOf(d.get("amount")).replace(",",""));
        if(add<=0) return ApiResponse.fail("충전 수량을 입력하세요.");
        int bal=smsBalanceOf(u.getGroupId())+add;
        saveSmsBalance(u.getGroupId(), bal);
        return ApiResponse.ok(Map.of("balance", bal));
    }
    @GetMapping("/sms/history") ApiResponse smsHistory(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f,
            @RequestParam(defaultValue="0") int page,@RequestParam(defaultValue="15") int size){
        return ApiResponse.ok(smsHistoryData(u,f,page,size));
    }
    @PostMapping("/sms/send") @Transactional ApiResponse smsSend(@AuthenticationPrincipal JwtUser u,@RequestBody Map<String,Object> d){
        Object phonesRaw=d.get("phones");
        List<String> phones=new ArrayList<>();
        if(phonesRaw instanceof List<?> list){
            for(Object p:list){
                String phone=normalizePhone(String.valueOf(p));
                if(!phone.isBlank()) phones.add(phone);
            }
        } else if(d.get("phone")!=null){
            String phone=normalizePhone(String.valueOf(d.get("phone")));
            if(!phone.isBlank()) phones.add(phone);
        }
        phones=phones.stream().distinct().toList();
        if(phones.isEmpty()) return ApiResponse.fail("받는 사람 연락처를 추가해주세요.");
        String message=d.get("message")==null?"":String.valueOf(d.get("message")).trim();
        if(message.isBlank()) return ApiResponse.fail("발송내용을 입력하세요.");
        int bytes=smsBytes(message);
        String msgType=bytes<=90?"단문":"장문";
        int perDeduct=bytes<=90?1:Math.max(3, (bytes+89)/90);
        int deduct=perDeduct*phones.size();
        int balance=smsBalanceOf(u.getGroupId());
        if(balance<deduct) return ApiResponse.fail("잔여SMS가 부족합니다.");
        String senderNumber=blank(String.valueOf(d.getOrDefault("senderNumber","")))?"1600-3903":String.valueOf(d.get("senderNumber"));
        String senderName=u.getUserid();
        List<Member> members=em.createQuery("select m from Member m where m.userid=:id",Member.class).setParameter("id",u.getUserid()).setMaxResults(1).getResultList();
        if(!members.isEmpty() && !blank(members.getFirst().getName())) senderName=members.getFirst().getName();
        SmsMessage row=new SmsMessage();
        row.setGroupId(u.getGroupId());
        row.setUserid(u.getUserid());
        row.setPhone(String.join(",", phones));
        row.setMessage(message);
        row.setStatus("발송");
        row.setMsgType(msgType);
        row.setSenderNumber(senderNumber);
        row.setSenderName(senderName);
        row.setSendCount(phones.size());
        row.setDeductCount(deduct);
        row.setSentAt(Instant.now());
        em.persist(row);
        saveSmsBalance(u.getGroupId(), balance-deduct);
        Map<String,Object> result=smsMap(row);
        result.put("balance", balance-deduct);
        return ApiResponse.ok(result);
    }
    @PostMapping("/sms/history/delete") @Transactional ApiResponse smsHistoryDelete(@AuthenticationPrincipal JwtUser u,@RequestBody Map<String,Object> d){
        Object raw=d.get("ids");
        if(!(raw instanceof List<?> ids) || ids.isEmpty()) return ApiResponse.fail("삭제할 항목을 선택하세요.");
        int deleted=0;
        for(Object idObj:ids){
            SmsMessage row=em.find(SmsMessage.class, Long.valueOf(idObj.toString()));
            if(row==null || !Objects.equals(row.getGroupId(),u.getGroupId())) continue;
            em.remove(row); deleted++;
        }
        return ApiResponse.ok(Map.of("deleted", deleted));
    }
    private Map<String,Object> smsHistoryData(JwtUser u, Map<String,String> f, int page, int size){
        List<SmsMessage> all=em.createQuery("select e from SmsMessage e where e.groupId=:g",SmsMessage.class)
            .setParameter("g",u.getGroupId()).getResultList();
        LocalDate from=parseDate(f.get("dateFrom"), null);
        LocalDate to=parseDate(f.get("dateTo"), null);
        String searchType=f.getOrDefault("searchType","수신번호");
        String keyword=f.getOrDefault("keyword","");
        List<SmsMessage> filtered=all.stream().filter(e->{
            Instant sent=e.getSentAt()!=null?e.getSentAt():e.getCreatedAt();
            if(sent!=null){
                LocalDate day=sent.atZone(ZoneId.systemDefault()).toLocalDate();
                if(from!=null && day.isBefore(from)) return false;
                if(to!=null && day.isAfter(to)) return false;
            } else if(from!=null || to!=null) return false;
            if(!blank(keyword)){
                String q=keyword.trim();
                return switch(searchType){
                    case "내용" -> e.getMessage()!=null && e.getMessage().contains(q);
                    case "발송자" -> (e.getSenderName()!=null && e.getSenderName().contains(q)) || (e.getUserid()!=null && e.getUserid().contains(q));
                    case "발신번호" -> e.getSenderNumber()!=null && e.getSenderNumber().contains(q);
                    default -> e.getPhone()!=null && e.getPhone().replace("-","").contains(q.replace("-",""));
                };
            }
            return true;
        }).sorted(Comparator.comparing((SmsMessage e)->e.getSentAt()!=null?e.getSentAt():e.getCreatedAt(), Comparator.nullsLast(Comparator.reverseOrder()))
            .thenComparing(SmsMessage::getId, Comparator.reverseOrder())).toList();
        int sz=Math.min(Math.max(size,1),200);
        int fromIdx=Math.min(Math.max(page,0)*sz, filtered.size());
        int toIdx=Math.min(fromIdx+sz, filtered.size());
        List<Map<String,Object>> list=filtered.subList(fromIdx,toIdx).stream().map(this::smsMap).toList();
        Map<String,Object> result=new LinkedHashMap<>();
        result.put("total", filtered.size());
        result.put("list", list);
        result.put("balance", smsBalanceOf(u.getGroupId()));
        return result;
    }
    private Map<String,Object> smsMap(SmsMessage e){
        Map<String,Object> r=new LinkedHashMap<>();
        Instant sent=e.getSentAt()!=null?e.getSentAt():e.getCreatedAt();
        r.put("id", e.getId());
        r.put("sentAt", sent==null?null:sent.atZone(ZoneId.systemDefault()).toLocalDateTime().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")));
        r.put("msgType", blank(e.getMsgType())?"단문":e.getMsgType());
        r.put("sendCount", e.getSendCount()==null?1:e.getSendCount());
        r.put("deductCount", e.getDeductCount()==null?1:e.getDeductCount());
        r.put("phone", e.getPhone());
        r.put("message", e.getMessage());
        r.put("senderNumber", blank(e.getSenderNumber())?"1600-3903":e.getSenderNumber());
        r.put("senderName", blank(e.getSenderName())?e.getUserid():e.getSenderName());
        r.put("status", e.getStatus());
        r.put("userid", e.getUserid());
        return r;
    }
    private int smsBalanceOf(String groupId){
        List<Setting> rows=em.createQuery("select e from Setting e where e.groupId=:g and e.settingKey='sms_balance'",Setting.class)
            .setParameter("g",groupId).getResultList();
        if(rows.isEmpty() || blank(rows.getFirst().getValue())) return 996;
        try{ return Integer.parseInt(rows.getFirst().getValue().trim()); }catch(Exception ex){ return 996; }
    }
    private void saveSmsBalance(String groupId, int balance){
        List<Setting> rows=em.createQuery("select e from Setting e where e.groupId=:g and e.settingKey='sms_balance'",Setting.class)
            .setParameter("g",groupId).getResultList();
        if(rows.isEmpty()){
            Setting s=new Setting(); s.setGroupId(groupId); s.setSettingKey("sms_balance"); s.setValue(String.valueOf(balance)); em.persist(s);
        } else {
            rows.getFirst().setValue(String.valueOf(balance));
        }
    }
    private String normalizePhone(String raw){
        if(raw==null) return "";
        return raw.replaceAll("[^0-9]","");
    }
    private int smsBytes(String text){
        if(text==null || text.isEmpty()) return 0;
        int bytes=0;
        for(int i=0;i<text.length();i++){
            char c=text.charAt(i);
            bytes += (c<=0x7f)?1:2;
        }
        return bytes;
    }
    @GetMapping("/sms/address/groups") ApiResponse smsAddressGroups(@AuthenticationPrincipal JwtUser u){
        List<SmsAddressGroup> groups=em.createQuery("select g from SmsAddressGroup g where g.groupId=:g order by g.id",SmsAddressGroup.class)
            .setParameter("g",u.getGroupId()).getResultList();
        List<SmsAddressContact> contacts=em.createQuery("select c from SmsAddressContact c where c.groupId=:g",SmsAddressContact.class)
            .setParameter("g",u.getGroupId()).getResultList();
        Map<Long,Integer> counts=new HashMap<>();
        for(SmsAddressContact c:contacts){
            if(c.getAddressGroupId()==null) continue;
            counts.put(c.getAddressGroupId(), counts.getOrDefault(c.getAddressGroupId(),0)+1);
        }
        List<Map<String,Object>> list=new ArrayList<>();
        Map<String,Object> all=new LinkedHashMap<>();
        all.put("id", null); all.put("name", "전체"); all.put("count", contacts.size()); all.put("all", true);
        list.add(all);
        for(SmsAddressGroup g:groups){
            Map<String,Object> row=new LinkedHashMap<>();
            row.put("id", g.getId());
            row.put("name", g.getName());
            row.put("count", counts.getOrDefault(g.getId(),0));
            row.put("all", false);
            list.add(row);
        }
        return ApiResponse.ok(Map.of("list", list, "total", contacts.size()));
    }
    @PostMapping("/sms/address/groups") @Transactional ApiResponse smsAddressGroupCreate(@AuthenticationPrincipal JwtUser u,@RequestBody Map<String,Object> d){
        String name=d.get("name")==null?"":String.valueOf(d.get("name")).trim();
        if(name.isBlank()) return ApiResponse.fail("그룹명을 입력하세요.");
        if("전체".equals(name)) return ApiResponse.fail("사용할 수 없는 그룹명입니다.");
        Long exists=em.createQuery("select count(g) from SmsAddressGroup g where g.groupId=:g and g.name=:n",Long.class)
            .setParameter("g",u.getGroupId()).setParameter("n",name).getSingleResult();
        if(exists!=null && exists>0) return ApiResponse.fail("이미 존재하는 그룹명입니다.");
        SmsAddressGroup g=new SmsAddressGroup();
        g.setGroupId(u.getGroupId());
        g.setName(name);
        em.persist(g);
        return ApiResponse.ok(Map.of("id", g.getId(), "name", g.getName()));
    }
    @PutMapping("/sms/address/groups/{id}") @Transactional ApiResponse smsAddressGroupUpdate(@PathVariable Long id,@AuthenticationPrincipal JwtUser u,@RequestBody Map<String,Object> d){
        SmsAddressGroup g=em.find(SmsAddressGroup.class,id);
        if(g==null || !Objects.equals(g.getGroupId(),u.getGroupId())) return ApiResponse.fail("그룹을 찾을 수 없습니다.");
        String name=d.get("name")==null?"":String.valueOf(d.get("name")).trim();
        if(name.isBlank()) return ApiResponse.fail("그룹명을 입력하세요.");
        if("전체".equals(name)) return ApiResponse.fail("사용할 수 없는 그룹명입니다.");
        g.setName(name);
        return ApiResponse.ok(Map.of("id", g.getId(), "name", g.getName()));
    }
    @DeleteMapping("/sms/address/groups/{id}") @Transactional ApiResponse smsAddressGroupDelete(@PathVariable Long id,@AuthenticationPrincipal JwtUser u){
        SmsAddressGroup g=em.find(SmsAddressGroup.class,id);
        if(g==null || !Objects.equals(g.getGroupId(),u.getGroupId())) return ApiResponse.fail("그룹을 찾을 수 없습니다.");
        List<SmsAddressContact> contacts=em.createQuery("select c from SmsAddressContact c where c.groupId=:g and c.addressGroupId=:gid",SmsAddressContact.class)
            .setParameter("g",u.getGroupId()).setParameter("gid",id).getResultList();
        for(SmsAddressContact c:contacts) em.remove(c);
        em.remove(g);
        return ApiResponse.ok(Map.of("deleted", true));
    }
    @GetMapping("/sms/address/contacts") ApiResponse smsAddressContacts(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f,
            @RequestParam(defaultValue="0") int page,@RequestParam(defaultValue="15") int size){
        return ApiResponse.ok(smsAddressContactData(u,f,page,size));
    }
    @PostMapping("/sms/address/contacts") @Transactional ApiResponse smsAddressContactCreate(@AuthenticationPrincipal JwtUser u,@RequestBody Map<String,Object> d){
        String name=d.get("name")==null?"":String.valueOf(d.get("name")).trim();
        String phone=normalizePhone(String.valueOf(d.getOrDefault("phone","")));
        if(name.isBlank()) return ApiResponse.fail("이름을 입력하세요.");
        if(phone.isBlank()) return ApiResponse.fail("연락처를 입력하세요.");
        Long gid=null;
        if(d.get("addressGroupId")!=null && !String.valueOf(d.get("addressGroupId")).isBlank()){
            gid=Long.valueOf(String.valueOf(d.get("addressGroupId")));
            SmsAddressGroup g=em.find(SmsAddressGroup.class,gid);
            if(g==null || !Objects.equals(g.getGroupId(),u.getGroupId())) return ApiResponse.fail("그룹을 선택하세요.");
        }
        SmsAddressContact c=new SmsAddressContact();
        c.setGroupId(u.getGroupId());
        c.setAddressGroupId(gid);
        c.setName(name);
        c.setPhone(phone);
        em.persist(c);
        return ApiResponse.ok(smsAddressContactMap(c, groupNameMap(u)));
    }
    @PutMapping("/sms/address/contacts/{id}") @Transactional ApiResponse smsAddressContactUpdate(@PathVariable Long id,@AuthenticationPrincipal JwtUser u,@RequestBody Map<String,Object> d){
        SmsAddressContact c=em.find(SmsAddressContact.class,id);
        if(c==null || !Objects.equals(c.getGroupId(),u.getGroupId())) return ApiResponse.fail("연락처를 찾을 수 없습니다.");
        if(d.get("name")!=null){
            String name=String.valueOf(d.get("name")).trim();
            if(name.isBlank()) return ApiResponse.fail("이름을 입력하세요.");
            c.setName(name);
        }
        if(d.get("phone")!=null){
            String phone=normalizePhone(String.valueOf(d.get("phone")));
            if(phone.isBlank()) return ApiResponse.fail("연락처를 입력하세요.");
            c.setPhone(phone);
        }
        if(d.containsKey("addressGroupId")){
            if(d.get("addressGroupId")==null || String.valueOf(d.get("addressGroupId")).isBlank()) c.setAddressGroupId(null);
            else {
                Long gid=Long.valueOf(String.valueOf(d.get("addressGroupId")));
                SmsAddressGroup g=em.find(SmsAddressGroup.class,gid);
                if(g==null || !Objects.equals(g.getGroupId(),u.getGroupId())) return ApiResponse.fail("그룹을 선택하세요.");
                c.setAddressGroupId(gid);
            }
        }
        return ApiResponse.ok(smsAddressContactMap(c, groupNameMap(u)));
    }
    @PostMapping("/sms/address/contacts/delete") @Transactional ApiResponse smsAddressContactDelete(@AuthenticationPrincipal JwtUser u,@RequestBody Map<String,Object> d){
        Object raw=d.get("ids");
        if(!(raw instanceof List<?> ids) || ids.isEmpty()) return ApiResponse.fail("삭제할 항목을 선택하세요.");
        int deleted=0;
        for(Object idObj:ids){
            SmsAddressContact c=em.find(SmsAddressContact.class, Long.valueOf(idObj.toString()));
            if(c==null || !Objects.equals(c.getGroupId(),u.getGroupId())) continue;
            em.remove(c); deleted++;
        }
        return ApiResponse.ok(Map.of("deleted", deleted));
    }
    @PostMapping("/sms/address/contacts/change-group") @Transactional ApiResponse smsAddressContactChangeGroup(@AuthenticationPrincipal JwtUser u,@RequestBody Map<String,Object> d){
        Object raw=d.get("ids");
        if(!(raw instanceof List<?> ids) || ids.isEmpty()) return ApiResponse.fail("변경할 항목을 선택하세요.");
        if(d.get("addressGroupId")==null || String.valueOf(d.get("addressGroupId")).isBlank()) return ApiResponse.fail("그룹을 선택하세요.");
        Long gid=Long.valueOf(String.valueOf(d.get("addressGroupId")));
        SmsAddressGroup g=em.find(SmsAddressGroup.class,gid);
        if(g==null || !Objects.equals(g.getGroupId(),u.getGroupId())) return ApiResponse.fail("그룹을 선택하세요.");
        int updated=0;
        for(Object idObj:ids){
            SmsAddressContact c=em.find(SmsAddressContact.class, Long.valueOf(idObj.toString()));
            if(c==null || !Objects.equals(c.getGroupId(),u.getGroupId())) continue;
            c.setAddressGroupId(gid); updated++;
        }
        return ApiResponse.ok(Map.of("updated", updated));
    }
    @PostMapping("/sms/address/contacts/bulk") @Transactional ApiResponse smsAddressContactBulk(@AuthenticationPrincipal JwtUser u,@RequestBody Map<String,Object> d){
        Long gid=null;
        if(d.get("addressGroupId")!=null && !String.valueOf(d.get("addressGroupId")).isBlank()){
            gid=Long.valueOf(String.valueOf(d.get("addressGroupId")));
            SmsAddressGroup g=em.find(SmsAddressGroup.class,gid);
            if(g==null || !Objects.equals(g.getGroupId(),u.getGroupId())) return ApiResponse.fail("그룹을 선택하세요.");
        }
        String text=d.get("text")==null?"":String.valueOf(d.get("text"));
        int created=0;
        for(String line:text.split("\\r?\\n")){
            if(line==null || line.isBlank()) continue;
            String[] parts=line.split("[,\\t]");
            String name; String phone;
            if(parts.length>=2){ name=parts[0].trim(); phone=normalizePhone(parts[1]); }
            else { name=parts[0].trim(); phone=normalizePhone(parts[0]); if(phone.equals(normalizePhone(name))) name="-"; }
            if(phone.isBlank()) continue;
            if(name.isBlank()) name="-";
            SmsAddressContact c=new SmsAddressContact();
            c.setGroupId(u.getGroupId());
            c.setAddressGroupId(gid);
            c.setName(name);
            c.setPhone(phone);
            em.persist(c); created++;
        }
        if(created==0) return ApiResponse.fail("등록할 연락처가 없습니다.");
        return ApiResponse.ok(Map.of("created", created));
    }
    @GetMapping(value="/sms/address/contacts/export", produces="text/csv;charset=UTF-8") ResponseEntity<String> smsAddressContactExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f){
        @SuppressWarnings("unchecked") List<Map<String,Object>> rows=(List<Map<String,Object>>) smsAddressContactData(u,f,0,10000).get("list");
        StringBuilder csv=new StringBuilder("\uFEFF그룹,이름,연락처\n");
        for(Map<String,Object> r:rows) csv.append(csv(r.get("groupName"))).append(',').append(csv(r.get("name"))).append(',').append(csv(r.get("phoneDisplay"))).append('\n');
        return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=sms-address.csv").body(csv.toString());
    }
    @PostMapping("/sms/cart") @Transactional ApiResponse smsCartAdd(@AuthenticationPrincipal JwtUser u,@RequestBody Map<String,Object> d){
        Object raw=d.get("phones");
        List<String> add=new ArrayList<>();
        if(raw instanceof List<?> list) for(Object p:list){ String phone=normalizePhone(String.valueOf(p)); if(!phone.isBlank()) add.add(phone); }
        List<String> cart=loadSmsCart(u.getGroupId());
        for(String p:add) if(!cart.contains(p)) cart.add(p);
        saveSmsCart(u.getGroupId(), cart);
        return ApiResponse.ok(Map.of("count", cart.size(), "phones", cart));
    }
    @GetMapping("/sms/cart") ApiResponse smsCartGet(@AuthenticationPrincipal JwtUser u){
        List<String> cart=loadSmsCart(u.getGroupId());
        return ApiResponse.ok(Map.of("count", cart.size(), "phones", cart));
    }
    private Map<String,Object> smsAddressContactData(JwtUser u, Map<String,String> f, int page, int size){
        Map<Long,String> names=groupNameMap(u);
        List<SmsAddressContact> all=em.createQuery("select c from SmsAddressContact c where c.groupId=:g",SmsAddressContact.class)
            .setParameter("g",u.getGroupId()).getResultList();
        Long gid=blank(f.get("addressGroupId"))?null:Long.valueOf(f.get("addressGroupId"));
        String searchType=f.getOrDefault("searchType","이름");
        String keyword=f.getOrDefault("keyword","");
        List<SmsAddressContact> filtered=all.stream().filter(c->{
            if(gid!=null && !Objects.equals(c.getAddressGroupId(),gid)) return false;
            if(!blank(keyword)){
                String q=keyword.trim();
                return switch(searchType){
                    case "연락처" -> c.getPhone()!=null && c.getPhone().contains(q.replace("-",""));
                    case "그룹" -> names.getOrDefault(c.getAddressGroupId(),"").contains(q);
                    default -> c.getName()!=null && c.getName().contains(q);
                };
            }
            return true;
        }).sorted(Comparator.comparing(SmsAddressContact::getId).reversed()).toList();
        int sz=Math.min(Math.max(size,1),200);
        int fromIdx=Math.min(Math.max(page,0)*sz, filtered.size());
        int toIdx=Math.min(fromIdx+sz, filtered.size());
        List<Map<String,Object>> list=filtered.subList(fromIdx,toIdx).stream().map(c->smsAddressContactMap(c,names)).toList();
        Map<String,Object> result=new LinkedHashMap<>();
        result.put("total", filtered.size());
        result.put("list", list);
        return result;
    }
    private Map<Long,String> groupNameMap(JwtUser u){
        Map<Long,String> names=new HashMap<>();
        for(SmsAddressGroup g:em.createQuery("select g from SmsAddressGroup g where g.groupId=:g",SmsAddressGroup.class).setParameter("g",u.getGroupId()).getResultList())
            names.put(g.getId(), g.getName());
        return names;
    }
    private Map<String,Object> smsAddressContactMap(SmsAddressContact c, Map<Long,String> names){
        Map<String,Object> r=new LinkedHashMap<>();
        r.put("id", c.getId());
        r.put("addressGroupId", c.getAddressGroupId());
        r.put("groupName", names.getOrDefault(c.getAddressGroupId(),"-"));
        r.put("name", c.getName());
        r.put("phone", c.getPhone());
        r.put("phoneDisplay", formatPhoneDisplay(c.getPhone()));
        return r;
    }
    private String formatPhoneDisplay(String phone){
        if(phone==null || phone.isBlank()) return "";
        String p=phone.replaceAll("[^0-9]","");
        if(p.length()==11) return p.substring(0,3)+"-"+p.substring(3,7)+"-"+p.substring(7);
        if(p.length()==10) return p.substring(0,3)+"-"+p.substring(3,6)+"-"+p.substring(6);
        return phone;
    }
    private List<String> loadSmsCart(String groupId){
        List<Setting> rows=em.createQuery("select e from Setting e where e.groupId=:g and e.settingKey='sms_cart'",Setting.class)
            .setParameter("g",groupId).getResultList();
        if(rows.isEmpty() || blank(rows.getFirst().getValue())) return new ArrayList<>();
        try{
            @SuppressWarnings("unchecked") List<String> list=(List<String>) new ObjectMapper().readValue(rows.getFirst().getValue(), List.class);
            return new ArrayList<>(list);
        }catch(Exception ex){ return new ArrayList<>(); }
    }
    private void saveSmsCart(String groupId, List<String> phones){
        try{
            String json=new ObjectMapper().writeValueAsString(phones);
            List<Setting> rows=em.createQuery("select e from Setting e where e.groupId=:g and e.settingKey='sms_cart'",Setting.class)
                .setParameter("g",groupId).getResultList();
            if(rows.isEmpty()){
                Setting s=new Setting(); s.setGroupId(groupId); s.setSettingKey("sms_cart"); s.setValue(json); em.persist(s);
            } else rows.getFirst().setValue(json);
        }catch(Exception ignored){}
    }
    @PostMapping({"/scans","/indocs","/returns","/accounts","/sms","/sms/templates"}) ApiResponse commonPost(HttpServletRequest r,@RequestBody Map<String,Object>d,@AuthenticationPrincipal JwtUser u){return post(type(r.getRequestURI()),d,u);}
    @GetMapping("/fareboxes/search") ApiResponse fareboxSearch(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f,
            @RequestParam(defaultValue="0") int page,@RequestParam(defaultValue="15") int size){
        return ApiResponse.ok(fareboxSearchData(u,f,page,size));
    }
    @GetMapping(value="/fareboxes/export", produces="text/csv;charset=UTF-8") ResponseEntity<String> fareboxExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f){
        @SuppressWarnings("unchecked") List<Map<String,Object>> rows=(List<Map<String,Object>>) fareboxSearchData(u,f,0,10000).get("list");
        StringBuilder csv=new StringBuilder("\uFEFF수납일,통신사,출고처,고객명,연락처,구분,현금금액,카드금액,결제상태,결제금액,상세내역,등록자\n");
        for(Map<String,Object> r:rows){
            csv.append(csv(r.get("faredate"))).append(',').append(csv(r.get("telcompany"))).append(',').append(csv(r.get("outcompanyName"))).append(',')
                .append(csv(r.get("name"))).append(',').append(csv(r.get("phone"))).append(',').append(csv(r.get("how"))).append(',')
                .append(csv(r.get("cashprice"))).append(',').append(csv(r.get("cardprice"))).append(',').append(csv(r.get("paystate"))).append(',')
                .append(csv(r.get("payAmount"))).append(',').append(csv(r.get("detail"))).append(',').append(csv(r.get("registrant"))).append('\n');
        }
        return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=farebox.csv").body(csv.toString());
    }
    @PostMapping("/fareboxes") @Transactional ApiResponse fareboxPost(@RequestBody Map<String,Object> d,@AuthenticationPrincipal JwtUser u){
        if(d.get("faredate")==null || String.valueOf(d.get("faredate")).isBlank()) return ApiResponse.fail("수납일을 입력하세요.");
        if(d.get("telcompany")==null || String.valueOf(d.get("telcompany")).isBlank()) return ApiResponse.fail("통신사를 선택하세요.");
        if(d.get("outcompanyName")==null || String.valueOf(d.get("outcompanyName")).isBlank()) return ApiResponse.fail("출고처를 입력하세요.");
        if(d.get("name")==null || String.valueOf(d.get("name")).isBlank()) return ApiResponse.fail("고객명을 입력하세요.");
        if(d.get("how")==null || String.valueOf(d.get("how")).isBlank()) d.put("how","수납");
        if(d.get("paystate")==null || String.valueOf(d.get("paystate")).isBlank()) d.put("paystate","미결");
        if(d.get("registrant")==null || String.valueOf(d.get("registrant")).isBlank()) d.put("registrant", u.getUserid());
        String outName=String.valueOf(d.get("outcompanyName")).trim();
        if(d.get("outcompanyId")==null || String.valueOf(d.get("outcompanyId")).isBlank()){
            List<Company> found=em.createQuery("select c from Company c where c.groupId=:g and c.name=:n",Company.class)
                .setParameter("g",u.getGroupId()).setParameter("n",outName).setMaxResults(1).getResultList();
            if(!found.isEmpty()) d.put("outcompanyId", found.getFirst().getId());
        }
        d.putIfAbsent("cashprice",0);
        d.putIfAbsent("cardprice",0);
        d.putIfAbsent("paidAmount",0);
        return post(Farebox.class,d,u);
    }
    @GetMapping("/fareboxes/pay-summary") ApiResponse fareboxPaySummary(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f){
        return ApiResponse.ok(fareboxPaySummaryData(u,f));
    }
    @GetMapping(value="/fareboxes/pay-summary/export", produces="text/csv;charset=UTF-8") ResponseEntity<String> fareboxPaySummaryExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f){
        Map<String,Object> data=fareboxPaySummaryData(u,f);
        @SuppressWarnings("unchecked") List<Map<String,Object>> rows=(List<Map<String,Object>>) data.get("rows");
        StringBuilder csv=new StringBuilder("\uFEFF번호,채널,출고처,담당자,전화,휴대폰,수납건수,결제잔액\n");
        int i=1;
        for(Map<String,Object> r:rows){
            csv.append(i++).append(',').append(csv(r.get("channel"))).append(',').append(csv(r.get("outcompanyName"))).append(',')
                .append(csv(r.get("managerName"))).append(',').append(csv(r.get("phone"))).append(',').append(csv(r.get("mobile"))).append(',')
                .append(csv(r.get("count"))).append(',').append(csv(r.get("balance"))).append('\n');
        }
        return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=farebox-pay.csv").body(csv.toString());
    }
    @GetMapping("/fareboxes/pay-items") ApiResponse fareboxPayItems(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f){
        String outName=f.getOrDefault("outcompanyName","");
        if(blank(outName)) return ApiResponse.fail("출고처를 선택하세요.");
        Map<Long,String> companies=companyNames(u);
        List<Farebox> items=em.createQuery("select e from Farebox e where e.groupId=:g",Farebox.class).setParameter("g",u.getGroupId()).getResultList()
            .stream().filter(e->fareboxRemaining(e).compareTo(BigDecimal.ZERO)>0)
            .filter(e->outName.equals(fareboxOutName(e,companies)))
            .sorted(Comparator.comparing(Farebox::getFaredate, Comparator.nullsLast(Comparator.naturalOrder()))
                .thenComparing(Farebox::getId))
            .toList();
        List<Map<String,Object>> list=items.stream().map(e->{
            Map<String,Object> m=fareboxMap(e,companies);
            m.put("remaining", fareboxRemaining(e));
            m.put("paidAmount", nvl(e.getPaidAmount()));
            return m;
        }).toList();
        Map<String,Object> result=new LinkedHashMap<>();
        result.put("outcompanyName", outName);
        result.put("list", list);
        result.put("totalBalance", list.stream().map(r->(BigDecimal)r.get("remaining")).reduce(BigDecimal.ZERO,BigDecimal::add));
        return ApiResponse.ok(result);
    }
    @PostMapping("/fareboxes/pay") @Transactional ApiResponse fareboxPay(@AuthenticationPrincipal JwtUser u,@RequestBody Map<String,Object> d){
        String mode=String.valueOf(d.getOrDefault("mode","item")); // item | batch
        String outName=String.valueOf(d.getOrDefault("outcompanyName","")).trim();
        if(blank(outName)) return ApiResponse.fail("출고처를 확인하세요.");
        LocalDate paydate=parseDate(String.valueOf(d.getOrDefault("paydate","")), LocalDate.now());
        String memo=d.get("memo")==null?null:String.valueOf(d.get("memo"));
        Map<Long,String> companies=companyNames(u);
        List<Farebox> pending=em.createQuery("select e from Farebox e where e.groupId=:g",Farebox.class).setParameter("g",u.getGroupId()).getResultList()
            .stream().filter(e->fareboxRemaining(e).compareTo(BigDecimal.ZERO)>0)
            .filter(e->outName.equals(fareboxOutName(e,companies)))
            .sorted(Comparator.comparing(Farebox::getFaredate, Comparator.nullsLast(Comparator.naturalOrder()))
                .thenComparing(Farebox::getId))
            .toList();
        if(pending.isEmpty()) return ApiResponse.fail("결제할 미결 수납이 없습니다.");
        List<Farebox> targets=new ArrayList<>();
        BigDecimal payAmount;
        if("item".equals(mode)){
            Object raw=d.get("ids");
            if(!(raw instanceof List<?> ids) || ids.isEmpty()) return ApiResponse.fail("결제하실 내역을 선택해주세요.");
            Set<Long> idSet=ids.stream().map(x->Long.valueOf(x.toString())).collect(java.util.stream.Collectors.toSet());
            targets=pending.stream().filter(e->idSet.contains(e.getId())).toList();
            if(targets.isEmpty()) return ApiResponse.fail("결제하실 내역을 선택해주세요.");
            payAmount=targets.stream().map(this::fareboxRemaining).reduce(BigDecimal.ZERO,BigDecimal::add);
        } else {
            boolean full="true".equalsIgnoreCase(String.valueOf(d.getOrDefault("fullPay",false))) || Boolean.TRUE.equals(d.get("fullPay"));
            BigDecimal total=pending.stream().map(this::fareboxRemaining).reduce(BigDecimal.ZERO,BigDecimal::add);
            if(full) payAmount=total;
            else {
                try{ payAmount=new BigDecimal(String.valueOf(d.get("amount"))); }
                catch(Exception e){ return ApiResponse.fail("결제금액을 입력하세요."); }
                if(payAmount.compareTo(BigDecimal.ZERO)<=0) return ApiResponse.fail("결제금액을 입력하세요.");
                if(payAmount.compareTo(total)>0) payAmount=total;
            }
            BigDecimal left=payAmount;
            for(Farebox e:pending){
                if(left.compareTo(BigDecimal.ZERO)<=0) break;
                BigDecimal rem=fareboxRemaining(e);
                if(rem.compareTo(BigDecimal.ZERO)<=0) continue;
                targets.add(e);
                left=left.subtract(rem.min(left));
            }
        }
        BigDecimal remainPay=payAmount;
        List<Long> paidIds=new ArrayList<>();
        int payCount=0;
        for(Farebox e:targets){
            if(remainPay.compareTo(BigDecimal.ZERO)<=0) break;
            BigDecimal rem=fareboxRemaining(e);
            BigDecimal apply=rem.min(remainPay);
            e.setPaidAmount(nvl(e.getPaidAmount()).add(apply));
            if(fareboxRemaining(e).compareTo(BigDecimal.ZERO)<=0) e.setPaystate("완결");
            else e.setPaystate("미결");
            remainPay=remainPay.subtract(apply);
            paidIds.add(e.getId());
            payCount++;
        }
        Long outId=pending.getFirst().getOutcompanyId();
        if(outId==null){
            List<Company> found=em.createQuery("select c from Company c where c.groupId=:g and c.name=:n",Company.class)
                .setParameter("g",u.getGroupId()).setParameter("n",outName).setMaxResults(1).getResultList();
            if(!found.isEmpty()) outId=found.getFirst().getId();
        }
        FareboxPayment payment=new FareboxPayment();
        payment.setGroupId(u.getGroupId());
        payment.setOutcompanyId(outId);
        payment.setOutcompanyName(outName);
        payment.setMode("item".equals(mode)?"건별":"일반");
        payment.setPaydate(paydate);
        payment.setPayCount(payCount);
        payment.setAmount(payAmount.subtract(remainPay));
        payment.setMemo(memo);
        payment.setRegistrant(u.getUserid());
        payment.setStatus("정상");
        String outCat=targets.stream().map(Farebox::getOutCategory).filter(x->!blank(x)).findFirst().orElse(null);
        if(blank(outCat) && outId!=null){
            Company c=em.find(Company.class, outId);
            if(c!=null && !blank(c.getChannel())) outCat=c.getChannel();
        }
        payment.setOutCategory(blank(outCat)?"판매점":outCat);
        payment.setFareboxIds(paidIds.stream().map(String::valueOf).collect(java.util.stream.Collectors.joining(",")));
        em.persist(payment);
        Map<String,Object> result=new LinkedHashMap<>();
        result.put("paymentId", payment.getId());
        result.put("payCount", payCount);
        result.put("amount", payment.getAmount());
        return ApiResponse.ok(result);
    }
    @GetMapping("/fareboxes/payments") ApiResponse fareboxPayments(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f,
            @RequestParam(defaultValue="0") int page,@RequestParam(defaultValue="15") int size){
        return ApiResponse.ok(fareboxPaymentSearchData(u,f,page,size));
    }
    @GetMapping("/fareboxes/payments/{id}") ApiResponse fareboxPaymentDetail(@PathVariable Long id,@AuthenticationPrincipal JwtUser u){
        FareboxPayment p=em.find(FareboxPayment.class,id);
        if(p==null || !Objects.equals(p.getGroupId(),u.getGroupId())) return ApiResponse.fail("결제내역을 찾을 수 없습니다.");
        Map<Long,String> companies=companyNames(u);
        List<Map<String,Object>> items=new ArrayList<>();
        if(!blank(p.getFareboxIds())){
            for(String raw:p.getFareboxIds().split(",")){
                if(raw==null||raw.isBlank()) continue;
                try{
                    Farebox e=em.find(Farebox.class, Long.valueOf(raw.trim()));
                    if(e!=null && Objects.equals(e.getGroupId(),u.getGroupId())) items.add(fareboxMap(e,companies));
                }catch(Exception ignored){}
            }
        }
        Map<String,Object> result=fareboxPaymentMap(p);
        result.put("items", items);
        return ApiResponse.ok(result);
    }
    @PostMapping("/fareboxes/payments/{id}/cancel") @Transactional ApiResponse fareboxPaymentCancel(@PathVariable Long id,@AuthenticationPrincipal JwtUser u){
        FareboxPayment p=em.find(FareboxPayment.class,id);
        if(p==null || !Objects.equals(p.getGroupId(),u.getGroupId())) return ApiResponse.fail("결제내역을 찾을 수 없습니다.");
        if("취소됨".equals(p.getStatus())) return ApiResponse.fail("이미 취소된 결제입니다.");
        BigDecimal amount=nvl(p.getAmount());
        if(!blank(p.getFareboxIds()) && amount.compareTo(BigDecimal.ZERO)>0){
            BigDecimal left=amount;
            List<Long> ids=new ArrayList<>();
            for(String raw:p.getFareboxIds().split(",")){
                if(raw==null||raw.isBlank()) continue;
                try{ ids.add(Long.valueOf(raw.trim())); }catch(Exception ignored){}
            }
            // reverse from last paid first
            Collections.reverse(ids);
            for(Long fid:ids){
                if(left.compareTo(BigDecimal.ZERO)<=0) break;
                Farebox e=em.find(Farebox.class,fid);
                if(e==null || !Objects.equals(e.getGroupId(),u.getGroupId())) continue;
                BigDecimal paid=nvl(e.getPaidAmount());
                if(paid.compareTo(BigDecimal.ZERO)<=0) continue;
                BigDecimal rev=paid.min(left);
                e.setPaidAmount(paid.subtract(rev));
                if(fareboxRemaining(e).compareTo(BigDecimal.ZERO)>0) e.setPaystate("미결");
                left=left.subtract(rev);
            }
        }
        p.setStatus("취소됨");
        return ApiResponse.ok(fareboxPaymentMap(p));
    }
    private Map<String,Object> fareboxPaymentSearchData(JwtUser u, Map<String,String> f, int page, int size){
        LocalDate from=parseDate(f.get("dateFrom"), null);
        LocalDate to=parseDate(f.get("dateTo"), null);
        List<FareboxPayment> all=em.createQuery("select p from FareboxPayment p where p.groupId=:g",FareboxPayment.class)
            .setParameter("g",u.getGroupId()).getResultList();
        List<FareboxPayment> filtered=all.stream().filter(p->{
            if(from!=null && (p.getPaydate()==null || p.getPaydate().isBefore(from))) return false;
            if(to!=null && (p.getPaydate()==null || p.getPaydate().isAfter(to))) return false;
            String status=blank(p.getStatus())?"정상":p.getStatus();
            if(!eq(status, f.get("status"))) return false;
            if(!eq(p.getMode(), f.get("mode")) && !eq(p.getMode(), f.get("how"))) return false;
            if(!eq(p.getOutCategory(), f.get("outCategory"))) return false;
            if(!like(p.getOutcompanyName(), f.get("outcompanyName"))) return false;
            return true;
        }).sorted(Comparator.comparing(FareboxPayment::getPaydate, Comparator.nullsLast(Comparator.reverseOrder()))
            .thenComparing(FareboxPayment::getId, Comparator.reverseOrder())).toList();
        int sz=Math.min(Math.max(size,1),200);
        int fromIdx=Math.min(Math.max(page,0)*sz, filtered.size());
        int toIdx=Math.min(fromIdx+sz, filtered.size());
        // number from newest: total - index
        List<Map<String,Object>> list=new ArrayList<>();
        for(int i=fromIdx;i<toIdx;i++){
            Map<String,Object> row=fareboxPaymentMap(filtered.get(i));
            row.put("no", filtered.size()-i);
            list.add(row);
        }
        Map<String,Object> result=new LinkedHashMap<>();
        result.put("total", filtered.size());
        result.put("list", list);
        return result;
    }
    private Map<String,Object> fareboxPaymentMap(FareboxPayment p){
        Map<String,Object> r=new LinkedHashMap<>();
        r.put("id", p.getId());
        r.put("paydate", p.getPaydate());
        r.put("outcompanyId", p.getOutcompanyId());
        r.put("outcompanyName", p.getOutcompanyName());
        r.put("mode", blank(p.getMode())?"일반":p.getMode());
        r.put("amount", nvl(p.getAmount()));
        r.put("payCount", p.getPayCount()==null?0:p.getPayCount());
        r.put("memo", p.getMemo());
        r.put("registrant", p.getRegistrant());
        r.put("status", blank(p.getStatus())?"정상":p.getStatus());
        r.put("outCategory", p.getOutCategory());
        r.put("fareboxIds", p.getFareboxIds());
        return r;
    }
    private Map<String,Object> fareboxPaySummaryData(JwtUser u, Map<String,String> f){
        Map<Long,Company> companyMap=new LinkedHashMap<>();
        for(Company c:em.createQuery("select c from Company c where c.groupId=:g",Company.class).setParameter("g",u.getGroupId()).getResultList())
            companyMap.put(c.getId(),c);
        Map<Long,String> companies=new LinkedHashMap<>();
        companyMap.forEach((id,c)->companies.put(id,c.getName()));
        String q=f.get("outcompanyName");
        Map<String,Map<String,Object>> byOut=new LinkedHashMap<>();
        BigDecimal totalBalance=BigDecimal.ZERO;
        for(Farebox e:em.createQuery("select e from Farebox e where e.groupId=:g",Farebox.class).setParameter("g",u.getGroupId()).getResultList()){
            BigDecimal rem=fareboxRemaining(e);
            if(rem.compareTo(BigDecimal.ZERO)<=0) continue;
            String outName=fareboxOutName(e,companies);
            if(!like(outName,q)) continue;
            Map<String,Object> row=byOut.computeIfAbsent(outName,k->{
                Map<String,Object> r=new LinkedHashMap<>();
                Company c=companyMap.values().stream().filter(x->outName.equals(x.getName())).findFirst().orElse(null);
                if(c==null && e.getOutcompanyId()!=null) c=companyMap.get(e.getOutcompanyId());
                r.put("outcompanyId", c==null?e.getOutcompanyId():c.getId());
                r.put("outcompanyName", outName);
                r.put("channel", c!=null && !blank(c.getChannel())?c.getChannel():(blank(e.getOutCategory())?"판매점":e.getOutCategory()));
                r.put("managerName", c==null||blank(c.getManagerName())?"-":c.getManagerName());
                r.put("phone", c==null||blank(c.getPhone())?"-":c.getPhone());
                r.put("mobile", c==null||blank(c.getPhone())?"-":c.getPhone());
                r.put("count", 0);
                r.put("balance", BigDecimal.ZERO);
                r.put("todayPaid", BigDecimal.ZERO);
                return r;
            });
            row.put("count", ((Number)row.get("count")).intValue()+1);
            row.put("balance", ((BigDecimal)row.get("balance")).add(rem));
            totalBalance=totalBalance.add(rem);
        }
        LocalDate today=LocalDate.now();
        BigDecimal todayPaid=BigDecimal.ZERO;
        for(FareboxPayment p:em.createQuery("select p from FareboxPayment p where p.groupId=:g and p.paydate=:d",FareboxPayment.class)
            .setParameter("g",u.getGroupId()).setParameter("d",today).getResultList()){
            if("취소됨".equals(blank(p.getStatus())?"정상":p.getStatus())) continue;
            todayPaid=todayPaid.add(nvl(p.getAmount()));
            String outName=blank(p.getOutcompanyName())?"-":p.getOutcompanyName();
            Map<String,Object> row=byOut.get(outName);
            if(row!=null) row.put("todayPaid", ((BigDecimal)row.get("todayPaid")).add(nvl(p.getAmount())));
        }
        List<Map<String,Object>> rows=new ArrayList<>(byOut.values());
        rows.sort(Comparator.comparing(r->String.valueOf(r.get("outcompanyName"))));
        int no=1;
        for(Map<String,Object> r:rows) r.put("no", no++);
        Map<String,Object> result=new LinkedHashMap<>();
        result.put("totalBalance", totalBalance);
        result.put("todayPaid", todayPaid);
        result.put("rows", rows);
        result.put("total", rows.size());
        return result;
    }
    private String fareboxOutName(Farebox e, Map<Long,String> companies){
        if(!blank(e.getOutcompanyName())) return e.getOutcompanyName().trim();
        return companies.getOrDefault(e.getOutcompanyId(),"-");
    }
    private BigDecimal fareboxTotal(Farebox e){ return nvl(e.getCashprice()).add(nvl(e.getCardprice())); }
    private BigDecimal fareboxRemaining(Farebox e){
        BigDecimal rem=fareboxTotal(e).subtract(nvl(e.getPaidAmount()));
        if(rem.compareTo(BigDecimal.ZERO)<0) return BigDecimal.ZERO;
        // 완결인데 paid 미기록이면 잔액 0
        if("완결".equals(normalizePayState(e.getPaystate())) && nvl(e.getPaidAmount()).compareTo(BigDecimal.ZERO)==0
            && fareboxTotal(e).compareTo(BigDecimal.ZERO)>0){
            // treat as unpaid if marked 완결 without paidAmount? Prefer remaining based on paidAmount only for 미결
            return BigDecimal.ZERO;
        }
        if("완결".equals(normalizePayState(e.getPaystate()))) return BigDecimal.ZERO;
        return rem;
    }
    private Map<String,Object> fareboxSearchData(JwtUser u, Map<String,String> f, int page, int size){
        Map<Long,String> companies=companyNames(u);
        List<Farebox> all=em.createQuery("select e from Farebox e where e.groupId=:g",Farebox.class).setParameter("g",u.getGroupId()).getResultList();
        LocalDate from=parseDate(f.get("dateFrom"), null);
        LocalDate to=parseDate(f.get("dateTo"), null);
        String tab=f.getOrDefault("tab","ALL");
        List<Farebox> base=all.stream().filter(e->matchesFarebox(e,f,companies,from,to,null)).toList();
        List<Farebox> filtered=base.stream()
            .filter(e->matchesFarebox(e,f,companies,from,to,tab))
            .sorted(Comparator.comparing(Farebox::getFaredate, Comparator.nullsLast(Comparator.reverseOrder()))
                .thenComparing(Farebox::getId, Comparator.reverseOrder()))
            .toList();
        Map<String,Long> counts=new LinkedHashMap<>();
        counts.put("ALL", (long) base.size());
        counts.put("미결", base.stream().filter(e->"미결".equals(normalizePayState(e.getPaystate()))).count());
        counts.put("완결", base.stream().filter(e->"완결".equals(normalizePayState(e.getPaystate()))).count());
        int sz=Math.min(Math.max(size,1),200);
        int fromIdx=Math.min(Math.max(page,0)*sz, filtered.size());
        int toIdx=Math.min(fromIdx+sz, filtered.size());
        List<Map<String,Object>> list=filtered.subList(fromIdx,toIdx).stream().map(e->fareboxMap(e,companies)).toList();
        Map<String,Object> result=new LinkedHashMap<>();
        result.put("total", filtered.size());
        result.put("list", list);
        result.put("counts", counts);
        return result;
    }
    private boolean matchesFarebox(Farebox e, Map<String,String> f, Map<Long,String> companies, LocalDate from, LocalDate to, String tab){
        if(!fareboxDateOk(e,from,to)) return false;
        if(!eq(e.getHow(), f.get("how"))) return false;
        if(!eq(e.getTelcompany(), f.get("telecom")) && !eq(e.getTelcompany(), f.get("telcompany"))) return false;
        if(!eq(e.getOutCategory(), f.get("outCategory"))) return false;
        String outName=blank(e.getOutcompanyName())?companies.getOrDefault(e.getOutcompanyId(),""):e.getOutcompanyName();
        if(!like(outName, f.get("outcompanyName"))) return false;
        String searchType=f.getOrDefault("searchType","고객명");
        String keyword=f.get("keyword");
        if(keyword!=null && !keyword.isBlank()){
            boolean ok=switch(searchType){
                case "연락처"->like(e.getPhone(), keyword);
                default->like(e.getName(), keyword);
            };
            if(!ok) return false;
        }
        if(tab!=null && !"ALL".equals(tab)){
            String state=normalizePayState(e.getPaystate());
            if("미결".equals(tab) && !"미결".equals(state)) return false;
            if("완결".equals(tab) && !"완결".equals(state)) return false;
        }
        return true;
    }
    private boolean fareboxDateOk(Farebox e, LocalDate from, LocalDate to){
        if(from!=null && (e.getFaredate()==null || e.getFaredate().isBefore(from))) return false;
        if(to!=null && (e.getFaredate()==null || e.getFaredate().isAfter(to))) return false;
        return true;
    }
    private String normalizePayState(String s){
        if(blank(s)) return "미결";
        if("완료".equals(s) || "DONE".equalsIgnoreCase(s) || "완결".equals(s)) return "완결";
        if("PENDING".equalsIgnoreCase(s) || "미결".equals(s)) return "미결";
        return s;
    }
    private Map<String,Object> fareboxMap(Farebox e, Map<Long,String> companies){
        Map<String,Object> r=new LinkedHashMap<>();
        r.put("id", e.getId());
        r.put("faredate", e.getFaredate());
        r.put("telcompany", e.getTelcompany());
        r.put("outcompanyId", e.getOutcompanyId());
        String outName=blank(e.getOutcompanyName())?companies.getOrDefault(e.getOutcompanyId(),"-"):e.getOutcompanyName();
        r.put("outcompanyName", outName);
        r.put("outCategory", e.getOutCategory());
        r.put("name", e.getName());
        r.put("phone", e.getPhone());
        r.put("how", blank(e.getHow())?"수납":e.getHow());
        r.put("cashprice", e.getCashprice()==null?BigDecimal.ZERO:e.getCashprice());
        r.put("cardprice", e.getCardprice()==null?BigDecimal.ZERO:e.getCardprice());
        r.put("paidAmount", e.getPaidAmount()==null?BigDecimal.ZERO:e.getPaidAmount());
        r.put("paystate", normalizePayState(e.getPaystate()));
        BigDecimal pay=nvl(e.getCashprice()).add(nvl(e.getCardprice()));
        r.put("payAmount", pay.compareTo(BigDecimal.ZERO)==0?null:pay);
        r.put("remaining", fareboxRemaining(e));
        r.put("detail", e.getDetail());
        r.put("registrant", e.getRegistrant());
        r.put("receiptNo", e.getReceiptNo());
        return r;
    }
    @PutMapping({"/scans/{id}","/indocs/{id}","/returns/{id}","/fareboxes/{id}"}) ApiResponse commonPut(HttpServletRequest r,@PathVariable Long id,@RequestBody Map<String,Object>d,@AuthenticationPrincipal JwtUser u){return put(type(r.getRequestURI()),id,d,u);}
    @DeleteMapping("/schedules/{id}") ApiResponse scheduleDel(@PathVariable Long id,@AuthenticationPrincipal JwtUser u){return del(ScheduleItem.class,id,u);}
    @PostMapping("/alims/{id}/confirm") ApiResponse alimConfirm(@PathVariable Long id,@AuthenticationPrincipal JwtUser u){return put(Alim.class,id,Map.of("confirmed",true),u);}
    @GetMapping("/settlements") ApiResponse settlements(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f){return list(SettlementMonth.class,u,f,0,100);}
    @GetMapping("/settlements/docs") ApiResponse settlementDocs(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f){
        String ym=f.getOrDefault("yearMonth",LocalDate.now().toString().substring(0,7));
        List<SettlementMonth> all=em.createQuery("select s from SettlementMonth s where s.groupId=:g and s.yearMonth=:y",SettlementMonth.class).setParameter("g",u.getGroupId()).setParameter("y",ym).getResultList();
        Map<Long,String> companies=companyNames(u);
        String outCategory=f.get("outCategory");
        List<SettlementMonth> filtered=all.stream().filter(s->eq(s.getOutCategory(),outCategory)).sorted(Comparator.comparing((SettlementMonth s)->s.getOutcompanyName()!=null?s.getOutcompanyName():companies.getOrDefault(s.getOutcompanyId(),""),Comparator.nullsLast(String::compareTo))).toList();
        List<Map<String,Object>> list=filtered.stream().map(s->settlementDocMap(s,companies)).toList();
        BigDecimal openAmount=BigDecimal.ZERO, afterAmount=BigDecimal.ZERO, homeAmount=BigDecimal.ZERO, realAmount=BigDecimal.ZERO;
        int openCount=0, afterCount=0, homeCount=0;
        for(Map<String,Object> r:list){
            openCount+=((Number)r.getOrDefault("openCount",0)).intValue();
            afterCount+=((Number)r.getOrDefault("afterSettleCount",0)).intValue();
            homeCount+=((Number)r.getOrDefault("homeProductCount",0)).intValue();
            openAmount=openAmount.add(nvl((BigDecimal)r.get("openAmount")));
            afterAmount=afterAmount.add(nvl((BigDecimal)r.get("afterSettleAmount")));
            homeAmount=homeAmount.add(nvl((BigDecimal)r.get("homeProductAmount")));
            realAmount=realAmount.add(nvl((BigDecimal)r.get("realSettleAmount")));
        }
        Map<String,Object> summary=new LinkedHashMap<>();
        summary.put("openCount",openCount); summary.put("openAmount",openAmount);
        summary.put("afterSettleCount",afterCount); summary.put("afterSettleAmount",afterAmount);
        summary.put("homeProductCount",homeCount); summary.put("homeProductAmount",homeAmount);
        summary.put("realSettleAmount",realAmount);
        return ApiResponse.ok(Map.of("yearMonth",ym,"total",list.size(),"list",list,"summary",summary));
    }
    @PostMapping("/settlements/docs/issue") @Transactional ApiResponse settlementIssue(@AuthenticationPrincipal JwtUser u,@RequestBody Map<String,Object> d){
        Object raw=d.get("ids");
        List<Long> ids=new ArrayList<>();
        if(raw instanceof List<?> list) for(Object o:list) if(o!=null) ids.add(Long.valueOf(o.toString()));
        if(ids.isEmpty()&&d.get("id")!=null) ids.add(Long.valueOf(d.get("id").toString()));
        if(ids.isEmpty()) return ApiResponse.fail("발행할 항목을 선택하세요.");
        int issued=0;
        for(Long id:ids){
            SettlementMonth s=em.find(SettlementMonth.class,id);
            if(s==null||!Objects.equals(s.getGroupId(),u.getGroupId())) continue;
            s.setIssued(true); s.setStatus("발행"); issued++;
        }
        return ApiResponse.ok(Map.of("issued",issued));
    }
    @PostMapping("/settlements/docs/delete") @Transactional ApiResponse settlementDocsDelete(@AuthenticationPrincipal JwtUser u,@RequestBody Map<String,Object> d){
        String ym=d.get("yearMonth")==null?null:String.valueOf(d.get("yearMonth"));
        Object raw=d.get("ids");
        int deleted=0;
        if(raw instanceof List<?> list && !list.isEmpty()){
            for(Object o:list){
                SettlementMonth s=em.find(SettlementMonth.class,Long.valueOf(o.toString()));
                if(s==null||!Objects.equals(s.getGroupId(),u.getGroupId())) continue;
                if(s.isIssued()){ s.setIssued(false); s.setStatus("READY"); deleted++; }
            }
        } else if(ym!=null&&!ym.isBlank()){
            List<SettlementMonth> rows=em.createQuery("select s from SettlementMonth s where s.groupId=:g and s.yearMonth=:y and s.issued=true",SettlementMonth.class).setParameter("g",u.getGroupId()).setParameter("y",ym).getResultList();
            for(SettlementMonth s:rows){ s.setIssued(false); s.setStatus("READY"); deleted++; }
        } else return ApiResponse.fail("삭제할 정산서를 선택하세요.");
        return ApiResponse.ok(Map.of("deleted",deleted));
    }
    @GetMapping(value="/settlements/docs/export", produces="text/csv;charset=UTF-8") ResponseEntity<String> settlementDocsExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f){
        @SuppressWarnings("unchecked") Map<String,Object> data=(Map<String,Object>) settlementDocs(u,f).data();
        @SuppressWarnings("unchecked") List<Map<String,Object>> rows=(List<Map<String,Object>>) data.get("list");
        StringBuilder csv=new StringBuilder("\uFEFF출고처,개통,개통금액,후정산,후정산금액,홈상품,홈상품금액,실정산금액,정산서발행,출고처문의,출고처동의,계산서상태,송금상태,비고\n");
        for(Map<String,Object> r:rows) csv.append(csv(r.get("outcompanyName"))).append(',').append(csv(r.get("openCount"))).append(',').append(csv(r.get("openAmount"))).append(',')
            .append(csv(r.get("afterSettleCount"))).append(',').append(csv(r.get("afterSettleAmount"))).append(',')
            .append(csv(r.get("homeProductCount"))).append(',').append(csv(r.get("homeProductAmount"))).append(',').append(csv(r.get("realSettleAmount"))).append(',')
            .append(csv(Boolean.TRUE.equals(r.get("issued"))?"발행":"미발행")).append(',').append(csv(r.get("inquiry"))).append(',').append(csv(r.get("agreement"))).append(',')
            .append(csv(r.get("invoiceStatus"))).append(',').append(csv(r.get("remittanceStatus"))).append(',').append(csv(r.get("memo"))).append('\n');
        return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=settlement-docs.csv").body(csv.toString());
    }
    private Map<String,Object> settlementDocMap(SettlementMonth s,Map<Long,String> companies){
        Map<String,Object> r=new LinkedHashMap<>();
        r.put("id",s.getId());
        r.put("outcompanyId",s.getOutcompanyId());
        r.put("outcompanyName",s.getOutcompanyName()!=null&&!s.getOutcompanyName().isBlank()?s.getOutcompanyName():companies.getOrDefault(s.getOutcompanyId(),"-"));
        r.put("yearMonth",s.getYearMonth()); r.put("outCategory",s.getOutCategory());
        r.put("openCount",s.getOpenCount()==null?0:s.getOpenCount());
        r.put("openAmount",s.getOpenAmount()==null?BigDecimal.ZERO:s.getOpenAmount());
        r.put("afterSettleCount",s.getAfterSettleCount()==null?0:s.getAfterSettleCount());
        r.put("afterSettleAmount",s.getAfterSettleAmount()==null?BigDecimal.ZERO:s.getAfterSettleAmount());
        r.put("homeProductCount",s.getHomeProductCount()==null?0:s.getHomeProductCount());
        r.put("homeProductAmount",s.getHomeProductAmount()==null?BigDecimal.ZERO:s.getHomeProductAmount());
        r.put("realSettleAmount",s.getRealSettleAmount()==null?BigDecimal.ZERO:s.getRealSettleAmount());
        r.put("issued",s.isIssued());
        r.put("inquiry",blank(s.getInquiry())?"-":s.getInquiry());
        r.put("agreement",blank(s.getAgreement())?"-":s.getAgreement());
        r.put("invoiceStatus",blank(s.getInvoiceStatus())?"-":s.getInvoiceStatus());
        r.put("remittanceStatus",blank(s.getRemittanceStatus())?"-":s.getRemittanceStatus());
        r.put("memo",s.getMemo()); r.put("status",s.getStatus());
        return r;
    }
    @PutMapping("/settlements") ApiResponse settlementPut(@RequestParam Long outcompanyId,@RequestParam String yearMonth,@RequestBody Map<String,Object>d,@AuthenticationPrincipal JwtUser u){
        List<SettlementMonth> rows=em.createQuery("select e from SettlementMonth e where e.groupId=:g and e.outcompanyId=:o and e.yearMonth=:y",SettlementMonth.class).setParameter("g",u.getGroupId()).setParameter("o",outcompanyId).setParameter("y",yearMonth).getResultList();
        if(rows.isEmpty()){d.put("outcompanyId",outcompanyId);d.put("yearMonth",yearMonth);return post(SettlementMonth.class,d,u);} return put(SettlementMonth.class,rows.getFirst().getId(),d,u);
    }
    @GetMapping("/accounts/summary") ApiResponse accountSummary(@AuthenticationPrincipal JwtUser u,@RequestParam(required=false) String yearMonth){PageResponse p=crud.list(AccountEntry.class,u,yearMonth==null?Map.of():Map.of("yearMonth",yearMonth),0,1000);BigDecimal in=BigDecimal.ZERO,out=BigDecimal.ZERO;for(Object o:p.list()){AccountEntry e=(AccountEntry)o;if("IN".equals(e.getType()))in=in.add(nvl(e.getAmount()));else out=out.add(nvl(e.getAmount()));}return ApiResponse.ok(Map.of("in",in,"out",out,"balance",in.subtract(out)));}
    @GetMapping({"/reports/stock","/reports/open","/reports/settle","/reports/sales","/reports/day","/reports/month"}) ApiResponse report(HttpServletRequest r,@AuthenticationPrincipal JwtUser u){String path=r.getRequestURI();Class<? extends BaseEntity> c=path.contains("stock")?Stock.class:path.contains("open")?OpenRecord.class:path.contains("settle")?SettlementMonth.class:AccountEntry.class;return ApiResponse.ok(Map.of("report",path.substring(path.lastIndexOf('/')+1),"total",count(c,u,null)));}
    @GetMapping("/settings/company") ApiResponse settingGet(@AuthenticationPrincipal JwtUser u){return list(Setting.class,u,Map.of("settingKey","company"),0,1);}
    @PutMapping("/settings/company") ApiResponse settingPut(@AuthenticationPrincipal JwtUser u,@RequestBody Map<String,Object>d){List<Setting> rows=em.createQuery("select e from Setting e where e.groupId=:g and e.settingKey='company'",Setting.class).setParameter("g",u.getGroupId()).getResultList();d.put("settingKey","company");return rows.isEmpty()?post(Setting.class,d,u):put(Setting.class,rows.getFirst().getId(),d,u);}
    @GetMapping("/settings/preference") ApiResponse preferenceGet(@AuthenticationPrincipal JwtUser u){
        return ApiResponse.ok(loadPreference(u.getGroupId()));
    }
    @PutMapping("/settings/preference") @Transactional ApiResponse preferencePut(@AuthenticationPrincipal JwtUser u,@RequestBody Map<String,Object> d){
        Map<String,Object> current=loadPreference(u.getGroupId());
        mergePreference(current,d);
        savePreference(u.getGroupId(),current);
        return ApiResponse.ok(current);
    }
    private Map<String,Object> loadPreference(String groupId){
        Map<String,Object> defaults=defaultPreference();
        List<Setting> rows=em.createQuery("select e from Setting e where e.groupId=:g and e.settingKey='preference'",Setting.class).setParameter("g",groupId).getResultList();
        if(!rows.isEmpty() && !blank(rows.getFirst().getValue())){
            try{
                @SuppressWarnings("unchecked") Map<String,Object> parsed=new com.fasterxml.jackson.databind.ObjectMapper().readValue(rows.getFirst().getValue(), Map.class);
                mergePreference(defaults,parsed);
            }catch(Exception ignored){}
        } else {
            // fallback: legacy settings/company JSON
            List<Setting> companyRows=em.createQuery("select e from Setting e where e.groupId=:g and e.settingKey='company'",Setting.class).setParameter("g",groupId).getResultList();
            if(!companyRows.isEmpty() && !blank(companyRows.getFirst().getValue())){
                try{
                    @SuppressWarnings("unchecked") Map<String,Object> legacy=new com.fasterxml.jackson.databind.ObjectMapper().readValue(companyRows.getFirst().getValue(), Map.class);
                    @SuppressWarnings("unchecked") Map<String,Object> company=(Map<String,Object>) defaults.get("company");
                    if(legacy.get("name")!=null) company.put("name", legacy.get("name"));
                    if(legacy.get("phone")!=null) company.put("mobile", legacy.get("phone"));
                    if(legacy.get("managerName")!=null) company.put("managerName", legacy.get("managerName"));
                    if(legacy.get("businessName")!=null) company.put("businessName", legacy.get("businessName"));
                    if(legacy.get("businessNumber")!=null) company.put("businessNumber", legacy.get("businessNumber"));
                    if(legacy.get("ceo")!=null) company.put("ceo", legacy.get("ceo"));
                    if(legacy.get("bizType")!=null) company.put("bizType", legacy.get("bizType"));
                    if(legacy.get("bizItem")!=null) company.put("bizItem", legacy.get("bizItem"));
                    if(legacy.get("address")!=null) company.put("address", legacy.get("address"));
                }catch(Exception ignored){}
            }
        }
        return defaults;
    }
    private void savePreference(String groupId, Map<String,Object> data){
        String json=writeSettingJson(data);
        List<Setting> rows=em.createQuery("select e from Setting e where e.groupId=:g and e.settingKey='preference'",Setting.class).setParameter("g",groupId).getResultList();
        if(rows.isEmpty()){
            Setting s=new Setting(); s.setGroupId(groupId); s.setSettingKey("preference"); s.setValue(json); em.persist(s);
        } else {
            rows.getFirst().setValue(json);
        }
        // keep legacy company key in sync for older clients
        @SuppressWarnings("unchecked") Map<String,Object> company=(Map<String,Object>) data.getOrDefault("company", Map.of());
        Map<String,Object> legacy=new LinkedHashMap<>();
        legacy.put("name", company.get("name"));
        legacy.put("phone", company.get("mobile"));
        legacy.put("managerName", company.get("managerName"));
        legacy.put("businessName", company.get("businessName"));
        legacy.put("businessNumber", company.get("businessNumber"));
        legacy.put("ceo", company.get("ceo"));
        legacy.put("bizType", company.get("bizType"));
        legacy.put("bizItem", company.get("bizItem"));
        legacy.put("address", company.get("address"));
        String legacyJson=writeSettingJson(legacy);
        List<Setting> companyRows=em.createQuery("select e from Setting e where e.groupId=:g and e.settingKey='company'",Setting.class).setParameter("g",groupId).getResultList();
        if(companyRows.isEmpty()){
            Setting s=new Setting(); s.setGroupId(groupId); s.setSettingKey("company"); s.setValue(legacyJson); em.persist(s);
        } else {
            companyRows.getFirst().setValue(legacyJson);
        }
    }
    @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> company=new LinkedHashMap<>();
        company.put("name","에이전트 데모");
        company.put("managerName","김대표");
        company.put("mobile","010-0000-0000");
        company.put("businessName","에이전트 데모");
        company.put("businessNumber","123-45-67890");
        company.put("ceo","김유신");
        company.put("bizType","도소매");
        company.put("bizItem","휴대폰판매");
        company.put("address","대구시 수성구 수성4가 먼길");
        Map<String,Object> accessIps=new LinkedHashMap<>();
        accessIps.put("enabled", false);
        accessIps.put("ips", List.of());
        Map<String,Object> masking=new LinkedHashMap<>();
        masking.put("enabled", true);
        masking.put("name", true);
        masking.put("phone", true);
        masking.put("rrn", true);
        masking.put("address", false);
        Map<String,Object> stock=new LinkedHashMap<>();
        stock.put("duplicateSerialCheck", true);
        stock.put("allowNegative", false);
        stock.put("autoRecallDays", 0);
        Map<String,Object> open=new LinkedHashMap<>();
        open.put("requireUsim", true);
        open.put("requirePhone", true);
        open.put("defaultOpenHow", "");
        open.put("autoSettle", false);
        Map<String,Object> m=new LinkedHashMap<>();
        m.put("company", company);
        m.put("accessIps", accessIps);
        m.put("masking", masking);
        m.put("outCategories", List.of("판매점","도매"));
        m.put("stock", stock);
        m.put("open", open);
        return m;
    }
    @GetMapping("/staff") ApiResponse staffList(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f,
            @RequestParam(defaultValue="0") int page,@RequestParam(defaultValue="15") int size){
        return ApiResponse.ok(staffData(u,f,page,size));
    }
    @GetMapping("/incompanies") ApiResponse incompanies(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f,
            @RequestParam(defaultValue="0") int page,@RequestParam(defaultValue="15") int size){
        return ApiResponse.ok(incompanyData(u,f,page,size));
    }
    @PostMapping("/incompanies") @Transactional ApiResponse incompanyCreate(@AuthenticationPrincipal JwtUser u,@RequestBody Map<String,Object> d){
        String name=d.get("name")==null?"":String.valueOf(d.get("name")).trim();
        if(name.isBlank()) return ApiResponse.fail("업체명을 입력하세요.");
        Company c=new Company();
        c.setGroupId(u.getGroupId());
        c.setType("IN");
        applyIncompanyFields(c,d);
        c.setName(name);
        if(d.get("active")==null && d.get("status")==null) c.setActive(true);
        em.persist(c);
        return ApiResponse.ok(incompanyMap(c));
    }
    @PutMapping("/incompanies/{id}") @Transactional ApiResponse incompanyUpdate(@PathVariable Long id,@AuthenticationPrincipal JwtUser u,@RequestBody Map<String,Object> d){
        Company c=em.find(Company.class,id);
        if(c==null || !Objects.equals(c.getGroupId(),u.getGroupId()) || !"IN".equals(c.getType())) return ApiResponse.fail("입고처를 찾을 수 없습니다.");
        if(d.get("name")!=null){
            String name=String.valueOf(d.get("name")).trim();
            if(name.isBlank()) return ApiResponse.fail("업체명을 입력하세요.");
            c.setName(name);
        }
        applyIncompanyFields(c,d);
        return ApiResponse.ok(incompanyMap(c));
    }
    @PostMapping("/incompanies/status") @Transactional ApiResponse incompanyStatus(@AuthenticationPrincipal JwtUser u,@RequestBody Map<String,Object> d){
        Object raw=d.get("ids");
        if(!(raw instanceof List<?> ids) || ids.isEmpty()) return ApiResponse.fail("항목을 선택하세요.");
        boolean active=!"미사용".equals(String.valueOf(d.getOrDefault("status",""))) && !"false".equalsIgnoreCase(String.valueOf(d.getOrDefault("active","")));
        int updated=0;
        for(Object idObj:ids){
            Company c=em.find(Company.class, Long.valueOf(idObj.toString()));
            if(c==null || !Objects.equals(c.getGroupId(),u.getGroupId()) || !"IN".equals(c.getType())) continue;
            c.setActive(active); updated++;
        }
        return ApiResponse.ok(Map.of("updated", updated));
    }
    @GetMapping(value="/incompanies/export", produces="text/csv;charset=UTF-8") ResponseEntity<String> incompanyExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f){
        @SuppressWarnings("unchecked") List<Map<String,Object>> rows=(List<Map<String,Object>>) incompanyData(u,f,0,10000).get("list");
        StringBuilder csv=new StringBuilder("\uFEFF상태,업체명,통신사,전화,팩스,담당자,휴대폰,상호,사업자번호,등록일,비고\n");
        for(Map<String,Object> r:rows) csv.append(csv(r.get("status"))).append(',').append(csv(r.get("name"))).append(',')
            .append(csv(r.get("telecom"))).append(',').append(csv(r.get("phone"))).append(',').append(csv(r.get("fax"))).append(',')
            .append(csv(r.get("managerName"))).append(',').append(csv(r.get("mobile"))).append(',').append(csv(r.get("businessName"))).append(',')
            .append(csv(r.get("businessNumber"))).append(',').append(csv(r.get("createdAt"))).append(',').append(csv(r.get("memo"))).append('\n');
        return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=incompanies.csv").body(csv.toString());
    }
    private void applyIncompanyFields(Company c, Map<String,Object> d){
        if(d.get("telecom")!=null) c.setTelecom(blank(String.valueOf(d.get("telecom")))?null:String.valueOf(d.get("telecom")).trim());
        if(d.get("phone")!=null) c.setPhone(blank(String.valueOf(d.get("phone")))?null:String.valueOf(d.get("phone")).trim());
        if(d.get("fax")!=null) c.setFax(blank(String.valueOf(d.get("fax")))?null:String.valueOf(d.get("fax")).trim());
        if(d.get("managerName")!=null) c.setManagerName(blank(String.valueOf(d.get("managerName")))?null:String.valueOf(d.get("managerName")).trim());
        if(d.get("mobile")!=null) c.setMobile(blank(String.valueOf(d.get("mobile")))?null:String.valueOf(d.get("mobile")).trim());
        if(d.get("businessName")!=null) c.setBusinessName(blank(String.valueOf(d.get("businessName")))?null:String.valueOf(d.get("businessName")).trim());
        if(d.get("businessNumber")!=null) c.setBusinessNumber(blank(String.valueOf(d.get("businessNumber")))?null:String.valueOf(d.get("businessNumber")).trim());
        if(d.get("memo")!=null) c.setMemo(blank(String.valueOf(d.get("memo")))?null:String.valueOf(d.get("memo")).trim());
        if(d.get("status")!=null) c.setActive(!"미사용".equals(String.valueOf(d.get("status"))) && !"중지".equals(String.valueOf(d.get("status"))));
        if(d.get("active")!=null) c.setActive(Boolean.parseBoolean(String.valueOf(d.get("active"))) || "true".equalsIgnoreCase(String.valueOf(d.get("active"))) || "사용".equals(String.valueOf(d.get("active"))));
    }
    private Map<String,Object> incompanyData(JwtUser u, Map<String,String> f, int page, int size){
        List<Company> all=em.createQuery("select c from Company c where c.groupId=:g and c.type='IN'",Company.class)
            .setParameter("g",u.getGroupId()).getResultList();
        String searchType=f.getOrDefault("searchType","업체명");
        String keyword=f.getOrDefault("keyword","");
        List<Company> filtered=all.stream().filter(c->{
            if(!blank(keyword)){
                String q=keyword.trim();
                return switch(searchType){
                    case "통신사" -> like(c.getTelecom(), q);
                    case "담당자" -> like(c.getManagerName(), q);
                    case "상호" -> like(c.getBusinessName(), q);
                    case "사업자번호" -> like(c.getBusinessNumber(), q);
                    default -> like(c.getName(), q);
                };
            }
            return true;
        }).sorted(incompanyComparator(f.get("sort"))).toList();
        int sz=Math.min(Math.max(size,1),200);
        int fromIdx=Math.min(Math.max(page,0)*sz, filtered.size());
        int toIdx=Math.min(fromIdx+sz, filtered.size());
        List<Map<String,Object>> list=filtered.subList(fromIdx,toIdx).stream().map(this::incompanyMap).toList();
        Map<String,Object> result=new LinkedHashMap<>();
        result.put("total", filtered.size());
        result.put("list", list);
        return result;
    }
    private Comparator<Company> incompanyComparator(String sort){
        String field=sort==null?"name,asc":sort; boolean asc=field.endsWith(",asc");
        Comparator<Company> c=switch(field.split(",")[0]){
            case "telecom"->Comparator.comparing(Company::getTelecom,Comparator.nullsLast(String::compareTo));
            case "createdAt"->Comparator.comparing(Company::getCreatedAt,Comparator.nullsLast(Instant::compareTo));
            default->Comparator.comparing(Company::getName,Comparator.nullsLast(String::compareTo));
        };
        return (asc?c:c.reversed()).thenComparing(Company::getId);
    }
    private Map<String,Object> incompanyMap(Company c){
        Map<String,Object> r=new LinkedHashMap<>();
        r.put("id", c.getId());
        r.put("status", c.isActive()?"사용":"미사용");
        r.put("active", c.isActive());
        r.put("name", c.getName());
        r.put("telecom", c.getTelecom());
        r.put("phone", c.getPhone());
        r.put("fax", c.getFax());
        r.put("managerName", c.getManagerName());
        r.put("mobile", c.getMobile());
        r.put("businessName", c.getBusinessName());
        r.put("businessNumber", c.getBusinessNumber());
        r.put("createdAt", c.getCreatedAt()==null?null:c.getCreatedAt().atZone(ZoneId.systemDefault()).toLocalDate().toString());
        r.put("memo", c.getMemo());
        return r;
    }
    @GetMapping("/outcompanies") ApiResponse outcompanies(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f,
            @RequestParam(defaultValue="0") int page,@RequestParam(defaultValue="15") int size){
        return ApiResponse.ok(outcompanyData(u,f,page,size));
    }
    @PostMapping("/outcompanies") @Transactional ApiResponse outcompanyCreate(@AuthenticationPrincipal JwtUser u,@RequestBody Map<String,Object> d){
        String name=d.get("name")==null?"":String.valueOf(d.get("name")).trim();
        if(name.isBlank()) return ApiResponse.fail("출고처명을 입력하세요.");
        Company c=new Company();
        c.setGroupId(u.getGroupId());
        c.setType(blank(String.valueOf(d.getOrDefault("type","")))?"OUT":String.valueOf(d.get("type")));
        if(!"OUT".equals(c.getType()) && !"SHOP".equals(c.getType())) c.setType("OUT");
        applyOutcompanyFields(c,d);
        c.setName(name);
        if(d.get("active")==null && d.get("status")==null) c.setActive(true);
        em.persist(c);
        return ApiResponse.ok(outcompanyMap(c));
    }
    @PutMapping("/outcompanies/{id}") @Transactional ApiResponse outcompanyUpdate(@PathVariable Long id,@AuthenticationPrincipal JwtUser u,@RequestBody Map<String,Object> d){
        Company c=em.find(Company.class,id);
        if(c==null || !Objects.equals(c.getGroupId(),u.getGroupId()) || !List.of("OUT","SHOP").contains(c.getType())) return ApiResponse.fail("출고처를 찾을 수 없습니다.");
        if(d.get("name")!=null){
            String name=String.valueOf(d.get("name")).trim();
            if(name.isBlank()) return ApiResponse.fail("출고처명을 입력하세요.");
            c.setName(name);
        }
        applyOutcompanyFields(c,d);
        return ApiResponse.ok(outcompanyMap(c));
    }
    @PostMapping("/outcompanies/status") @Transactional ApiResponse outcompanyStatus(@AuthenticationPrincipal JwtUser u,@RequestBody Map<String,Object> d){
        Object raw=d.get("ids");
        if(!(raw instanceof List<?> ids) || ids.isEmpty()) return ApiResponse.fail("항목을 선택하세요.");
        boolean active=!"미사용".equals(String.valueOf(d.getOrDefault("status",""))) && !"false".equalsIgnoreCase(String.valueOf(d.getOrDefault("active","")));
        int updated=0;
        for(Object idObj:ids){
            Company c=em.find(Company.class, Long.valueOf(idObj.toString()));
            if(c==null || !Objects.equals(c.getGroupId(),u.getGroupId()) || !List.of("OUT","SHOP").contains(c.getType())) continue;
            c.setActive(active); updated++;
        }
        return ApiResponse.ok(Map.of("updated", updated));
    }
    @GetMapping(value="/outcompanies/export", produces="text/csv;charset=UTF-8") ResponseEntity<String> outcompanyExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f){
        @SuppressWarnings("unchecked") List<Map<String,Object>> rows=(List<Map<String,Object>>) outcompanyData(u,f,0,10000).get("list");
        StringBuilder csv=new StringBuilder("\uFEFF상태,채널,업체명,P코드,영업담당,전화,담당자,휴대폰,상호,사업자번호,계좌,등록일,비고\n");
        for(Map<String,Object> r:rows) csv.append(csv(r.get("status"))).append(',').append(csv(r.get("channel"))).append(',')
            .append(csv(r.get("name"))).append(',').append(csv(r.get("pcode"))).append(',').append(csv(r.get("salesperson"))).append(',')
            .append(csv(r.get("phone"))).append(',').append(csv(r.get("managerName"))).append(',').append(csv(r.get("mobile"))).append(',')
            .append(csv(r.get("businessName"))).append(',').append(csv(r.get("businessNumber"))).append(',').append(csv(r.get("bankAccount"))).append(',')
            .append(csv(r.get("createdAt"))).append(',').append(csv(r.get("memo"))).append('\n');
        return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=outcompanies.csv").body(csv.toString());
    }
    private void applyOutcompanyFields(Company c, Map<String,Object> d){
        if(d.get("channel")!=null) c.setChannel(blank(String.valueOf(d.get("channel")))?null:String.valueOf(d.get("channel")).trim());
        if(d.get("category")!=null) c.setCategory(blank(String.valueOf(d.get("category")))?null:String.valueOf(d.get("category")).trim());
        if(d.get("pcode")!=null) c.setPcode(blank(String.valueOf(d.get("pcode")))?null:String.valueOf(d.get("pcode")).trim());
        if(d.get("salesperson")!=null) c.setSalesperson(blank(String.valueOf(d.get("salesperson")))?null:String.valueOf(d.get("salesperson")).trim());
        if(d.get("phone")!=null) c.setPhone(blank(String.valueOf(d.get("phone")))?null:String.valueOf(d.get("phone")).trim());
        if(d.get("managerName")!=null) c.setManagerName(blank(String.valueOf(d.get("managerName")))?null:String.valueOf(d.get("managerName")).trim());
        if(d.get("mobile")!=null) c.setMobile(blank(String.valueOf(d.get("mobile")))?null:String.valueOf(d.get("mobile")).trim());
        if(d.get("businessName")!=null) c.setBusinessName(blank(String.valueOf(d.get("businessName")))?null:String.valueOf(d.get("businessName")).trim());
        if(d.get("businessNumber")!=null) c.setBusinessNumber(blank(String.valueOf(d.get("businessNumber")))?null:String.valueOf(d.get("businessNumber")).trim());
        if(d.get("bankAccount")!=null) c.setBankAccount(blank(String.valueOf(d.get("bankAccount")))?null:String.valueOf(d.get("bankAccount")).trim());
        if(d.get("memo")!=null) c.setMemo(blank(String.valueOf(d.get("memo")))?null:String.valueOf(d.get("memo")).trim());
        if(d.get("status")!=null) c.setActive(!"미사용".equals(String.valueOf(d.get("status"))) && !"중지".equals(String.valueOf(d.get("status"))));
        if(d.get("active")!=null) c.setActive(Boolean.parseBoolean(String.valueOf(d.get("active"))) || "true".equalsIgnoreCase(String.valueOf(d.get("active"))) || "사용".equals(String.valueOf(d.get("active"))));
    }
    private Map<String,Object> outcompanyData(JwtUser u, Map<String,String> f, int page, int size){
        List<Company> all=em.createQuery("select c from Company c where c.groupId=:g and c.type in ('OUT','SHOP')",Company.class)
            .setParameter("g",u.getGroupId()).getResultList();
        String status=f.getOrDefault("status","");
        String channel=f.getOrDefault("channel","");
        String category=f.getOrDefault("category","");
        String searchType=f.getOrDefault("searchType","출고처명");
        String keyword=f.getOrDefault("keyword","");
        List<Company> filtered=all.stream().filter(c->{
            if("사용".equals(status) && !c.isActive()) return false;
            if("미사용".equals(status) && c.isActive()) return false;
            String ch=blank(c.getChannel())?"기타":c.getChannel();
            if(!eq(ch, channel)) return false;
            String cat=blank(c.getCategory())?("SHOP".equals(c.getType())?"판매점":"출고처"):c.getCategory();
            if(!eq(cat, category)) return false;
            if(!blank(keyword)){
                String q=keyword.trim();
                return switch(searchType){
                    case "P코드" -> like(c.getPcode(), q);
                    case "담당자" -> like(c.getManagerName(), q);
                    case "영업담당" -> like(c.getSalesperson(), q);
                    case "상호" -> like(c.getBusinessName(), q);
                    case "사업자번호" -> like(c.getBusinessNumber(), q);
                    default -> like(c.getName(), q);
                };
            }
            return true;
        }).sorted(outcompanyComparator(f.get("sort"))).toList();
        int sz=Math.min(Math.max(size,1),200);
        int fromIdx=Math.min(Math.max(page,0)*sz, filtered.size());
        int toIdx=Math.min(fromIdx+sz, filtered.size());
        List<Map<String,Object>> list=filtered.subList(fromIdx,toIdx).stream().map(this::outcompanyMap).toList();
        Map<String,Object> result=new LinkedHashMap<>();
        result.put("total", filtered.size());
        result.put("list", list);
        return result;
    }
    private Comparator<Company> outcompanyComparator(String sort){
        String field=sort==null?"name,asc":sort; boolean asc=field.endsWith(",asc");
        Comparator<Company> c=switch(field.split(",")[0]){
            case "pcode"->Comparator.comparing(Company::getPcode,Comparator.nullsLast(String::compareTo));
            case "channel"->Comparator.comparing(x->blank(x.getChannel())?"기타":x.getChannel(),Comparator.nullsLast(String::compareTo));
            case "createdAt"->Comparator.comparing(Company::getCreatedAt,Comparator.nullsLast(Instant::compareTo));
            default->Comparator.comparing(Company::getName,Comparator.nullsLast(String::compareTo));
        };
        return (asc?c:c.reversed()).thenComparing(Company::getId);
    }
    private Map<String,Object> outcompanyMap(Company c){
        Map<String,Object> r=new LinkedHashMap<>();
        r.put("id", c.getId());
        r.put("status", c.isActive()?"사용":"미사용");
        r.put("active", c.isActive());
        r.put("channel", blank(c.getChannel())?"기타":c.getChannel());
        r.put("category", blank(c.getCategory())?("SHOP".equals(c.getType())?"판매점":"출고처"):c.getCategory());
        r.put("name", c.getName());
        r.put("pcode", c.getPcode());
        r.put("salesperson", c.getSalesperson());
        r.put("phone", c.getPhone());
        r.put("managerName", c.getManagerName());
        r.put("mobile", c.getMobile());
        r.put("businessName", c.getBusinessName());
        r.put("businessNumber", c.getBusinessNumber());
        r.put("bankAccount", c.getBankAccount());
        r.put("createdAt", c.getCreatedAt()==null?null:c.getCreatedAt().atZone(ZoneId.systemDefault()).toLocalDate().toString());
        r.put("memo", c.getMemo());
        r.put("type", c.getType());
        return r;
    }
    @GetMapping("/deliveries") ApiResponse deliveries(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f,
            @RequestParam(defaultValue="0") int page,@RequestParam(defaultValue="15") int size){
        return ApiResponse.ok(deliveryData(u,f,page,size));
    }
    @PostMapping("/deliveries") @Transactional ApiResponse deliveryCreate(@AuthenticationPrincipal JwtUser u,@RequestBody Map<String,Object> d){
        String name=d.get("name")==null?"":String.valueOf(d.get("name")).trim();
        if(name.isBlank()) return ApiResponse.fail("업체명을 입력하세요.");
        Company c=new Company();
        c.setGroupId(u.getGroupId());
        c.setType("DELIVERY");
        c.setName(name);
        if(d.get("phone")!=null) c.setPhone(blank(String.valueOf(d.get("phone")))?null:String.valueOf(d.get("phone")).trim());
        if(d.get("memo")!=null) c.setMemo(blank(String.valueOf(d.get("memo")))?null:String.valueOf(d.get("memo")).trim());
        c.setActive(!"미사용".equals(String.valueOf(d.getOrDefault("status",""))));
        if(d.get("active")==null && d.get("status")==null) c.setActive(true);
        em.persist(c);
        return ApiResponse.ok(deliveryMap(c));
    }
    @PutMapping("/deliveries/{id}") @Transactional ApiResponse deliveryUpdate(@PathVariable Long id,@AuthenticationPrincipal JwtUser u,@RequestBody Map<String,Object> d){
        Company c=em.find(Company.class,id);
        if(c==null || !Objects.equals(c.getGroupId(),u.getGroupId()) || !"DELIVERY".equals(c.getType())) return ApiResponse.fail("배송처를 찾을 수 없습니다.");
        if(d.get("name")!=null){
            String name=String.valueOf(d.get("name")).trim();
            if(name.isBlank()) return ApiResponse.fail("업체명을 입력하세요.");
            c.setName(name);
        }
        if(d.get("phone")!=null) c.setPhone(blank(String.valueOf(d.get("phone")))?null:String.valueOf(d.get("phone")).trim());
        if(d.get("memo")!=null) c.setMemo(blank(String.valueOf(d.get("memo")))?null:String.valueOf(d.get("memo")).trim());
        if(d.get("status")!=null) c.setActive(!"미사용".equals(String.valueOf(d.get("status"))));
        if(d.get("active")!=null) c.setActive(Boolean.parseBoolean(String.valueOf(d.get("active"))) || "true".equalsIgnoreCase(String.valueOf(d.get("active"))) || "사용".equals(String.valueOf(d.get("active"))));
        return ApiResponse.ok(deliveryMap(c));
    }
    @PostMapping("/deliveries/status") @Transactional ApiResponse deliveryStatus(@AuthenticationPrincipal JwtUser u,@RequestBody Map<String,Object> d){
        Object raw=d.get("ids");
        if(!(raw instanceof List<?> ids) || ids.isEmpty()) return ApiResponse.fail("항목을 선택하세요.");
        boolean active=!"미사용".equals(String.valueOf(d.getOrDefault("status","")));
        int updated=0;
        for(Object idObj:ids){
            Company c=em.find(Company.class, Long.valueOf(idObj.toString()));
            if(c==null || !Objects.equals(c.getGroupId(),u.getGroupId()) || !"DELIVERY".equals(c.getType())) continue;
            c.setActive(active); updated++;
        }
        return ApiResponse.ok(Map.of("updated", updated));
    }
    @GetMapping(value="/deliveries/export", produces="text/csv;charset=UTF-8") ResponseEntity<String> deliveryExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f){
        @SuppressWarnings("unchecked") List<Map<String,Object>> rows=(List<Map<String,Object>>) deliveryData(u,f,0,10000).get("list");
        StringBuilder csv=new StringBuilder("\uFEFF상태,업체명,연락처,비고,등록일\n");
        for(Map<String,Object> r:rows) csv.append(csv(r.get("status"))).append(',').append(csv(r.get("name"))).append(',')
            .append(csv(r.get("phone"))).append(',').append(csv(r.get("memo"))).append(',').append(csv(r.get("createdAt"))).append('\n');
        return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=deliveries.csv").body(csv.toString());
    }
    private Map<String,Object> deliveryData(JwtUser u, Map<String,String> f, int page, int size){
        List<Company> all=em.createQuery("select c from Company c where c.groupId=:g and c.type='DELIVERY'",Company.class)
            .setParameter("g",u.getGroupId()).getResultList();
        String searchType=f.getOrDefault("searchType","업체명");
        String keyword=f.getOrDefault("keyword","");
        List<Company> filtered=all.stream().filter(c->{
            if(!blank(keyword)){
                String q=keyword.trim();
                return switch(searchType){
                    case "연락처" -> like(c.getPhone(), q);
                    case "비고" -> like(c.getMemo(), q);
                    default -> like(c.getName(), q);
                };
            }
            return true;
        }).sorted(deliveryComparator(f.get("sort"))).toList();
        int sz=Math.min(Math.max(size,1),200);
        int fromIdx=Math.min(Math.max(page,0)*sz, filtered.size());
        int toIdx=Math.min(fromIdx+sz, filtered.size());
        List<Map<String,Object>> list=filtered.subList(fromIdx,toIdx).stream().map(this::deliveryMap).toList();
        Map<String,Object> result=new LinkedHashMap<>();
        result.put("total", filtered.size());
        result.put("list", list);
        return result;
    }
    private Comparator<Company> deliveryComparator(String sort){
        String field=sort==null?"name,asc":sort; boolean asc=field.endsWith(",asc");
        Comparator<Company> c=switch(field.split(",")[0]){
            case "createdAt"->Comparator.comparing(Company::getCreatedAt,Comparator.nullsLast(Instant::compareTo));
            case "phone"->Comparator.comparing(Company::getPhone,Comparator.nullsLast(String::compareTo));
            default->Comparator.comparing(Company::getName,Comparator.nullsLast(String::compareTo));
        };
        return (asc?c:c.reversed()).thenComparing(Company::getId);
    }
    private Map<String,Object> deliveryMap(Company c){
        Map<String,Object> r=new LinkedHashMap<>();
        r.put("id", c.getId());
        r.put("status", c.isActive()?"사용":"미사용");
        r.put("active", c.isActive());
        r.put("name", c.getName());
        r.put("phone", c.getPhone());
        r.put("memo", c.getMemo());
        r.put("createdAt", c.getCreatedAt()==null?null:c.getCreatedAt().atZone(ZoneId.systemDefault()).toLocalDate().toString());
        return r;
    }
    @PostMapping("/staff") @Transactional ApiResponse staffCreate(@AuthenticationPrincipal JwtUser u,@RequestBody Map<String,Object> d){
        String userid=d.get("userid")==null?"":String.valueOf(d.get("userid")).trim();
        String password=d.get("password")==null?"":String.valueOf(d.get("password"));
        String name=d.get("name")==null?"":String.valueOf(d.get("name")).trim();
        if(userid.isBlank()) return ApiResponse.fail("아이디를 입력하세요.");
        if(password.isBlank()) return ApiResponse.fail("비밀번호를 입력하세요.");
        if(name.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.setPassword(encoder.encode(password));
        m.setPlainPassword(password);
        m.setName(name);
        m.setPhone(formatStaffPhone(String.valueOf(d.getOrDefault("phone",""))));
        m.setRole("AGENT");
        m.setStaffPermission(blank(String.valueOf(d.getOrDefault("staffPermission","")))?"영업":String.valueOf(d.get("staffPermission")));
        m.setActive(!"중지".equals(String.valueOf(d.getOrDefault("status",""))) && !"false".equalsIgnoreCase(String.valueOf(d.getOrDefault("active",""))));
        m.setLoginCount(0);
        m.setAssignedOutNames(blank(String.valueOf(d.getOrDefault("assignedOutNames","")))?null:String.valueOf(d.get("assignedOutNames")).trim());
        m.setMemo(blank(String.valueOf(d.getOrDefault("memo","")))?null:String.valueOf(d.get("memo")).trim());
        em.persist(m);
        return ApiResponse.ok(staffMap(m));
    }
    @PutMapping("/staff/{id}") @Transactional ApiResponse staffUpdate(@PathVariable Long id,@AuthenticationPrincipal JwtUser u,@RequestBody Map<String,Object> d){
        Member m=em.find(Member.class,id);
        if(m==null || !Objects.equals(m.getGroupId(),u.getGroupId()) || !"AGENT".equals(m.getRole())) return ApiResponse.fail("직원을 찾을 수 없습니다.");
        if(d.get("name")!=null){
            String name=String.valueOf(d.get("name")).trim();
            if(name.isBlank()) return ApiResponse.fail("이름을 입력하세요.");
            m.setName(name);
        }
        if(d.get("phone")!=null) m.setPhone(formatStaffPhone(String.valueOf(d.get("phone"))));
        if(d.get("staffPermission")!=null && !String.valueOf(d.get("staffPermission")).isBlank()) m.setStaffPermission(String.valueOf(d.get("staffPermission")));
        if(d.get("assignedOutNames")!=null) m.setAssignedOutNames(blank(String.valueOf(d.get("assignedOutNames")))?null:String.valueOf(d.get("assignedOutNames")).trim());
        if(d.get("memo")!=null) m.setMemo(blank(String.valueOf(d.get("memo")))?null:String.valueOf(d.get("memo")).trim());
        if(d.get("status")!=null) m.setActive(!"중지".equals(String.valueOf(d.get("status"))));
        if(d.get("active")!=null) m.setActive(Boolean.parseBoolean(String.valueOf(d.get("active"))) || "true".equalsIgnoreCase(String.valueOf(d.get("active"))) || "사용".equals(String.valueOf(d.get("active"))));
        if(d.get("password")!=null && !String.valueOf(d.get("password")).isBlank()){
            String password=String.valueOf(d.get("password"));
            m.setPassword(encoder.encode(password));
            m.setPlainPassword(password);
        }
        return ApiResponse.ok(staffMap(m));
    }
    @GetMapping(value="/staff/export", produces="text/csv;charset=UTF-8") ResponseEntity<String> staffExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f){
        @SuppressWarnings("unchecked") List<Map<String,Object>> rows=(List<Map<String,Object>>) staffData(u,f,0,10000).get("list");
        StringBuilder csv=new StringBuilder("\uFEFF상태,권한,아이디,이름,휴대폰,담당출고처,접속수,최종접속,등록일,비고\n");
        for(Map<String,Object> r:rows) csv.append(csv(r.get("status"))).append(',').append(csv(r.get("staffPermission"))).append(',')
            .append(csv(r.get("userid"))).append(',').append(csv(r.get("name"))).append(',').append(csv(r.get("phone"))).append(',')
            .append(csv(r.get("assignedOutNames"))).append(',').append(csv(r.get("loginCount"))).append(',')
            .append(csv(r.get("lastLoginAt"))).append(',').append(csv(r.get("createdAt"))).append(',').append(csv(r.get("memo"))).append('\n');
        return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=staff.csv").body(csv.toString());
    }
    private Map<String,Object> staffData(JwtUser u, Map<String,String> f, int page, int size){
        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();
        String status=f.getOrDefault("status","");
        String keyword=f.getOrDefault("keyword","");
        List<Member> filtered=all.stream().filter(m->{
            if("사용".equals(status) && !m.isActive()) return false;
            if("중지".equals(status) && m.isActive()) return false;
            if(!blank(keyword)){
                String q=keyword.trim().toLowerCase();
                boolean hit=(m.getName()!=null && m.getName().toLowerCase().contains(q))
                    || (m.getUserid()!=null && m.getUserid().toLowerCase().contains(q));
                if(!hit) return false;
            }
            return true;
        }).sorted(staffComparator(f.get("sort"))).toList();
        int sz=Math.min(Math.max(size,1),200);
        int fromIdx=Math.min(Math.max(page,0)*sz, filtered.size());
        int toIdx=Math.min(fromIdx+sz, filtered.size());
        List<Map<String,Object>> list=filtered.subList(fromIdx,toIdx).stream().map(this::staffMap).toList();
        Map<String,Object> result=new LinkedHashMap<>();
        result.put("total", filtered.size());
        result.put("list", list);
        return result;
    }
    private Comparator<Member> staffComparator(String sort){
        String field=sort==null?"createdAt,asc":sort; boolean asc=field.endsWith(",asc");
        Comparator<Member> c=switch(field.split(",")[0]){
            case "staffPermission"->Comparator.comparing(m->blank(m.getStaffPermission())?"영업":m.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));
            default->Comparator.comparing(Member::getCreatedAt,Comparator.nullsLast(Instant::compareTo));
        };
        // 대표 first
        Comparator<Member> ownerFirst=(a,b)->{
            boolean ao="대표".equals(a.getStaffPermission());
            boolean bo="대표".equals(b.getStaffPermission());
            if(ao==bo) return 0;
            return ao?-1:1;
        };
        return ownerFirst.thenComparing(asc?c:c.reversed()).thenComparing(Member::getId);
    }
    private Map<String,Object> staffMap(Member m){
        Map<String,Object> r=new LinkedHashMap<>();
        r.put("id", m.getId());
        r.put("status", m.isActive()?"사용":"중지");
        r.put("active", m.isActive());
        r.put("staffPermission", blank(m.getStaffPermission())?("demo".equals(m.getUserid())?"대표":"영업"):m.getStaffPermission());
        r.put("userid", m.getUserid());
        r.put("name", m.getName());
        r.put("phone", formatStaffPhone(m.getPhone()));
        r.put("assignedOutNames", m.getAssignedOutNames());
        r.put("loginCount", m.getLoginCount()==null?0:m.getLoginCount());
        r.put("lastLoginAt", m.getLastLoginAt()==null?null:m.getLastLoginAt().format(java.time.format.DateTimeFormatter.ofPattern("MM-dd HH:mm")));
        r.put("createdAt", m.getCreatedAt()==null?null:m.getCreatedAt().atZone(ZoneId.systemDefault()).toLocalDate().toString());
        r.put("memo", m.getMemo());
        r.put("isOwner", "대표".equals(r.get("staffPermission")) || "demo".equals(m.getUserid()));
        return r;
    }
    private String formatStaffPhone(String phone){
        if(phone==null || phone.isBlank()) return "";
        String p=phone.replaceAll("[^0-9]","");
        if(p.length()==11) return p.substring(0,3)+"-"+p.substring(3,7)+"-"+p.substring(7);
        if(p.length()==10) return p.substring(0,3)+"-"+p.substring(3,6)+"-"+p.substring(6);
        return phone;
    }
    @GetMapping("/settings/agentshop") ApiResponse agentShopSettingGet(@AuthenticationPrincipal JwtUser u){
        return ApiResponse.ok(loadAgentShopSetting(u.getGroupId()));
    }
    @PutMapping("/settings/agentshop") @Transactional ApiResponse agentShopSettingPut(@AuthenticationPrincipal JwtUser u,@RequestBody Map<String,Object> d){
        Map<String,Object> current=loadAgentShopSetting(u.getGroupId());
        current.putAll(d);
        String json=writeSettingJson(current);
        List<Setting> rows=em.createQuery("select e from Setting e where e.groupId=:g and e.settingKey='agentshop'",Setting.class).setParameter("g",u.getGroupId()).getResultList();
        if(rows.isEmpty()){
            Setting s=new Setting(); s.setGroupId(u.getGroupId()); s.setSettingKey("agentshop"); s.setValue(json); em.persist(s);
        } else {
            rows.getFirst().setValue(json);
        }
        return ApiResponse.ok(current);
    }
    private Map<String,Object> loadAgentShopSetting(String groupId){
        Map<String,Object> defaults=defaultAgentShopSetting();
        List<Setting> rows=em.createQuery("select e from Setting e where e.groupId=:g and e.settingKey='agentshop'",Setting.class).setParameter("g",groupId).getResultList();
        if(rows.isEmpty()||blank(rows.getFirst().getValue())) return defaults;
        try{
            @SuppressWarnings("unchecked") Map<String,Object> parsed=new com.fasterxml.jackson.databind.ObjectMapper().readValue(rows.getFirst().getValue(), Map.class);
            defaults.putAll(parsed);
        }catch(Exception ignored){}
        return defaults;
    }
    private Map<String,Object> defaultAgentShopSetting(){
        Map<String,Object> m=new LinkedHashMap<>();
        m.put("access",true);
        m.put("policy",true); m.put("pds",true); m.put("returnPhone",true); m.put("indoc",true);
        m.put("scan",true); m.put("scanReceiveSms",false); m.put("scanReceivePhones","");
        m.put("scanSendSms",false); m.put("scanSenderPhone","1600-3903");
        m.put("scanCompleteMsg","스캔신청서가 완료 처리되었습니다.");
        m.put("scanHoldMsg","스캔신청서가 보류 처리되었습니다.");
        m.put("stock",true); m.put("stockPeriod","always");
        m.put("open",true); m.put("settle",true); m.put("farebox",true);
        m.put("shopUrl","https://shop.bizcare.co.kr");
        return m;
    }
    private String writeSettingJson(Map<String,Object> m){
        try{ return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(m); }
        catch(Exception e){ return "{}"; }
    }
    @DeleteMapping({"/scans/{id}","/indocs/{id}","/returns/{id}","/fareboxes/{id}","/accounts/{id}","/sms/{id}","/cs/{id}"}) ApiResponse commonDel(HttpServletRequest r,@PathVariable Long id,@AuthenticationPrincipal JwtUser u){return del(type(r.getRequestURI()),id,u);}
    @PutMapping({"/schedules/{id}","/accounts/{id}","/sms/{id}"}) ApiResponse commonPut2(HttpServletRequest r,@PathVariable Long id,@RequestBody Map<String,Object>d,@AuthenticationPrincipal JwtUser u){return put(type(r.getRequestURI()),id,d,u);}
    @GetMapping("/basic/phones") ApiResponse phones(@AuthenticationPrincipal JwtUser u,@RequestParam(required=false,defaultValue="") String q){return list(PhoneModel.class,u,q.isBlank()?Map.of():Map.of("modelName",q),0,100);}
    @GetMapping("/basic/usims") ApiResponse usims(@AuthenticationPrincipal JwtUser u,@RequestParam(required=false,defaultValue="") String q){return list(Stock.class,u,Map.of("gubun","유심"),0,100);}
    @GetMapping("/basic/plans") ApiResponse plans(@AuthenticationPrincipal JwtUser u,@RequestParam(required=false,defaultValue="") String telecom){return list(PlanMaster.class,u,telecom==null||telecom.isBlank()?Map.of():Map.of("telecom",telecom),0,100);}
    @GetMapping("/basic/petnames") ApiResponse petnames(@AuthenticationPrincipal JwtUser u,@RequestParam(required=false,defaultValue="") String q){return list(Company.class,u,q.isBlank()?Map.of():Map.of("name",q),0,100);}
    @GetMapping("/stocks/have-own") ApiResponse haveOwn(@AuthenticationPrincipal JwtUser u, @RequestParam Map<String,String> f) {
        return ApiResponse.ok(haveOwnData(u, f));
    }
    @GetMapping(value="/stocks/have-own/export", produces="text/csv;charset=UTF-8") ResponseEntity<String> haveOwnExport(@AuthenticationPrincipal JwtUser u, @RequestParam Map<String,String> f) {
        Map<String,Object> data=haveOwnData(u,f);
        @SuppressWarnings("unchecked") List<String> models=(List<String>) data.get("models");
        @SuppressWarnings("unchecked") List<Map<String,Object>> rows=(List<Map<String,Object>>) data.get("rows");
        StringBuilder csv=new StringBuilder("\uFEFF채널,영업사원,출고처,전체");
        for(String m:models) csv.append(',').append(csv(m));
        csv.append('\n');
        for(Map<String,Object> r:rows) {
            csv.append(csv(r.get("channel"))).append(',').append(csv(r.get("salesperson"))).append(',')
                .append(csv(r.get("outcompanyName"))).append(',').append(csv(r.get("total")));
            @SuppressWarnings("unchecked") Map<String,Integer> counts=(Map<String,Integer>) r.get("counts");
            for(String m:models) csv.append(',').append(csv(counts.getOrDefault(m,0)==0?"-":counts.get(m)));
            csv.append('\n');
        }
        return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=have-own.csv").body(csv.toString());
    }
    private Map<String,Object> haveOwnData(JwtUser u, Map<String,String> f) {
        Map<Long,String> companies=companyNames(u);
        boolean includeColor="true".equalsIgnoreCase(f.getOrDefault("includeColor","false"));
        List<Stock> stocks=em.createQuery("select s from Stock s where s.groupId=:g",Stock.class).setParameter("g",u.getGroupId()).getResultList();
        List<Stock> held=stocks.stream().filter(s->{
            if(!Set.of("IN","OUT","RECOVERY").contains(s.getState()==null?"":s.getState())) return false;
            if(!eq(s.getGubun(),f.get("gubun"))) return false;
            if(!eqId(s.getIncompanyId(),f.get("incompanyId"))) return false;
            if(!eqId(s.getOutcompanyId(),f.get("outcompanyId"))) return false;
            return true;
        }).toList();
        LinkedHashSet<String> modelSet=new LinkedHashSet<>();
        Map<String,Map<String,Object>> rowMap=new LinkedHashMap<>();
        for(Stock s:held) {
            String channel=haveOwnChannel(s);
            String salesperson=blank(s.getSalesperson())?( "IN".equals(s.getState())?"본점":"-"):s.getSalesperson();
            String outName;
            Long outId=s.getOutcompanyId();
            if(outId!=null) outName=companies.getOrDefault(outId,"-");
            else if("IN".equals(s.getState())) outName="본점";
            else outName="-";
            String modelKey=blank(s.getModel())?"-":s.getModel();
            if(includeColor) {
                String color=blank(s.getColor())?"-":s.getColor();
                modelKey=modelKey+" / "+color;
            }
            modelSet.add(modelKey);
            String rowKey=channel+"|"+salesperson+"|"+(outId==null?"0":outId)+"|"+outName;
            Map<String,Object> row=rowMap.computeIfAbsent(rowKey,k->{
                Map<String,Object> r=new LinkedHashMap<>();
                r.put("channel",channel); r.put("salesperson",salesperson);
                r.put("outcompanyId",outId); r.put("outcompanyName",outName);
                r.put("total",0); r.put("counts",new LinkedHashMap<String,Integer>());
                return r;
            });
            @SuppressWarnings("unchecked") Map<String,Integer> counts=(Map<String,Integer>) row.get("counts");
            counts.put(modelKey, counts.getOrDefault(modelKey,0)+1);
            row.put("total", ((Integer)row.get("total"))+1);
        }
        List<String> models=modelSet.stream().sorted().toList();
        List<Map<String,Object>> rows=new ArrayList<>(rowMap.values());
        rows.sort(Comparator
            .comparing((Map<String,Object> r)->String.valueOf(r.get("channel")))
            .thenComparing(r->String.valueOf(r.get("salesperson")))
            .thenComparing(r->String.valueOf(r.get("outcompanyName"))));
        // row spans
        for(int i=0;i<rows.size();) {
            String ch=String.valueOf(rows.get(i).get("channel"));
            int chEnd=i;
            while(chEnd<rows.size() && ch.equals(String.valueOf(rows.get(chEnd).get("channel")))) chEnd++;
            for(int j=i;j<chEnd;) {
                String sp=String.valueOf(rows.get(j).get("salesperson"));
                int spEnd=j;
                while(spEnd<chEnd && sp.equals(String.valueOf(rows.get(spEnd).get("salesperson")))) spEnd++;
                for(int k=j;k<spEnd;k++) {
                    rows.get(k).put("channelRowSpan", k==i?chEnd-i:0);
                    rows.get(k).put("salespersonRowSpan", k==j?spEnd-j:0);
                }
                j=spEnd;
            }
            i=chEnd;
        }
        Map<String,Object> result=new LinkedHashMap<>();
        result.put("asOf", LocalDate.now().toString());
        result.put("title", LocalDate.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyy년 MM월 dd일"))+" 현재보유재고");
        result.put("models", models);
        result.put("rows", rows);
        result.put("totalQty", held.size());
        return result;
    }
    private String haveOwnChannel(Stock s) {
        if("도매".equals(s.getOutCategory())) return "도매";
        if("IN".equals(s.getState()) && s.getOutcompanyId()==null) return "본점";
        if("본사".equals(s.getSource()) || "판매점".equals(s.getOutCategory())) return "본점";
        return blank(s.getOutCategory())?"본점":s.getOutCategory();
    }
    @GetMapping("/stocks/have-model") ApiResponse haveModel(@AuthenticationPrincipal JwtUser u, @RequestParam Map<String,String> f) {
        return ApiResponse.ok(haveModelData(u, f));
    }
    @GetMapping(value="/stocks/have-model/export", produces="text/csv;charset=UTF-8") ResponseEntity<String> haveModelExport(@AuthenticationPrincipal JwtUser u, @RequestParam Map<String,String> f) {
        Map<String,Object> data=haveModelData(u,f);
        boolean totalsOnly="true".equalsIgnoreCase(f.getOrDefault("totalsOnly","false"));
        @SuppressWarnings("unchecked") List<String> holders=(List<String>) data.get("holders");
        @SuppressWarnings("unchecked") List<Map<String,Object>> rows=(List<Map<String,Object>>) data.get("rows");
        StringBuilder csv=new StringBuilder("\uFEFF종류,모델명,색상,전체,불량,본점");
        if(!totalsOnly) for(String h:holders) csv.append(',').append(csv(h));
        csv.append('\n');
        for(Map<String,Object> r:rows) {
            csv.append(csv(r.get("gubun"))).append(',').append(csv(r.get("model"))).append(',')
                .append(csv(r.get("color"))).append(',').append(csv(dashZero(r.get("total")))).append(',')
                .append(csv(dashZero(r.get("defect")))).append(',').append(csv(dashZero(r.get("hq"))));
            if(!totalsOnly) {
                @SuppressWarnings("unchecked") Map<String,Integer> counts=(Map<String,Integer>) r.get("counts");
                for(String h:holders) csv.append(',').append(csv(dashZero(counts.getOrDefault(h,0))));
            }
            csv.append('\n');
        }
        String filename=totalsOnly?"have-model-totals.csv":"have-model.csv";
        return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename="+filename).body(csv.toString());
    }
    private Object dashZero(Object v){ if(v==null) return "-"; if(v instanceof Number n && n.intValue()==0) return "-"; return v; }
    private Map<String,Object> haveModelData(JwtUser u, Map<String,String> f) {
        Map<Long,String> companies=companyNames(u);
        List<Stock> stocks=em.createQuery("select s from Stock s where s.groupId=:g",Stock.class).setParameter("g",u.getGroupId()).getResultList();
        List<Stock> held=stocks.stream().filter(s->{
            if(!Set.of("IN","OUT","RECOVERY").contains(s.getState()==null?"":s.getState())) return false;
            if(!eqId(s.getIncompanyId(),f.get("incompanyId"))) return false;
            if(!like(s.getModel(),f.get("model"))) return false;
            return true;
        }).toList();
        LinkedHashSet<String> holderSet=new LinkedHashSet<>();
        Map<String,Map<String,Object>> rowMap=new LinkedHashMap<>();
        for(Stock s:held) {
            String gubun=blank(s.getGubun())?"-":s.getGubun();
            String model=blank(s.getModel())?"-":s.getModel();
            String color=blank(s.getColor())?"-":s.getColor();
            String rowKey=gubun+"|"+model+"|"+color;
            Map<String,Object> row=rowMap.computeIfAbsent(rowKey,k->{
                Map<String,Object> r=new LinkedHashMap<>();
                r.put("gubun",gubun); r.put("model",model); r.put("color",color);
                r.put("total",0); r.put("defect",0); r.put("hq",0);
                r.put("counts",new LinkedHashMap<String,Integer>());
                return r;
            });
            row.put("total", ((Integer)row.get("total"))+1);
            if("불량".equals(s.getCond())) row.put("defect", ((Integer)row.get("defect"))+1);
            boolean isHq="IN".equals(s.getState()) && s.getOutcompanyId()==null;
            if(isHq) {
                row.put("hq", ((Integer)row.get("hq"))+1);
            } else {
                String holder;
                if(s.getOutcompanyId()!=null) holder=companies.getOrDefault(s.getOutcompanyId(),"-");
                else if(!blank(s.getSalesperson())) holder=s.getSalesperson();
                else holder="-";
                holderSet.add(holder);
                @SuppressWarnings("unchecked") Map<String,Integer> counts=(Map<String,Integer>) row.get("counts");
                counts.put(holder, counts.getOrDefault(holder,0)+1);
            }
        }
        List<String> holders=holderSet.stream().sorted().toList();
        List<Map<String,Object>> rows=new ArrayList<>(rowMap.values());
        rows.sort(Comparator
            .comparing((Map<String,Object> r)->String.valueOf(r.get("gubun")))
            .thenComparing(r->String.valueOf(r.get("model")))
            .thenComparing(r->String.valueOf(r.get("color"))));
        for(int i=0;i<rows.size();) {
            String gubun=String.valueOf(rows.get(i).get("gubun"));
            int gEnd=i;
            while(gEnd<rows.size() && gubun.equals(String.valueOf(rows.get(gEnd).get("gubun")))) gEnd++;
            for(int j=i;j<gEnd;) {
                String model=String.valueOf(rows.get(j).get("model"));
                int mEnd=j;
                while(mEnd<gEnd && model.equals(String.valueOf(rows.get(mEnd).get("model")))) mEnd++;
                for(int k=j;k<mEnd;k++) {
                    rows.get(k).put("gubunRowSpan", k==i?gEnd-i:0);
                    rows.get(k).put("modelRowSpan", k==j?mEnd-j:0);
                }
                j=mEnd;
            }
            i=gEnd;
        }
        Map<String,Object> result=new LinkedHashMap<>();
        result.put("asOf", LocalDate.now().toString());
        result.put("title", LocalDate.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyy년 MM월 dd일"))+" 현재보유재고");
        result.put("holders", holders);
        result.put("rows", rows);
        result.put("totalQty", held.size());
        return result;
    }
    @GetMapping("/stocks/turnover") ApiResponse turnover(@AuthenticationPrincipal JwtUser u, @RequestParam Map<String,String> f) {
        return ApiResponse.ok(turnoverData(u, f));
    }
    @GetMapping(value="/stocks/turnover/export", produces="text/csv;charset=UTF-8") ResponseEntity<String> turnoverExport(@AuthenticationPrincipal JwtUser u, @RequestParam Map<String,String> f) {
        Map<String,Object> data=turnoverData(u,f);
        String mode=String.valueOf(data.getOrDefault("mode","outcompany"));
        @SuppressWarnings("unchecked") List<Map<String,Object>> rows=(List<Map<String,Object>>) data.get("rows");
        StringBuilder csv=new StringBuilder("\uFEFF순위,");
        if("model".equals(mode)) csv.append("모델명,개통수,현재 재고수,회전율,회전일,개통예상\n");
        else csv.append("출고처,개통수,현재 재고수,회전율,회전일,개통예상,영업담당자\n");
        for(Map<String,Object> r:rows) {
            csv.append(csv(r.get("rank"))).append(',');
            if("model".equals(mode)) {
                csv.append(csv(r.get("model"))).append(',').append(csv(r.get("openCount"))).append(',')
                    .append(csv(r.get("stockCount"))).append(',').append(csv(r.get("turnoverRate"))).append(',')
                    .append(csv(r.get("turnoverDays"))).append(',').append(csv(r.get("expectedOpen"))).append('\n');
            } else {
                csv.append(csv(r.get("outcompanyName"))).append(',').append(csv(r.get("openCount"))).append(',')
                    .append(csv(r.get("stockCount"))).append(',').append(csv(r.get("turnoverRate"))).append(',')
                    .append(csv(r.get("turnoverDays"))).append(',').append(csv(r.get("expectedOpen"))).append(',')
                    .append(csv(r.get("salesperson"))).append('\n');
            }
        }
        return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=turnover.csv").body(csv.toString());
    }
    private Map<String,Object> turnoverData(JwtUser u, Map<String,String> f) {
        String mode=f.getOrDefault("mode","outcompany");
        Map<Long,String> companies=companyNames(u);
        List<Stock> stocks=em.createQuery("select s from Stock s where s.groupId=:g",Stock.class).setParameter("g",u.getGroupId()).getResultList()
            .stream().filter(s->!"유심".equals(s.getGubun())).toList();
        List<OpenRecord> opens=em.createQuery("select o from OpenRecord o where o.groupId=:g",OpenRecord.class).setParameter("g",u.getGroupId()).getResultList();
        Map<String,int[]> agg=new LinkedHashMap<>();
        Map<String,String> salesMap=new LinkedHashMap<>();
        for(OpenRecord o:opens) {
            String key="model".equals(mode)
                ? turnoverModelKey(o.getPmodel(), o.getPcolor())
                : (o.getOutcompanyId()==null?"본점":companies.getOrDefault(o.getOutcompanyId(),"-"));
            int[] v=agg.computeIfAbsent(key,k->new int[]{0,0});
            v[0]++;
        }
        for(Stock s:stocks) {
            if("OPEN".equals(s.getState())) {
                String key="model".equals(mode)
                    ? turnoverModelKey(s.getModel(), s.getColor())
                    : (s.getOutcompanyId()==null?"본점":companies.getOrDefault(s.getOutcompanyId(),"-"));
                int[] v=agg.computeIfAbsent(key,k->new int[]{0,0});
                v[0]++;
                if(!blank(s.getSalesperson())) salesMap.putIfAbsent(key, s.getSalesperson());
            }
            if(Set.of("IN","OUT","RECOVERY").contains(s.getState()==null?"":s.getState())) {
                String key="model".equals(mode)
                    ? turnoverModelKey(s.getModel(), s.getColor())
                    : (s.getOutcompanyId()==null?"본점":companies.getOrDefault(s.getOutcompanyId(),"-"));
                if(!"model".equals(mode) && "IN".equals(s.getState()) && s.getOutcompanyId()!=null) continue;
                int[] v=agg.computeIfAbsent(key,k->new int[]{0,0});
                v[1]++;
                if(!blank(s.getSalesperson())) salesMap.putIfAbsent(key, s.getSalesperson());
            }
        }
        List<Map<String,Object>> rows=new ArrayList<>();
        for(var e:agg.entrySet()) {
            int openCount=e.getValue()[0], stockCount=e.getValue()[1];
            if(openCount==0 && stockCount==0) continue;
            double rate=stockCount>0 ? Math.round(openCount * 10000.0 / stockCount) / 100.0 : 0;
            int days=openCount>0 ? (int)Math.round(stockCount * 10.0 / openCount) : 0;
            Map<String,Object> row=new LinkedHashMap<>();
            if("model".equals(mode)) row.put("model", e.getKey());
            else row.put("outcompanyName", e.getKey());
            row.put("openCount", openCount);
            row.put("stockCount", stockCount);
            row.put("turnoverRate", rate);
            row.put("turnoverDays", days);
            // 10일 기준 개통수를 월(30일) 예상으로 환산
            row.put("expectedOpen", openCount == 0 ? 0 : openCount * 3);
            row.put("salesperson", salesMap.getOrDefault(e.getKey(),"-"));
            rows.add(row);
        }
        String sort=f.getOrDefault("sort","turnoverRate,desc");
        boolean asc=sort.endsWith(",asc");
        String field=sort.split(",")[0];
        rows.sort((a,b)->{
            int cmp=switch(field){
                case "outcompanyName"->String.valueOf(a.get("outcompanyName")).compareTo(String.valueOf(b.get("outcompanyName")));
                case "model"->String.valueOf(a.get("model")).compareTo(String.valueOf(b.get("model")));
                case "openCount"->Integer.compare((Integer)a.get("openCount"),(Integer)b.get("openCount"));
                case "stockCount"->Integer.compare((Integer)a.get("stockCount"),(Integer)b.get("stockCount"));
                case "turnoverDays"->Integer.compare((Integer)a.get("turnoverDays"),(Integer)b.get("turnoverDays"));
                case "expectedOpen"->Integer.compare((Integer)a.get("expectedOpen"),(Integer)b.get("expectedOpen"));
                case "salesperson"->String.valueOf(a.get("salesperson")).compareTo(String.valueOf(b.get("salesperson")));
                default->Double.compare(((Number)a.get("turnoverRate")).doubleValue(),((Number)b.get("turnoverRate")).doubleValue());
            };
            return asc?cmp:-cmp;
        });
        for(int i=0;i<rows.size();i++) rows.get(i).put("rank", i+1);
        LocalDate now=LocalDate.now();
        String info="* 이 정보는 "+now.getYear()+"년 "+String.format("%02d", now.getMonthValue())+"월말 개통 및 재고이며, 현재시간 "
            +String.format("%02d", now.getMonthValue())+"월 "+String.format("%02d", now.getDayOfMonth())+"일 "
            +java.time.LocalTime.now().format(java.time.format.DateTimeFormatter.ofPattern("HH시 mm분"))
            +" 기준으로 작성되었습니다.";
        Map<String,Object> result=new LinkedHashMap<>();
        result.put("mode", mode);
        result.put("info", info);
        result.put("rows", rows);
        result.put("total", rows.size());
        return result;
    }
    private String turnoverModelKey(String model, String color) {
        String m=blank(model)?"-":model.trim();
        if(blank(color)) return m;
        return m+" "+color.trim();
    }
    @GetMapping("/reports/mon-open-stats") ApiResponse monOpenStats(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f){
        return ApiResponse.ok(monOpenStatsData(u,f));
    }
    @GetMapping(value="/reports/mon-open-stats/export", produces="text/csv;charset=UTF-8") ResponseEntity<String> monOpenStatsExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f){
        Map<String,Object> data=monOpenStatsData(u,f);
        String mode=String.valueOf(data.getOrDefault("mode","outcompany"));
        String firstCol=switch(mode){ case "incompany"->"입고처"; case "model"->"모델명"; default->"출고처"; };
        @SuppressWarnings("unchecked") List<Map<String,Object>> rows=(List<Map<String,Object>>) data.get("rows");
        StringBuilder csv=new StringBuilder("\uFEFF").append(firstCol);
        for(int m=1;m<=12;m++) csv.append(',').append(m).append("월");
        csv.append(",합계\n");
        for(Map<String,Object> r:rows){
            csv.append(csv(r.get("name")));
            @SuppressWarnings("unchecked") List<Integer> months=(List<Integer>) r.get("months");
            for(int m=0;m<12;m++) csv.append(',').append(csv(dashZero(months.get(m))));
            csv.append(',').append(csv(dashZero(r.get("total")))).append('\n');
        }
        @SuppressWarnings("unchecked") Map<String,Object> totals=(Map<String,Object>) data.get("totals");
        if(totals!=null){
            csv.append(csv("합계"));
            @SuppressWarnings("unchecked") List<Integer> months=(List<Integer>) totals.get("months");
            for(int m=0;m<12;m++) csv.append(',').append(csv(dashZero(months.get(m))));
            csv.append(',').append(csv(dashZero(totals.get("total")))).append('\n');
        }
        return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=mon-open-stats.csv").body(csv.toString());
    }
    @GetMapping("/reports/open-charts") ApiResponse openCharts(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f){
        return ApiResponse.ok(openChartsData(u,f));
    }
    private Map<String,Object> openChartsData(JwtUser u, Map<String,String> f){
        String mode=f.getOrDefault("mode","outcompany");
        return switch(mode){
            case "outcompany-annual"->openChartAnnual(u,f,"outcompany");
            case "sales-annual"->openChartSalesAnnual(u,f);
            case "sales"->openChartPeriod(u,f,"sales");
            default->openChartPeriod(u,f,"outcompany");
        };
    }
    private Map<String,Object> openChartPeriod(JwtUser u, Map<String,String> f, String dim){
        LocalDate from=parseDate(f.get("from"), LocalDate.now().minusDays(29));
        LocalDate to=parseDate(f.get("to"), LocalDate.now());
        if(to.isBefore(from)){ LocalDate t=from; from=to; to=t; }
        Map<Long,String> companies=companyNames(u);
        Map<String,Integer> counts=new LinkedHashMap<>();
        if("outcompany".equals(dim)){
            List<Company> outs=em.createQuery("select c from Company c where c.groupId=:g and c.type in ('OUT','SHOP') order by c.name",Company.class)
                .setParameter("g",u.getGroupId()).getResultList();
            for(Company c:outs) counts.putIfAbsent(blank(c.getName())?"-":c.getName(),0);
        }
        List<OpenRecord> opens=em.createQuery("select o from OpenRecord o where o.groupId=:g and o.opendate between :a and :b",OpenRecord.class)
            .setParameter("g",u.getGroupId()).setParameter("a",from).setParameter("b",to).getResultList();
        for(OpenRecord o:opens){
            if(!eq(o.getTelecom(),f.get("telecom"))) continue;
            String key;
            if("sales".equals(dim)){
                if(blank(o.getSalesperson())) continue;
                key=o.getSalesperson().trim();
            } else {
                key=o.getOutcompanyId()==null?"본점":companies.getOrDefault(o.getOutcompanyId(),"-");
                if("-".equals(key)) continue;
            }
            counts.put(key, counts.getOrDefault(key,0)+1);
        }
        if("sales".equals(dim) && counts.isEmpty()){
            // keep empty chart categories from known salespeople on stocks
            List<String> names=em.createQuery("select distinct s.salesperson from Stock s where s.groupId=:g and s.salesperson is not null and s.salesperson<>''",String.class)
                .setParameter("g",u.getGroupId()).getResultList();
            for(String n:names) counts.putIfAbsent(n,0);
        }
        String sort=f.getOrDefault("sort","name");
        List<Map.Entry<String,Integer>> entries=new ArrayList<>(counts.entrySet());
        if("count".equals(sort) || "value".equals(sort))
            entries.sort((a,b)->{
                int c=Integer.compare(b.getValue(),a.getValue());
                return c!=0?c:a.getKey().compareTo(b.getKey());
            });
        else entries.sort(Map.Entry.comparingByKey());
        List<Map<String,Object>> items=new ArrayList<>();
        for(var e:entries){
            Map<String,Object> row=new LinkedHashMap<>();
            row.put("name", e.getKey());
            row.put("value", e.getValue());
            items.add(row);
        }
        Map<String,Object> result=new LinkedHashMap<>();
        result.put("mode", "sales".equals(dim)?"sales":"outcompany");
        result.put("chartType", "bar");
        result.put("title", "sales".equals(dim)?"영업사원별 개통":"출고처별 개통");
        result.put("subtitle", "기간 : "+from+" ~ "+to);
        result.put("from", from.toString());
        result.put("to", to.toString());
        result.put("items", items);
        return result;
    }
    private Map<String,Object> openChartAnnual(JwtUser u, Map<String,String> f, String dim){
        int year=parseInt(f.get("year"), LocalDate.now().getYear());
        LocalDate from=LocalDate.of(year,1,1);
        LocalDate to=LocalDate.of(year,12,31);
        Map<Long,String> companies=companyNames(u);
        Map<String,int[]> seriesMap=new LinkedHashMap<>();
        if("outcompany".equals(dim)){
            List<Company> outs=em.createQuery("select c from Company c where c.groupId=:g and c.type in ('OUT','SHOP') order by c.name",Company.class)
                .setParameter("g",u.getGroupId()).getResultList();
            for(Company c:outs) seriesMap.putIfAbsent(blank(c.getName())?"-":c.getName(), new int[12]);
        }
        List<OpenRecord> opens=em.createQuery("select o from OpenRecord o where o.groupId=:g and o.opendate between :a and :b",OpenRecord.class)
            .setParameter("g",u.getGroupId()).setParameter("a",from).setParameter("b",to).getResultList();
        for(OpenRecord o:opens){
            if(!eq(o.getTelecom(),f.get("telecom"))) continue;
            String key=o.getOutcompanyId()==null?"본점":companies.getOrDefault(o.getOutcompanyId(),"-");
            if("-".equals(key)) continue;
            int[] arr=seriesMap.computeIfAbsent(key,k->new int[12]);
            arr[o.getOpendate().getMonthValue()-1]++;
        }
        List<String> categories=new ArrayList<>();
        for(int m=1;m<=12;m++) categories.add(m+"월");
        List<Map<String,Object>> series=new ArrayList<>();
        for(var e:seriesMap.entrySet()){
            Map<String,Object> s=new LinkedHashMap<>();
            s.put("name", e.getKey());
            List<Integer> data=new ArrayList<>(12);
            for(int v:e.getValue()) data.add(v);
            s.put("data", data);
            series.add(s);
        }
        series.sort(Comparator.comparing(s->String.valueOf(s.get("name"))));
        Map<String,Object> result=new LinkedHashMap<>();
        result.put("mode", "outcompany-annual");
        result.put("chartType", "line");
        result.put("title", "출고처별 개통");
        result.put("subtitle", year+"년");
        result.put("year", year);
        result.put("categories", categories);
        result.put("series", series);
        return result;
    }
    private Map<String,Object> openChartSalesAnnual(JwtUser u, Map<String,String> f){
        int year=parseInt(f.get("year"), LocalDate.now().getYear());
        int month=parseInt(f.get("month"), LocalDate.now().getMonthValue());
        if(month<1||month>12) month=LocalDate.now().getMonthValue();
        LocalDate end=LocalDate.of(year, month, 1);
        LocalDate start=end.minusMonths(11);
        LocalDate from=start.withDayOfMonth(1);
        LocalDate to=end.withDayOfMonth(end.lengthOfMonth());
        Map<String,int[]> seriesMap=new LinkedHashMap<>();
        List<OpenRecord> opens=em.createQuery("select o from OpenRecord o where o.groupId=:g and o.opendate between :a and :b",OpenRecord.class)
            .setParameter("g",u.getGroupId()).setParameter("a",from).setParameter("b",to).getResultList();
        for(OpenRecord o:opens){
            if(!eq(o.getTelecom(),f.get("telecom"))) continue;
            if(blank(o.getSalesperson())) continue;
            String key=o.getSalesperson().trim();
            int[] arr=seriesMap.computeIfAbsent(key,k->new int[12]);
            int idx=monthIndexInWindow(o.getOpendate(), start);
            if(idx>=0) arr[idx]++;
        }
        if(seriesMap.isEmpty()){
            List<String> names=em.createQuery("select distinct s.salesperson from Stock s where s.groupId=:g and s.salesperson is not null and s.salesperson<>''",String.class)
                .setParameter("g",u.getGroupId()).getResultList();
            for(String n:names) seriesMap.putIfAbsent(n, new int[12]);
        }
        List<String> categories=new ArrayList<>();
        for(int i=0;i<12;i++){
            LocalDate d=start.plusMonths(i);
            categories.add(d.getMonthValue()+"월");
        }
        int[] sum=new int[12];
        int nSeries=Math.max(seriesMap.size(),1);
        List<Map<String,Object>> series=new ArrayList<>();
        List<String> keys=new ArrayList<>(seriesMap.keySet());
        keys.sort(String::compareTo);
        for(String key:keys){
            int[] arr=seriesMap.get(key);
            Map<String,Object> s=new LinkedHashMap<>();
            s.put("name", key);
            s.put("type", "bar");
            List<Integer> data=new ArrayList<>(12);
            for(int i=0;i<12;i++){ data.add(arr[i]); sum[i]+=arr[i]; }
            s.put("data", data);
            series.add(s);
        }
        List<Double> avg=new ArrayList<>(12);
        for(int i=0;i<12;i++) avg.add(Math.round(sum[i]*100.0/nSeries)/100.0);
        Map<String,Object> avgSeries=new LinkedHashMap<>();
        avgSeries.put("name", "평균");
        avgSeries.put("type", "line");
        avgSeries.put("data", avg);
        series.add(avgSeries);
        Map<String,Object> result=new LinkedHashMap<>();
        result.put("mode", "sales-annual");
        result.put("chartType", "combo");
        result.put("title", "영업사원별 개통");
        result.put("subtitle", year+"년 "+String.format("%02d", month)+"월");
        result.put("year", year);
        result.put("month", month);
        result.put("categories", categories);
        result.put("series", series);
        return result;
    }
    private int monthIndexInWindow(LocalDate date, LocalDate windowStart){
        if(date==null) return -1;
        int idx=(date.getYear()-windowStart.getYear())*12+(date.getMonthValue()-windowStart.getMonthValue());
        return (idx>=0 && idx<12)?idx:-1;
    }
    private LocalDate parseDate(String v, LocalDate fallback){
        if(blank(v)) return fallback;
        try{ return LocalDate.parse(v); } catch(Exception e){ return fallback; }
    }
    private int parseInt(String v, int fallback){
        if(blank(v)) return fallback;
        try{ return Integer.parseInt(v); } catch(Exception e){ return fallback; }
    }
    private Map<String,Object> monOpenStatsData(JwtUser u, Map<String,String> f){
        String mode=f.getOrDefault("mode","outcompany");
        int year;
        try{ year=Integer.parseInt(f.getOrDefault("year",String.valueOf(LocalDate.now().getYear()))); }
        catch(Exception e){ year=LocalDate.now().getYear(); }
        LocalDate from=LocalDate.of(year,1,1);
        LocalDate to=LocalDate.of(year,12,31);
        Map<Long,String> companies=companyNames(u);
        Map<String,int[]> agg=new LinkedHashMap<>();
        if("outcompany".equals(mode)){
            List<Company> outs=em.createQuery("select c from Company c where c.groupId=:g and c.type in ('OUT','SHOP') order by c.name",Company.class)
                .setParameter("g",u.getGroupId()).getResultList();
            for(Company c:outs) agg.putIfAbsent(c.getName()==null?"-":c.getName(), new int[12]);
        } else if("incompany".equals(mode)){
            List<Company> ins=em.createQuery("select c from Company c where c.groupId=:g and c.type='IN' order by c.name",Company.class)
                .setParameter("g",u.getGroupId()).getResultList();
            for(Company c:ins) agg.putIfAbsent(c.getName()==null?"-":c.getName(), new int[12]);
        }
        List<OpenRecord> opens=em.createQuery("select o from OpenRecord o where o.groupId=:g and o.opendate between :a and :b",OpenRecord.class)
            .setParameter("g",u.getGroupId()).setParameter("a",from).setParameter("b",to).getResultList();
        for(OpenRecord o:opens){
            if(!eq(o.getTelecom(),f.get("telecom"))) continue;
            if(!eq(o.getOutCategory(),f.get("outCategory"))) continue;
            if(!eqId(o.getIncompanyId(),f.get("incompanyId"))) continue;
            if(!eqId(o.getOutcompanyId(),f.get("outcompanyId"))) continue;
            String key;
            if("incompany".equals(mode)){
                if(o.getIncompanyId()==null) continue;
                key=companies.getOrDefault(o.getIncompanyId(),"-");
            } else if("model".equals(mode)){
                if(blank(o.getPmodel())) continue;
                key=o.getPmodel().trim();
            } else {
                key=o.getOutcompanyId()==null?"본점":companies.getOrDefault(o.getOutcompanyId(),"-");
            }
            if("-".equals(key)) continue;
            int[] months=agg.computeIfAbsent(key,k->new int[12]);
            int mi=o.getOpendate().getMonthValue()-1;
            months[mi]++;
        }
        List<Map<String,Object>> rows=new ArrayList<>();
        int[] totalMonths=new int[12];
        int grand=0;
        List<String> keys=new ArrayList<>(agg.keySet());
        keys.sort(String::compareTo);
        long id=1;
        for(String key:keys){
            int[] months=agg.get(key);
            int rowTotal=0;
            List<Integer> monthList=new ArrayList<>(12);
            for(int i=0;i<12;i++){ monthList.add(months[i]); rowTotal+=months[i]; totalMonths[i]+=months[i]; }
            if("model".equals(mode) && rowTotal==0) continue;
            grand+=rowTotal;
            Map<String,Object> row=new LinkedHashMap<>();
            row.put("id", id++);
            row.put("name", key);
            row.put("months", monthList);
            row.put("total", rowTotal);
            rows.add(row);
        }
        List<Integer> totalMonthList=new ArrayList<>(12);
        for(int v:totalMonths) totalMonthList.add(v);
        Map<String,Object> totals=new LinkedHashMap<>();
        totals.put("id", -1L);
        totals.put("name", "합계");
        totals.put("months", totalMonthList);
        totals.put("total", grand);
        Map<String,Object> result=new LinkedHashMap<>();
        result.put("mode", mode);
        result.put("year", year);
        result.put("nameLabel", switch(mode){ case "incompany"->"입고처"; case "model"->"모델명"; default->"출고처"; });
        result.put("rows", rows);
        result.put("totals", totals);
        return result;
    }
    @GetMapping("/reports/open-stats") ApiResponse openStats(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f){
        String mode=f.getOrDefault("mode","daily");
        if("monthly".equals(mode)) return ApiResponse.ok(openStatsMonthly(u,f));
        return ApiResponse.ok(openStatsDaily(u,f));
    }
    @GetMapping(value="/reports/open-stats/export", produces="text/csv;charset=UTF-8") ResponseEntity<String> openStatsExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f){
        String mode=f.getOrDefault("mode","daily");
        Map<String,Object> data="monthly".equals(mode)?openStatsMonthly(u,f):openStatsDaily(u,f);
        @SuppressWarnings("unchecked") List<Map<String,Object>> rows=(List<Map<String,Object>>) data.get("rows");
        String firstCol="monthly".equals(mode)?"월":"일자";
        StringBuilder csv=new StringBuilder("\uFEFF").append(firstCol).append(",신규,MNP,보상,기변,에이징,가개통,선불개통,합계,정산금액,후정산,후정산금액,철회,마진\n");
        for(Map<String,Object> r:rows) appendOpenStatCsv(csv,r,"monthly".equals(mode)?"label":"label");
        @SuppressWarnings("unchecked") Map<String,Object> totals=(Map<String,Object>) data.get("totals");
        if(totals!=null){ totals=new LinkedHashMap<>(totals); totals.put("label","합계"); appendOpenStatCsv(csv,totals,"label"); }
        return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=open-stats.csv").body(csv.toString());
    }
    private void appendOpenStatCsv(StringBuilder csv,Map<String,Object> r,String labelKey){
        csv.append(csv(r.get(labelKey))).append(',').append(csv(r.get("newCount"))).append(',').append(csv(r.get("mnpCount"))).append(',')
            .append(csv(r.get("rewardCount"))).append(',').append(csv(r.get("changeCount"))).append(',').append(csv(r.get("agingCount"))).append(',')
            .append(csv(r.get("tempCount"))).append(',').append(csv(r.get("prepaidCount"))).append(',').append(csv(r.get("totalCount"))).append(',')
            .append(csv(r.get("settleAmount"))).append(',').append(csv(r.get("afterCount"))).append(',').append(csv(r.get("afterAmount"))).append(',')
            .append(csv(r.get("withdrawCount"))).append(',').append(csv(r.get("margin"))).append('\n');
    }
    @GetMapping("/reports/home-stats") ApiResponse homeStats(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f){
        String mode=f.getOrDefault("mode","daily");
        if("monthly".equals(mode)) return ApiResponse.ok(homeStatsMonthly(u,f));
        return ApiResponse.ok(homeStatsDaily(u,f));
    }
    @GetMapping(value="/reports/home-stats/export", produces="text/csv;charset=UTF-8") ResponseEntity<String> homeStatsExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f){
        String mode=f.getOrDefault("mode","daily");
        Map<String,Object> data="monthly".equals(mode)?homeStatsMonthly(u,f):homeStatsDaily(u,f);
        @SuppressWarnings("unchecked") List<Map<String,Object>> rows=(List<Map<String,Object>>) data.get("rows");
        String firstCol="monthly".equals(mode)?"월":"일자";
        StringBuilder csv=new StringBuilder("\uFEFF").append(firstCol).append(",인터넷,TV,집전화,인터넷전화,신용카드,홈IoT,동판,기타,후결합,재약정,소계,합계,정산금액\n");
        for(Map<String,Object> r:rows) appendHomeStatCsv(csv,r);
        @SuppressWarnings("unchecked") Map<String,Object> totals=(Map<String,Object>) data.get("totals");
        if(totals!=null){ totals=new LinkedHashMap<>(totals); totals.put("label","합계"); appendHomeStatCsv(csv,totals); }
        return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=home-stats.csv").body(csv.toString());
    }
    private void appendHomeStatCsv(StringBuilder csv,Map<String,Object> r){
        csv.append(csv(r.get("label"))).append(',').append(csv(r.get("internet"))).append(',').append(csv(r.get("tv"))).append(',')
            .append(csv(r.get("homePhone"))).append(',').append(csv(r.get("voip"))).append(',').append(csv(r.get("creditCard"))).append(',')
            .append(csv(r.get("homeIot"))).append(',').append(csv(r.get("dongpan"))).append(',').append(csv(r.get("etc"))).append(',')
            .append(csv(r.get("postBind"))).append(',').append(csv(r.get("renew"))).append(',').append(csv(r.get("subtotal"))).append(',')
            .append(csv(r.get("saleCount"))).append(',').append(csv(r.get("settleAmount"))).append('\n');
    }
    private Map<String,Object> homeStatsDaily(JwtUser u,Map<String,String> f){
        int year=Integer.parseInt(f.getOrDefault("year",String.valueOf(LocalDate.now().getYear())));
        int month=Integer.parseInt(f.getOrDefault("month",String.valueOf(LocalDate.now().getMonthValue())));
        LocalDate from=LocalDate.of(year,month,1);
        LocalDate to=from.withDayOfMonth(from.lengthOfMonth());
        Map<Integer,Map<String,Object>> byDay=new LinkedHashMap<>();
        for(int d=1;d<=to.getDayOfMonth();d++) byDay.put(d,emptyHomeStatRow(d+"일("+weekdayKo(from.withDayOfMonth(d))+")",d));
        for(HomeSale h:loadHomeSales(u,f,from,to)){
            Map<String,Object> target=byDay.get(h.getInstallDate().getDayOfMonth());
            if(target!=null) applyHomeSale(target,h);
        }
        List<Map<String,Object>> rows=new ArrayList<>(byDay.values());
        Map<String,Object> out=new LinkedHashMap<>();
        out.put("mode","daily"); out.put("year",year); out.put("month",month);
        out.put("title",year+"년 "+month+"월"); out.put("rows",rows); out.put("totals",sumHomeStatRows(rows));
        out.put("today",LocalDate.now().getYear()==year&&LocalDate.now().getMonthValue()==month?LocalDate.now().getDayOfMonth():null);
        return out;
    }
    private Map<String,Object> homeStatsMonthly(JwtUser u,Map<String,String> f){
        int year=Integer.parseInt(f.getOrDefault("year",String.valueOf(LocalDate.now().getYear())));
        LocalDate from=LocalDate.of(year,1,1);
        LocalDate to=LocalDate.of(year,12,31);
        Map<Integer,Map<String,Object>> byMonth=new LinkedHashMap<>();
        for(int m=1;m<=12;m++) byMonth.put(m,emptyHomeStatRow(m+"월",m));
        for(HomeSale h:loadHomeSales(u,f,from,to)){
            Map<String,Object> target=byMonth.get(h.getInstallDate().getMonthValue());
            if(target!=null) applyHomeSale(target,h);
        }
        List<Map<String,Object>> rows=new ArrayList<>(byMonth.values());
        Map<String,Object> out=new LinkedHashMap<>();
        out.put("mode","monthly"); out.put("year",year);
        out.put("title",year+"년"); out.put("rows",rows); out.put("totals",sumHomeStatRows(rows));
        out.put("currentMonth",LocalDate.now().getYear()==year?LocalDate.now().getMonthValue():null);
        return out;
    }
    private List<HomeSale> loadHomeSales(JwtUser u,Map<String,String> f,LocalDate from,LocalDate to){
        List<HomeSale> all=em.createQuery("select h from HomeSale h where h.groupId=:g and h.installDate between :a and :b",HomeSale.class)
            .setParameter("g",u.getGroupId()).setParameter("a",from).setParameter("b",to).getResultList();
        return all.stream().filter(h->{
            if(!"완료".equals(h.getStatus())&&!"판매완료".equals(h.getStatus())) return false;
            if(!eq(h.getTelecom(),f.get("telecom"))) return false;
            if(!eqId(h.getIncompanyId(),f.get("incompanyId"))) return false;
            if(!eqId(h.getOutcompanyId(),f.get("outcompanyId"))) return false;
            if(!eq(h.getOutCategory(),f.get("outCategory"))) return false;
            return h.getInstallDate()!=null;
        }).toList();
    }
    private void applyHomeSale(Map<String,Object> target,HomeSale h){
        addInt(target,"saleCount",1);
        addDec(target,"settleAmount",nvl(h.getSettleAmount()));
        String products=h.getProducts()==null?"":h.getProducts();
        int productHits=0;
        for(String raw:products.split("[,/|]")){
            String token=raw==null?"":raw.trim();
            if(token.isEmpty()) continue;
            String bucket=homeProductBucket(token);
            if(bucket==null) continue;
            addInt(target,bucket,1);
            productHits++;
        }
        if(productHits==0){ addInt(target,"etc",1); productHits=1; }
        addInt(target,"subtotal",productHits);
    }
    private String homeProductBucket(String token){
        String t=token.replace(" ","");
        if(t.equalsIgnoreCase("인터넷")) return "internet";
        if(t.equalsIgnoreCase("TV")||t.equals("티비")) return "tv";
        if(t.equals("집전화")) return "homePhone";
        if(t.equals("인터넷전화")) return "voip";
        if(t.equals("신용카드")) return "creditCard";
        if(t.equalsIgnoreCase("홈IoT")||t.equalsIgnoreCase("홈IOT")||t.equals("홈아이오티")) return "homeIot";
        if(t.equals("동판")) return "dongpan";
        if(t.equals("후결합")) return "postBind";
        if(t.equals("재약정")) return "renew";
        return "etc";
    }
    private Map<String,Object> emptyHomeStatRow(String label,int key){
        Map<String,Object> r=new LinkedHashMap<>();
        r.put("key",key); r.put("label",label);
        r.put("internet",0); r.put("tv",0); r.put("homePhone",0); r.put("voip",0); r.put("creditCard",0);
        r.put("homeIot",0); r.put("dongpan",0); r.put("etc",0); r.put("postBind",0); r.put("renew",0);
        r.put("subtotal",0); r.put("saleCount",0); r.put("settleAmount",BigDecimal.ZERO);
        return r;
    }
    private void mergeHomeStat(Map<String,Object> target,Map<String,Object> src){
        for(String k:List.of("internet","tv","homePhone","voip","creditCard","homeIot","dongpan","etc","postBind","renew","subtotal","saleCount"))
            addInt(target,k,((Number)src.getOrDefault(k,0)).intValue());
        addDec(target,"settleAmount",nvl((BigDecimal)src.get("settleAmount")));
    }
    private Map<String,Object> sumHomeStatRows(List<Map<String,Object>> rows){
        Map<String,Object> t=emptyHomeStatRow("합계",0);
        for(Map<String,Object> r:rows) mergeHomeStat(t,r);
        return t;
    }
    @GetMapping("/reports/outcompany-stats") ApiResponse outcompanyStats(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f){
        return ApiResponse.ok(outcompanyStatsData(u,f));
    }
    @GetMapping(value="/reports/outcompany-stats/export", produces="text/csv;charset=UTF-8") ResponseEntity<String> outcompanyStatsExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f){
        Map<String,Object> data=outcompanyStatsData(u,f);
        @SuppressWarnings("unchecked") List<Map<String,Object>> rows=(List<Map<String,Object>>) data.get("rows");
        StringBuilder csv=new StringBuilder("\uFEFF지역,출고처,개통,개통금액,후정산,후정산금액,홈상품,홈상품금액,실정산금액,마진\n");
        for(Map<String,Object> r:rows){
            csv.append(csv(r.get("region"))).append(',').append(csv(r.get("name"))).append(',')
                .append(csv(r.get("openCount"))).append(',').append(csv(r.get("openAmount"))).append(',')
                .append(csv(r.get("afterCount"))).append(',').append(csv(r.get("afterAmount"))).append(',')
                .append(csv(r.get("homeCount"))).append(',').append(csv(r.get("homeAmount"))).append(',')
                .append(csv(r.get("realSettleAmount"))).append(',').append(csv(r.get("margin"))).append('\n');
        }
        @SuppressWarnings("unchecked") Map<String,Object> totals=(Map<String,Object>) data.get("totals");
        if(totals!=null){
            csv.append(csv("합계")).append(',').append(csv("")).append(',')
                .append(csv(totals.get("openCount"))).append(',').append(csv(totals.get("openAmount"))).append(',')
                .append(csv(totals.get("afterCount"))).append(',').append(csv(totals.get("afterAmount"))).append(',')
                .append(csv(totals.get("homeCount"))).append(',').append(csv(totals.get("homeAmount"))).append(',')
                .append(csv(totals.get("realSettleAmount"))).append(',').append(csv(totals.get("margin"))).append('\n');
        }
        return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=outcompany-stats.csv").body(csv.toString());
    }
    private Map<String,Object> outcompanyStatsData(JwtUser u,Map<String,String> f){
        int year=Integer.parseInt(f.getOrDefault("year",String.valueOf(LocalDate.now().getYear())));
        int month=Integer.parseInt(f.getOrDefault("month",String.valueOf(LocalDate.now().getMonthValue())));
        LocalDate from=LocalDate.of(year,month,1);
        LocalDate to=from.withDayOfMonth(from.lengthOfMonth());
        Map<Long,String> companies=companyNames(u);
        Map<Long,Map<String,Object>> byOut=new LinkedHashMap<>();
        List<OpenRecord> opens=em.createQuery("select o from OpenRecord o where o.groupId=:g and o.opendate between :a and :b",OpenRecord.class)
            .setParameter("g",u.getGroupId()).setParameter("a",from).setParameter("b",to).getResultList();
        for(OpenRecord o:opens){
            if(o.getOutcompanyId()==null) continue;
            if(!eq(o.getTelecom(),f.get("telecom"))) continue;
            if(!eqId(o.getIncompanyId(),f.get("incompanyId"))) continue;
            if(!eq(o.getOutCategory(),f.get("outCategory"))) continue;
            Map<String,Object> row=outcompanyStatRow(byOut,o.getOutcompanyId(),companies);
            addInt(row,"openCount",1);
            addDec(row,"openAmount",nvl(o.getSellprice()));
            addDec(row,"margin",nvl(o.getDealermargin1()).add(nvl(o.getDealermargin2())));
        }
        List<AfterBalance> afters=em.createQuery("select b from AfterBalance b where b.groupId=:g and b.basedate between :a and :b",AfterBalance.class)
            .setParameter("g",u.getGroupId()).setParameter("a",from).setParameter("b",to).getResultList();
        for(AfterBalance b:afters){
            if(b.getOutcompanyId()==null) continue;
            if(!eq(b.getTelecom(),f.get("telecom"))) continue;
            if(!eq(b.getOutCategory(),f.get("outCategory"))) continue;
            Map<String,Object> row=outcompanyStatRow(byOut,b.getOutcompanyId(),companies);
            addInt(row,"afterCount",1);
            addDec(row,"afterAmount",nvl(b.getAmount()));
        }
        List<HomeSale> homes=em.createQuery("select h from HomeSale h where h.groupId=:g and h.installDate between :a and :b",HomeSale.class)
            .setParameter("g",u.getGroupId()).setParameter("a",from).setParameter("b",to).getResultList();
        for(HomeSale h:homes){
            if(h.getOutcompanyId()==null) continue;
            if(!"완료".equals(h.getStatus())&&!"판매완료".equals(h.getStatus())) continue;
            if(!eq(h.getTelecom(),f.get("telecom"))) continue;
            if(!eqId(h.getIncompanyId(),f.get("incompanyId"))) continue;
            if(!eq(h.getOutCategory(),f.get("outCategory"))) continue;
            Map<String,Object> row=outcompanyStatRow(byOut,h.getOutcompanyId(),companies);
            addInt(row,"homeCount",1);
            addDec(row,"homeAmount",nvl(h.getSettleAmount()));
        }
        List<Map<String,Object>> rows=new ArrayList<>();
        for(Map<String,Object> r:byOut.values()){
            BigDecimal real=nvl((BigDecimal)r.get("openAmount")).add(nvl((BigDecimal)r.get("afterAmount"))).add(nvl((BigDecimal)r.get("homeAmount")));
            r.put("realSettleAmount",real);
            rows.add(r);
        }
        rows.sort(Comparator
            .comparing((Map<String,Object> r)->String.valueOf(r.get("region")),Comparator.nullsLast(String::compareTo))
            .thenComparing(r->String.valueOf(r.get("name")),Comparator.nullsLast(String::compareTo)));
        Map<String,Object> totals=emptyOutcompanyStatRow("","합계",0L);
        for(Map<String,Object> r:rows){
            addInt(totals,"openCount",((Number)r.getOrDefault("openCount",0)).intValue());
            addInt(totals,"afterCount",((Number)r.getOrDefault("afterCount",0)).intValue());
            addInt(totals,"homeCount",((Number)r.getOrDefault("homeCount",0)).intValue());
            addDec(totals,"openAmount",nvl((BigDecimal)r.get("openAmount")));
            addDec(totals,"afterAmount",nvl((BigDecimal)r.get("afterAmount")));
            addDec(totals,"homeAmount",nvl((BigDecimal)r.get("homeAmount")));
            addDec(totals,"realSettleAmount",nvl((BigDecimal)r.get("realSettleAmount")));
            addDec(totals,"margin",nvl((BigDecimal)r.get("margin")));
        }
        Map<String,Object> out=new LinkedHashMap<>();
        out.put("year",year); out.put("month",month);
        out.put("title",year+"년 "+month+"월");
        out.put("total",rows.size()); out.put("rows",rows); out.put("totals",totals);
        return out;
    }
    private Map<String,Object> outcompanyStatRow(Map<Long,Map<String,Object>> byOut,Long outId,Map<Long,String> companies){
        return byOut.computeIfAbsent(outId,id->{
            String full=companies.getOrDefault(id,"-");
            String[] parts=splitRegionName(full);
            return emptyOutcompanyStatRow(parts[0],parts[1],id);
        });
    }
    private Map<String,Object> emptyOutcompanyStatRow(String region,String name,Long id){
        Map<String,Object> r=new LinkedHashMap<>();
        r.put("id",id); r.put("region",region); r.put("name",name);
        r.put("openCount",0); r.put("openAmount",BigDecimal.ZERO);
        r.put("afterCount",0); r.put("afterAmount",BigDecimal.ZERO);
        r.put("homeCount",0); r.put("homeAmount",BigDecimal.ZERO);
        r.put("realSettleAmount",BigDecimal.ZERO); r.put("margin",BigDecimal.ZERO);
        return r;
    }
    private String[] splitRegionName(String full){
        if(full==null||full.isBlank()) return new String[]{"-","-"};
        String t=full.trim();
        int sp=t.indexOf(' ');
        if(sp>0){
            String left=t.substring(0,sp).trim();
            String right=t.substring(sp+1).trim();
            if(!left.isEmpty()&&!right.isEmpty()&&(left.matches(".*(시|군|구)$")||List.of("대구","경산","서울","부산","인천","광주","대전","울산","세종","수원","성남","용인","창원","청주","전주","포항","제주").contains(left)))
                return new String[]{left,right};
        }
        return new String[]{"-",t};
    }
    private Map<String,Object> openStatsDaily(JwtUser u,Map<String,String> f){
        int year=Integer.parseInt(f.getOrDefault("year",String.valueOf(LocalDate.now().getYear())));
        int month=Integer.parseInt(f.getOrDefault("month",String.valueOf(LocalDate.now().getMonthValue())));
        LocalDate from=LocalDate.of(year,month,1);
        LocalDate to=from.withDayOfMonth(from.lengthOfMonth());
        Map<Integer,Map<String,Object>> byDay=new LinkedHashMap<>();
        for(int d=1;d<=to.getDayOfMonth();d++) byDay.put(d,emptyOpenStatRow(d+"일("+weekdayKo(from.withDayOfMonth(d))+")",d));
        accumulateOpenStats(u,f,from,to,(date,row)->{
            Map<String,Object> target=byDay.get(date.getDayOfMonth());
            if(target!=null) mergeOpenStat(target,row);
        },(date,afterCount,afterAmount)->{
            Map<String,Object> target=byDay.get(date.getDayOfMonth());
            if(target!=null){ addInt(target,"afterCount",afterCount); addDec(target,"afterAmount",afterAmount); }
        },(date,withdrawCount)->{
            Map<String,Object> target=byDay.get(date.getDayOfMonth());
            if(target!=null) addInt(target,"withdrawCount",withdrawCount);
        });
        List<Map<String,Object>> rows=new ArrayList<>(byDay.values());
        Map<String,Object> totals=sumOpenStatRows(rows);
        Map<String,Object> out=new LinkedHashMap<>();
        out.put("mode","daily"); out.put("year",year); out.put("month",month);
        out.put("title",year+"년 "+month+"월"); out.put("rows",rows); out.put("totals",totals);
        out.put("today",LocalDate.now().getYear()==year&&LocalDate.now().getMonthValue()==month?LocalDate.now().getDayOfMonth():null);
        return out;
    }
    private Map<String,Object> openStatsMonthly(JwtUser u,Map<String,String> f){
        int year=Integer.parseInt(f.getOrDefault("year",String.valueOf(LocalDate.now().getYear())));
        LocalDate from=LocalDate.of(year,1,1);
        LocalDate to=LocalDate.of(year,12,31);
        Map<Integer,Map<String,Object>> byMonth=new LinkedHashMap<>();
        for(int m=1;m<=12;m++) byMonth.put(m,emptyOpenStatRow(m+"월",m));
        accumulateOpenStats(u,f,from,to,(date,row)->{
            Map<String,Object> target=byMonth.get(date.getMonthValue());
            if(target!=null) mergeOpenStat(target,row);
        },(date,afterCount,afterAmount)->{
            Map<String,Object> target=byMonth.get(date.getMonthValue());
            if(target!=null){ addInt(target,"afterCount",afterCount); addDec(target,"afterAmount",afterAmount); }
        },(date,withdrawCount)->{
            Map<String,Object> target=byMonth.get(date.getMonthValue());
            if(target!=null) addInt(target,"withdrawCount",withdrawCount);
        });
        List<Map<String,Object>> rows=new ArrayList<>(byMonth.values());
        Map<String,Object> totals=sumOpenStatRows(rows);
        Map<String,Object> out=new LinkedHashMap<>();
        out.put("mode","monthly"); out.put("year",year);
        out.put("title",year+"년"); out.put("rows",rows); out.put("totals",totals);
        out.put("currentMonth",LocalDate.now().getYear()==year?LocalDate.now().getMonthValue():null);
        return out;
    }
    private interface OpenDayConsumer { void accept(LocalDate date,Map<String,Object> row); }
    private interface AfterDayConsumer { void accept(LocalDate date,int count,BigDecimal amount); }
    private interface WithdrawDayConsumer { void accept(LocalDate date,int count); }
    private void accumulateOpenStats(JwtUser u,Map<String,String> f,LocalDate from,LocalDate to,
                                     OpenDayConsumer openFn,AfterDayConsumer afterFn,WithdrawDayConsumer withdrawFn){
        Map<Long,String> companies=companyNames(u);
        List<OpenRecord> opens=em.createQuery("select o from OpenRecord o where o.groupId=:g and o.opendate between :a and :b",OpenRecord.class)
            .setParameter("g",u.getGroupId()).setParameter("a",from).setParameter("b",to).getResultList();
        for(OpenRecord o:opens){
            if(!matchesOpenStat(o,f,companies)) continue;
            Map<String,Object> row=emptyOpenStatRow("",0);
            String bucket=openStatBucket(o.getOpenhow());
            if(bucket!=null) addInt(row,bucket,1);
            addInt(row,"totalCount",1);
            addDec(row,"settleAmount",nvl(o.getSellprice()));
            addDec(row,"margin",nvl(o.getDealermargin1()).add(nvl(o.getDealermargin2())));
            openFn.accept(o.getOpendate(),row);
        }
        List<AfterBalance> afters=em.createQuery("select b from AfterBalance b where b.groupId=:g and b.basedate between :a and :b",AfterBalance.class)
            .setParameter("g",u.getGroupId()).setParameter("a",from).setParameter("b",to).getResultList();
        for(AfterBalance b:afters){
            if(b.getBasedate()==null) continue;
            if(!eqId(b.getOutcompanyId(),f.get("outcompanyId"))) continue;
            if(!eq(b.getTelecom(),f.get("telecom"))) continue;
            if(!eq(b.getOutCategory(),f.get("outCategory"))) continue;
            afterFn.accept(b.getBasedate(),1,nvl(b.getAmount()));
        }
        List<OpenWithdrawal> withdraws=em.createQuery("select w from OpenWithdrawal w where w.groupId=:g and w.withdrawDate between :a and :b",OpenWithdrawal.class)
            .setParameter("g",u.getGroupId()).setParameter("a",from).setParameter("b",to).getResultList();
        for(OpenWithdrawal w:withdraws){
            if(w.getWithdrawDate()==null) continue;
            if(!eqId(w.getOutcompanyId(),f.get("outcompanyId"))) continue;
            withdrawFn.accept(w.getWithdrawDate(),1);
        }
    }
    private boolean matchesOpenStat(OpenRecord o,Map<String,String> f,Map<Long,String> companies){
        if(!eq(o.getTelecom(),f.get("telecom"))) return false;
        if(!eqId(o.getIncompanyId(),f.get("incompanyId"))) return false;
        if(!eqId(o.getOutcompanyId(),f.get("outcompanyId"))) return false;
        if(!eq(o.getOutCategory(),f.get("outCategory"))) return false;
        return true;
    }
    private String openStatBucket(String openhow){
        if(blank(openhow)) return null;
        return switch(openhow){
            case "신규"->"newCount";
            case "번호이동","MNP"->"mnpCount";
            case "보상"->"rewardCount";
            case "기변"->"changeCount";
            case "에이징","메이징"->"agingCount";
            case "가개통"->"tempCount";
            case "선불개통"->"prepaidCount";
            default->null;
        };
    }
    private Map<String,Object> emptyOpenStatRow(String label,int key){
        Map<String,Object> r=new LinkedHashMap<>();
        r.put("key",key); r.put("label",label);
        r.put("newCount",0); r.put("mnpCount",0); r.put("rewardCount",0); r.put("changeCount",0);
        r.put("agingCount",0); r.put("tempCount",0); r.put("prepaidCount",0); r.put("totalCount",0);
        r.put("settleAmount",BigDecimal.ZERO); r.put("afterCount",0); r.put("afterAmount",BigDecimal.ZERO);
        r.put("withdrawCount",0); r.put("margin",BigDecimal.ZERO);
        return r;
    }
    private void mergeOpenStat(Map<String,Object> target,Map<String,Object> src){
        for(String k:List.of("newCount","mnpCount","rewardCount","changeCount","agingCount","tempCount","prepaidCount","totalCount","afterCount","withdrawCount"))
            addInt(target,k,((Number)src.getOrDefault(k,0)).intValue());
        for(String k:List.of("settleAmount","afterAmount","margin"))
            addDec(target,k,nvl((BigDecimal)src.get(k)));
    }
    private Map<String,Object> sumOpenStatRows(List<Map<String,Object>> rows){
        Map<String,Object> t=emptyOpenStatRow("합계",0);
        for(Map<String,Object> r:rows) mergeOpenStat(t,r);
        return t;
    }
    private void addInt(Map<String,Object> m,String k,int v){ m.put(k,((Number)m.getOrDefault(k,0)).intValue()+v); }
    private void addDec(Map<String,Object> m,String k,BigDecimal v){ m.put(k,nvl((BigDecimal)m.get(k)).add(nvl(v))); }
    private String weekdayKo(LocalDate d){
        return switch(d.getDayOfWeek()){
            case MONDAY->"월"; case TUESDAY->"화"; case WEDNESDAY->"수"; case THURSDAY->"목";
            case FRIDAY->"금"; case SATURDAY->"토"; case SUNDAY->"일";
        };
    }
    @GetMapping("/stocks/report-day") ApiResponse reportDay(@AuthenticationPrincipal JwtUser u, @RequestParam Map<String,String> f) {
        if(blank(f.get("year")) || blank(f.get("month"))) return ApiResponse.fail("년월을 선택하세요.");
        return ApiResponse.ok(reportDayData(u, f));
    }
    @GetMapping(value="/stocks/report-day/export", produces="text/csv;charset=UTF-8") ResponseEntity<String> reportDayExport(@AuthenticationPrincipal JwtUser u, @RequestParam Map<String,String> f) {
        if(blank(f.get("year")) || blank(f.get("month"))) return ResponseEntity.badRequest().body("년월을 선택하세요.");
        Map<String,Object> data=reportDayData(u,f);
        @SuppressWarnings("unchecked") List<Map<String,Object>> rows=(List<Map<String,Object>>) data.get("rows");
        StringBuilder csv=new StringBuilder("\uFEFF일자,입고,출고,회수,반품,분실,개통,판매,합계\n");
        for(Map<String,Object> r:rows) {
            csv.append(csv(r.get("day"))).append(',').append(csv(r.get("inCount"))).append(',')
                .append(csv(r.get("outCount"))).append(',').append(csv(r.get("recoveryCount"))).append(',')
                .append(csv(r.get("returnCount"))).append(',').append(csv(r.get("lossCount"))).append(',')
                .append(csv(r.get("openCount"))).append(',').append(csv(r.get("sellCount"))).append(',')
                .append(csv(r.get("total"))).append('\n');
        }
        @SuppressWarnings("unchecked") Map<String,Object> totals=(Map<String,Object>) data.get("totals");
        if(totals!=null) {
            csv.append(csv("합계")).append(',').append(csv(totals.get("inCount"))).append(',')
                .append(csv(totals.get("outCount"))).append(',').append(csv(totals.get("recoveryCount"))).append(',')
                .append(csv(totals.get("returnCount"))).append(',').append(csv(totals.get("lossCount"))).append(',')
                .append(csv(totals.get("openCount"))).append(',').append(csv(totals.get("sellCount"))).append(',')
                .append(csv(totals.get("total"))).append('\n');
        }
        return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=report-day.csv").body(csv.toString());
    }
    private Map<String,Object> reportDayData(JwtUser u, Map<String,String> f) {
        int year=Integer.parseInt(f.get("year"));
        int month=Integer.parseInt(f.get("month"));
        LocalDate from=LocalDate.of(year, month, 1);
        LocalDate to=from.withDayOfMonth(from.lengthOfMonth());
        LocalDate minAllowed=LocalDate.now().minusYears(1).withDayOfMonth(1);
        if(from.isBefore(minAllowed)) from=minAllowed;
        Map<Long,String> companies=companyNames(u);
        Long inFilter=blank(f.get("incompanyId"))?null:Long.valueOf(f.get("incompanyId"));
        List<Stock> stocks=em.createQuery("select s from Stock s where s.groupId=:g",Stock.class).setParameter("g",u.getGroupId()).getResultList();
        Map<Integer,int[]> days=new LinkedHashMap<>(); // [in,out,recovery,return,loss,open,sell]
        for(int d=1;d<=to.getDayOfMonth();d++) days.put(d, new int[]{0,0,0,0,0,0,0});
        for(Stock s:stocks) {
            if(inFilter!=null && !Objects.equals(s.getIncompanyId(), inFilter)) continue;
            bumpDay(days, s.getIndate(), from, to, 0);
            bumpDay(days, s.getOutdate(), from, to, 1);
            if("RECOVERY".equals(s.getState())) bumpDay(days, s.getProcessDate()!=null?s.getProcessDate():s.getIndate(), from, to, 2);
            bumpDay(days, s.getReturndate(), from, to, 3);
            bumpDay(days, s.getLossdate(), from, to, 4);
            bumpDay(days, s.getSelldate(), from, to, 6);
            if("RETURN".equals(s.getState()) && s.getReturndate()==null) bumpDay(days, s.getProcessDate(), from, to, 3);
            if("LOSS".equals(s.getState()) && s.getLossdate()==null) bumpDay(days, s.getProcessDate(), from, to, 4);
            if("SELL".equals(s.getState()) && s.getSelldate()==null) bumpDay(days, s.getProcessDate(), from, to, 6);
            if("OUT".equals(s.getState()) && s.getOutdate()==null) bumpDay(days, s.getProcessDate(), from, to, 1);
            if("OPEN".equals(s.getState())) bumpDay(days, s.getProcessDate()!=null?s.getProcessDate():s.getIndate(), from, to, 5);
        }
        // OpenRecord는 stock OPEN과 중복될 수 있어 stock 기준만 사용
        List<Map<String,Object>> rows=new ArrayList<>();
        int[] sum=new int[7];
        for(var e:days.entrySet()) {
            int[] c=e.getValue();
            int total=c[0]+c[1]+c[2]+c[3]+c[4]+c[5]+c[6];
            if(total==0) continue;
            for(int i=0;i<7;i++) sum[i]+=c[i];
            Map<String,Object> row=new LinkedHashMap<>();
            row.put("day", String.format("%04d-%02d-%02d", year, month, e.getKey()));
            row.put("inCount", c[0]); row.put("outCount", c[1]); row.put("recoveryCount", c[2]);
            row.put("returnCount", c[3]); row.put("lossCount", c[4]); row.put("openCount", c[5]);
            row.put("sellCount", c[6]); row.put("total", total);
            rows.add(row);
        }
        Map<String,Object> totals=new LinkedHashMap<>();
        totals.put("inCount", sum[0]); totals.put("outCount", sum[1]); totals.put("recoveryCount", sum[2]);
        totals.put("returnCount", sum[3]); totals.put("lossCount", sum[4]); totals.put("openCount", sum[5]);
        totals.put("sellCount", sum[6]); totals.put("total", sum[0]+sum[1]+sum[2]+sum[3]+sum[4]+sum[5]+sum[6]);
        Map<String,Object> result=new LinkedHashMap<>();
        result.put("year", year); result.put("month", month);
        result.put("title", year+"년 "+month+"월 일별이력통계");
        result.put("rows", rows); result.put("totals", totals);
        result.put("incompanyName", inFilter==null?null:companies.get(inFilter));
        return result;
    }
    private void bumpDay(Map<Integer,int[]> days, LocalDate date, LocalDate from, LocalDate to, int idx) {
        if(date==null || date.isBefore(from) || date.isAfter(to)) return;
        int[] c=days.get(date.getDayOfMonth());
        if(c!=null) c[idx]++;
    }
    @GetMapping("/stocks/history") ApiResponse stockHistory(@AuthenticationPrincipal JwtUser u, @RequestParam Map<String,String> f,
            @RequestParam(defaultValue="0") int page, @RequestParam(defaultValue="15") int size) {
        if(blank(f.get("dateFrom")) || blank(f.get("dateTo"))) return ApiResponse.fail("날짜를 선택하세요.");
        return ApiResponse.ok(stockHistoryData(u, f, page, size));
    }
    @PostMapping("/stocks/history/delete") @Transactional ApiResponse stockHistoryDelete(@AuthenticationPrincipal JwtUser u, @RequestBody Map<String,Object> d) {
        Object raw=d.get("ids");
        if(!(raw instanceof List<?> ids) || ids.isEmpty()) return ApiResponse.fail("삭제할 이력을 선택하세요.");
        int deleted=0;
        for(Object idObj:ids) {
            StockHistory h=em.find(StockHistory.class, Long.valueOf(idObj.toString()));
            if(h==null || !Objects.equals(h.getGroupId(),u.getGroupId())) continue;
            em.remove(h); deleted++;
        }
        return ApiResponse.ok(Map.of("deleted", deleted));
    }
    @GetMapping(value="/stocks/history/export", produces="text/csv;charset=UTF-8") ResponseEntity<String> stockHistoryExport(@AuthenticationPrincipal JwtUser u, @RequestParam Map<String,String> f) {
        if(blank(f.get("dateFrom")) || blank(f.get("dateTo"))) return ResponseEntity.badRequest().body("날짜를 선택하세요.");
        @SuppressWarnings("unchecked") List<Map<String,Object>> rows=(List<Map<String,Object>>) stockHistoryData(u,f,0,10000).get("list");
        StringBuilder csv=new StringBuilder("\uFEFF처리일,현황,종류,일련번호,모델명,색상,상태,입고처,출고처,처리자,비고\n");
        for(Map<String,Object> r:rows) csv.append(csv(r.get("processDate"))).append(',').append(csv(r.get("statusLabel"))).append(',')
            .append(csv(r.get("gubun"))).append(',').append(csv(r.get("serial"))).append(',').append(csv(r.get("model"))).append(',')
            .append(csv(r.get("color"))).append(',').append(csv(r.get("cond"))).append(',').append(csv(r.get("incompanyName"))).append(',')
            .append(csv(r.get("outcompanyName"))).append(',').append(csv(r.get("processor"))).append(',').append(csv(r.get("memo"))).append('\n');
        return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=stock-history.csv").body(csv.toString());
    }
    private Map<String,Object> stockHistoryData(JwtUser u, Map<String,String> f, int page, int size) {
        Map<Long,String> companies=companyNames(u);
        LocalDate from=LocalDate.parse(f.get("dateFrom"));
        LocalDate to=LocalDate.parse(f.get("dateTo"));
        List<StockHistory> all=em.createQuery("select h from StockHistory h where h.groupId=:g",StockHistory.class).setParameter("g",u.getGroupId()).getResultList();
        String searchType=f.getOrDefault("searchType","일련번호");
        String keyword=f.get("keyword");
        List<StockHistory> filtered=all.stream().filter(h->{
            if(h.getProcessDate()==null || h.getProcessDate().isBefore(from) || h.getProcessDate().isAfter(to)) return false;
            if(keyword!=null && !keyword.isBlank()) {
                boolean ok=switch(searchType){
                    case "모델명"->like(h.getModel(),keyword);
                    case "색상"->like(h.getColor(),keyword);
                    case "펫네임"->like(h.getSalesperson(),keyword);
                    default->like(h.getSerial(),keyword);
                };
                if(!ok) return false;
            }
            return true;
        }).sorted((a,b)->{
            int cmp=Comparator.nullsLast(LocalDate::compareTo).compare(b.getProcessDate(), a.getProcessDate());
            return cmp!=0?cmp:Long.compare(b.getId(), a.getId());
        }).toList();
        int fromIdx=Math.min(Math.max(page,0)*Math.max(size,1), filtered.size());
        int toIdx=Math.min(fromIdx+Math.min(Math.max(size,1),200), filtered.size());
        List<Map<String,Object>> list=filtered.subList(fromIdx,toIdx).stream().map(h->{
            Map<String,Object> r=new LinkedHashMap<>();
            r.put("id", h.getId()); r.put("stockId", h.getStockId());
            r.put("processDate", h.getProcessDate()); r.put("statusLabel", h.getStatusLabel());
            r.put("state", h.getState()); r.put("gubun", h.getGubun());
            r.put("serial", h.getSerial()); r.put("model", h.getModel()); r.put("color", h.getColor());
            r.put("cond", h.getCond()); r.put("memo", h.getMemo()); r.put("processor", h.getProcessor());
            r.put("salesperson", h.getSalesperson());
            r.put("incompanyName", companies.getOrDefault(h.getIncompanyId(),"-"));
            r.put("outcompanyName", companies.getOrDefault(h.getOutcompanyId(),"-"));
            return r;
        }).toList();
        return Map.of("total", filtered.size(), "list", list);
    }
    private void writeStockHistory(Stock s, JwtUser u) {
        StockHistory h=new StockHistory();
        h.setGroupId(u.getGroupId());
        h.setStockId(s.getId());
        h.setIncompanyId(s.getIncompanyId());
        h.setOutcompanyId(s.getOutcompanyId());
        h.setProcessDate(s.getProcessDate()!=null?s.getProcessDate():LocalDate.now());
        h.setGubun(s.getGubun()); h.setState(s.getState()); h.setStatusLabel(statusLabel(s.getState()));
        h.setSerial(s.getSerial()); h.setModel(s.getModel()); h.setColor(s.getColor());
        h.setCond(s.getCond()); h.setMemo(s.getMemo());
        h.setProcessor(s.getProcessor()!=null?s.getProcessor():memberName(u));
        h.setSalesperson(s.getSalesperson());
        if("OUT".equals(s.getState()) && s.getOutcompanyId()!=null){
            Company oc=em.find(Company.class, s.getOutcompanyId());
            if(oc!=null){
                String label=oc.getName();
                String cat=s.getOutCategory()!=null&&!s.getOutCategory().isBlank()?s.getOutCategory():oc.getCategory();
                if(cat!=null&&!cat.isBlank()) label=label+" ("+cat+")";
                h.setCompanyLabel(label);
            }
        } else if("IN".equals(s.getState()) && s.getIncompanyId()!=null){
            Company ic=em.find(Company.class, s.getIncompanyId());
            if(ic!=null) h.setCompanyLabel(ic.getName());
        }
        em.persist(h);
    }
    @GetMapping("/stocks/abooks") ApiResponse abooks(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f,
            @RequestParam(defaultValue="0") int page,@RequestParam(defaultValue="15") int size) {
        return ApiResponse.ok(abookData(u,f,page,size));
    }
    @PostMapping("/stocks/abooks") @Transactional ApiResponse abookCreate(@AuthenticationPrincipal JwtUser u,@RequestBody Map<String,Object> d) {
        UsimLedger row=new UsimLedger();
        row.setGroupId(u.getGroupId());
        Long outId=null;
        if(d.get("outcompanyId")!=null && !d.get("outcompanyId").toString().isBlank()) {
            outId=Long.valueOf(d.get("outcompanyId").toString());
        } else {
            String name=d.get("outcompanyName")==null?"":d.get("outcompanyName").toString().trim();
            if(name.isBlank()) return ApiResponse.fail("출고처명을 입력해주세요.");
            List<Company> found=em.createQuery("select c from Company c where c.groupId=:g and c.name=:n",Company.class)
                .setParameter("g",u.getGroupId()).setParameter("n",name).getResultList();
            if(!found.isEmpty()) outId=found.getFirst().getId();
            else {
                Company c=new Company(); c.setGroupId(u.getGroupId()); c.setType("OUT"); c.setName(name); c.setActive(true);
                em.persist(c); em.flush(); outId=c.getId();
            }
        }
        if(d.get("telecom")==null || d.get("telecom").toString().isBlank()) return ApiResponse.fail("통신사를 선택하세요.");
        if(d.get("qty")==null || d.get("qty").toString().isBlank()) return ApiResponse.fail("수량을 입력하세요.");
        if(d.get("amount")==null || d.get("amount").toString().isBlank()) return ApiResponse.fail("거래금액을 입력하세요.");
        row.setOutcompanyId(outId);
        row.setTradeDate(d.get("tradeDate")==null||d.get("tradeDate").toString().isBlank()?LocalDate.now():LocalDate.parse(d.get("tradeDate").toString()));
        row.setTelecom(String.valueOf(d.get("telecom")));
        row.setQty(Integer.valueOf(d.get("qty").toString().replace(",","")));
        row.setAmount(new BigDecimal(d.get("amount").toString().replace(",","")));
        row.setDetail(d.get("detail")==null||String.valueOf(d.get("detail")).isBlank()?null:String.valueOf(d.get("detail")));
        row.setRegistrant(d.get("registrant")==null||String.valueOf(d.get("registrant")).isBlank()?u.getUserid():String.valueOf(d.get("registrant")));
        row.setStatus("정상거래");
        em.persist(row);
        return ApiResponse.ok(abookMap(row, companyNames(u)));
    }
    @PostMapping("/stocks/abooks/delete") @Transactional ApiResponse abookDelete(@AuthenticationPrincipal JwtUser u,@RequestBody Map<String,Object> d) {
        Object raw=d.get("ids");
        if(!(raw instanceof List<?> ids) || ids.isEmpty()) return ApiResponse.fail("삭제할 항목을 선택하세요.");
        int deleted=0;
        for(Object idObj:ids) {
            UsimLedger row=em.find(UsimLedger.class, Long.valueOf(idObj.toString()));
            if(row==null || !Objects.equals(row.getGroupId(),u.getGroupId())) continue;
            em.remove(row); deleted++;
        }
        return ApiResponse.ok(Map.of("deleted",deleted));
    }
    @GetMapping(value="/stocks/abooks/export", produces="text/csv;charset=UTF-8") ResponseEntity<String> abookExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f) {
        @SuppressWarnings("unchecked") List<Map<String,Object>> rows=(List<Map<String,Object>>) abookData(u,f,0,10000).get("list");
        StringBuilder csv=new StringBuilder("\uFEFF거래일,출고처,통신사,수량,거래금액,거래명세,등록자\n");
        for(Map<String,Object> r:rows) csv.append(csv(r.get("tradeDate"))).append(',').append(csv(r.get("outcompanyName"))).append(',')
            .append(csv(r.get("telecom"))).append(',').append(csv(r.get("qty"))).append(',').append(csv(r.get("amount"))).append(',')
            .append(csv(r.get("detail"))).append(',').append(csv(r.get("registrant"))).append('\n');
        return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=abooks.csv").body(csv.toString());
    }
    @GetMapping(value="/stocks/abooks/summary-export", produces="text/csv;charset=UTF-8") ResponseEntity<String> abookSummaryExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f) {
        @SuppressWarnings("unchecked") List<Map<String,Object>> rows=(List<Map<String,Object>>) abookData(u,f,0,10000).get("summary");
        StringBuilder csv=new StringBuilder("\uFEFF출고처명,거래,수량,금액\n");
        for(Map<String,Object> r:rows) csv.append(csv(r.get("outcompanyName"))).append(',').append(csv(r.get("trades"))).append(',')
            .append(csv(r.get("qty"))).append(',').append(csv(r.get("amount"))).append('\n');
        return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=abooks-summary.csv").body(csv.toString());
    }
    private Map<Long,String> companyNames(JwtUser u){
        Map<Long,String> companies=new HashMap<>();
        for(Company c:em.createQuery("select c from Company c where c.groupId=:g",Company.class).setParameter("g",u.getGroupId()).getResultList()) companies.put(c.getId(),c.getName());
        return companies;
    }
    private Map<String,Object> abookData(JwtUser u,Map<String,String> f,int page,int size) {
        Map<Long,String> companies=companyNames(u);
        List<UsimLedger> all=em.createQuery("select e from UsimLedger e where e.groupId=:g",UsimLedger.class).setParameter("g",u.getGroupId()).getResultList();
        String status=f.getOrDefault("status","정상거래");
        Long outFilter=blank(f.get("outcompanyId"))?null:Long.valueOf(f.get("outcompanyId"));
        LocalDate from=blank(f.get("dateFrom"))?null:LocalDate.parse(f.get("dateFrom"));
        LocalDate to=blank(f.get("dateTo"))?null:LocalDate.parse(f.get("dateTo"));
        List<UsimLedger> filtered=all.stream().filter(e->{
            if(!"전체".equals(status) && !status.equals(e.getStatus())) return false;
            if(outFilter!=null && !Objects.equals(e.getOutcompanyId(),outFilter)) return false;
            if(from!=null && (e.getTradeDate()==null || e.getTradeDate().isBefore(from))) return false;
            return to==null || (e.getTradeDate()!=null && !e.getTradeDate().isAfter(to));
        }).sorted(abookComparator(f.get("sort"))).toList();
        Map<Long,int[]> qtyMap=new LinkedHashMap<>();
        Map<Long,BigDecimal> amountMap=new LinkedHashMap<>();
        Map<Long,Integer> tradeMap=new LinkedHashMap<>();
        for(UsimLedger e:filtered) {
            Long key=e.getOutcompanyId()==null?-1L:e.getOutcompanyId();
            tradeMap.put(key, tradeMap.getOrDefault(key,0)+1);
            qtyMap.put(key, new int[]{qtyMap.getOrDefault(key,new int[]{0})[0]+(e.getQty()==null?0:e.getQty())});
            amountMap.put(key, amountMap.getOrDefault(key,BigDecimal.ZERO).add(e.getAmount()==null?BigDecimal.ZERO:e.getAmount()));
        }
        boolean summaryAsc=f.getOrDefault("summarySort","amount,desc").endsWith(",asc");
        String summaryField=f.getOrDefault("summarySort","amount,desc").split(",")[0];
        List<Map<String,Object>> summary=tradeMap.keySet().stream().map(key->{
            Map<String,Object> row=new LinkedHashMap<>();
            row.put("outcompanyId", key<0?null:key);
            row.put("outcompanyName", key<0?"-":companies.getOrDefault(key,"-"));
            row.put("trades", tradeMap.get(key));
            row.put("qty", qtyMap.get(key)[0]);
            row.put("amount", amountMap.get(key));
            return row;
        }).sorted((a,b)->{
            int cmp=switch(summaryField){
                case "outcompanyName"->String.valueOf(a.get("outcompanyName")).compareTo(String.valueOf(b.get("outcompanyName")));
                case "trades"->Integer.compare((Integer)a.get("trades"),(Integer)b.get("trades"));
                case "qty"->Integer.compare((Integer)a.get("qty"),(Integer)b.get("qty"));
                default->((BigDecimal)a.get("amount")).compareTo((BigDecimal)b.get("amount"));
            };
            return summaryAsc?cmp:-cmp;
        }).toList();
        int tradeTotal=filtered.size();
        int qtyTotal=filtered.stream().mapToInt(e->e.getQty()==null?0:e.getQty()).sum();
        BigDecimal amountTotal=filtered.stream().map(e->e.getAmount()==null?BigDecimal.ZERO:e.getAmount()).reduce(BigDecimal.ZERO,BigDecimal::add);
        int fromIdx=Math.min(Math.max(page,0)*Math.max(size,1),filtered.size());
        int toIdx=Math.min(fromIdx+Math.min(Math.max(size,1),200),filtered.size());
        List<Map<String,Object>> list=filtered.subList(fromIdx,toIdx).stream().map(e->abookMap(e,companies)).toList();
        Map<String,Object> totals=new LinkedHashMap<>();
        totals.put("trades",tradeTotal); totals.put("qty",qtyTotal); totals.put("amount",amountTotal);
        return Map.of("total",tradeTotal,"list",list,"summary",summary,"totals",totals);
    }
    private Comparator<UsimLedger> abookComparator(String sort){
        String field=sort==null?"tradeDate,desc":sort; boolean asc=field.endsWith(",asc");
        Comparator<UsimLedger> c=switch(field.split(",")[0]){
            case "telecom"->Comparator.comparing(UsimLedger::getTelecom,Comparator.nullsLast(String::compareTo));
            case "qty"->Comparator.comparing(UsimLedger::getQty,Comparator.nullsLast(Integer::compareTo));
            case "amount"->Comparator.comparing(UsimLedger::getAmount,Comparator.nullsLast(BigDecimal::compareTo));
            case "registrant"->Comparator.comparing(UsimLedger::getRegistrant,Comparator.nullsLast(String::compareTo));
            default->Comparator.comparing(UsimLedger::getTradeDate,Comparator.nullsLast(LocalDate::compareTo));
        };
        return (asc?c:c.reversed()).thenComparing(UsimLedger::getId);
    }
    private Map<String,Object> abookMap(UsimLedger e,Map<Long,String> companies){
        Map<String,Object> r=new LinkedHashMap<>();
        r.put("id",e.getId()); r.put("outcompanyId",e.getOutcompanyId());
        r.put("outcompanyName",companies.getOrDefault(e.getOutcompanyId(),"-"));
        r.put("tradeDate",e.getTradeDate()); r.put("telecom",e.getTelecom());
        r.put("qty",e.getQty()); r.put("amount",e.getAmount()); r.put("detail",e.getDetail());
        r.put("registrant",e.getRegistrant()); r.put("status",e.getStatus());
        return r;
    }
    @SuppressWarnings("unchecked") private <T extends BaseEntity> Class<T> type(String uri){if(uri.contains("scans"))return (Class<T>)ScanDoc.class;if(uri.contains("indocs"))return (Class<T>)IncompleteDoc.class;if(uri.contains("returns"))return (Class<T>)UnreturnedPhone.class;if(uri.contains("fareboxes"))return (Class<T>)Farebox.class;if(uri.contains("accounts"))return (Class<T>)AccountEntry.class;if(uri.contains("schedules"))return (Class<T>)ScheduleItem.class;if(uri.contains("templates"))return (Class<T>)SmsTemplate.class;if(uri.contains("sms"))return (Class<T>)SmsMessage.class;if(uri.contains("alims"))return (Class<T>)Alim.class;return (Class<T>)CsInquiry.class;}
    private BigDecimal nvl(BigDecimal v){return v==null?BigDecimal.ZERO:v;}
}
