This commit is contained in:
2025-09-20 12:05:53 +08:00
parent bff3d0414d
commit 9b107d665a
73 changed files with 2903 additions and 140 deletions

View File

@@ -0,0 +1,65 @@
package com.example.demo.account;
import com.example.demo.common.AppDefaultsProperties;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/accounts")
public class AccountController {
private final AccountService accountService;
private final AppDefaultsProperties defaults;
public AccountController(AccountService accountService, AppDefaultsProperties defaults) {
this.accountService = accountService;
this.defaults = defaults;
}
@GetMapping
public ResponseEntity<?> list(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
@RequestParam(name = "kw", required = false) String kw,
@RequestParam(name = "status", required = false) Integer status,
@RequestParam(name = "page", defaultValue = "1") int page,
@RequestParam(name = "size", defaultValue = "50") int size) {
Long sid = (shopId == null ? defaults.getShopId() : shopId);
return ResponseEntity.ok(accountService.list(sid, kw, status == null ? 1 : status, Math.max(0, page - 1), size));
}
@PostMapping
public ResponseEntity<?> create(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
@RequestHeader(name = "X-User-Id", required = false) Long userId,
@RequestBody AccountDtos.CreateAccountRequest req) {
Long sid = (shopId == null ? defaults.getShopId() : shopId);
Long uid = (userId == null ? defaults.getUserId() : userId);
Long id = accountService.create(sid, uid, req);
java.util.Map<String,Object> body = new java.util.HashMap<>();
body.put("id", id);
return ResponseEntity.ok(body);
}
@PutMapping("/{id}")
public ResponseEntity<?> update(@PathVariable("id") Long id,
@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
@RequestHeader(name = "X-User-Id", required = false) Long userId,
@RequestBody AccountDtos.CreateAccountRequest req) {
Long sid = (shopId == null ? defaults.getShopId() : shopId);
Long uid = (userId == null ? defaults.getUserId() : userId);
accountService.update(id, sid, uid, req);
return ResponseEntity.ok().build();
}
@GetMapping("/{id}/ledger")
public ResponseEntity<?> ledger(@PathVariable("id") Long id,
@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
@RequestParam(name = "startDate", required = false) String startDate,
@RequestParam(name = "endDate", required = false) String endDate,
@RequestParam(name = "kw", required = false) String kw,
@RequestParam(name = "page", defaultValue = "1") int page,
@RequestParam(name = "size", defaultValue = "20") int size) {
Long sid = (shopId == null ? defaults.getShopId() : shopId);
return ResponseEntity.ok(accountService.ledger(id, sid, kw, Math.max(0, page-1), size, startDate, endDate));
}
}

View File

@@ -0,0 +1,17 @@
package com.example.demo.account;
import java.math.BigDecimal;
public class AccountDtos {
public static class CreateAccountRequest {
public String name;
public String type; // cash, bank, alipay, wechat, other
public String bankName;
public String bankAccount;
public BigDecimal openingBalance; // 可选,创建时作为期初
public Integer status; // 1 启用 0 停用
}
}

View File

