From bb9a45f097a026691fd71982a6d33c20b8cf1e17 Mon Sep 17 00:00:00 2001 From: Macbook Date: Sun, 12 Jul 2026 05:07:21 +0900 Subject: [PATCH] =?UTF-8?q?=EC=86=8C=EC=8A=A4=EB=93=B1=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/poncle/agent/ApiControllers.java | 4738 +++++++++++++++++ 1 file changed, 4738 insertions(+) create mode 100644 backend/src/main/java/com/poncle/agent/ApiControllers.java diff --git a/backend/src/main/java/com/poncle/agent/ApiControllers.java b/backend/src/main/java/com/poncle/agent/ApiControllers.java new file mode 100644 index 0000000..0990ee8 --- /dev/null +++ b/backend/src/main/java/com/poncle/agent/ApiControllers.java @@ -0,0 +1,4738 @@ +package com.poncle.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 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 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 users = em.createQuery("select m from Member m where m.userid=:id", Member.class) + .setParameter("id", u.getUserid()).getResultList(); + Map 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 create(Class type, Map data, JwtUser user) { + T value=mapper.convertValue(data,type); value.setId(null); value.setGroupId(user.getGroupId()); em.persist(value); return value; + } + @Transactional T update(Class type, Long id, Map 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 void delete(Class type, Long id, JwtUser user) { em.remove(get(type,id,user)); } + T get(Class 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; + } + PageResponse list(Class type, JwtUser user, Map filters, int page, int size) { + String entity=type.getSimpleName(); StringBuilder where=new StringBuilder(" where e.groupId=:group"); Map params=new HashMap<>(); params.put("group",user.getGroupId()); + Set 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 q=em.createQuery("select e from "+entity+" e"+where+" order by e.id desc",type); TypedQuery 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 ApiResponse list(Class c, JwtUser u, Map f,int p,int s){return ApiResponse.ok(crud.list(c,u,f,p,s));} + private ApiResponse post(Class c,Mapd,JwtUser u){return ApiResponse.ok(crud.create(c,d,u));} + private ApiResponse get(Class c,Long id,JwtUser u){return ApiResponse.ok(crud.get(c,id,u));} + private ApiResponse put(Class c,Long id,Mapd,JwtUser u){return ApiResponse.ok(crud.update(c,id,d,u));} + private ApiResponse del(Class c,Long id,JwtUser u){crud.delete(c,id,u);return ApiResponse.ok(null);} + @GetMapping("/stocks") ApiResponse stocks(@AuthenticationPrincipal JwtUser u,@RequestParam Map 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 Mapd,@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 Mapd,@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 Mapd,@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 Mapd,@AuthenticationPrincipal JwtUser u){ + return stockAction(id,d,u); + } + @PostMapping("/stocks/{id}/notes") @Transactional ApiResponse stockNoteCreate(@PathVariable Long id,@RequestBody Mapd,@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 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 stockDetail(Stock s, JwtUser u){ + Map 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 names=new HashMap<>(); + companyMap.forEach((k,v)->names.put(k,v.getName())); + Map 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 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> historyList=histories.stream().map(h->{ + Map 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> 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 r=new LinkedHashMap<>(); + r.put("stock", stock); + r.put("histories", historyList); + r.put("notes", notes); + return r; + } + private Map stockNoteMap(StockNote n){ + Map 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 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 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 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 f, + @RequestParam(defaultValue="0") int page, @RequestParam(defaultValue="15") int size) { + if(blank(f.get("dateFrom")) || blank(f.get("dateTo"))) return ApiResponse.fail("날짜를 선택하세요."); + Map 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 stockSheetExport(@AuthenticationPrincipal JwtUser u, @RequestParam Map f) { + if(blank(f.get("dateFrom")) || blank(f.get("dateTo"))) return ResponseEntity.badRequest().body("날짜를 선택하세요."); + Map filters=new HashMap<>(f); + filters.put("dateBasis","처리일기준"); + filters.putIfAbsent("tab","ALL"); + @SuppressWarnings("unchecked") List> rows=(List>) stockSearchData(u,filters,0,10000).get("list"); + StringBuilder csv=new StringBuilder("\uFEFF처리일,현황,상태,종류,일련번호,모델명,색상,입고가,입고처,출고처,처리자,비고\n"); + for(Map 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 stockExport(@AuthenticationPrincipal JwtUser u, @RequestParam Map f) { + @SuppressWarnings("unchecked") List> rows=(List>) stockSearchData(u,f,0,10000).get("list"); + StringBuilder csv=new StringBuilder("\uFEFF현황,입고일,상태,종류,일련번호,모델명,색상,입고가,입고처,출고처,처리일,처리자,비고\n"); + for(Map 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 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 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 stockSearchData(JwtUser u,Map f,int page,int size) { + List stocks=em.createQuery("select s from Stock s where s.groupId=:g",Stock.class).setParameter("g",u.getGroupId()).getResultList(); + Map 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 filtered=stocks.stream().filter(s->matchesStock(s,f,companies)).sorted(stockComparator(f.get("sort"))).toList(); + Map 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> list=filtered.subList(from,to).stream().map(s->stockMap(s,companies)).toList(); + return Map.of("total",filtered.size(),"list",list,"counts",counts); + } + private Map withoutTab(Map f){Map copy=new HashMap<>(f);copy.remove("tab");return copy;} + private boolean matchesStock(Stock s,Map f,Map 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 stockComparator(String sort){String field=sort==null?"indate,desc":sort;boolean asc=field.endsWith(",asc");Comparator 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 stockMap(Stock s,Map companies){Map 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 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 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 openExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map f) { + @SuppressWarnings("unchecked") List> rows=(List>) openSearchData(u,f,0,10000).get("list"); + StringBuilder csv=new StringBuilder("\uFEFF개통일,통신사,출고처,고객명,구분,종류,개통번호,상태,모델명,일련번호,입고처,정산,차감,마진\n"); + for(Map 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 Mapd,@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 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 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 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 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 openAdjustExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + if(blank(f.get("dateFrom"))||blank(f.get("dateTo"))) return ResponseEntity.badRequest().body("개통일을 선택하세요."); + @SuppressWarnings("unchecked") List> rows=(List>) openAdjustData(u,f).get("list"); + StringBuilder csv=new StringBuilder("\uFEFF개통일,입고처,출고처,고객명,종류,모델명,일련번호,지원방법,요금제,정산금액,기본정책,구두정책,추가정책\n"); + for(Map 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 Mapd,@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 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 openSearchData(JwtUser u,Map f,int page,int size){ + List opens=em.createQuery("select o from OpenRecord o where o.groupId=:g",OpenRecord.class).setParameter("g",u.getGroupId()).getResultList(); + Map 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 filtered=opens.stream().filter(o->matchesOpen(o,f,companies)).sorted(openComparator(f.get("sort"))).toList(); + Map 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> 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 f,Map 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 openComparator(String sort){ + String field=sort==null?"opendate,desc":sort; boolean asc=field.endsWith(",asc"); + Comparator 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 openMap(OpenRecord o,Map companies){ + Map 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 openAdjustData(JwtUser u,Map f){ + List opens=em.createQuery("select o from OpenRecord o where o.groupId=:g",OpenRecord.class).setParameter("g",u.getGroupId()).getResultList(); + Map 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 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 f,Map 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 adjustComparator(Map f){ + List 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 c=null; + for(String key:keys){ + Comparator 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 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 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 Mapd,@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 Mapd,@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 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 Mapd,@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 Mapd,@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 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 filters=new HashMap<>(f); filters.put("telecom",t.getTelecom()); + if(t.getIncompanyId()!=null) filters.put("incompanyId",String.valueOf(t.getIncompanyId())); + List opens=em.createQuery("select o from OpenRecord o where o.groupId=:g",OpenRecord.class).setParameter("g",u.getGroupId()).getResultList(); + Map 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> 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 body){ + UnitCostTable t=em.find(UnitCostTable.class,id); if(t==null||!Objects.equals(t.getGroupId(),u.getGroupId())) return ApiResponse.fail("단가표를 찾을 수 없습니다."); + List 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 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 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 unitCostMap(UnitCostTable t,Map companies){ + Map 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 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 Mapd,@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 Mapd,@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 homeSaleExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + @SuppressWarnings("unchecked") List> rows=(List>) homeSaleSearchData(u,f,0,10000).get("list"); + StringBuilder csv=new StringBuilder("\uFEFF설치일,출고처,상태,입고처,통신사,고객명,휴대폰,가입상품,정산,마진,접수일\n"); + for(Map 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 homeSaleSearchData(JwtUser u,Map f,int page,int size){ + List rows=em.createQuery("select h from HomeSale h where h.groupId=:g",HomeSale.class).setParameter("g",u.getGroupId()).getResultList(); + Map 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 filtered=rows.stream().filter(h->matchesHomeSale(h,f,companies)).sorted(homeSaleComparator(f.get("sort"))).toList(); + Map counts=new LinkedHashMap<>(); + List 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> 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 f,Map 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 homeSaleComparator(String sort){ + String field=sort==null?"installDate,desc":sort; boolean asc=field.endsWith(",asc"); + Comparator 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 homeSaleMap(HomeSale h,Map companies){ + Map 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 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 homeIndocExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + if(blank(f.get("dateFrom"))||blank(f.get("dateTo"))) return ResponseEntity.badRequest().body("날짜를 선택하세요."); + @SuppressWarnings("unchecked") List> rows=(List>) homeIndocSearchData(u,f,0,10000).get("list"); + StringBuilder csv=new StringBuilder("\uFEFF설치일,통신사,입고처,출고처,고객명,휴대폰,가입상품,미비서류,차감금,차감사유,마감일,상태\n"); + for(Map 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 homeIndocSearchData(JwtUser u,Map f,int page,int size){ + List docs=em.createQuery("select d from HomeIncompleteDoc d where d.groupId=:g",HomeIncompleteDoc.class).setParameter("g",u.getGroupId()).getResultList(); + Map 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 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> 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 f,Map 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 homeIndocMap(HomeIncompleteDoc d,Map companies){ + Map 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 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 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 exchangeExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + @SuppressWarnings("unchecked") List> rows=(List>) exchangeSearchData(u,f,0,10000).get("list"); + StringBuilder csv=new StringBuilder("\uFEFF번호,교품일,출고처,고객명,개통일,개통번호,종류,교품,기존,처리직원,처리일,비고\n"); + for(Map 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 exchangeSearchData(JwtUser u,Map f,int page,int size){ + List all=em.createQuery("select e from OpenExchange e where e.groupId=:g",OpenExchange.class).setParameter("g",u.getGroupId()).getResultList(); + Map 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 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> list=new ArrayList<>(); + for(int i=from;i f,Map 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 exchangeMap(OpenExchange e,Map companies,int no){ + Map 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 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 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 nameChangeExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + @SuppressWarnings("unchecked") List> rows=(List>) nameChangeSearchData(u,f,0,10000).get("list"); + StringBuilder csv=new StringBuilder("\uFEFF번호,명변일,출고처,고객명,개통일,개통번호,기존명의자,기존연락처,기존유심,처리직원,처리일,비고\n"); + for(Map 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 nameChangeSearchData(JwtUser u,Map f,int page,int size){ + List all=em.createQuery("select e from OpenNameChange e where e.groupId=:g",OpenNameChange.class).setParameter("g",u.getGroupId()).getResultList(); + Map 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 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> list=new ArrayList<>(); + for(int i=from;i f,Map 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 nameChangeMap(OpenNameChange e,Map companies,int no){ + Map 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 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 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 withdrawalExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + @SuppressWarnings("unchecked") List> rows=(List>) withdrawalSearchData(u,f,0,10000).get("list"); + StringBuilder csv=new StringBuilder("\uFEFF번호,철회일,출고처,고객명,개통일,개통번호,처리직원,처리일,사유 및 비고\n"); + for(Map 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 withdrawalSearchData(JwtUser u,Map f,int page,int size){ + List all=em.createQuery("select e from OpenWithdrawal e where e.groupId=:g",OpenWithdrawal.class).setParameter("g",u.getGroupId()).getResultList(); + Map 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 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> list=new ArrayList<>(); + for(int i=from;i f,Map 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 withdrawalMap(OpenWithdrawal e,Map companies,int no){ + Map 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 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 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 settleHistoryExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + @SuppressWarnings("unchecked") List> rows=(List>) settleHistorySearchData(u,f,0,10000).get("list"); + StringBuilder csv=new StringBuilder("\uFEFF번호,변경일,출고처,고객명,개통일,개통번호,기존정산금,변경정산금,처리직원,처리일,비고\n"); + for(Map 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 settleHistorySearchData(JwtUser u,Map f,int page,int size){ + List all=em.createQuery("select e from OpenSettleHistory e where e.groupId=:g",OpenSettleHistory.class).setParameter("g",u.getGroupId()).getResultList(); + Map 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 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> list=new ArrayList<>(); + for(int i=from;i f,Map 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 settleHistoryMap(OpenSettleHistory e,Map companies,int no){ + Map 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 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 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 deleteHistoryExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + @SuppressWarnings("unchecked") List> rows=(List>) deleteHistorySearchData(u,f,0,10000).get("list"); + StringBuilder csv=new StringBuilder("\uFEFF번호,철회일,출고처,고객명,개통일,개통번호,처리직원,처리일,비고\n"); + for(Map 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 deleteHistorySearchData(JwtUser u,Map f,int page,int size){ + List all=em.createQuery("select e from OpenDeleteHistory e where e.groupId=:g",OpenDeleteHistory.class).setParameter("g",u.getGroupId()).getResultList(); + Map 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 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> list=new ArrayList<>(); + for(int i=from;i f,Map 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 deleteHistoryMap(OpenDeleteHistory e,Map companies,int no){ + Map 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 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 f){ + List 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 members=em.createQuery("select m from Member m where m.groupId=:g and m.role='SHOP'",Member.class) + .setParameter("g",u.getGroupId()).getResultList(); + Map 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> list=new ArrayList<>(); + Map 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 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 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 outcompanyAccountMap(Company c,Member m,String status){ + Map 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 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 parseMenuFlags(String json){ + Map 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 defaultMenuFlags(){ + Map 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 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> list=new ArrayList<>(); + for(int i=0;id,@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 Mapd,@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 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 boardGroupMap(BoardGroup g,int no){ + Map 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 boardGroupCompanyIds(BoardGroup g){ + if(g.getCompanyIds()==null||g.getCompanyIds().isBlank()) return List.of(); + List 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 Mapd,@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 Mapd,@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 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 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 Mapd,@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 Mapd,@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 bbsSearchData(JwtUser u,Map f,int page,int size){ + String code=f.getOrDefault("code","shop"); + List 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 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> list=new ArrayList<>(); + for(int i=from;i 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 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 bbsMap(BbsPost b,int no){ + Map 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 d){ + if(slot<1||slot>8) return ApiResponse.fail("슬롯 번호가 올바르지 않습니다."); + List> list=loadPostits(u.getGroupId()); + Map 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 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 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 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 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> mobileDaily=new ArrayList<>(); + for(Map.Entry e:dailyOpen.entrySet()) mobileDaily.add(Map.of("day", e.getKey(), "value", e.getValue())); + + List> homeDaily=new ArrayList<>(); + for(int d=1;d<=monthEnd.getDayOfMonth();d++) homeDaily.add(Map.of("day", d, "value", 0)); + + List> 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 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> 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 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 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 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 outcompany=new LinkedHashMap<>(); + outcompany.put("total", outCount); outcompany.put("newStand", Math.max(1, outCount>0?1:0)); + Map farebox=new LinkedHashMap<>(); + farebox.put("pending", farePending); farebox.put("done", fareDone); + Map 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 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 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> loadPostits(String groupId){ + List> defaults=defaultPostits(); + List 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> parsed=new com.fasterxml.jackson.databind.ObjectMapper().readValue(rows.getFirst().getValue(), List.class); + for(int i=0;i 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> 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 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> 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> list=new ArrayList<>(); + for(int i=0;i<8;i++){ + Map 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 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 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 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 Mapd,@AuthenticationPrincipal JwtUser u){return post(AfterBalance.class,d,u);} + @PutMapping("/balances/{id}") ApiResponse balancePut(@PathVariable Long id,@RequestBody Mapd,@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 balanceExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + @SuppressWarnings("unchecked") List> rows=(List>) balanceSearchData(u,f,0,10000).get("list"); + StringBuilder csv=new StringBuilder("\uFEFF개통일,출고처,고객명,개통번호,구분,기준일,사유,정산금액,비고,통신사,입고처\n"); + for(Map 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 returnExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + @SuppressWarnings("unchecked") List> rows=(List>) returnSearchData(u,f,0,10000).get("list"); + StringBuilder csv=new StringBuilder("\uFEFF개통일,출고처,고객명,개통번호,구분,모델명,일련번호,차감금액,통신사,입고처\n"); + for(Map 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 indocExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + if(blank(f.get("dateFrom"))||blank(f.get("dateTo"))) return ResponseEntity.badRequest().body("날짜를 선택하세요."); + @SuppressWarnings("unchecked") List> rows=(List>) indocSearchData(u,f,0,10000).get("list"); + StringBuilder csv=new StringBuilder("\uFEFF개통일,통신사,입고처,출고처,고객명,개통번호,미비서류,차감금,차감사유,마감일,상태\n"); + for(Map 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 indocSearchData(JwtUser u,Map f,int page,int size){ + List docs=em.createQuery("select d from IncompleteDoc d where d.groupId=:g",IncompleteDoc.class).setParameter("g",u.getGroupId()).getResultList(); + Map 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 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> 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 f,Map 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 indocMap(IncompleteDoc d,Map companies){ + Map 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 returnSearchData(JwtUser u,Map f,int page,int size){ + List rows=em.createQuery("select r from UnreturnedPhone r where r.groupId=:g",UnreturnedPhone.class).setParameter("g",u.getGroupId()).getResultList(); + Map 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 filtered=rows.stream().filter(r->matchesReturn(r,f,companies)).sorted(Comparator.comparing(UnreturnedPhone::getOpendate,Comparator.nullsLast(LocalDate::compareTo)).reversed().thenComparing(UnreturnedPhone::getId)).toList(); + Map 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> 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 f,Map 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 returnMap(UnreturnedPhone r,Map companies){ + Map 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 balanceSearchData(JwtUser u,Map f,int page,int size){ + List rows=em.createQuery("select b from AfterBalance b where b.groupId=:g",AfterBalance.class).setParameter("g",u.getGroupId()).getResultList(); + Map 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 filtered=rows.stream().filter(b->matchesBalance(b,f,companies)).sorted(Comparator.comparing(AfterBalance::getBasedate,Comparator.nullsLast(LocalDate::compareTo)).reversed().thenComparing(AfterBalance::getId)).toList(); + Map 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> 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 f,Map 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 balanceMap(AfterBalance b,Map companies){ + Map 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 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 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 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 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 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 scanSearchData(JwtUser u,Map f,int page,int size){ + List all=em.createQuery("select s from ScanDoc s where s.groupId=:g",ScanDoc.class).setParameter("g",u.getGroupId()).getResultList(); + Map companies=companyNames(u); + Map 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 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> 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 f,Map 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 scanMap(ScanDoc s,Map companies){ + Map 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 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 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 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 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 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 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 scheduleSearchData(JwtUser u, Map f, int page, int size){ + List 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 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> list=new ArrayList<>(); + for(int i=from;i scheduleMap(ScheduleItem s){ + Map 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 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 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 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 csSearchData(JwtUser u, Map f, int page, int size){ + List 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 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> list=new ArrayList<>(); + for(int i=from;i 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 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 csMap(CsInquiry c,int no){ + Map 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 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 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 d){ + Object phonesRaw=d.get("phones"); + List 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 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 result=smsMap(row); + result.put("balance", balance-deduct); + return ApiResponse.ok(result); + } + @PostMapping("/sms/history/delete") @Transactional ApiResponse smsHistoryDelete(@AuthenticationPrincipal JwtUser u,@RequestBody Map 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 smsHistoryData(JwtUser u, Map f, int page, int size){ + List 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 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> list=filtered.subList(fromIdx,toIdx).stream().map(this::smsMap).toList(); + Map result=new LinkedHashMap<>(); + result.put("total", filtered.size()); + result.put("list", list); + result.put("balance", smsBalanceOf(u.getGroupId())); + return result; + } + private Map smsMap(SmsMessage e){ + Map 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 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 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 groups=em.createQuery("select g from SmsAddressGroup g where g.groupId=:g order by g.id",SmsAddressGroup.class) + .setParameter("g",u.getGroupId()).getResultList(); + List contacts=em.createQuery("select c from SmsAddressContact c where c.groupId=:g",SmsAddressContact.class) + .setParameter("g",u.getGroupId()).getResultList(); + Map counts=new HashMap<>(); + for(SmsAddressContact c:contacts){ + if(c.getAddressGroupId()==null) continue; + counts.put(c.getAddressGroupId(), counts.getOrDefault(c.getAddressGroupId(),0)+1); + } + List> list=new ArrayList<>(); + Map 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 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 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 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 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 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 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 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 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 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 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 smsAddressContactExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + @SuppressWarnings("unchecked") List> rows=(List>) smsAddressContactData(u,f,0,10000).get("list"); + StringBuilder csv=new StringBuilder("\uFEFF그룹,이름,연락처\n"); + for(Map 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 d){ + Object raw=d.get("phones"); + List 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 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 cart=loadSmsCart(u.getGroupId()); + return ApiResponse.ok(Map.of("count", cart.size(), "phones", cart)); + } + private Map smsAddressContactData(JwtUser u, Map f, int page, int size){ + Map names=groupNameMap(u); + List 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 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> list=filtered.subList(fromIdx,toIdx).stream().map(c->smsAddressContactMap(c,names)).toList(); + Map result=new LinkedHashMap<>(); + result.put("total", filtered.size()); + result.put("list", list); + return result; + } + private Map groupNameMap(JwtUser u){ + Map 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 smsAddressContactMap(SmsAddressContact c, Map names){ + Map 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 loadSmsCart(String groupId){ + List 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 list=(List) new ObjectMapper().readValue(rows.getFirst().getValue(), List.class); + return new ArrayList<>(list); + }catch(Exception ex){ return new ArrayList<>(); } + } + private void saveSmsCart(String groupId, List phones){ + try{ + String json=new ObjectMapper().writeValueAsString(phones); + List 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 Mapd,@AuthenticationPrincipal JwtUser u){return post(type(r.getRequestURI()),d,u);} + @GetMapping("/fareboxes/search") ApiResponse fareboxSearch(@AuthenticationPrincipal JwtUser u,@RequestParam Map 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 fareboxExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + @SuppressWarnings("unchecked") List> rows=(List>) fareboxSearchData(u,f,0,10000).get("list"); + StringBuilder csv=new StringBuilder("\uFEFF수납일,통신사,출고처,고객명,연락처,구분,현금금액,카드금액,결제상태,결제금액,상세내역,등록자\n"); + for(Map 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 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 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 f){ + return ApiResponse.ok(fareboxPaySummaryData(u,f)); + } + @GetMapping(value="/fareboxes/pay-summary/export", produces="text/csv;charset=UTF-8") ResponseEntity fareboxPaySummaryExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + Map data=fareboxPaySummaryData(u,f); + @SuppressWarnings("unchecked") List> rows=(List>) data.get("rows"); + StringBuilder csv=new StringBuilder("\uFEFF번호,채널,출고처,담당자,전화,휴대폰,수납건수,결제잔액\n"); + int i=1; + for(Map 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 f){ + String outName=f.getOrDefault("outcompanyName",""); + if(blank(outName)) return ApiResponse.fail("출고처를 선택하세요."); + Map companies=companyNames(u); + List 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> list=items.stream().map(e->{ + Map m=fareboxMap(e,companies); + m.put("remaining", fareboxRemaining(e)); + m.put("paidAmount", nvl(e.getPaidAmount())); + return m; + }).toList(); + Map 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 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 companies=companyNames(u); + List 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 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 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 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 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 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 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 companies=companyNames(u); + List> 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 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 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 fareboxPaymentSearchData(JwtUser u, Map f, int page, int size){ + LocalDate from=parseDate(f.get("dateFrom"), null); + LocalDate to=parseDate(f.get("dateTo"), null); + List all=em.createQuery("select p from FareboxPayment p where p.groupId=:g",FareboxPayment.class) + .setParameter("g",u.getGroupId()).getResultList(); + List 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> list=new ArrayList<>(); + for(int i=fromIdx;i row=fareboxPaymentMap(filtered.get(i)); + row.put("no", filtered.size()-i); + list.add(row); + } + Map result=new LinkedHashMap<>(); + result.put("total", filtered.size()); + result.put("list", list); + return result; + } + private Map fareboxPaymentMap(FareboxPayment p){ + Map 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 fareboxPaySummaryData(JwtUser u, Map f){ + Map 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 companies=new LinkedHashMap<>(); + companyMap.forEach((id,c)->companies.put(id,c.getName())); + String q=f.get("outcompanyName"); + Map> 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 row=byOut.computeIfAbsent(outName,k->{ + Map 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 row=byOut.get(outName); + if(row!=null) row.put("todayPaid", ((BigDecimal)row.get("todayPaid")).add(nvl(p.getAmount()))); + } + List> rows=new ArrayList<>(byOut.values()); + rows.sort(Comparator.comparing(r->String.valueOf(r.get("outcompanyName")))); + int no=1; + for(Map r:rows) r.put("no", no++); + Map 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 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 fareboxSearchData(JwtUser u, Map f, int page, int size){ + Map companies=companyNames(u); + List 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 base=all.stream().filter(e->matchesFarebox(e,f,companies,from,to,null)).toList(); + List 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 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> list=filtered.subList(fromIdx,toIdx).stream().map(e->fareboxMap(e,companies)).toList(); + Map result=new LinkedHashMap<>(); + result.put("total", filtered.size()); + result.put("list", list); + result.put("counts", counts); + return result; + } + private boolean matchesFarebox(Farebox e, Map f, Map 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 fareboxMap(Farebox e, Map companies){ + Map 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 Mapd,@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 f){return list(SettlementMonth.class,u,f,0,100);} + @GetMapping("/settlements/docs") ApiResponse settlementDocs(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + String ym=f.getOrDefault("yearMonth",LocalDate.now().toString().substring(0,7)); + List 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 companies=companyNames(u); + String outCategory=f.get("outCategory"); + List 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> 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 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 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 d){ + Object raw=d.get("ids"); + List 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 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 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 settlementDocsExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + @SuppressWarnings("unchecked") Map data=(Map) settlementDocs(u,f).data(); + @SuppressWarnings("unchecked") List> rows=(List>) data.get("list"); + StringBuilder csv=new StringBuilder("\uFEFF출고처,개통,개통금액,후정산,후정산금액,홈상품,홈상품금액,실정산금액,정산서발행,출고처문의,출고처동의,계산서상태,송금상태,비고\n"); + for(Map 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 settlementDocMap(SettlementMonth s,Map companies){ + Map 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 Mapd,@AuthenticationPrincipal JwtUser u){ + List 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 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 Mapd){List 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 d){ + Map current=loadPreference(u.getGroupId()); + mergePreference(current,d); + savePreference(u.getGroupId(),current); + return ApiResponse.ok(current); + } + private Map loadPreference(String groupId){ + Map defaults=defaultPreference(); + List 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 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 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 legacy=new com.fasterxml.jackson.databind.ObjectMapper().readValue(companyRows.getFirst().getValue(), Map.class); + @SuppressWarnings("unchecked") Map company=(Map) 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 data){ + String json=writeSettingJson(data); + List 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 company=(Map) data.getOrDefault("company", Map.of()); + Map 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 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 target, Map patch){ + if(patch==null) return; + for(Map.Entry e:patch.entrySet()){ + Object val=e.getValue(); + if(val instanceof Map m && target.get(e.getKey()) instanceof Map){ + Map dest=new LinkedHashMap<>((Map) target.get(e.getKey())); + dest.putAll((Map) m); + target.put(e.getKey(), dest); + } else { + target.put(e.getKey(), val); + } + } + } + private Map defaultPreference(){ + Map 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 accessIps=new LinkedHashMap<>(); + accessIps.put("enabled", false); + accessIps.put("ips", List.of()); + Map masking=new LinkedHashMap<>(); + masking.put("enabled", true); + masking.put("name", true); + masking.put("phone", true); + masking.put("rrn", true); + masking.put("address", false); + Map stock=new LinkedHashMap<>(); + stock.put("duplicateSerialCheck", true); + stock.put("allowNegative", false); + stock.put("autoRecallDays", 0); + Map open=new LinkedHashMap<>(); + open.put("requireUsim", true); + open.put("requirePhone", true); + open.put("defaultOpenHow", ""); + open.put("autoSettle", false); + Map 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 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 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 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 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 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 incompanyExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + @SuppressWarnings("unchecked") List> rows=(List>) incompanyData(u,f,0,10000).get("list"); + StringBuilder csv=new StringBuilder("\uFEFF상태,업체명,통신사,전화,팩스,담당자,휴대폰,상호,사업자번호,등록일,비고\n"); + for(Map 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 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 incompanyData(JwtUser u, Map f, int page, int size){ + List 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 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> list=filtered.subList(fromIdx,toIdx).stream().map(this::incompanyMap).toList(); + Map result=new LinkedHashMap<>(); + result.put("total", filtered.size()); + result.put("list", list); + return result; + } + private Comparator incompanyComparator(String sort){ + String field=sort==null?"name,asc":sort; boolean asc=field.endsWith(",asc"); + Comparator 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 incompanyMap(Company c){ + Map 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 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 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 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 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 outcompanyExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + @SuppressWarnings("unchecked") List> rows=(List>) outcompanyData(u,f,0,10000).get("list"); + StringBuilder csv=new StringBuilder("\uFEFF상태,채널,업체명,P코드,영업담당,전화,담당자,휴대폰,상호,사업자번호,계좌,등록일,비고\n"); + for(Map 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 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 outcompanyData(JwtUser u, Map f, int page, int size){ + List 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 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> list=filtered.subList(fromIdx,toIdx).stream().map(this::outcompanyMap).toList(); + Map result=new LinkedHashMap<>(); + result.put("total", filtered.size()); + result.put("list", list); + return result; + } + private Comparator outcompanyComparator(String sort){ + String field=sort==null?"name,asc":sort; boolean asc=field.endsWith(",asc"); + Comparator 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 outcompanyMap(Company c){ + Map 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 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 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 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 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 deliveryExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + @SuppressWarnings("unchecked") List> rows=(List>) deliveryData(u,f,0,10000).get("list"); + StringBuilder csv=new StringBuilder("\uFEFF상태,업체명,연락처,비고,등록일\n"); + for(Map 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 deliveryData(JwtUser u, Map f, int page, int size){ + List 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 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> list=filtered.subList(fromIdx,toIdx).stream().map(this::deliveryMap).toList(); + Map result=new LinkedHashMap<>(); + result.put("total", filtered.size()); + result.put("list", list); + return result; + } + private Comparator deliveryComparator(String sort){ + String field=sort==null?"name,asc":sort; boolean asc=field.endsWith(",asc"); + Comparator 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 deliveryMap(Company c){ + Map 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 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 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 staffExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + @SuppressWarnings("unchecked") List> rows=(List>) staffData(u,f,0,10000).get("list"); + StringBuilder csv=new StringBuilder("\uFEFF상태,권한,아이디,이름,휴대폰,담당출고처,접속수,최종접속,등록일,비고\n"); + for(Map 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 staffData(JwtUser u, Map f, int page, int size){ + List 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 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> list=filtered.subList(fromIdx,toIdx).stream().map(this::staffMap).toList(); + Map result=new LinkedHashMap<>(); + result.put("total", filtered.size()); + result.put("list", list); + return result; + } + private Comparator staffComparator(String sort){ + String field=sort==null?"createdAt,asc":sort; boolean asc=field.endsWith(",asc"); + Comparator 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 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 staffMap(Member m){ + Map 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 d){ + Map current=loadAgentShopSetting(u.getGroupId()); + current.putAll(d); + String json=writeSettingJson(current); + List 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 loadAgentShopSetting(String groupId){ + Map defaults=defaultAgentShopSetting(); + List 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 parsed=new com.fasterxml.jackson.databind.ObjectMapper().readValue(rows.getFirst().getValue(), Map.class); + defaults.putAll(parsed); + }catch(Exception ignored){} + return defaults; + } + private Map defaultAgentShopSetting(){ + Map 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.poncle.co.kr"); + return m; + } + private String writeSettingJson(Map 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 Mapd,@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 f) { + return ApiResponse.ok(haveOwnData(u, f)); + } + @GetMapping(value="/stocks/have-own/export", produces="text/csv;charset=UTF-8") ResponseEntity haveOwnExport(@AuthenticationPrincipal JwtUser u, @RequestParam Map f) { + Map data=haveOwnData(u,f); + @SuppressWarnings("unchecked") List models=(List) data.get("models"); + @SuppressWarnings("unchecked") List> rows=(List>) data.get("rows"); + StringBuilder csv=new StringBuilder("\uFEFF채널,영업사원,출고처,전체"); + for(String m:models) csv.append(',').append(csv(m)); + csv.append('\n'); + for(Map 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 counts=(Map) 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 haveOwnData(JwtUser u, Map f) { + Map companies=companyNames(u); + boolean includeColor="true".equalsIgnoreCase(f.getOrDefault("includeColor","false")); + List stocks=em.createQuery("select s from Stock s where s.groupId=:g",Stock.class).setParameter("g",u.getGroupId()).getResultList(); + List 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 modelSet=new LinkedHashSet<>(); + Map> 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 row=rowMap.computeIfAbsent(rowKey,k->{ + Map 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()); + return r; + }); + @SuppressWarnings("unchecked") Map counts=(Map) row.get("counts"); + counts.put(modelKey, counts.getOrDefault(modelKey,0)+1); + row.put("total", ((Integer)row.get("total"))+1); + } + List models=modelSet.stream().sorted().toList(); + List> rows=new ArrayList<>(rowMap.values()); + rows.sort(Comparator + .comparing((Map 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 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 f) { + return ApiResponse.ok(haveModelData(u, f)); + } + @GetMapping(value="/stocks/have-model/export", produces="text/csv;charset=UTF-8") ResponseEntity haveModelExport(@AuthenticationPrincipal JwtUser u, @RequestParam Map f) { + Map data=haveModelData(u,f); + boolean totalsOnly="true".equalsIgnoreCase(f.getOrDefault("totalsOnly","false")); + @SuppressWarnings("unchecked") List holders=(List) data.get("holders"); + @SuppressWarnings("unchecked") List> rows=(List>) data.get("rows"); + StringBuilder csv=new StringBuilder("\uFEFF종류,모델명,색상,전체,불량,본점"); + if(!totalsOnly) for(String h:holders) csv.append(',').append(csv(h)); + csv.append('\n'); + for(Map 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 counts=(Map) 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 haveModelData(JwtUser u, Map f) { + Map companies=companyNames(u); + List stocks=em.createQuery("select s from Stock s where s.groupId=:g",Stock.class).setParameter("g",u.getGroupId()).getResultList(); + List 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 holderSet=new LinkedHashSet<>(); + Map> 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 row=rowMap.computeIfAbsent(rowKey,k->{ + Map 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()); + 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 counts=(Map) row.get("counts"); + counts.put(holder, counts.getOrDefault(holder,0)+1); + } + } + List holders=holderSet.stream().sorted().toList(); + List> rows=new ArrayList<>(rowMap.values()); + rows.sort(Comparator + .comparing((Map 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 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 f) { + return ApiResponse.ok(turnoverData(u, f)); + } + @GetMapping(value="/stocks/turnover/export", produces="text/csv;charset=UTF-8") ResponseEntity turnoverExport(@AuthenticationPrincipal JwtUser u, @RequestParam Map f) { + Map data=turnoverData(u,f); + String mode=String.valueOf(data.getOrDefault("mode","outcompany")); + @SuppressWarnings("unchecked") List> rows=(List>) data.get("rows"); + StringBuilder csv=new StringBuilder("\uFEFF순위,"); + if("model".equals(mode)) csv.append("모델명,개통수,현재 재고수,회전율,회전일,개통예상\n"); + else csv.append("출고처,개통수,현재 재고수,회전율,회전일,개통예상,영업담당자\n"); + for(Map 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 turnoverData(JwtUser u, Map f) { + String mode=f.getOrDefault("mode","outcompany"); + Map companies=companyNames(u); + List 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 opens=em.createQuery("select o from OpenRecord o where o.groupId=:g",OpenRecord.class).setParameter("g",u.getGroupId()).getResultList(); + Map agg=new LinkedHashMap<>(); + Map 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> 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 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 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 f){ + return ApiResponse.ok(monOpenStatsData(u,f)); + } + @GetMapping(value="/reports/mon-open-stats/export", produces="text/csv;charset=UTF-8") ResponseEntity monOpenStatsExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + Map data=monOpenStatsData(u,f); + String mode=String.valueOf(data.getOrDefault("mode","outcompany")); + String firstCol=switch(mode){ case "incompany"->"입고처"; case "model"->"모델명"; default->"출고처"; }; + @SuppressWarnings("unchecked") List> rows=(List>) 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 r:rows){ + csv.append(csv(r.get("name"))); + @SuppressWarnings("unchecked") List months=(List) 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 totals=(Map) data.get("totals"); + if(totals!=null){ + csv.append(csv("합계")); + @SuppressWarnings("unchecked") List months=(List) 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 f){ + return ApiResponse.ok(openChartsData(u,f)); + } + private Map openChartsData(JwtUser u, Map 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 openChartPeriod(JwtUser u, Map 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 companies=companyNames(u); + Map counts=new LinkedHashMap<>(); + if("outcompany".equals(dim)){ + List 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 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 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> 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> items=new ArrayList<>(); + for(var e:entries){ + Map row=new LinkedHashMap<>(); + row.put("name", e.getKey()); + row.put("value", e.getValue()); + items.add(row); + } + Map 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 openChartAnnual(JwtUser u, Map 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 companies=companyNames(u); + Map seriesMap=new LinkedHashMap<>(); + if("outcompany".equals(dim)){ + List 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 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 categories=new ArrayList<>(); + for(int m=1;m<=12;m++) categories.add(m+"월"); + List> series=new ArrayList<>(); + for(var e:seriesMap.entrySet()){ + Map s=new LinkedHashMap<>(); + s.put("name", e.getKey()); + List 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 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 openChartSalesAnnual(JwtUser u, Map 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 seriesMap=new LinkedHashMap<>(); + List 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 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 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> series=new ArrayList<>(); + List keys=new ArrayList<>(seriesMap.keySet()); + keys.sort(String::compareTo); + for(String key:keys){ + int[] arr=seriesMap.get(key); + Map s=new LinkedHashMap<>(); + s.put("name", key); + s.put("type", "bar"); + List 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 avg=new ArrayList<>(12); + for(int i=0;i<12;i++) avg.add(Math.round(sum[i]*100.0/nSeries)/100.0); + Map avgSeries=new LinkedHashMap<>(); + avgSeries.put("name", "평균"); + avgSeries.put("type", "line"); + avgSeries.put("data", avg); + series.add(avgSeries); + Map 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 monOpenStatsData(JwtUser u, Map 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 companies=companyNames(u); + Map agg=new LinkedHashMap<>(); + if("outcompany".equals(mode)){ + List 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 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 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> rows=new ArrayList<>(); + int[] totalMonths=new int[12]; + int grand=0; + List 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 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 row=new LinkedHashMap<>(); + row.put("id", id++); + row.put("name", key); + row.put("months", monthList); + row.put("total", rowTotal); + rows.add(row); + } + List totalMonthList=new ArrayList<>(12); + for(int v:totalMonths) totalMonthList.add(v); + Map totals=new LinkedHashMap<>(); + totals.put("id", -1L); + totals.put("name", "합계"); + totals.put("months", totalMonthList); + totals.put("total", grand); + Map 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 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 openStatsExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + String mode=f.getOrDefault("mode","daily"); + Map data="monthly".equals(mode)?openStatsMonthly(u,f):openStatsDaily(u,f); + @SuppressWarnings("unchecked") List> rows=(List>) data.get("rows"); + String firstCol="monthly".equals(mode)?"월":"일자"; + StringBuilder csv=new StringBuilder("\uFEFF").append(firstCol).append(",신규,MNP,보상,기변,에이징,가개통,선불개통,합계,정산금액,후정산,후정산금액,철회,마진\n"); + for(Map r:rows) appendOpenStatCsv(csv,r,"monthly".equals(mode)?"label":"label"); + @SuppressWarnings("unchecked") Map totals=(Map) 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 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 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 homeStatsExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + String mode=f.getOrDefault("mode","daily"); + Map data="monthly".equals(mode)?homeStatsMonthly(u,f):homeStatsDaily(u,f); + @SuppressWarnings("unchecked") List> rows=(List>) data.get("rows"); + String firstCol="monthly".equals(mode)?"월":"일자"; + StringBuilder csv=new StringBuilder("\uFEFF").append(firstCol).append(",인터넷,TV,집전화,인터넷전화,신용카드,홈IoT,동판,기타,후결합,재약정,소계,합계,정산금액\n"); + for(Map r:rows) appendHomeStatCsv(csv,r); + @SuppressWarnings("unchecked") Map totals=(Map) 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 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 homeStatsDaily(JwtUser u,Map 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> 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 target=byDay.get(h.getInstallDate().getDayOfMonth()); + if(target!=null) applyHomeSale(target,h); + } + List> rows=new ArrayList<>(byDay.values()); + Map 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 homeStatsMonthly(JwtUser u,Map 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> 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 target=byMonth.get(h.getInstallDate().getMonthValue()); + if(target!=null) applyHomeSale(target,h); + } + List> rows=new ArrayList<>(byMonth.values()); + Map 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 loadHomeSales(JwtUser u,Map f,LocalDate from,LocalDate to){ + List 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 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 emptyHomeStatRow(String label,int key){ + Map 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 target,Map 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 sumHomeStatRows(List> rows){ + Map t=emptyHomeStatRow("합계",0); + for(Map r:rows) mergeHomeStat(t,r); + return t; + } + @GetMapping("/reports/outcompany-stats") ApiResponse outcompanyStats(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + return ApiResponse.ok(outcompanyStatsData(u,f)); + } + @GetMapping(value="/reports/outcompany-stats/export", produces="text/csv;charset=UTF-8") ResponseEntity outcompanyStatsExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + Map data=outcompanyStatsData(u,f); + @SuppressWarnings("unchecked") List> rows=(List>) data.get("rows"); + StringBuilder csv=new StringBuilder("\uFEFF지역,출고처,개통,개통금액,후정산,후정산금액,홈상품,홈상품금액,실정산금액,마진\n"); + for(Map 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 totals=(Map) 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 outcompanyStatsData(JwtUser u,Map 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 companies=companyNames(u); + Map> byOut=new LinkedHashMap<>(); + List 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 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 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 row=outcompanyStatRow(byOut,b.getOutcompanyId(),companies); + addInt(row,"afterCount",1); + addDec(row,"afterAmount",nvl(b.getAmount())); + } + List 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 row=outcompanyStatRow(byOut,h.getOutcompanyId(),companies); + addInt(row,"homeCount",1); + addDec(row,"homeAmount",nvl(h.getSettleAmount())); + } + List> rows=new ArrayList<>(); + for(Map 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 r)->String.valueOf(r.get("region")),Comparator.nullsLast(String::compareTo)) + .thenComparing(r->String.valueOf(r.get("name")),Comparator.nullsLast(String::compareTo))); + Map totals=emptyOutcompanyStatRow("","합계",0L); + for(Map 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 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 outcompanyStatRow(Map> byOut,Long outId,Map companies){ + return byOut.computeIfAbsent(outId,id->{ + String full=companies.getOrDefault(id,"-"); + String[] parts=splitRegionName(full); + return emptyOutcompanyStatRow(parts[0],parts[1],id); + }); + } + private Map emptyOutcompanyStatRow(String region,String name,Long id){ + Map 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 openStatsDaily(JwtUser u,Map 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> 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 target=byDay.get(date.getDayOfMonth()); + if(target!=null) mergeOpenStat(target,row); + },(date,afterCount,afterAmount)->{ + Map target=byDay.get(date.getDayOfMonth()); + if(target!=null){ addInt(target,"afterCount",afterCount); addDec(target,"afterAmount",afterAmount); } + },(date,withdrawCount)->{ + Map target=byDay.get(date.getDayOfMonth()); + if(target!=null) addInt(target,"withdrawCount",withdrawCount); + }); + List> rows=new ArrayList<>(byDay.values()); + Map totals=sumOpenStatRows(rows); + Map 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 openStatsMonthly(JwtUser u,Map 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> byMonth=new LinkedHashMap<>(); + for(int m=1;m<=12;m++) byMonth.put(m,emptyOpenStatRow(m+"월",m)); + accumulateOpenStats(u,f,from,to,(date,row)->{ + Map target=byMonth.get(date.getMonthValue()); + if(target!=null) mergeOpenStat(target,row); + },(date,afterCount,afterAmount)->{ + Map target=byMonth.get(date.getMonthValue()); + if(target!=null){ addInt(target,"afterCount",afterCount); addDec(target,"afterAmount",afterAmount); } + },(date,withdrawCount)->{ + Map target=byMonth.get(date.getMonthValue()); + if(target!=null) addInt(target,"withdrawCount",withdrawCount); + }); + List> rows=new ArrayList<>(byMonth.values()); + Map totals=sumOpenStatRows(rows); + Map 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 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 f,LocalDate from,LocalDate to, + OpenDayConsumer openFn,AfterDayConsumer afterFn,WithdrawDayConsumer withdrawFn){ + Map companies=companyNames(u); + List 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 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 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 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 f,Map 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 emptyOpenStatRow(String label,int key){ + Map 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 target,Map 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 sumOpenStatRows(List> rows){ + Map t=emptyOpenStatRow("합계",0); + for(Map r:rows) mergeOpenStat(t,r); + return t; + } + private void addInt(Map m,String k,int v){ m.put(k,((Number)m.getOrDefault(k,0)).intValue()+v); } + private void addDec(Map 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 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 reportDayExport(@AuthenticationPrincipal JwtUser u, @RequestParam Map f) { + if(blank(f.get("year")) || blank(f.get("month"))) return ResponseEntity.badRequest().body("년월을 선택하세요."); + Map data=reportDayData(u,f); + @SuppressWarnings("unchecked") List> rows=(List>) data.get("rows"); + StringBuilder csv=new StringBuilder("\uFEFF일자,입고,출고,회수,반품,분실,개통,판매,합계\n"); + for(Map 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 totals=(Map) 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 reportDayData(JwtUser u, Map 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 companies=companyNames(u); + Long inFilter=blank(f.get("incompanyId"))?null:Long.valueOf(f.get("incompanyId")); + List stocks=em.createQuery("select s from Stock s where s.groupId=:g",Stock.class).setParameter("g",u.getGroupId()).getResultList(); + Map 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> 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 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 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 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 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 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 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 stockHistoryExport(@AuthenticationPrincipal JwtUser u, @RequestParam Map f) { + if(blank(f.get("dateFrom")) || blank(f.get("dateTo"))) return ResponseEntity.badRequest().body("날짜를 선택하세요."); + @SuppressWarnings("unchecked") List> rows=(List>) stockHistoryData(u,f,0,10000).get("list"); + StringBuilder csv=new StringBuilder("\uFEFF처리일,현황,종류,일련번호,모델명,색상,상태,입고처,출고처,처리자,비고\n"); + for(Map 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 stockHistoryData(JwtUser u, Map f, int page, int size) { + Map companies=companyNames(u); + LocalDate from=LocalDate.parse(f.get("dateFrom")); + LocalDate to=LocalDate.parse(f.get("dateTo")); + List 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 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> list=filtered.subList(fromIdx,toIdx).stream().map(h->{ + Map 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 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 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 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 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 abookExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map f) { + @SuppressWarnings("unchecked") List> rows=(List>) abookData(u,f,0,10000).get("list"); + StringBuilder csv=new StringBuilder("\uFEFF거래일,출고처,통신사,수량,거래금액,거래명세,등록자\n"); + for(Map 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 abookSummaryExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map f) { + @SuppressWarnings("unchecked") List> rows=(List>) abookData(u,f,0,10000).get("summary"); + StringBuilder csv=new StringBuilder("\uFEFF출고처명,거래,수량,금액\n"); + for(Map 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 companyNames(JwtUser u){ + Map 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 abookData(JwtUser u,Map f,int page,int size) { + Map companies=companyNames(u); + List 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 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 qtyMap=new LinkedHashMap<>(); + Map amountMap=new LinkedHashMap<>(); + Map 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> summary=tradeMap.keySet().stream().map(key->{ + Map 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> list=filtered.subList(fromIdx,toIdx).stream().map(e->abookMap(e,companies)).toList(); + Map 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 abookComparator(String sort){ + String field=sort==null?"tradeDate,desc":sort; boolean asc=field.endsWith(",asc"); + Comparator 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 abookMap(UsimLedger e,Map companies){ + Map 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 Class type(String uri){if(uri.contains("scans"))return (Class)ScanDoc.class;if(uri.contains("indocs"))return (Class)IncompleteDoc.class;if(uri.contains("returns"))return (Class)UnreturnedPhone.class;if(uri.contains("fareboxes"))return (Class)Farebox.class;if(uri.contains("accounts"))return (Class)AccountEntry.class;if(uri.contains("schedules"))return (Class)ScheduleItem.class;if(uri.contains("templates"))return (Class)SmsTemplate.class;if(uri.contains("sms"))return (Class)SmsMessage.class;if(uri.contains("alims"))return (Class)Alim.class;return (Class)CsInquiry.class;} + private BigDecimal nvl(BigDecimal v){return v==null?BigDecimal.ZERO:v;} +}