@@ -0,0 +1,143 @@
package com.example.demo.account;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
public class AccountService {
private final JdbcTemplate jdbcTemplate;
public AccountService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public Object list(Long shopId, String kw, Integer status, int page, int size) {
StringBuilder sql = new StringBuilder("SELECT id, name, type, bank_name, bank_account, balance, status FROM accounts WHERE shop_id=?");
java.util.List<Object> ps = new java.util.ArrayList<>(); ps.add(shopId);
if (status != null) { sql.append(" AND status=?"); ps.add(status); }
if (kw != null && !kw.isBlank()) { sql.append(" AND (name LIKE ? OR bank_name LIKE ? OR bank_account LIKE ?)"); ps.add('%'+kw+'%'); ps.add('%'+kw+'%'); ps.add('%'+kw+'%'); }
sql.append(" ORDER BY id DESC LIMIT ? OFFSET ?"); ps.add(size); ps.add(page * size);
List<Map<String,Object>> list = jdbcTemplate.queryForList(sql.toString(), ps.toArray());
Map<String,Object> body = new HashMap<>();
body.put("list", list);
return body;
}
@Transactional
public Long create(Long shopId, Long userId, AccountDtos.CreateAccountRequest req) {
if (req == null || req.name == null || req.name.isBlank()) throw new IllegalArgumentException("账户名称必填");
String type = (req.type == null || req.type.isBlank()) ? "cash" : req.type.toLowerCase();
int status = req.status == null ? 1 : req.status;
jdbcTemplate.update("INSERT INTO accounts (shop_id,user_id,name,type,bank_name,bank_account,balance,status,created_at,updated_at) VALUES (?,?,?,?,?,?,0,?,NOW(),NOW())",
shopId, userId, req.name, type, req.bankName, req.bankAccount, status);
Long id = jdbcTemplate.queryForObject("SELECT id FROM accounts WHERE shop_id=? AND name=? ORDER BY id DESC LIMIT 1", Long.class, shopId, req.name);
BigDecimal opening = req.openingBalance == null ? BigDecimal.ZERO : req.openingBalance.setScale(2, java.math.RoundingMode.HALF_UP);
if (opening.compareTo(BigDecimal.ZERO) != 0) {
java.sql.Timestamp now = new java.sql.Timestamp(System.currentTimeMillis());
// other_transactions
String otType = opening.compareTo(BigDecimal.ZERO) > 0 ? "income" : "expense";
BigDecimal amt = opening.abs();
jdbcTemplate.update("INSERT INTO other_transactions (shop_id,user_id,type,category,counterparty_type,counterparty_id,account_id,amount,tx_time,remark,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,NOW(),NOW())",
shopId, userId, otType, "account_operation", null, null, id, amt, now, "期初余额");
// payments
String direction = opening.compareTo(BigDecimal.ZERO) > 0 ? "in" : "out";
jdbcTemplate.update("INSERT INTO payments (shop_id,user_id,biz_type,biz_id,account_id,direction,amount,pay_time,remark,created_at) VALUES (?,?,?,?,?,?,?,?,?,NOW())",
shopId, userId, "other", null, id, direction, amt, now, "期初余额");
// update balance
BigDecimal delta = opening;
jdbcTemplate.update("UPDATE accounts SET balance = balance + ?, updated_at=NOW() WHERE id=? AND shop_id=?", delta, id, shopId);
}
return id;
}
@Transactional
public void update(Long id, Long shopId, Long userId, AccountDtos.CreateAccountRequest req) {
StringBuilder sql = new StringBuilder("UPDATE accounts SET updated_at=NOW()");
java.util.List<Object> ps = new java.util.ArrayList<>();
if (req.name != null) { sql.append(", name=?"); ps.add(req.name); }
if (req.type != null) { sql.append(", type=?"); ps.add(req.type.toLowerCase()); }
if (req.bankName != null) { sql.append(", bank_name=?"); ps.add(req.bankName); }
if (req.bankAccount != null) { sql.append(", bank_account=?"); ps.add(req.bankAccount); }
if (req.status != null) { sql.append(", status=?"); ps.add(req.status); }
sql.append(" WHERE id=? AND shop_id=?"); ps.add(id); ps.add(shopId);
jdbcTemplate.update(sql.toString(), ps.toArray());
}
public Map<String,Object> ledger(Long accountId, Long shopId, String kw, int page, int size, String startDate, String endDate) {
// 汇总
String baseCond = " shop_id=? AND account_id=?";
java.util.List<Object> basePs = new java.util.ArrayList<>(); basePs.add(shopId); basePs.add(accountId);
java.util.function.BiFunction<String, java.util.List<Object>, java.math.BigDecimal> sum = (sql, ps) -> {
java.math.BigDecimal v = jdbcTemplate.queryForObject(sql, java.math.BigDecimal.class, ps.toArray());
return v == null ? java.math.BigDecimal.ZERO : v;
};
String dateStart = (startDate == null || startDate.isBlank()) ? null : startDate;
String dateEnd = (endDate == null || endDate.isBlank()) ? null : endDate;
// opening = 截止开始日期前净额
String payOpenSql = "SELECT COALESCE(SUM(CASE WHEN direction='in' THEN amount ELSE -amount END),0) FROM payments WHERE" + baseCond + (dateStart==null?"":" AND pay_time<?");
java.util.List<Object> payOpenPs = new java.util.ArrayList<>(basePs);
if (dateStart!=null) payOpenPs.add(java.sql.Timestamp.valueOf(dateStart + " 00:00:00"));
String otOpenSql = "SELECT COALESCE(SUM(CASE WHEN type='income' THEN amount ELSE -amount END),0) FROM other_transactions WHERE" + baseCond + (dateStart==null?"":" AND tx_time<?");
java.util.List<Object> otOpenPs = new java.util.ArrayList<>(basePs);
if (dateStart!=null) otOpenPs.add(java.sql.Timestamp.valueOf(dateStart + " 00:00:00"));
java.math.BigDecimal opening = sum.apply(payOpenSql, payOpenPs).add(sum.apply(otOpenSql, otOpenPs));
// 区间收入/支出(含两表)
String payRangeSql = "SELECT COALESCE(SUM(CASE WHEN direction='in' THEN amount ELSE 0 END),0), COALESCE(SUM(CASE WHEN direction='out' THEN amount ELSE 0 END),0) FROM payments WHERE" + baseCond +
(dateStart==null?"":" AND pay_time>=?") + (dateEnd==null?"":" AND pay_time<=?");
java.util.List<Object> payRangePs = new java.util.ArrayList<>(basePs);
if (dateStart!=null) payRangePs.add(java.sql.Timestamp.valueOf(dateStart + " 00:00:00"));
if (dateEnd!=null) payRangePs.add(java.sql.Timestamp.valueOf(dateEnd + " 23:59:59"));
java.util.Map<String, Object> pr = jdbcTemplate.queryForMap(payRangeSql, payRangePs.toArray());
java.math.BigDecimal payIn = (java.math.BigDecimal) pr.values().toArray()[0];
java.math.BigDecimal payOut = (java.math.BigDecimal) pr.values().toArray()[1];
String otRangeSql = "SELECT COALESCE(SUM(CASE WHEN type='income' THEN amount ELSE 0 END),0), COALESCE(SUM(CASE WHEN type='expense' THEN amount ELSE 0 END),0) FROM other_transactions WHERE" + baseCond +
(dateStart==null?"":" AND tx_time>=?") + (dateEnd==null?"":" AND tx_time<=?");
java.util.List<Object> otRangePs = new java.util.ArrayList<>(basePs);
if (dateStart!=null) otRangePs.add(java.sql.Timestamp.valueOf(dateStart + " 00:00:00"));
if (dateEnd!=null) otRangePs.add(java.sql.Timestamp.valueOf(dateEnd + " 23:59:59"));
java.util.Map<String, Object> or = jdbcTemplate.queryForMap(otRangeSql, otRangePs.toArray());
java.math.BigDecimal otIn = (java.math.BigDecimal) or.values().toArray()[0];
java.math.BigDecimal otOut = (java.math.BigDecimal) or.values().toArray()[1];
java.math.BigDecimal income = payIn.add(otIn);
java.math.BigDecimal expense = payOut.add(otOut);
java.math.BigDecimal ending = opening.add(income).subtract(expense);
// 明细列表(合并两表,按时间倒序)
String listSql = "SELECT id, biz_type AS src, pay_time AS tx_time, direction, amount, remark, biz_id, NULL AS category FROM payments WHERE" + baseCond +
(dateStart==null?"":" AND pay_time>=?") + (dateEnd==null?"":" AND pay_time<=?") +
" UNION ALL " +
"SELECT id, 'other' AS src, tx_time, CASE WHEN type='income' THEN 'in' ELSE 'out' END AS direction, amount, remark, NULL AS biz_id, category FROM other_transactions WHERE" + baseCond +
(dateStart==null?"":" AND tx_time>=?") + (dateEnd==null?"":" AND tx_time<=?") +
" ORDER BY tx_time DESC LIMIT ? OFFSET ?";
java.util.List<Object> lp = new java.util.ArrayList<>(basePs);
if (dateStart!=null) { lp.add(java.sql.Timestamp.valueOf(dateStart + " 00:00:00")); }
if (dateEnd!=null) { lp.add(java.sql.Timestamp.valueOf(dateEnd + " 23:59:59")); }
if (dateStart!=null) { lp.add(java.sql.Timestamp.valueOf(dateStart + " 00:00:00")); }
if (dateEnd!=null) { lp.add(java.sql.Timestamp.valueOf(dateEnd + " 23:59:59")); }
lp.add(size); lp.add(page * size);
List<Map<String,Object>> list = jdbcTemplate.queryForList(listSql, lp.toArray());
Map<String,Object> resp = new HashMap<>();
resp.put("opening", opening);
resp.put("income", income);
resp.put("expense", expense);
resp.put("ending", ending);
resp.put("list", list);
return resp;
}
}

View File

@@ -0,0 +1,25 @@
package com.example.demo.common;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Component
@ConfigurationProperties(prefix = "app.account.defaults")
public class AccountDefaultsProperties {
private String cashName = "现金";
private String bankName = "银行存款";
private String wechatName = "微信";
private String alipayName = "支付宝";
public String getCashName() { return cashName; }
public void setCashName(String cashName) { this.cashName = cashName; }
public String getBankName() { return bankName; }
public void setBankName(String bankName) { this.bankName = bankName; }
public String getWechatName() { return wechatName; }
public void setWechatName(String wechatName) { this.wechatName = wechatName; }
public String getAlipayName() { return alipayName; }
public void setAlipayName(String alipayName) { this.alipayName = alipayName; }
}

View File

@@ -10,10 +10,25 @@ public class AppDefaultsProperties {
private Long shopId = 1L;
private Long userId = 2L;
// 默认账户名称(可配置,避免硬编码)
private String accountCashName = "现金";
private String accountBankName = "银行存款";
private String accountWechatName = "微信";
private String accountAlipayName = "支付宝";
public Long getShopId() { return shopId; }
public void setShopId(Long shopId) { this.shopId = shopId; }
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public String getAccountCashName() { return accountCashName; }
public void setAccountCashName(String accountCashName) { this.accountCashName = accountCashName; }
public String getAccountBankName() { return accountBankName; }
public void setAccountBankName(String accountBankName) { this.accountBankName = accountBankName; }
public String getAccountWechatName() { return accountWechatName; }
public void setAccountWechatName(String accountWechatName) { this.accountWechatName = accountWechatName; }
public String getAccountAlipayName() { return accountAlipayName; }
public void setAccountAlipayName(String accountAlipayName) { this.accountAlipayName = accountAlipayName; }
}

View File

@@ -0,0 +1,29 @@
package com.example.demo.common;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
public class FinanceController {
private final FinanceService financeService;
private final AppDefaultsProperties defaults;
public FinanceController(FinanceService financeService, AppDefaultsProperties defaults) {
this.financeService = financeService;
this.defaults = defaults;
}
@GetMapping("/api/finance/categories")
public ResponseEntity<?> listCategories(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId) {
Long sid = (shopId == null ? defaults.getShopId() : shopId);
Map<String, Object> body = financeService.getCategories(sid);
return ResponseEntity.ok(body);
}
}

View File

@@ -0,0 +1,21 @@
package com.example.demo.common;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Component
@ConfigurationProperties(prefix = "app.finance")
public class FinanceDefaultsProperties {
// 形如 key:label, key:label 用逗号分隔
private String incomeCategories;
private String expenseCategories;
public String getIncomeCategories() { return incomeCategories; }
public void setIncomeCategories(String incomeCategories) { this.incomeCategories = incomeCategories; }
public String getExpenseCategories() { return expenseCategories; }
public void setExpenseCategories(String expenseCategories) { this.expenseCategories = expenseCategories; }
}

View File

@@ -0,0 +1,126 @@
package com.example.demo.common;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.stream.Collectors;
@Service
public class FinanceService {
private static final String PARAM_KEY = "finance.categories";
private final SystemParameterRepository systemParameterRepository;
private final FinanceDefaultsProperties financeDefaultsProperties;
private final javax.sql.DataSource dataSource;
private final AppDefaultsProperties appDefaults;
private final ObjectMapper objectMapper = new ObjectMapper();
public FinanceService(SystemParameterRepository systemParameterRepository, FinanceDefaultsProperties financeDefaultsProperties, javax.sql.DataSource dataSource, AppDefaultsProperties appDefaults) {
this.systemParameterRepository = systemParameterRepository;
this.financeDefaultsProperties = financeDefaultsProperties;
this.dataSource = dataSource;
this.appDefaults = appDefaults;
}
public Map<String, Object> getCategories(Long shopId) {
Map<String, Object> body = new HashMap<>();
// 0) 优先从 finance_categories 表读取(避免中文乱码/统一排序)
List<Map<String, String>> income = queryCategoriesFromTable(shopId, "income");
List<Map<String, String>> expense = queryCategoriesFromTable(shopId, "expense");
// 1) 回落读取 system_parameters
try {
if (income == null || income.isEmpty() || expense == null || expense.isEmpty()) {
Optional<SystemParameter> opt = systemParameterRepository.findByShopIdAndKey(shopId, PARAM_KEY);
if (opt.isPresent()) {
String json = opt.get().getValue();
if (json != null && !json.isBlank()) {
JsonNode root = objectMapper.readTree(json);
if (income == null || income.isEmpty()) {
JsonNode incNode = root.get("income");
if (incNode != null && incNode.isArray()) {
income = objectMapper.convertValue(incNode, new TypeReference<List<Map<String,String>>>(){});
}
}
if (expense == null || expense.isEmpty()) {
JsonNode expNode = root.get("expense");
if (expNode != null && expNode.isArray()) {
expense = objectMapper.convertValue(expNode, new TypeReference<List<Map<String,String>>>(){});
}
}
}
}
}
} catch (Exception ignored) {
// 忽略异常,回落至默认配置
}
// 2) 回落:应用配置 app.finance.*
if (income == null || income.isEmpty()) {
income = parsePairs(financeDefaultsProperties.getIncomeCategories());
}
if (expense == null || expense.isEmpty()) {
expense = parsePairs(financeDefaultsProperties.getExpenseCategories());
}
body.put("incomeCategories", income);
body.put("expenseCategories", expense);
return body;
}
private List<Map<String, String>> parsePairs(String pairs) {
if (pairs == null || pairs.isBlank()) return Collections.emptyList();
return Arrays.stream(pairs.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.map(s -> {
int idx = s.indexOf(":");
String key = idx > 0 ? s.substring(0, idx).trim() : s.trim();
String label = idx > 0 ? s.substring(idx + 1).trim() : key;
Map<String, String> m = new HashMap<>();
m.put("key", key);
m.put("label", label);
return m;
})
.collect(Collectors.toList());
}
private List<Map<String, String>> queryCategoriesFromTable(Long shopId, String type) {
Long fallbackShopId = appDefaults == null ? 1L : (appDefaults.getShopId() == null ? 1L : appDefaults.getShopId());
try (java.sql.Connection c = dataSource.getConnection();
java.sql.PreparedStatement ps = c.prepareStatement(
"SELECT shop_id, `key`, label FROM finance_categories WHERE shop_id IN (?, ?) AND type=? AND status=1 " +
"ORDER BY CASE WHEN shop_id=? THEN 0 ELSE 1 END, sort_order, id")) {
ps.setLong(1, shopId);
ps.setLong(2, fallbackShopId);
ps.setString(3, type);
ps.setLong(4, shopId);
try (java.sql.ResultSet rs = ps.executeQuery()) {
java.util.Map<String,String> firstByKey = new java.util.LinkedHashMap<>();
while (rs.next()) {
String key = rs.getString(2);
String label = rs.getString(3);
if (!firstByKey.containsKey(key)) {
firstByKey.put(key, label);
}
}
java.util.List<java.util.Map<String,String>> list = new java.util.ArrayList<>();
for (java.util.Map.Entry<String,String> e : firstByKey.entrySet()) {
java.util.Map<String,String> m = new java.util.HashMap<>();
m.put("key", e.getKey());
m.put("label", e.getValue());
list.add(m);
}
return list;
}
} catch (Exception ignored) {
return java.util.Collections.emptyList();
}
}
}

View File

@@ -0,0 +1,50 @@
package com.example.demo.common;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.lang.NonNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component
public class RequestLoggingFilter extends OncePerRequestFilter {
private static final Logger log = LoggerFactory.getLogger(RequestLoggingFilter.class);
@Override
protected void doFilterInternal(@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain) throws ServletException, IOException {
long start = System.currentTimeMillis();
String method = request.getMethod();
String uri = request.getRequestURI();
String query = request.getQueryString();
String shopId = request.getHeader("X-Shop-Id");
String userId = request.getHeader("X-User-Id");
try {
filterChain.doFilter(request, response);
} finally {
long cost = System.currentTimeMillis() - start;
int status = response.getStatus();
if (log.isDebugEnabled()) {
log.debug("{} {}{} | status={} cost={}ms | shopId={} userId={}",
method,
uri,
(query == null ? "" : ("?" + query)),
status,
cost,
(shopId == null ? "" : shopId),
(userId == null ? "" : userId));
}
}
}
}

View File

@@ -0,0 +1,48 @@
package com.example.demo.common;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "system_parameters")
public class SystemParameter {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "shop_id", nullable = false)
private Long shopId;
@Column(name = "user_id", nullable = false)
private Long userId;
@Column(name = "`key`", nullable = false, length = 64)
private String key;
@Column(name = "value", nullable = false, columnDefinition = "JSON")
private String value;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Long getShopId() { return shopId; }
public void setShopId(Long shopId) { this.shopId = shopId; }
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public String getKey() { return key; }
public void setKey(String key) { this.key = key; }
public String getValue() { return value; }
public void setValue(String value) { this.value = value; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
}

View File

@@ -0,0 +1,11 @@
package com.example.demo.common;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface SystemParameterRepository extends JpaRepository<SystemParameter, Long> {
Optional<SystemParameter> findByShopIdAndKey(Long shopId, String key);
}

View File

@@ -60,6 +60,13 @@ public class OrderController {
return ResponseEntity.ok(orderService.list(sid, biz, type, kw, Math.max(0, page-1), size, startDate, endDate));
}
@GetMapping("/orders/{id}")
public ResponseEntity<?> getOrderDetail(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
@PathVariable("id") Long id) {
Long sid = (shopId == null ? defaults.getShopId() : shopId);
return ResponseEntity.ok(orderService.getSalesOrderDetail(sid, id));
}
// 兼容前端直接调用 /api/purchase-orders
@GetMapping("/purchase-orders")
public ResponseEntity<?> purchaseOrders(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
@@ -74,6 +81,13 @@ public class OrderController {
return ResponseEntity.ok(orderService.list(sid, "purchase", type, kw, Math.max(0, page-1), size, startDate, endDate));
}
@GetMapping("/purchase-orders/{id}")
public ResponseEntity<?> getPurchaseOrderDetail(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
@PathVariable("id") Long id) {
Long sid = (shopId == null ? defaults.getShopId() : shopId);
return ResponseEntity.ok(orderService.getPurchaseOrderDetail(sid, id));
}
@GetMapping("/payments")
public ResponseEntity<?> listPayments(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
@RequestParam(name = "direction", required = false) String direction,
@@ -101,6 +115,15 @@ public class OrderController {
return ResponseEntity.ok(orderService.listOtherTransactions(sid, type, accountId, kw, Math.max(0, page-1), size, startDate, endDate));
}
@PostMapping("/other-transactions")
public ResponseEntity<?> createOtherTransaction(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
@RequestHeader(name = "X-User-Id", required = false) Long userId,
@RequestBody OrderDtos.CreateOtherTransactionRequest req) {
Long sid = (shopId == null ? defaults.getShopId() : shopId);
Long uid = (userId == null ? defaults.getUserId() : userId);
return ResponseEntity.ok(orderService.createOtherTransaction(sid, uid, req));
}
@GetMapping("/inventories/logs")
public ResponseEntity<?> listInventoryLogs(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
@RequestParam(name = "productId", required = false) Long productId,

View File

@@ -1,5 +1,6 @@
package com.example.demo.order;
import com.example.demo.common.AccountDefaultsProperties;
import com.example.demo.order.dto.OrderDtos;
import com.example.demo.product.entity.Inventory;
import com.example.demo.product.repo.InventoryRepository;
@@ -18,14 +19,15 @@ import java.util.List;
public class OrderService {
private final InventoryRepository inventoryRepository;
private final AccountDefaultsProperties accountDefaults;
private final JdbcTemplate jdbcTemplate;
public OrderService(InventoryRepository inventoryRepository,
JdbcTemplate jdbcTemplate) {
JdbcTemplate jdbcTemplate,
AccountDefaultsProperties accountDefaults) {
this.inventoryRepository = inventoryRepository;
this.jdbcTemplate = jdbcTemplate;
this.accountDefaults = accountDefaults;
}
@Transactional
@@ -163,6 +165,10 @@ public class OrderService {
if (req == null) return new OrderDtos.CreatePaymentsResponse(ids);
String direction = "sale".equals(bizType) ? "in" : "out";
for (OrderDtos.PaymentItem p : req) {
// 收/付款必须绑定订单(资金类不在此接口中处理)
if (("sale".equals(bizType) || "purchase".equals(bizType)) && p.orderId == null) {
throw new IllegalArgumentException("收/付款必须绑定订单");
}
Long accountId = resolveAccountId(shopId, userId, p.method);
KeyHolder kh = new GeneratedKeyHolder();
String sql = "INSERT INTO payments (shop_id,user_id,biz_type,biz_id,account_id,direction,amount,pay_time,remark,created_at) " +
@@ -247,39 +253,56 @@ public class OrderService {
}
public java.util.Map<String,Object> list(Long shopId, String biz, String type, String kw, int page, int size, String startDate, String endDate) {
String headTable;
if ("sale".equals(biz)) {
headTable = ("sale.return".equals(type) ? "sales_return_orders" : "sales_orders");
} else if ("purchase".equals(biz)) {
headTable = ("purchase.return".equals(type) ? "purchase_orders" : "purchase_orders");
} else {
// 若未传,默认销售出货
headTable = "sales_orders";
}
StringBuilder sql = new StringBuilder("SELECT id, order_no, order_time, amount FROM " + headTable + " WHERE shop_id=?");
StringBuilder sql = new StringBuilder();
java.util.List<Object> ps = new java.util.ArrayList<>();
ps.add(shopId);
if ("purchase".equals(biz) && "purchase.return".equals(type)) {
sql.append(" AND status='returned'");
if ("purchase".equals(biz)) {
// 进货单(含退货按状态过滤),返回驼峰并带供应商名称
sql.append("SELECT po.id, po.order_no AS orderNo, po.order_time AS orderTime, po.amount, s.name AS supplierName,\n")
.append("CASE WHEN po.status='returned' THEN 'purchase.return' ELSE 'purchase.in' END AS docType\n")
.append("FROM purchase_orders po\n")
.append("LEFT JOIN suppliers s ON s.id = po.supplier_id\n")
.append("WHERE po.shop_id=?");
if ("purchase.return".equals(type)) {
sql.append(" AND po.status='returned'");
}
if (kw != null && !kw.isBlank()) { sql.append(" AND (po.order_no LIKE ?)"); ps.add('%' + kw + '%'); }
if (startDate != null && !startDate.isBlank()) { sql.append(" AND po.order_time>=?"); ps.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); }
if (endDate != null && !endDate.isBlank()) { sql.append(" AND po.order_time<=?"); ps.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); }
sql.append(" ORDER BY po.order_time DESC LIMIT ? OFFSET ?");
} else { // 默认销售
// 销售单,返回驼峰并带客户名称
sql.append("SELECT so.id, so.order_no AS orderNo, so.order_time AS orderTime, so.amount, c.name AS customerName, 'sale.out' AS docType\n")
.append("FROM sales_orders so\n")
.append("LEFT JOIN customers c ON c.id = so.customer_id\n")
.append("WHERE so.shop_id=?");
if (kw != null && !kw.isBlank()) { sql.append(" AND (so.order_no LIKE ?)"); ps.add('%' + kw + '%'); }
if (startDate != null && !startDate.isBlank()) { sql.append(" AND so.order_time>=?"); ps.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); }
if (endDate != null && !endDate.isBlank()) { sql.append(" AND so.order_time<=?"); ps.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); }
sql.append(" ORDER BY so.order_time DESC LIMIT ? OFFSET ?");
}
if (kw != null && !kw.isBlank()) {
sql.append(" AND (order_no LIKE ?)");
ps.add('%' + kw + '%');
}
if (startDate != null && !startDate.isBlank()) { sql.append(" AND order_time>=?"); ps.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); }
if (endDate != null && !endDate.isBlank()) { sql.append(" AND order_time<=?"); ps.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); }
sql.append(" ORDER BY order_time DESC LIMIT ? OFFSET ?");
ps.add(size);
ps.add(page * size);
java.util.List<java.util.Map<String,Object>> list = jdbcTemplate.queryForList(sql.toString(), ps.toArray());
// 汇总
StringBuilder sumSql = new StringBuilder("SELECT COALESCE(SUM(amount),0) FROM " + headTable + " WHERE shop_id=?");
StringBuilder sumSql;
java.util.List<Object> sumPs = new java.util.ArrayList<>();
sumPs.add(shopId);
if ("purchase".equals(biz) && "purchase.return".equals(type)) { sumSql.append(" AND status='returned'"); }
if (kw != null && !kw.isBlank()) { sumSql.append(" AND (order_no LIKE ?)"); sumPs.add('%' + kw + '%'); }
if (startDate != null && !startDate.isBlank()) { sumSql.append(" AND order_time>=?"); sumPs.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); }
if (endDate != null && !endDate.isBlank()) { sumSql.append(" AND order_time<=?"); sumPs.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); }
if ("purchase".equals(biz)) {
sumSql = new StringBuilder("SELECT COALESCE(SUM(amount),0) FROM purchase_orders WHERE shop_id=?");
if ("purchase.return".equals(type)) { sumSql.append(" AND status='returned'"); }
if (kw != null && !kw.isBlank()) { sumSql.append(" AND (order_no LIKE ?)"); sumPs.add('%' + kw + '%'); }
if (startDate != null && !startDate.isBlank()) { sumSql.append(" AND order_time>=?"); sumPs.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); }
if (endDate != null && !endDate.isBlank()) { sumSql.append(" AND order_time<=?"); sumPs.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); }
} else {
sumSql = new StringBuilder("SELECT COALESCE(SUM(amount),0) FROM sales_orders WHERE shop_id=?");
if (kw != null && !kw.isBlank()) { sumSql.append(" AND (order_no LIKE ?)"); sumPs.add('%' + kw + '%'); }
if (startDate != null && !startDate.isBlank()) { sumSql.append(" AND order_time>=?"); sumPs.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); }
if (endDate != null && !endDate.isBlank()) { sumSql.append(" AND order_time<=?"); sumPs.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); }
}
java.math.BigDecimal total = jdbcTemplate.queryForObject(sumSql.toString(), java.math.BigDecimal.class, sumPs.toArray());
java.util.Map<String,Object> resp = new java.util.HashMap<>();
resp.put("list", list);
@@ -288,53 +311,132 @@ public class OrderService {
}
public java.util.Map<String,Object> listPayments(Long shopId, String direction, String bizType, Long accountId, String kw, int page, int size, String startDate, String endDate) {
StringBuilder sql = new StringBuilder("SELECT id, biz_type, account_id, direction, amount, pay_time FROM payments WHERE shop_id=?");
StringBuilder sql = new StringBuilder("SELECT p.id, p.biz_type AS bizType, p.account_id, a.name AS accountName, p.direction, p.amount, p.pay_time AS orderTime,\n" +
"CASE \n" +
" WHEN p.biz_type='sale' AND p.direction='in' THEN 'sale.collect' \n" +
" WHEN p.biz_type='purchase' AND p.direction='out' THEN 'purchase.pay' \n" +
" WHEN p.biz_type='other' AND p.direction='in' THEN 'other.income' \n" +
" WHEN p.biz_type='other' AND p.direction='out' THEN 'other.expense' \n" +
" ELSE CONCAT(p.biz_type, '.', p.direction) END AS docType\n" +
"FROM payments p LEFT JOIN accounts a ON a.id=p.account_id WHERE p.shop_id=?");
java.util.List<Object> ps = new java.util.ArrayList<>();
ps.add(shopId);
if (direction != null && !direction.isBlank()) { sql.append(" AND direction=?"); ps.add(direction); }
if (bizType != null && !bizType.isBlank()) { sql.append(" AND biz_type=?"); ps.add(bizType); }
if (accountId != null) { sql.append(" AND account_id=?"); ps.add(accountId); }
if (kw != null && !kw.isBlank()) { sql.append(" AND (CAST(id AS CHAR) LIKE ?)"); ps.add('%'+kw+'%'); }
if (startDate != null && !startDate.isBlank()) { sql.append(" AND pay_time>=?"); ps.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); }
if (endDate != null && !endDate.isBlank()) { sql.append(" AND pay_time<=?"); ps.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); }
sql.append(" ORDER BY pay_time DESC LIMIT ? OFFSET ?");
if (direction != null && !direction.isBlank()) { sql.append(" AND p.direction=?"); ps.add(direction); }
if (bizType != null && !bizType.isBlank()) { sql.append(" AND p.biz_type=?"); ps.add(bizType); }
if (accountId != null) { sql.append(" AND p.account_id=?"); ps.add(accountId); }
if (kw != null && !kw.isBlank()) { sql.append(" AND (CAST(p.id AS CHAR) LIKE ?)"); ps.add('%'+kw+'%'); }
if (startDate != null && !startDate.isBlank()) { sql.append(" AND p.pay_time>=?"); ps.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); }
if (endDate != null && !endDate.isBlank()) { sql.append(" AND p.pay_time<=?"); ps.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); }
sql.append(" ORDER BY p.pay_time DESC LIMIT ? OFFSET ?");
ps.add(size); ps.add(page * size);
java.util.List<java.util.Map<String,Object>> list = jdbcTemplate.queryForList(sql.toString(), ps.toArray());
StringBuilder sumSql = new StringBuilder("SELECT COALESCE(SUM(amount),0) FROM payments WHERE shop_id=?");
StringBuilder sumSql = new StringBuilder("SELECT COALESCE(SUM(p.amount),0) FROM payments p WHERE p.shop_id=?");
java.util.List<Object> sumPs = new java.util.ArrayList<>(); sumPs.add(shopId);
if (direction != null && !direction.isBlank()) { sumSql.append(" AND direction=?"); sumPs.add(direction); }
if (bizType != null && !bizType.isBlank()) { sumSql.append(" AND biz_type=?"); sumPs.add(bizType); }
if (accountId != null) { sumSql.append(" AND account_id=?"); sumPs.add(accountId); }
if (kw != null && !kw.isBlank()) { sumSql.append(" AND (CAST(id AS CHAR) LIKE ?)"); sumPs.add('%'+kw+'%'); }
if (startDate != null && !startDate.isBlank()) { sumSql.append(" AND pay_time>=?"); sumPs.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); }
if (endDate != null && !endDate.isBlank()) { sumSql.append(" AND pay_time<=?"); sumPs.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); }
if (direction != null && !direction.isBlank()) { sumSql.append(" AND p.direction=?"); sumPs.add(direction); }
if (bizType != null && !bizType.isBlank()) { sumSql.append(" AND p.biz_type=?"); sumPs.add(bizType); }
if (accountId != null) { sumSql.append(" AND p.account_id=?"); sumPs.add(accountId); }
if (kw != null && !kw.isBlank()) { sumSql.append(" AND (CAST(p.id AS CHAR) LIKE ?)"); sumPs.add('%'+kw+'%'); }
if (startDate != null && !startDate.isBlank()) { sumSql.append(" AND p.pay_time>=?"); sumPs.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); }
if (endDate != null && !endDate.isBlank()) { sumSql.append(" AND p.pay_time<=?"); sumPs.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); }
java.math.BigDecimal total = jdbcTemplate.queryForObject(sumSql.toString(), java.math.BigDecimal.class, sumPs.toArray());
java.util.Map<String,Object> resp = new java.util.HashMap<>(); resp.put("list", list); resp.put("totalAmount", total == null ? java.math.BigDecimal.ZERO : total); return resp;
}
public java.util.Map<String,Object> listOtherTransactions(Long shopId, String type, Long accountId, String kw, int page, int size, String startDate, String endDate) {
StringBuilder sql = new StringBuilder("SELECT id, `type`, account_id, amount, tx_time, remark FROM other_transactions WHERE shop_id=?");
StringBuilder sql = new StringBuilder("SELECT ot.id, ot.`type`, CONCAT('other.', ot.`type`) AS docType, ot.account_id, a.name AS accountName, ot.amount, ot.tx_time AS txTime, ot.remark FROM other_transactions ot LEFT JOIN accounts a ON a.id=ot.account_id WHERE ot.shop_id=?");
java.util.List<Object> ps = new java.util.ArrayList<>(); ps.add(shopId);
if (type != null && !type.isBlank()) { sql.append(" AND `type`=?"); ps.add(type); }
if (accountId != null) { sql.append(" AND account_id=?"); ps.add(accountId); }
if (kw != null && !kw.isBlank()) { sql.append(" AND (remark LIKE ? OR category LIKE ?)"); ps.add('%'+kw+'%'); ps.add('%'+kw+'%'); }
if (startDate != null && !startDate.isBlank()) { sql.append(" AND tx_time>=?"); ps.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); }
if (endDate != null && !endDate.isBlank()) { sql.append(" AND tx_time<=?"); ps.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); }
sql.append(" ORDER BY tx_time DESC LIMIT ? OFFSET ?"); ps.add(size); ps.add(page * size);
if (type != null && !type.isBlank()) { sql.append(" AND ot.`type`=?"); ps.add(type); }
if (accountId != null) { sql.append(" AND ot.account_id=?"); ps.add(accountId); }
if (kw != null && !kw.isBlank()) { sql.append(" AND (ot.remark LIKE ? OR ot.category LIKE ?)"); ps.add('%'+kw+'%'); ps.add('%'+kw+'%'); }
if (startDate != null && !startDate.isBlank()) { sql.append(" AND ot.tx_time>=?"); ps.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); }
if (endDate != null && !endDate.isBlank()) { sql.append(" AND ot.tx_time<=?"); ps.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); }
sql.append(" ORDER BY ot.tx_time DESC LIMIT ? OFFSET ?"); ps.add(size); ps.add(page * size);
java.util.List<java.util.Map<String,Object>> list = jdbcTemplate.queryForList(sql.toString(), ps.toArray());
StringBuilder sumSql = new StringBuilder("SELECT COALESCE(SUM(amount),0) FROM other_transactions WHERE shop_id=?");
StringBuilder sumSql = new StringBuilder("SELECT COALESCE(SUM(ot.amount),0) FROM other_transactions ot WHERE ot.shop_id=?");
java.util.List<Object> sumPs = new java.util.ArrayList<>(); sumPs.add(shopId);
if (type != null && !type.isBlank()) { sumSql.append(" AND `type`=?"); sumPs.add(type); }
if (accountId != null) { sumSql.append(" AND account_id=?"); sumPs.add(accountId); }
if (kw != null && !kw.isBlank()) { sumSql.append(" AND (remark LIKE ? OR category LIKE ?)"); sumPs.add('%'+kw+'%'); sumPs.add('%'+kw+'%'); }
if (startDate != null && !startDate.isBlank()) { sumSql.append(" AND tx_time>=?"); sumPs.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); }
if (endDate != null && !endDate.isBlank()) { sumSql.append(" AND tx_time<=?"); sumPs.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); }
if (type != null && !type.isBlank()) { sumSql.append(" AND ot.`type`=?"); sumPs.add(type); }
if (accountId != null) { sumSql.append(" AND ot.account_id=?"); sumPs.add(accountId); }
if (kw != null && !kw.isBlank()) { sumSql.append(" AND (ot.remark LIKE ? OR ot.category LIKE ?)"); sumPs.add('%'+kw+'%'); sumPs.add('%'+kw+'%'); }
if (startDate != null && !startDate.isBlank()) { sumSql.append(" AND ot.tx_time>=?"); sumPs.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); }
if (endDate != null && !endDate.isBlank()) { sumSql.append(" AND ot.tx_time<=?"); sumPs.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); }
java.math.BigDecimal total = jdbcTemplate.queryForObject(sumSql.toString(), java.math.BigDecimal.class, sumPs.toArray());
java.util.Map<String,Object> resp = new java.util.HashMap<>(); resp.put("list", list); resp.put("totalAmount", total == null ? java.math.BigDecimal.ZERO : total); return resp;
}
@org.springframework.transaction.annotation.Transactional
public java.util.Map<String, Object> createOtherTransaction(Long shopId, Long userId, OrderDtos.CreateOtherTransactionRequest req) {
if (req == null) throw new IllegalArgumentException("请求为空");
String type = req.type == null ? null : req.type.trim().toLowerCase();
if (!"income".equals(type) && !"expense".equals(type)) throw new IllegalArgumentException("type 仅支持 income/expense");
if (req.accountId == null) throw new IllegalArgumentException("账户必选");
java.math.BigDecimal amt = n(req.amount);
if (amt.compareTo(java.math.BigDecimal.ZERO) <= 0) throw new IllegalArgumentException("金额需大于0");
java.time.LocalDateTime when;
if (req.txTime == null || req.txTime.isBlank()) when = nowUtc();
else {
// 允许 yyyy-MM-dd 或完整时间
try {
if (req.txTime.length() == 10) when = java.time.LocalDate.parse(req.txTime).atStartOfDay();
else when = java.time.LocalDateTime.parse(req.txTime);
} catch (Exception e) { when = nowUtc(); }
}
final java.sql.Timestamp whenTs = java.sql.Timestamp.from(when.atZone(java.time.ZoneOffset.UTC).toInstant());
// 插入 other_transactions
org.springframework.jdbc.support.GeneratedKeyHolder kh = new org.springframework.jdbc.support.GeneratedKeyHolder();
String sql = "INSERT INTO other_transactions (shop_id,user_id,`type`,category,counterparty_type,counterparty_id,account_id,amount,tx_time,remark,created_at,updated_at) " +
"VALUES (?,?,?,?,?,?,?,?,?, ?, NOW(), NOW())";
jdbcTemplate.update(con -> {
java.sql.PreparedStatement ps = con.prepareStatement(sql, new String[]{"id"});
int i = 1;
ps.setLong(i++, shopId);
ps.setLong(i++, userId);
ps.setString(i++, type);
ps.setString(i++, req.category);
if (req.counterpartyType == null) ps.setNull(i++, java.sql.Types.VARCHAR); else ps.setString(i++, req.counterpartyType);
if (req.counterpartyId == null) ps.setNull(i++, java.sql.Types.BIGINT); else ps.setLong(i++, req.counterpartyId);
ps.setLong(i++, req.accountId);
ps.setBigDecimal(i++, scale2(amt));
ps.setTimestamp(i++, whenTs);
ps.setString(i, req.remark);
return ps;
}, kh);
Number key = kh.getKey();
Long id = key == null ? null : key.longValue();
// 写支付流水,联动账户余额
String direction = "income".equals(type) ? "in" : "out";
org.springframework.jdbc.support.GeneratedKeyHolder payKh = new org.springframework.jdbc.support.GeneratedKeyHolder();
final Long idForPayment = id;
jdbcTemplate.update(con -> {
java.sql.PreparedStatement ps = con.prepareStatement(
"INSERT INTO payments (shop_id,user_id,biz_type,biz_id,account_id,direction,amount,pay_time,remark,created_at) VALUES (?,?,?,?,?,?,?,?,?,NOW())",
new String[]{"id"}
);
int i = 1;
ps.setLong(i++, shopId);
ps.setLong(i++, userId);
ps.setString(i++, "other");
if (idForPayment == null) ps.setNull(i++, java.sql.Types.BIGINT); else ps.setLong(i++, idForPayment);
ps.setLong(i++, req.accountId);
ps.setString(i++, direction);
ps.setBigDecimal(i++, scale2(amt));
ps.setTimestamp(i++, whenTs);
ps.setString(i, req.remark);
return ps;
}, payKh);
// 联动账户余额:收入加,支出减
java.math.BigDecimal delta = "income".equals(type) ? amt : amt.negate();
jdbcTemplate.update("UPDATE accounts SET balance = balance + ?, updated_at=NOW() WHERE id=? AND shop_id=?", scale2(delta), req.accountId, shopId);
java.util.Map<String,Object> resp = new java.util.HashMap<>();
resp.put("id", id);
return resp;
}
public java.util.Map<String,Object> listInventoryLogs(Long shopId, Long productId, String reason, String kw, int page, int size, String startDate, String endDate) {
StringBuilder sql = new StringBuilder("SELECT id, product_id, qty_delta, amount_delta, reason, tx_time FROM inventory_movements WHERE shop_id=?");
StringBuilder sql = new StringBuilder("SELECT id, product_id, qty_delta, amount_delta, COALESCE(amount_delta,0) AS amount, reason, tx_time AS txTime, remark FROM inventory_movements WHERE shop_id=?");
java.util.List<Object> ps = new java.util.ArrayList<>(); ps.add(shopId);
if (productId != null) { sql.append(" AND product_id=?"); ps.add(productId); }
if (reason != null && !reason.isBlank()) { sql.append(" AND reason=?"); ps.add(reason); }
@@ -354,6 +456,50 @@ public class OrderService {
java.util.Map<String,Object> resp = new java.util.HashMap<>(); resp.put("list", list); resp.put("totalAmount", total == null ? java.math.BigDecimal.ZERO : total); return resp;
}
// 详情:销售单
public java.util.Map<String,Object> getSalesOrderDetail(Long shopId, Long id) {
java.util.List<java.util.Map<String,Object>> heads = jdbcTemplate.queryForList(
"SELECT so.id, so.order_no AS orderNo, so.order_time AS orderTime, so.status, so.amount, so.paid_amount AS paidAmount, so.customer_id AS customerId, c.name AS customerName, so.remark\n" +
"FROM sales_orders so LEFT JOIN customers c ON c.id=so.customer_id WHERE so.shop_id=? AND so.id=?",
shopId, id);
if (heads.isEmpty()) return java.util.Map.of();
java.util.Map<String,Object> head = heads.get(0);
java.util.List<java.util.Map<String,Object>> items = jdbcTemplate.queryForList(
"SELECT i.id, i.product_id AS productId, p.name, p.spec, i.quantity, i.unit_price AS unitPrice, i.discount_rate AS discountRate, i.amount\n" +
"FROM sales_order_items i JOIN products p ON p.id=i.product_id WHERE i.order_id=?",
id);
java.util.List<java.util.Map<String,Object>> pays = jdbcTemplate.queryForList(
"SELECT p.id, p.amount, p.pay_time AS payTime, p.account_id AS accountId, a.name AS accountName, p.direction\n" +
"FROM payments p LEFT JOIN accounts a ON a.id=p.account_id WHERE p.biz_type='sale' AND p.biz_id=?",
id);
java.util.Map<String,Object> resp = new java.util.HashMap<>(head);
resp.put("items", items);
resp.put("payments", pays);
return resp;
}
// 详情:进货单
public java.util.Map<String,Object> getPurchaseOrderDetail(Long shopId, Long id) {
java.util.List<java.util.Map<String,Object>> heads = jdbcTemplate.queryForList(
"SELECT po.id, po.order_no AS orderNo, po.order_time AS orderTime, po.status, po.amount, po.paid_amount AS paidAmount, po.supplier_id AS supplierId, s.name AS supplierName, po.remark\n" +
"FROM purchase_orders po LEFT JOIN suppliers s ON s.id=po.supplier_id WHERE po.shop_id=? AND po.id=?",
shopId, id);
if (heads.isEmpty()) return java.util.Map.of();
java.util.Map<String,Object> head = heads.get(0);
java.util.List<java.util.Map<String,Object>> items = jdbcTemplate.queryForList(
"SELECT i.id, i.product_id AS productId, p.name, p.spec, i.quantity, i.unit_price AS unitPrice, i.amount\n" +
"FROM purchase_order_items i JOIN products p ON p.id=i.product_id WHERE i.order_id=?",
id);
java.util.List<java.util.Map<String,Object>> pays = jdbcTemplate.queryForList(
"SELECT p.id, p.amount, p.pay_time AS payTime, p.account_id AS accountId, a.name AS accountName, p.direction\n" +
"FROM payments p LEFT JOIN accounts a ON a.id=p.account_id WHERE p.biz_type='purchase' AND p.biz_id=?",
id);
java.util.Map<String,Object> resp = new java.util.HashMap<>(head);
resp.put("items", items);
resp.put("payments", pays);
return resp;
}
private static BigDecimal n(BigDecimal v) { return v == null ? BigDecimal.ZERO : v; }
private static BigDecimal scale2(BigDecimal v) { return v.setScale(2, java.math.RoundingMode.HALF_UP); }
private static LocalDateTime nowUtc() { return LocalDateTime.now(java.time.Clock.systemUTC()); }
@@ -364,10 +510,11 @@ public class OrderService {
}
private void ensureDefaultAccounts(Long shopId, Long userId) {
// 为 cash/bank/wechat 分别确保存在一条账户记录;按 type→name 顺序检查,避免同名唯一冲突
ensureAccount(shopId, userId, "cash", "现金");
ensureAccount(shopId, userId, "bank", "银行存款");
ensureAccount(shopId, userId, "wechat", "微信");
// 为 cash/bank/wechat/alipay 分别确保存在一条账户记录;按 type→name 顺序检查,避免同名唯一冲突
ensureAccount(shopId, userId, "cash", accountDefaults.getCashName());
ensureAccount(shopId, userId, "bank", accountDefaults.getBankName());
ensureAccount(shopId, userId, "wechat", accountDefaults.getWechatName());
ensureAccount(shopId, userId, "alipay", accountDefaults.getAlipayName());
}
private void ensureAccount(Long shopId, Long userId, String type, String name) {
@@ -383,8 +530,11 @@ public class OrderService {
String type = "cash";
if ("bank".equalsIgnoreCase(method)) type = "bank";
if ("wechat".equalsIgnoreCase(method)) type = "wechat";
String name = "现金";
if ("bank".equals(type)) name = "银行存款"; else if ("wechat".equals(type)) name = "微信";
if ("alipay".equalsIgnoreCase(method)) type = "alipay";
String name = accountDefaults.getCashName();
if ("bank".equals(type)) name = accountDefaults.getBankName();
else if ("wechat".equals(type)) name = accountDefaults.getWechatName();
else if ("alipay".equals(type)) name = accountDefaults.getAlipayName();
// 先按 type 查
List<Long> byType = jdbcTemplate.query("SELECT id FROM accounts WHERE shop_id=? AND type=? LIMIT 1", (rs,rn)->rs.getLong(1), shopId, type);
if (!byType.isEmpty()) return byType.get(0);

View File

@@ -39,6 +39,17 @@ public class OrderDtos {
public java.util.List<Long> paymentIds;
public CreatePaymentsResponse(java.util.List<Long> ids) { this.paymentIds = ids; }
}
public static class CreateOtherTransactionRequest {
public String type; // income | expense
public String category; // 分类key
public String counterpartyType; // customer | supplier | other
public Long counterpartyId; // 可空
public Long accountId; // 必填
public java.math.BigDecimal amount; // 必填,>0
public String txTime; // yyyy-MM-dd 或 ISO8601
public String remark; // 可空
}
}

View File

@@ -4,7 +4,7 @@ spring.application.name=demo
# 正确的配置
# 格式为: jdbc:mysql://<主机名>:<端口号>/<数据库名>?参数
# 默认附带 MySQL 8 推荐参数,避免握手/时区/编码问题
spring.datasource.url=${DB_URL:jdbc:mysql://mysql.tonaspace.com:3306/partsinquiry?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf8}
spring.datasource.url=${DB_URL:jdbc:mysql://mysql.tonaspace.com:3306/partsinquiry?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf8mb4&connectionCollation=utf8mb4_0900_ai_ci}
# 用户名和密码直接写值
spring.datasource.username=${DB_USER:root}
@@ -16,6 +16,10 @@ spring.jpa.open-in-view=false
spring.jpa.show-sql=false
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect
# 日志级别(开发调试)
logging.level.com.example.demo=DEBUG
logging.level.org.springframework.web.filter.CommonsRequestLoggingFilter=INFO
# CORS 简单放开(如需跨域)
spring.web.cors.allowed-origins=${CORS_ALLOWED_ORIGINS:*}
spring.web.cors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS
@@ -38,3 +42,13 @@ attachments.placeholder.url-path=${ATTACHMENTS_PLACEHOLDER_URL:/api/attachments/
# 应用默认上下文(用于开发/演示环境)
app.defaults.shop-id=${APP_DEFAULT_SHOP_ID:1}
app.defaults.user-id=${APP_DEFAULT_USER_ID:2}
# 财务分类默认配置(前端请调用 /api/finance/categories 获取,禁止硬编码)
app.finance.income-categories=${APP_FINANCE_INCOME:operation_income:经营所得,interest_income:利息收入,other_income:其它收入,deposit_ar_income:收订金/欠款,investment_income:投资收入,sale_income:销售收入,account_operation:账户操作,fund_transfer_in:资金转账转入}
app.finance.expense-categories=${APP_FINANCE_EXPENSE:operation_expense:经营支出,office_supplies:办公用品,rent:房租,interest_expense:利息支出,other_expense:其它支出,account_operation:账户操作,fund_transfer_out:资金转账转出}
# 账户默认名称(避免硬编码,可被环境变量覆盖)
app.account.defaults.cash-name=${APP_ACCOUNT_CASH_NAME:现金}
app.account.defaults.bank-name=${APP_ACCOUNT_BANK_NAME:银行存款}
app.account.defaults.wechat-name=${APP_ACCOUNT_WECHAT_NAME:微信}
app.account.defaults.alipay-name=${APP_ACCOUNT_ALIPAY_NAME:支付宝}