Ver código fonte

销售管理模块后端代码

wwh 1 semana atrás
pai
commit
e47d79f16f
63 arquivos alterados com 4820 adições e 0 exclusões
  1. 210 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/controller/BaseCustomerController.java
  2. 265 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/controller/RelCustomerMarketController.java
  3. 539 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/controller/SalesAllowanceController.java
  4. 399 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/controller/SalesDeliveryController.java
  5. 728 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/controller/SalesOrderController.java
  6. 425 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/controller/SalesPriceController.java
  7. 530 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/controller/SalesReturnController.java
  8. 111 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/domain/BaseCustomer.java
  9. 72 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/domain/RelCustomerMarket.java
  10. 88 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/domain/SalesAllowance.java
  11. 65 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/domain/SalesAllowanceItem.java
  12. 83 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/domain/SalesDelivery.java
  13. 74 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/domain/SalesDeliveryGoods.java
  14. 118 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/domain/SalesOrder.java
  15. 66 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/domain/SalesOrderGoods.java
  16. 67 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/domain/SalesPrice.java
  17. 81 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/domain/SalesPriceItem.java
  18. 104 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/domain/SalesReturn.java
  19. 67 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/domain/SalesReturnGoods.java
  20. 7 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/mapper/BaseCustomerMapper.java
  21. 18 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/mapper/RelCustomerMarketMapper.java
  22. 7 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/mapper/SalesAllowanceItemMapper.java
  23. 7 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/mapper/SalesAllowanceMapper.java
  24. 7 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/mapper/SalesDeliveryGoodsMapper.java
  25. 7 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/mapper/SalesDeliveryMapper.java
  26. 7 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/mapper/SalesOrderGoodsMapper.java
  27. 7 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/mapper/SalesOrderMapper.java
  28. 47 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/mapper/SalesPriceItemMapper.java
  29. 7 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/mapper/SalesPriceMapper.java
  30. 7 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/mapper/SalesReturnGoodsMapper.java
  31. 7 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/mapper/SalesReturnMapper.java
  32. 7 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/service/IBaseCustomerService.java
  33. 14 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/service/IRelCustomerMarketService.java
  34. 7 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/service/ISalesAllowanceItemService.java
  35. 7 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/service/ISalesAllowanceService.java
  36. 7 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/service/ISalesDeliveryGoodsService.java
  37. 7 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/service/ISalesDeliveryService.java
  38. 7 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/service/ISalesOrderGoodsService.java
  39. 7 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/service/ISalesOrderService.java
  40. 7 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/service/ISalesPriceItemService.java
  41. 7 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/service/ISalesPriceService.java
  42. 7 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/service/ISalesReturnGoodsService.java
  43. 7 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/service/ISalesReturnService.java
  44. 11 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/service/impl/BaseCustomerServiceImpl.java
  45. 18 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/service/impl/RelCustomerMarketServiceImpl.java
  46. 11 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/service/impl/SalesAllowanceItemServiceImpl.java
  47. 11 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/service/impl/SalesAllowanceServiceImpl.java
  48. 11 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/service/impl/SalesDeliveryGoodsServiceImpl.java
  49. 11 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/service/impl/SalesDeliveryServiceImpl.java
  50. 11 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/service/impl/SalesOrderGoodsServiceImpl.java
  51. 11 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/service/impl/SalesOrderServiceImpl.java
  52. 11 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/service/impl/SalesPriceItemServiceImpl.java
  53. 11 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/service/impl/SalesPriceServiceImpl.java
  54. 11 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/service/impl/SalesReturnGoodsServiceImpl.java
  55. 11 0
      ruoyi-admin/src/main/java/com/ruoyi/web/base/service/impl/SalesReturnServiceImpl.java
  56. 54 0
      ruoyi-admin/src/main/resources/mapper/RelCustomerMarketMapper.xml
  57. 38 0
      sql/base_customer.sql
  58. 22 0
      sql/rel_customer_market.sql
  59. 49 0
      sql/sales_allowance.sql
  60. 53 0
      sql/sales_delivery.sql
  61. 54 0
      sql/sales_order.sql
  62. 39 0
      sql/sales_price.sql
  63. 54 0
      sql/sales_return.sql

+ 210 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/controller/BaseCustomerController.java

@@ -0,0 +1,210 @@
+package com.ruoyi.web.base.controller;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.utils.StringUtils;
+import com.ruoyi.common.utils.poi.ExcelUtil;
+import com.ruoyi.framework.web.service.TokenService;
+import com.ruoyi.web.base.domain.BaseCustomer;
+import com.ruoyi.web.base.service.IBaseCustomerService;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import io.swagger.annotations.ApiParam;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.util.List;
+import java.util.Map;
+
+import static com.ruoyi.common.core.domain.AjaxResult.success;
+import static com.ruoyi.common.utils.SecurityUtils.getUsername;
+
+/**
+ * 客户管理。权限标识与菜单 sys_menu.perms、前端 v-hasPermi 一致:base:customer:list/query/add/edit/remove/export。
+ * {@link #getByCustomerNum} 供销售等模块按编号引用客户,不单独校验客户菜单权限,仅需登录。
+ */
+@RestController
+@Api(tags = "客户管理")
+@RequestMapping("/base-customer")
+public class BaseCustomerController {
+
+    @Autowired
+    private IBaseCustomerService baseCustomerService;
+    @Autowired
+    private TokenService tokenService;
+
+    @ApiOperation("客户管理添加")
+    @PreAuthorize("@ss.hasPermi('base:customer:add')")
+    @PostMapping("/add")
+    public AjaxResult add(@RequestBody BaseCustomer baseCustomer, HttpServletRequest request) throws Exception {
+        validateCustomerLevel(baseCustomer.getCustomerLevel());
+        validateQuarantineStatType(baseCustomer.getQuarantineStatType());
+        validateInvoiceType(baseCustomer.getInvoiceType());
+        validateSwitchValue(baseCustomer.getCreditControl(), "信用控制");
+        if (baseCustomerService.count(new QueryWrapper<BaseCustomer>()
+                .eq("customer_num", baseCustomer.getCustomerNum())
+                .eq("del_flag", "0")) > 0) {
+            throw new Exception("该编号已存在");
+        }
+        String loginOrgId = tokenService.getLoginOrgId(request);
+        baseCustomer.setOrgId(loginOrgId);
+        baseCustomer.setDelFlag("0");
+        baseCustomer.setCreateBy(getUsername());
+        return success(baseCustomerService.save(baseCustomer));
+    }
+
+    @ApiOperation("客户管理修改")
+    @PreAuthorize("@ss.hasPermi('base:customer:edit')")
+    @PostMapping("/edit")
+    public AjaxResult edit(@RequestBody BaseCustomer baseCustomer, HttpServletRequest request) throws Exception {
+        validateCustomerLevel(baseCustomer.getCustomerLevel());
+        validateQuarantineStatType(baseCustomer.getQuarantineStatType());
+        validateInvoiceType(baseCustomer.getInvoiceType());
+        validateSwitchValue(baseCustomer.getCreditControl(), "信用控制");
+        if (baseCustomerService.count(new QueryWrapper<BaseCustomer>()
+                .ne("id", baseCustomer.getId())
+                .eq("customer_num", baseCustomer.getCustomerNum())
+                .eq("del_flag", "0")) > 0) {
+            throw new Exception("该编号已存在");
+        }
+        baseCustomer.setUpdateBy(getUsername());
+        return success(baseCustomerService.updateById(baseCustomer));
+    }
+
+    @ApiOperation("客户管理删除")
+    @PreAuthorize("@ss.hasPermi('base:customer:remove')")
+    @PostMapping("/delete")
+    public AjaxResult delete(@ApiParam("删除参数: ids(逗号分隔)") @RequestBody Map<String, String> paramsMap) {
+        String ids = paramsMap.get("ids");
+        for (String id : ids.split(",")) {
+            BaseCustomer customer = new BaseCustomer();
+            customer.setId(Integer.valueOf(id));
+            customer.setDelFlag("2");
+            baseCustomerService.updateById(customer);
+        }
+        return success();
+    }
+
+    @ApiOperation("客户管理列表")
+    @PreAuthorize("@ss.hasPermi('base:customer:list')")
+    @PostMapping("/list")
+    public AjaxResult listAll(HttpServletRequest request) {
+        return success(baseCustomerService.list(new QueryWrapper<BaseCustomer>()
+                .eq("org_id", tokenService.getLoginOrgId(request))
+                .eq("del_flag", "0")));
+    }
+
+    @ApiOperation("客户管理分页")
+    @PreAuthorize("@ss.hasPermi('base:customer:list')")
+    @GetMapping("/page")
+    public AjaxResult page(@ApiParam("页码") @RequestParam("pageNum") Integer pageNum,
+                           @ApiParam("每页大小") @RequestParam("pageSize") Integer pageSize,
+                           BaseCustomer query, HttpServletRequest request) {
+        QueryWrapper<BaseCustomer> wrapper = buildWrapper(query, tokenService.getLoginOrgId(request));
+        return success(baseCustomerService.page(new Page<BaseCustomer>(pageNum, pageSize), wrapper));
+    }
+
+    @ApiOperation("客户管理详情")
+    @PreAuthorize("@ss.hasPermi('base:customer:query')")
+    @PostMapping("/listById")
+    public AjaxResult listById(@ApiParam("详情参数: id") @RequestBody Map<String, String> paramsMap) {
+        String id = paramsMap.get("id");
+        return success(baseCustomerService.getById(id));
+    }
+
+    /**
+     * 按客户编号查询(销售订单等模块引用)。不校验 base:customer:*,避免无「客户管理」菜单权限的用户无法录单。
+     */
+    @ApiOperation("按客户编号查询客户")
+    @PostMapping("/getByCustomerNum")
+    public AjaxResult getByCustomerNum(@RequestBody Map<String, String> params, HttpServletRequest request) {
+        String customerNum = params.get("customerNum");
+        if (StringUtils.isBlank(customerNum)) {
+            return success(null);
+        }
+        BaseCustomer c = baseCustomerService.getOne(new QueryWrapper<BaseCustomer>()
+                .eq("org_id", tokenService.getLoginOrgId(request))
+                .eq("customer_num", customerNum.trim())
+                .eq("del_flag", "0")
+                .last("limit 1"));
+        return success(c);
+    }
+
+    @ApiOperation("客户管理导出")
+    @PreAuthorize("@ss.hasPermi('base:customer:export')")
+    @PostMapping("/export")
+    public void export(HttpServletResponse response, BaseCustomer query, HttpServletRequest request) {
+        QueryWrapper<BaseCustomer> wrapper = buildWrapper(query, tokenService.getLoginOrgId(request));
+        List<BaseCustomer> list = baseCustomerService.list(wrapper);
+        ExcelUtil<BaseCustomer> util = new ExcelUtil<BaseCustomer>(BaseCustomer.class);
+        util.exportExcel(response, list, "客户数据");
+    }
+
+    private QueryWrapper<BaseCustomer> buildWrapper(BaseCustomer query, String orgId) {
+        QueryWrapper<BaseCustomer> wrapper = new QueryWrapper<BaseCustomer>()
+                .eq("org_id", orgId)
+                .eq("del_flag", "0");
+        if (query == null) {
+            return wrapper;
+        }
+        if (StringUtils.isNotBlank(query.getCustomerNum())) {
+            wrapper.like("customer_num", query.getCustomerNum());
+        }
+        if (StringUtils.isNotBlank(query.getCustomerName())) {
+            wrapper.like("customer_name", query.getCustomerName());
+        }
+        if (StringUtils.isNotBlank(query.getCustomerAddress())) {
+            wrapper.like("customer_address", query.getCustomerAddress());
+        }
+        if (query.getCustomerLevel() != null) {
+            wrapper.eq("customer_level", query.getCustomerLevel());
+        }
+        if (StringUtils.isNotBlank(query.getContactPerson())) {
+            wrapper.like("contact_person", query.getContactPerson());
+        }
+        if (StringUtils.isNotBlank(query.getContactPhone())) {
+            wrapper.like("contact_phone", query.getContactPhone());
+        }
+        if (StringUtils.isNotBlank(query.getReceivableCustomer())) {
+            wrapper.like("receivable_customer", query.getReceivableCustomer());
+        }
+        if (StringUtils.isNotBlank(query.getPayeeCustomer())) {
+            wrapper.like("payee_customer", query.getPayeeCustomer());
+        }
+        if (StringUtils.isNotBlank(query.getDeliveryCustomer())) {
+            wrapper.like("delivery_customer", query.getDeliveryCustomer());
+        }
+        if (query.getCreditControl() != null) {
+            wrapper.eq("credit_control", query.getCreditControl());
+        }
+        return wrapper;
+    }
+
+    private void validateCustomerLevel(Integer customerLevel) throws Exception {
+        if (customerLevel != null && (customerLevel < 1 || customerLevel > 5)) {
+            throw new Exception("客户等级仅支持:1(1级)、2(2级)、3(3级)、4(4级)、5(5级)");
+        }
+    }
+
+    private void validateSwitchValue(Integer value, String label) throws Exception {
+        if (value != null && value != 0 && value != 1 && value != 2) {
+            throw new Exception(label + "仅支持:0(' ')、1(启用)、2(不启用)");
+        }
+    }
+
+    private void validateQuarantineStatType(Integer quarantineStatType) throws Exception {
+        if (quarantineStatType != null && quarantineStatType != 0 && quarantineStatType != 1 && quarantineStatType != 2) {
+            throw new Exception("检疫统计类型仅支持:0(无)、1(按客户)、2(按市场)");
+        }
+    }
+
+    private void validateInvoiceType(Integer invoiceType) throws Exception {
+        if (invoiceType != null && invoiceType != 0 && invoiceType != 1 && invoiceType != 2) {
+            throw new Exception("开票类型仅支持:0(' ')、1(普票)、2(增票)");
+        }
+    }
+}

+ 265 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/controller/RelCustomerMarketController.java

@@ -0,0 +1,265 @@
+package com.ruoyi.web.base.controller;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.utils.StringUtils;
+import com.ruoyi.common.utils.poi.ExcelUtil;
+import com.ruoyi.framework.web.service.TokenService;
+import com.ruoyi.web.base.domain.BaseCustomer;
+import com.ruoyi.web.base.domain.RelCustomerMarket;
+import com.ruoyi.web.base.domain.BaseLine;
+import com.ruoyi.web.base.domain.BaseMarket;
+import com.ruoyi.web.base.service.IRelCustomerMarketService;
+import com.ruoyi.web.base.service.IBaseCustomerService;
+import com.ruoyi.web.base.service.IBaseLineService;
+import com.ruoyi.web.base.service.IBaseMarketService;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.util.*;
+import java.util.stream.Collectors;
+
+import static com.ruoyi.common.core.domain.AjaxResult.success;
+import static com.ruoyi.common.utils.SecurityUtils.getUsername;
+
+@RestController
+@Api(tags = "客户市场维护")
+@RequestMapping("/rel-customer-market")
+public class RelCustomerMarketController {
+    @Autowired
+    private IRelCustomerMarketService relCustomerMarketService;
+    @Autowired
+    private IBaseCustomerService customerService;
+    @Autowired
+    private IBaseLineService lineService;
+    @Autowired
+    private IBaseMarketService marketService;
+    @Autowired
+    private TokenService tokenService;
+
+    @ApiOperation("客户市场维护分页")
+    @GetMapping("/page")
+    public AjaxResult page(@RequestParam("pageNum") Integer pageNum,
+                           @RequestParam("pageSize") Integer pageSize,
+                           RelCustomerMarket query, HttpServletRequest request) {
+        String orgId = tokenService.getLoginOrgId(request);
+        int pn = normalizePageNum(pageNum);
+        int ps = normalizePageSize(pageSize);
+        String lineNum = query != null ? query.getLineNum() : null;
+        String marketNum = query != null ? query.getMarketNum() : null;
+        IPage<RelCustomerMarket> page = relCustomerMarketService.pageWithDetail(
+                new Page<RelCustomerMarket>(pn, ps),
+                orgId,
+                StringUtils.isNotBlank(lineNum) ? lineNum : null,
+                StringUtils.isNotBlank(marketNum) ? marketNum : null);
+        return success(page);
+    }
+
+    /** 防止超大 pageSize 拖垮数据库 */
+    private static int normalizePageSize(Integer pageSize) {
+        if (pageSize == null || pageSize < 1) {
+            return 10;
+        }
+        return Math.min(pageSize, 200);
+    }
+
+    private static int normalizePageNum(Integer pageNum) {
+        if (pageNum == null || pageNum < 1) {
+            return 1;
+        }
+        return pageNum;
+    }
+
+    @ApiOperation("客户市场维护新增(支持批量客户)")
+    @PostMapping("/add")
+    public AjaxResult add(@RequestBody Map<String, Object> params, HttpServletRequest request) throws Exception {
+        String orgId = tokenService.getLoginOrgId(request);
+        String marketNum = stringVal(params.get("marketNum"));
+        String customerNums = stringVal(params.get("customerNums"));
+        if (StringUtils.isBlank(marketNum) || StringUtils.isBlank(customerNums)) {
+            throw new Exception("市场、客户不能为空");
+        }
+        BaseMarket market = marketService.getOne(new QueryWrapper<BaseMarket>()
+                .eq("org_id", orgId)
+                .eq("market_num", marketNum)
+                .eq("del_flag", "0")
+                .last("limit 1"));
+        if (market == null) {
+            throw new Exception("市场不存在");
+        }
+        String lineNum = market.getLineNum();
+        if (StringUtils.isBlank(lineNum)) {
+            throw new Exception("该市场未绑定物流线");
+        }
+        List<String> customerNumList = Arrays.stream(customerNums.split(","))
+                .map(String::trim)
+                .filter(StringUtils::isNotBlank)
+                .distinct()
+                .collect(Collectors.toList());
+        if (customerNumList.isEmpty()) {
+            throw new Exception("客户不能为空");
+        }
+
+        List<BaseCustomer> existCustomers = customerService.list(new QueryWrapper<BaseCustomer>()
+                .eq("org_id", orgId)
+                .eq("del_flag", "0")
+                .in("customer_num", customerNumList));
+        Set<String> existCustomerNums = existCustomers.stream().map(BaseCustomer::getCustomerNum).collect(Collectors.toSet());
+        List<String> missingCustomerNums = customerNumList.stream().filter(num -> !existCustomerNums.contains(num)).collect(Collectors.toList());
+        if (!missingCustomerNums.isEmpty()) {
+            throw new Exception("以下客户编号在客户管理中不存在:" + String.join(",", missingCustomerNums));
+        }
+
+        String username = getUsername();
+        Date now = new Date();
+        for (String customerNum : customerNumList) {
+            List<RelCustomerMarket> existRelCustomerMarkets = relCustomerMarketService.list(new QueryWrapper<RelCustomerMarket>()
+                    .eq("customer_num", customerNum)
+                    .eq("del_flag", "0"));
+            if (StringUtils.isNotEmpty(existRelCustomerMarkets)) {
+                BaseMarket dbMarket = marketService.getOne(new QueryWrapper<BaseMarket>()
+                        .eq("org_id", orgId)
+                        .eq("market_num", existRelCustomerMarkets.get(0).getMarketNum())
+                        .eq("del_flag", "0")
+                        .last("limit 1"));
+
+                throw new Exception("市场["+ dbMarket.getMarketName() +"]下已存在编号为[" + customerNum + "]的客户");
+            }
+            RelCustomerMarket item = new RelCustomerMarket();
+            item.setOrgId(orgId);
+            item.setLineNum(lineNum);
+            item.setMarketNum(marketNum);
+            item.setCustomerNum(customerNum);
+            item.setDelFlag("0");
+            item.setCreateBy(username);
+            item.setCreateTime(now);
+            relCustomerMarketService.save(item);
+        }
+        return success();
+    }
+
+    @ApiOperation("客户市场维护删除")
+    @PostMapping("/delete")
+    public AjaxResult delete(@RequestBody Map<String, String> params) {
+        String ids = params.get("ids");
+        String username = getUsername();
+        Date now = new Date();
+        for (String id : ids.split(",")) {
+            RelCustomerMarket item = new RelCustomerMarket();
+            item.setId(Integer.valueOf(id));
+            item.setDelFlag("2");
+            item.setUpdateBy(username);
+            item.setUpdateTime(now);
+            relCustomerMarketService.updateById(item);
+        }
+        return success();
+    }
+
+    @ApiOperation("客户调市场")
+    @PostMapping("/move")
+    public AjaxResult move(@RequestBody Map<String, Object> params) throws Exception {
+        String id = stringVal(params.get("id"));
+        String newMarketNum = StringUtils.trimToNull(stringVal(params.get("newMarketNum")));
+        if (StringUtils.isBlank(id) || newMarketNum == null) {
+            throw new Exception("请传入 id、newMarketNum");
+        }
+        RelCustomerMarket item = relCustomerMarketService.getById(id);
+        if (item == null || !"0".equals(item.getDelFlag())) {
+            throw new Exception("记录不存在");
+        }
+        BaseMarket newMarket = marketService.getOne(new QueryWrapper<BaseMarket>()
+                .eq("org_id", item.getOrgId())
+                .eq("market_num", newMarketNum)
+                .eq("del_flag", "0")
+                .last("limit 1"));
+        if (newMarket == null) {
+            throw new Exception("新市场不存在");
+        }
+        if (StringUtils.isBlank(newMarket.getLineNum())) {
+            throw new Exception("新市场未绑定物流线");
+        }
+        long exists = relCustomerMarketService.count(new QueryWrapper<RelCustomerMarket>()
+                .ne("id", item.getId())
+                .eq("org_id", item.getOrgId())
+                .eq("customer_num", item.getCustomerNum())
+                .eq("market_num", newMarketNum)
+                .eq("del_flag", "0"));
+        if (exists > 0) {
+            throw new Exception("目标市场已存在该客户");
+        }
+        item.setLineNum(newMarket.getLineNum());
+        item.setMarketNum(newMarketNum);
+        item.setUpdateBy(getUsername());
+        item.setUpdateTime(new Date());
+        return success(relCustomerMarketService.updateById(item));
+    }
+
+    @ApiOperation("客户市场维护导出")
+    @PostMapping("/export")
+    public void export(HttpServletResponse response, RelCustomerMarket query, HttpServletRequest request) {
+        String orgId = tokenService.getLoginOrgId(request);
+        QueryWrapper<RelCustomerMarket> wrapper = new QueryWrapper<RelCustomerMarket>()
+                .eq("org_id", orgId)
+                .eq("del_flag", "0");
+        if (StringUtils.isNotBlank(query.getLineNum())) {
+            wrapper.eq("line_num", query.getLineNum());
+        }
+        if (StringUtils.isNotBlank(query.getMarketNum())) {
+            wrapper.eq("market_num", query.getMarketNum());
+        }
+        List<RelCustomerMarket> list = relCustomerMarketService.list(wrapper);
+        fillDisplayFields(list, orgId);
+        ExcelUtil<RelCustomerMarket> util = new ExcelUtil<RelCustomerMarket>(RelCustomerMarket.class);
+        util.exportExcel(response, list, "客户市场维护数据");
+    }
+
+    private void fillDisplayFields(List<RelCustomerMarket> records, String orgId) {
+        if (records == null || records.isEmpty()) {
+            return;
+        }
+        Set<String> customerNums = records.stream().map(RelCustomerMarket::getCustomerNum).collect(Collectors.toSet());
+        Set<String> lineNums = records.stream().map(RelCustomerMarket::getLineNum).collect(Collectors.toSet());
+        Set<String> marketNums = records.stream().map(RelCustomerMarket::getMarketNum).collect(Collectors.toSet());
+
+        Map<String, BaseCustomer> customerMap = customerService.list(new QueryWrapper<BaseCustomer>()
+                        .eq("org_id", orgId).eq("del_flag", "0").in("customer_num", customerNums))
+                .stream().collect(Collectors.toMap(BaseCustomer::getCustomerNum, e -> e, (a, b) -> a));
+        Map<String, BaseLine> lineMap = lineService.list(new QueryWrapper<BaseLine>()
+                        .eq("org_id", orgId).eq("del_flag", "0").in("line_num", lineNums))
+                .stream().collect(Collectors.toMap(BaseLine::getLineNum, e -> e, (a, b) -> a));
+        Map<String, BaseMarket> marketMap = marketService.list(new QueryWrapper<BaseMarket>()
+                        .eq("org_id", orgId).eq("del_flag", "0").in("market_num", marketNums))
+                .stream().collect(Collectors.toMap(BaseMarket::getMarketNum, e -> e, (a, b) -> a));
+
+        for (RelCustomerMarket item : records) {
+            BaseCustomer customer = customerMap.get(item.getCustomerNum());
+            if (customer != null) {
+                item.setCustomerName(customer.getCustomerName());
+                item.setCustomerLevel(customer.getCustomerLevel());
+                item.setReceivableCustomer(customer.getReceivableCustomer());
+                item.setPayeeCustomer(customer.getPayeeCustomer());
+                item.setCreditControl(customer.getCreditControl());
+                item.setCustomerChannel(customer.getCustomerChannel());
+                item.setRemark(customer.getRemark());
+            }
+            BaseLine line = lineMap.get(item.getLineNum());
+            if (line != null) {
+                item.setLineName(line.getLineName());
+            }
+            BaseMarket market = marketMap.get(item.getMarketNum());
+            if (market != null) {
+                item.setMarketName(market.getMarketName());
+            }
+        }
+    }
+
+    private String stringVal(Object obj) {
+        return obj == null ? null : String.valueOf(obj);
+    }
+}

+ 539 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/controller/SalesAllowanceController.java

@@ -0,0 +1,539 @@
+package com.ruoyi.web.base.controller;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.utils.StringUtils;
+import com.ruoyi.common.utils.poi.ExcelUtil;
+import com.ruoyi.framework.web.service.TokenService;
+import com.ruoyi.web.base.domain.BaseCustomer;
+import com.ruoyi.web.base.domain.BaseEmployee;
+import com.ruoyi.web.base.domain.BaseMaterial;
+import com.ruoyi.web.base.domain.SalesAllowance;
+import com.ruoyi.web.base.domain.SalesAllowanceItem;
+import com.ruoyi.web.base.domain.SalesDelivery;
+import com.ruoyi.web.base.domain.SalesDeliveryGoods;
+import com.ruoyi.web.base.service.IBaseCustomerService;
+import com.ruoyi.web.base.service.IBaseEmployeeService;
+import com.ruoyi.web.base.service.IBaseMaterialService;
+import com.ruoyi.web.base.service.ISalesAllowanceItemService;
+import com.ruoyi.web.base.service.ISalesAllowanceService;
+import com.ruoyi.web.base.service.ISalesDeliveryGoodsService;
+import com.ruoyi.web.base.service.ISalesDeliveryService;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import io.swagger.annotations.ApiParam;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.bind.annotation.*;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import static com.ruoyi.common.core.domain.AjaxResult.success;
+import static com.ruoyi.common.utils.SecurityUtils.getUsername;
+import static com.ruoyi.web.base.util.NumUtils.generateString;
+import static com.ruoyi.web.base.util.NumUtils.substringToInt;
+
+@RestController
+@Api(tags = "销售折让单")
+@RequestMapping("/sales-allowance")
+public class SalesAllowanceController {
+
+    @Autowired
+    private ISalesAllowanceService salesAllowanceService;
+    @Autowired
+    private ISalesAllowanceItemService salesAllowanceItemService;
+    @Autowired
+    private ISalesDeliveryService salesDeliveryService;
+    @Autowired
+    private ISalesDeliveryGoodsService salesDeliveryGoodsService;
+    @Autowired
+    private IBaseCustomerService customerService;
+    @Autowired
+    private IBaseEmployeeService baseEmployeeService;
+    @Autowired
+    private IBaseMaterialService baseMaterialService;
+    @Autowired
+    private TokenService tokenService;
+
+    @ApiOperation("销售折让单新增")
+    @PostMapping("/add")
+    @Transactional
+    public AjaxResult add(@RequestBody SalesAllowance allowance, HttpServletRequest request) throws Exception {
+        String orgId = tokenService.getLoginOrgId(request);
+        validateBeforeSave(allowance, orgId, true);
+        fillCustomerInfo(allowance, orgId);
+        recalcItemAmountAndTotal(allowance);
+        allowance.setOrgId(orgId);
+        allowance.setDelFlag("0");
+        allowance.setAuditStatus(1);
+        if (allowance.getOffsetStatus() == null) {
+            allowance.setOffsetStatus(0);
+        }
+        String username = getUsername();
+        allowance.setId(null);
+        allowance.setCreateBy(username);
+        allowance.setCreateTime(new Date());
+        allowance.setUpdateBy(null);
+        allowance.setUpdateTime(null);
+        salesAllowanceService.save(allowance);
+        saveItems(allowance, orgId, username);
+        return success();
+    }
+
+    @ApiOperation("销售折让单修改")
+    @PostMapping("/edit")
+    @Transactional
+    public AjaxResult edit(@RequestBody SalesAllowance allowance, HttpServletRequest request) throws Exception {
+        if (allowance.getId() == null) {
+            throw new Exception("id不能为空");
+        }
+        SalesAllowance old = salesAllowanceService.getById(allowance.getId());
+        if (old == null || !"0".equals(old.getDelFlag())) {
+            throw new Exception("销售折让单不存在");
+        }
+        if (old.getAuditStatus() != null && old.getAuditStatus() == 1) {
+            throw new Exception("已审核的销售折让单不能修改,请先反审核");
+        }
+        if (old.getOffsetStatus() != null && old.getOffsetStatus() == 1) {
+            throw new Exception("已冲销的销售折让单不能修改");
+        }
+        String orgId = tokenService.getLoginOrgId(request);
+        validateBeforeSave(allowance, orgId, false);
+        fillCustomerInfo(allowance, orgId);
+        recalcItemAmountAndTotal(allowance);
+        allowance.setOrgId(orgId);
+        allowance.setAuditStatus(1);
+        allowance.setOffsetStatus(old.getOffsetStatus());
+        String username = getUsername();
+        allowance.setCreateBy(old.getCreateBy());
+        allowance.setCreateTime(old.getCreateTime());
+        allowance.setUpdateBy(username);
+        allowance.setUpdateTime(new Date());
+        salesAllowanceService.updateById(allowance);
+        String oldNum = StringUtils.isNotBlank(old.getAllowanceNum()) ? old.getAllowanceNum().trim() : null;
+        deleteItemsByAllowanceNum(orgId, oldNum);
+        saveItems(allowance, orgId, username);
+        return success();
+    }
+
+    @ApiOperation("销售折让单删除")
+    @PostMapping("/delete")
+    public AjaxResult delete(@RequestBody Map<String, String> params) throws Exception {
+        String ids = params.get("ids");
+        for (String id : ids.split(",")) {
+            SalesAllowance allowance = salesAllowanceService.getById(id);
+            if (allowance == null || !"0".equals(allowance.getDelFlag())) {
+                continue;
+            }
+            if (allowance.getAuditStatus() != null && allowance.getAuditStatus() == 1) {
+                throw new Exception("已审核的销售折让单不能删除,请先反审核");
+            }
+            if (allowance.getOffsetStatus() != null && allowance.getOffsetStatus() == 1) {
+                throw new Exception("已冲销的销售折让单不能删除");
+            }
+            deleteItemsByAllowanceNum(allowance.getOrgId(), allowance.getAllowanceNum());
+            SalesAllowance up = new SalesAllowance();
+            up.setId(Integer.valueOf(id));
+            up.setDelFlag("2");
+            up.setUpdateBy(getUsername());
+            up.setUpdateTime(new Date());
+            salesAllowanceService.updateById(up);
+        }
+        return success();
+    }
+
+    @ApiOperation("销售折让单详情")
+    @PostMapping("/listById")
+    public AjaxResult listById(@ApiParam("详情参数: id") @RequestBody Map<String, String> params, HttpServletRequest request) {
+        SalesAllowance allowance = salesAllowanceService.getById(params.get("id"));
+        if (allowance == null) {
+            return success(null);
+        }
+        String orgId = tokenService.getLoginOrgId(request);
+        List<SalesAllowanceItem> items = salesAllowanceItemService.list(new QueryWrapper<SalesAllowanceItem>()
+                .eq("org_id", orgId)
+                .eq("allowance_num", allowance.getAllowanceNum())
+                .eq("del_flag", "0"));
+        allowance.setItems(items);
+        enrichAllowanceDisplay(allowance, orgId);
+        return success(allowance);
+    }
+
+    @ApiOperation("销售折让单分页")
+    @GetMapping("/page")
+    public AjaxResult page(@RequestParam("pageNum") Integer pageNum,
+                           @RequestParam("pageSize") Integer pageSize,
+                           SalesAllowance query,
+                           HttpServletRequest request) {
+        String orgId = tokenService.getLoginOrgId(request);
+        QueryWrapper<SalesAllowance> wrapper = buildWrapper(query, orgId);
+        Page<SalesAllowance> page = salesAllowanceService.page(new Page<SalesAllowance>(pageNum, pageSize), wrapper);
+        for (SalesAllowance row : page.getRecords()) {
+            enrichAllowanceDisplay(row, orgId);
+        }
+        return success(page);
+    }
+
+    @ApiOperation("销售折让单列表")
+    @PostMapping("/list")
+    public AjaxResult list(@RequestBody SalesAllowance query, HttpServletRequest request) {
+        String orgId = tokenService.getLoginOrgId(request);
+        List<SalesAllowance> list = salesAllowanceService.list(buildWrapper(query, orgId));
+        for (SalesAllowance row : list) {
+            enrichAllowanceDisplay(row, orgId);
+        }
+        attachItemsForListRows(list, orgId);
+        return success(list);
+    }
+
+    @ApiOperation("销售折让单导出")
+    @PostMapping("/export")
+    public void export(HttpServletResponse response, SalesAllowance query, HttpServletRequest request) {
+        String orgId = tokenService.getLoginOrgId(request);
+        List<SalesAllowance> list = salesAllowanceService.list(buildWrapper(query, orgId));
+        for (SalesAllowance row : list) {
+            enrichAllowanceDisplay(row, orgId);
+        }
+        ExcelUtil<SalesAllowance> util = new ExcelUtil<SalesAllowance>(SalesAllowance.class);
+        util.exportExcel(response, list, "销售折让单");
+    }
+
+    @ApiOperation("销售折让单审核")
+    @PostMapping("/audit")
+    public AjaxResult audit(@RequestBody Map<String, String> params) throws Exception {
+        SalesAllowance allowance = getActiveById(params.get("id"));
+        allowance.setAuditStatus(1);
+        allowance.setUpdateBy(getUsername());
+        allowance.setUpdateTime(new Date());
+        return success(salesAllowanceService.updateById(allowance));
+    }
+
+    @ApiOperation("销售折让单反审核")
+    @PostMapping("/unaudit")
+    public AjaxResult unaudit(@RequestBody Map<String, String> params) throws Exception {
+        SalesAllowance allowance = getActiveById(params.get("id"));
+        if (allowance.getOffsetStatus() != null && allowance.getOffsetStatus() == 1) {
+            throw new Exception("已做冲销的销售折让单不可反审核");
+        }
+        allowance.setAuditStatus(0);
+        allowance.setUpdateBy(getUsername());
+        allowance.setUpdateTime(new Date());
+        return success(salesAllowanceService.updateById(allowance));
+    }
+
+    @ApiOperation("销售折让单打印数据")
+    @PostMapping("/print")
+    public AjaxResult print(@RequestBody Map<String, String> params, HttpServletRequest request) throws Exception {
+        SalesAllowance allowance = getActiveById(params.get("id"));
+        String orgId = tokenService.getLoginOrgId(request);
+        List<SalesAllowanceItem> items = salesAllowanceItemService.list(new QueryWrapper<SalesAllowanceItem>()
+                .eq("org_id", orgId)
+                .eq("allowance_num", allowance.getAllowanceNum())
+                .eq("del_flag", "0"));
+        allowance.setItems(items);
+        enrichAllowanceDisplay(allowance, orgId);
+        return success(allowance);
+    }
+
+    @ApiOperation("获取最新折让编号")
+    @PostMapping("/getAllowanceNum")
+    public AjaxResult getAllowanceNum(HttpServletRequest request) throws Exception {
+        String orgId = tokenService.getLoginOrgId(request);
+        SalesAllowance one = salesAllowanceService.getOne(new QueryWrapper<SalesAllowance>()
+                .eq("org_id", orgId).eq("del_flag", "0").orderByDesc("id").last("limit 1"));
+        if (one == null || StringUtils.isBlank(one.getAllowanceNum())) {
+            return success(generateString("zr", 1));
+        }
+        return success(generateString("zr", substringToInt(one.getAllowanceNum())));
+    }
+
+    @ApiOperation("按销售出库单生成折让清单行(追加用,优先传主键id以避免单号重复时误命中)")
+    @PostMapping("/fillLinesByDelivery")
+    public AjaxResult fillLinesByDelivery(@RequestBody Map<String, String> params, HttpServletRequest request) throws Exception {
+        String orgId = tokenService.getLoginOrgId(request);
+        SalesDelivery delivery = resolveDeliveryForFill(params, orgId);
+        return success(buildAllowanceItemsFromDelivery(delivery, orgId));
+    }
+
+    @ApiOperation("按销售出库单编号生成折让清单行(追加用)")
+    @PostMapping("/fillLinesByDeliveryNum")
+    public AjaxResult fillLinesByDeliveryNum(@RequestBody Map<String, String> params, HttpServletRequest request) throws Exception {
+        String orgId = tokenService.getLoginOrgId(request);
+        SalesDelivery delivery = resolveDeliveryForFill(params, orgId);
+        return success(buildAllowanceItemsFromDelivery(delivery, orgId));
+    }
+
+    private SalesDelivery resolveDeliveryForFill(Map<String, String> params, String orgId) throws Exception {
+        String id = params.get("id");
+        if (StringUtils.isNotBlank(id)) {
+            SalesDelivery delivery = salesDeliveryService.getById(id.trim());
+            if (delivery == null || !orgId.equals(delivery.getOrgId()) || !"0".equals(delivery.getDelFlag())) {
+                throw new Exception("销售出库单不存在");
+            }
+            return delivery;
+        }
+        String deliveryNum = params.get("deliveryNum");
+        if (StringUtils.isBlank(deliveryNum)) {
+            throw new Exception("销售出库单主键id或编号不能为空");
+        }
+        SalesDelivery delivery = salesDeliveryService.getOne(new QueryWrapper<SalesDelivery>()
+                .eq("org_id", orgId).eq("delivery_num", deliveryNum.trim()).eq("del_flag", "0").orderByDesc("id").last("limit 1"));
+        if (delivery == null) {
+            throw new Exception("销售出库单不存在");
+        }
+        return delivery;
+    }
+
+    private List<SalesAllowanceItem> buildAllowanceItemsFromDelivery(SalesDelivery delivery, String orgId) {
+        List<SalesDeliveryGoods> deliveryGoods = salesDeliveryGoodsService.list(new QueryWrapper<SalesDeliveryGoods>()
+                .eq("org_id", orgId).eq("delivery_num", delivery.getDeliveryNum()).eq("del_flag", "0"));
+        List<SalesAllowanceItem> items = new ArrayList<SalesAllowanceItem>();
+        for (SalesDeliveryGoods g : deliveryGoods) {
+            SalesAllowanceItem item = new SalesAllowanceItem();
+            item.setDeliveryNum(delivery.getDeliveryNum());
+            item.setSalesDate(delivery.getDocumentDate());
+            item.setBatchNum(g.getBatchNum());
+            BaseMaterial m = null;
+            if (StringUtils.isNotBlank(g.getGoodsNum())) {
+                m = baseMaterialService.getOne(new QueryWrapper<BaseMaterial>()
+                        .eq("org_id", orgId).eq("goods_num", g.getGoodsNum()).eq("del_flag", "0").last("limit 1"));
+            }
+            item.setGoodsName(m != null ? m.getGoodsName() : null);
+            item.setGoodsNum(g.getGoodsNum());
+            item.setQuantity(g.getBaseNum());
+            item.setUnit(g.getBaseUnit());
+            item.setUnitPrice(g.getUnitPrice());
+            BigDecimal p = parseMoney(item.getUnitPrice());
+            BigDecimal q = parseMoney(item.getQuantity());
+            item.setAmount((p != null && q != null) ? formatMoney(p.multiply(q)) : g.getAmount());
+            item.setGoodsRemark(g.getGoodsRemark());
+            items.add(item);
+        }
+        return items;
+    }
+
+    private QueryWrapper<SalesAllowance> buildWrapper(SalesAllowance query, String orgId) {
+        QueryWrapper<SalesAllowance> wrapper = new QueryWrapper<SalesAllowance>()
+                .eq("org_id", orgId).eq("del_flag", "0");
+        if (query == null) {
+            return wrapper.orderByDesc("id");
+        }
+        if (query.getAllowanceDateStart() != null) {
+            wrapper.ge("allowance_date", query.getAllowanceDateStart());
+        }
+        if (query.getAllowanceDateEnd() != null) {
+            wrapper.le("allowance_date", query.getAllowanceDateEnd());
+        }
+        if (StringUtils.isNotBlank(query.getAllowanceNum())) {
+            wrapper.like("allowance_num", query.getAllowanceNum());
+        }
+        if (StringUtils.isNotBlank(query.getCustomerNum())) {
+            wrapper.like("customer_num", query.getCustomerNum());
+        }
+        if (StringUtils.isNotBlank(query.getCustomerName())) {
+            String safe = query.getCustomerName().trim().replace("'", "''");
+            wrapper.inSql("customer_num", "SELECT customer_num FROM base_customer WHERE org_id='" + orgId + "' AND del_flag='0' AND customer_name LIKE '%" + safe + "%'");
+        }
+        if (StringUtils.isNotBlank(query.getEmployeeNum())) {
+            wrapper.like("employee_num", query.getEmployeeNum());
+        }
+        if (StringUtils.isNotBlank(query.getDepartmentNum())) {
+            wrapper.like("department_num", query.getDepartmentNum());
+        }
+        if (StringUtils.isNotBlank(query.getRemark())) {
+            wrapper.like("remark", query.getRemark());
+        }
+        if (query.getAuditStatus() != null) {
+            wrapper.eq("audit_status", query.getAuditStatus());
+        }
+        return wrapper.orderByDesc("id");
+    }
+
+    private void validateBeforeSave(SalesAllowance allowance, String orgId, boolean isAdd) throws Exception {
+        if (allowance == null) {
+            throw new Exception("参数不能为空");
+        }
+        if (StringUtils.isAnyBlank(allowance.getAllowanceNum(), allowance.getCustomerNum())) {
+            throw new Exception("折让编号、客户编号不能为空");
+        }
+        QueryWrapper<SalesAllowance> numWrapper = new QueryWrapper<SalesAllowance>()
+                .eq("org_id", orgId).eq("allowance_num", allowance.getAllowanceNum()).eq("del_flag", "0");
+        if (!isAdd && allowance.getId() != null) {
+            numWrapper.ne("id", allowance.getId());
+        }
+        if (salesAllowanceService.count(numWrapper) > 0) {
+            throw new Exception("该折让编号已存在");
+        }
+        if (allowance.getItems() == null || allowance.getItems().isEmpty()) {
+            throw new Exception("销售出库单清单不能为空");
+        }
+        if (StringUtils.isNotBlank(allowance.getEmployeeNum())) {
+            BaseEmployee emp = baseEmployeeService.getOne(new QueryWrapper<BaseEmployee>()
+                    .eq("org_id", orgId).eq("employee_num", allowance.getEmployeeNum().trim()).eq("del_flag", "0").last("limit 1"));
+            if (emp == null) {
+                throw new Exception("业务员编号不存在");
+            }
+        }
+    }
+
+    private void fillCustomerInfo(SalesAllowance allowance, String orgId) throws Exception {
+        BaseCustomer customer = customerService.getOne(new QueryWrapper<BaseCustomer>()
+                .eq("org_id", orgId).eq("customer_num", allowance.getCustomerNum()).eq("del_flag", "0").last("limit 1"));
+        if (customer == null) {
+            throw new Exception("客户编号不存在");
+        }
+    }
+
+    private void recalcItemAmountAndTotal(SalesAllowance allowance) {
+        List<SalesAllowanceItem> items = allowance.getItems();
+        if (items == null || items.isEmpty()) {
+            allowance.setTotalAmount(null);
+            return;
+        }
+        BigDecimal sum = BigDecimal.ZERO;
+        boolean any = false;
+        for (SalesAllowanceItem it : items) {
+            BigDecimal p = parseMoney(it.getUnitPrice());
+            BigDecimal q = parseMoney(it.getQuantity());
+            BigDecimal line = (p != null && q != null) ? p.multiply(q) : parseMoney(it.getAmount());
+            if (line != null) {
+                it.setAmount(formatMoney(line));
+                sum = sum.add(line);
+                any = true;
+            } else {
+                it.setAmount(null);
+            }
+        }
+        allowance.setTotalAmount(any ? formatMoney(sum) : null);
+    }
+
+    private void saveItems(SalesAllowance allowance, String orgId, String username) {
+        List<SalesAllowanceItem> items = allowance.getItems();
+        if (items == null) {
+            items = Collections.emptyList();
+        }
+        Date now = new Date();
+        for (SalesAllowanceItem it : items) {
+            it.setId(null);
+            it.setOrgId(orgId);
+            it.setAllowanceNum(allowance.getAllowanceNum());
+            it.setDelFlag("0");
+            it.setCreateBy(username);
+            it.setCreateTime(now);
+            it.setUpdateBy(null);
+            it.setUpdateTime(null);
+            salesAllowanceItemService.save(it);
+        }
+    }
+
+    /**
+     * 列表接口带出折让明细:按折让单号批量查子表,与 listById 一致。
+     */
+    private void attachItemsForListRows(List<SalesAllowance> list, String orgId) {
+        if (list == null || list.isEmpty()) {
+            return;
+        }
+        List<String> allowanceNums = list.stream()
+                .filter(a -> a != null && StringUtils.isNotBlank(a.getAllowanceNum()))
+                .map(a -> a.getAllowanceNum().trim())
+                .distinct()
+                .collect(Collectors.toList());
+        if (allowanceNums.isEmpty()) {
+            return;
+        }
+        List<SalesAllowanceItem> allItems = salesAllowanceItemService.list(new QueryWrapper<SalesAllowanceItem>()
+                .eq("org_id", orgId)
+                .in("allowance_num", allowanceNums)
+                .eq("del_flag", "0")
+                .orderByAsc("id"));
+        Map<String, List<SalesAllowanceItem>> byAllowanceNum = new HashMap<String, List<SalesAllowanceItem>>();
+        for (SalesAllowanceItem it : allItems) {
+            if (it == null || StringUtils.isBlank(it.getAllowanceNum())) {
+                continue;
+            }
+            String key = it.getAllowanceNum().trim();
+            byAllowanceNum.computeIfAbsent(key, k -> new ArrayList<SalesAllowanceItem>()).add(it);
+        }
+        for (SalesAllowance row : list) {
+            if (row == null || StringUtils.isBlank(row.getAllowanceNum())) {
+                continue;
+            }
+            List<SalesAllowanceItem> items = byAllowanceNum.get(row.getAllowanceNum().trim());
+            row.setItems(items != null ? new ArrayList<SalesAllowanceItem>(items) : new ArrayList<SalesAllowanceItem>());
+        }
+    }
+
+    private void enrichAllowanceDisplay(SalesAllowance allowance, String orgId) {
+        if (allowance == null) {
+            return;
+        }
+        allowance.setCustomerName(null);
+        allowance.setEmployeeName(null);
+        allowance.setDepartmentName(null);
+        if (StringUtils.isNotBlank(allowance.getCustomerNum())) {
+            BaseCustomer c = customerService.getOne(new QueryWrapper<BaseCustomer>()
+                    .eq("org_id", orgId).eq("customer_num", allowance.getCustomerNum().trim()).eq("del_flag", "0").last("limit 1"));
+            if (c != null) {
+                allowance.setCustomerName(c.getCustomerName());
+            }
+        }
+        if (StringUtils.isNotBlank(allowance.getEmployeeNum())) {
+            BaseEmployee e = baseEmployeeService.getOne(new QueryWrapper<BaseEmployee>()
+                    .eq("org_id", orgId).eq("employee_num", allowance.getEmployeeNum().trim()).eq("del_flag", "0").last("limit 1"));
+            if (e != null) {
+                allowance.setEmployeeName(e.getEmployeeName());
+                allowance.setDepartmentName(e.getDepartmentName());
+            }
+        }
+    }
+
+    private void deleteItemsByAllowanceNum(String orgId, String allowanceNum) {
+        if (StringUtils.isBlank(orgId) || StringUtils.isBlank(allowanceNum)) {
+            return;
+        }
+        SalesAllowanceItem up = new SalesAllowanceItem();
+        up.setDelFlag("2");
+        up.setUpdateBy(getUsername());
+        up.setUpdateTime(new Date());
+        salesAllowanceItemService.update(up, new QueryWrapper<SalesAllowanceItem>()
+                .eq("org_id", orgId)
+                .eq("allowance_num", allowanceNum)
+                .eq("del_flag", "0"));
+    }
+
+    private BigDecimal parseMoney(String s) {
+        if (StringUtils.isBlank(s)) {
+            return null;
+        }
+        try {
+            return new BigDecimal(s.trim().replace(",", ""));
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    private String formatMoney(BigDecimal n) {
+        if (n == null) {
+            return null;
+        }
+        return n.stripTrailingZeros().toPlainString();
+    }
+
+    private SalesAllowance getActiveById(String id) throws Exception {
+        SalesAllowance allowance = salesAllowanceService.getById(id);
+        if (allowance == null || !"0".equals(allowance.getDelFlag())) {
+            throw new Exception("销售折让单不存在");
+        }
+        return allowance;
+    }
+}

+ 399 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/controller/SalesDeliveryController.java

@@ -0,0 +1,399 @@
+package com.ruoyi.web.base.controller;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.utils.StringUtils;
+import com.ruoyi.common.utils.poi.ExcelUtil;
+import com.ruoyi.framework.web.service.TokenService;
+import com.ruoyi.web.base.domain.BaseCustomer;
+import com.ruoyi.web.base.domain.BaseEmployee;
+import com.ruoyi.web.base.domain.BaseMaterial;
+import com.ruoyi.web.base.domain.SalesDelivery;
+import com.ruoyi.web.base.domain.SalesDeliveryGoods;
+import com.ruoyi.web.base.service.IBaseCustomerService;
+import com.ruoyi.web.base.service.IBaseEmployeeService;
+import com.ruoyi.web.base.service.IBaseMaterialService;
+import com.ruoyi.web.base.service.ISalesDeliveryGoodsService;
+import com.ruoyi.web.base.service.ISalesDeliveryService;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import io.swagger.annotations.ApiParam;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.bind.annotation.*;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.math.BigDecimal;
+import java.util.Calendar;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+import static com.ruoyi.common.core.domain.AjaxResult.success;
+import static com.ruoyi.common.utils.SecurityUtils.getUsername;
+import static com.ruoyi.web.base.util.NumUtils.generateString;
+import static com.ruoyi.web.base.util.NumUtils.substringToInt;
+
+@RestController
+@Api(tags = "销售出库单")
+@RequestMapping("/sales-delivery")
+public class SalesDeliveryController {
+
+    @Autowired
+    private ISalesDeliveryService salesDeliveryService;
+    @Autowired
+    private ISalesDeliveryGoodsService salesDeliveryGoodsService;
+    @Autowired
+    private IBaseCustomerService customerService;
+    @Autowired
+    private IBaseEmployeeService baseEmployeeService;
+    @Autowired
+    private IBaseMaterialService baseMaterialService;
+    @Autowired
+    private TokenService tokenService;
+
+    @ApiOperation("销售出库单新增")
+    @PostMapping("/add")
+    @Transactional
+    public AjaxResult add(@RequestBody SalesDelivery delivery, HttpServletRequest request) throws Exception {
+        String orgId = tokenService.getLoginOrgId(request);
+        validateBeforeSave(delivery, orgId, true);
+        fillCustomerInfo(delivery, orgId);
+        recalcGoodsAmountAndTotal(delivery);
+        delivery.setOrgId(orgId);
+        delivery.setDelFlag("0");
+        delivery.setAuditStatus(1);
+        if (delivery.getOffsetStatus() == null) {
+            delivery.setOffsetStatus(0);
+        }
+        String username = getUsername();
+        delivery.setId(null);
+        delivery.setCreateBy(username);
+        delivery.setCreateTime(new Date());
+        delivery.setUpdateBy(null);
+        delivery.setUpdateTime(null);
+        salesDeliveryService.save(delivery);
+        saveGoods(delivery, orgId, username);
+        return success();
+    }
+
+    @ApiOperation("销售出库单修改")
+    @PostMapping("/edit")
+    @Transactional
+    public AjaxResult edit(@RequestBody SalesDelivery delivery, HttpServletRequest request) throws Exception {
+        if (delivery.getId() == null) {
+            throw new Exception("id不能为空");
+        }
+        SalesDelivery old = salesDeliveryService.getById(delivery.getId());
+        if (old == null || !"0".equals(old.getDelFlag())) {
+            throw new Exception("销售出库单不存在");
+        }
+        if (old.getAuditStatus() != null && old.getAuditStatus() == 1) {
+            throw new Exception("已审核的销售出库单不能修改,请先反审核");
+        }
+        String orgId = tokenService.getLoginOrgId(request);
+        validateBeforeSave(delivery, orgId, false);
+        fillCustomerInfo(delivery, orgId);
+        recalcGoodsAmountAndTotal(delivery);
+        delivery.setOrgId(orgId);
+        delivery.setAuditStatus(1);
+        delivery.setOffsetStatus(old.getOffsetStatus());
+        String username = getUsername();
+        delivery.setCreateBy(old.getCreateBy());
+        delivery.setCreateTime(old.getCreateTime());
+        delivery.setUpdateBy(username);
+        delivery.setUpdateTime(new Date());
+        salesDeliveryService.updateById(delivery);
+        String oldNum = StringUtils.isNotBlank(old.getDeliveryNum()) ? old.getDeliveryNum().trim() : null;
+        deleteGoodsByDeliveryNum(orgId, oldNum);
+        saveGoods(delivery, orgId, username);
+        return success();
+    }
+
+    @ApiOperation("销售出库单删除")
+    @PostMapping("/delete")
+    public AjaxResult delete(@RequestBody Map<String, String> params) throws Exception {
+        String ids = params.get("ids");
+        for (String id : ids.split(",")) {
+            SalesDelivery delivery = salesDeliveryService.getById(id);
+            if (delivery == null || !"0".equals(delivery.getDelFlag())) {
+                continue;
+            }
+            if (delivery.getAuditStatus() != null && delivery.getAuditStatus() == 1) {
+                throw new Exception("已审核的销售出库单不能删除,请先反审核");
+            }
+            deleteGoodsByDeliveryNum(delivery.getOrgId(), delivery.getDeliveryNum());
+            SalesDelivery up = new SalesDelivery();
+            up.setId(Integer.valueOf(id));
+            up.setDelFlag("2");
+            up.setUpdateBy(getUsername());
+            up.setUpdateTime(new Date());
+            salesDeliveryService.updateById(up);
+        }
+        return success();
+    }
+
+    @ApiOperation("销售出库单详情")
+    @PostMapping("/listById")
+    public AjaxResult listById(@ApiParam("详情参数: id") @RequestBody Map<String, String> params, HttpServletRequest request) {
+        SalesDelivery delivery = salesDeliveryService.getById(params.get("id"));
+        if (delivery == null) {
+            return success(null);
+        }
+        String orgId = tokenService.getLoginOrgId(request);
+        List<SalesDeliveryGoods> goods = salesDeliveryGoodsService.list(new QueryWrapper<SalesDeliveryGoods>()
+                .eq("org_id", orgId)
+                .eq("delivery_num", delivery.getDeliveryNum())
+                .eq("del_flag", "0"));
+        enrichGoodsDisplay(goods, orgId);
+        enrichDeliveryDisplay(delivery, orgId);
+        delivery.setGoods(goods);
+        return success(delivery);
+    }
+
+    @ApiOperation("销售出库单分页")
+    @GetMapping("/page")
+    public AjaxResult page(@RequestParam("pageNum") Integer pageNum,
+                           @RequestParam("pageSize") Integer pageSize,
+                           SalesDelivery query,
+                           @RequestParam(value = "goodsNum", required = false) String goodsNum,
+                           HttpServletRequest request) {
+        String orgId = tokenService.getLoginOrgId(request);
+        QueryWrapper<SalesDelivery> wrapper = buildWrapper(query, orgId, goodsNum);
+        Page<SalesDelivery> page = salesDeliveryService.page(new Page<SalesDelivery>(pageNum, pageSize), wrapper);
+        for (SalesDelivery row : page.getRecords()) {
+            enrichDeliveryDisplay(row, orgId);
+        }
+        return success(page);
+    }
+
+    @ApiOperation("销售出库单列表")
+    @PostMapping("/list")
+    public AjaxResult list(@RequestBody SalesDelivery query, HttpServletRequest request) {
+        String orgId = tokenService.getLoginOrgId(request);
+        String goodsNum = query == null ? null : query.getGoodsNum();
+        List<SalesDelivery> list = salesDeliveryService.list(buildWrapper(query, orgId, goodsNum));
+        for (SalesDelivery row : list) {
+            enrichDeliveryDisplay(row, orgId);
+        }
+        return success(list);
+    }
+
+    @ApiOperation("销售出库单导出")
+    @PostMapping("/export")
+    public void export(HttpServletResponse response, SalesDelivery query, HttpServletRequest request) {
+        String orgId = tokenService.getLoginOrgId(request);
+        List<SalesDelivery> list = salesDeliveryService.list(buildWrapper(query, orgId, null));
+        for (SalesDelivery row : list) {
+            enrichDeliveryDisplay(row, orgId);
+        }
+        ExcelUtil<SalesDelivery> util = new ExcelUtil<SalesDelivery>(SalesDelivery.class);
+        util.exportExcel(response, list, "销售出库单");
+    }
+
+    @ApiOperation("销售出库单审核")
+    @PostMapping("/audit")
+    public AjaxResult audit(@RequestBody Map<String, String> params) throws Exception {
+        SalesDelivery delivery = getActiveById(params.get("id"));
+        delivery.setAuditStatus(1);
+        delivery.setUpdateBy(getUsername());
+        delivery.setUpdateTime(new Date());
+        return success(salesDeliveryService.updateById(delivery));
+    }
+
+    @ApiOperation("销售出库单反审核")
+    @PostMapping("/unaudit")
+    public AjaxResult unaudit(@RequestBody Map<String, String> params) throws Exception {
+        SalesDelivery delivery = getActiveById(params.get("id"));
+        if (delivery.getOffsetStatus() != null && delivery.getOffsetStatus() == 1) {
+            throw new Exception("已做冲销的销售出库单不可反审核!请先反冲销并删除相应现金收款!");
+        }
+        delivery.setAuditStatus(0);
+        delivery.setUpdateBy(getUsername());
+        delivery.setUpdateTime(new Date());
+        return success(salesDeliveryService.updateById(delivery));
+    }
+
+    @ApiOperation("销售出库单打印数据")
+    @PostMapping("/print")
+    public AjaxResult print(@RequestBody Map<String, String> params, HttpServletRequest request) throws Exception {
+        SalesDelivery delivery = getActiveById(params.get("id"));
+        String orgId = tokenService.getLoginOrgId(request);
+        List<SalesDeliveryGoods> goods = salesDeliveryGoodsService.list(new QueryWrapper<SalesDeliveryGoods>()
+                .eq("org_id", orgId)
+                .eq("delivery_num", delivery.getDeliveryNum())
+                .eq("del_flag", "0"));
+        enrichGoodsDisplay(goods, orgId);
+        enrichDeliveryDisplay(delivery, orgId);
+        delivery.setGoods(goods);
+        return success(delivery);
+    }
+
+    @ApiOperation("获取最新销售出库单编号")
+    @PostMapping("/getDeliveryNum")
+    public AjaxResult getDeliveryNum(HttpServletRequest request) throws Exception {
+        String orgId = tokenService.getLoginOrgId(request);
+        SalesDelivery one = salesDeliveryService.getOne(new QueryWrapper<SalesDelivery>()
+                .eq("org_id", orgId).eq("del_flag", "0").orderByDesc("id").last("limit 1"));
+        if (one == null || StringUtils.isBlank(one.getDeliveryNum())) {
+            return success(generateString("xc", 1));
+        }
+        return success(generateString("xc", substringToInt(one.getDeliveryNum())));
+    }
+
+    private QueryWrapper<SalesDelivery> buildWrapper(SalesDelivery query, String orgId, String goodsNum) {
+        QueryWrapper<SalesDelivery> wrapper = new QueryWrapper<SalesDelivery>()
+                .eq("org_id", orgId).eq("del_flag", "0");
+        if (query == null) return wrapper.orderByDesc("id");
+        if (query.getDocumentDateStart() != null) wrapper.ge("document_date", query.getDocumentDateStart());
+        if (query.getDocumentDateEnd() != null) {
+            // 兼容 document_date 为 datetime 的场景:结束日按“次日零点前”筛选,避免漏掉结束日当天数据
+            Calendar c = Calendar.getInstance();
+            c.setTime(query.getDocumentDateEnd());
+            c.add(Calendar.DAY_OF_MONTH, 1);
+            wrapper.lt("document_date", c.getTime());
+        }
+        if (StringUtils.isNotBlank(query.getDeliveryNum())) wrapper.like("delivery_num", query.getDeliveryNum());
+        if (StringUtils.isNotBlank(query.getCustomerNum())) wrapper.like("customer_num", query.getCustomerNum());
+        if (StringUtils.isNotBlank(query.getCustomerName())) wrapper.like("customer_name", query.getCustomerName());
+        if (StringUtils.isNotBlank(query.getEmployeeNum())) wrapper.like("employee_num", query.getEmployeeNum());
+        if (query.getAuditStatus() != null) wrapper.eq("audit_status", query.getAuditStatus());
+        if (StringUtils.isNotBlank(goodsNum)) {
+            String safe = goodsNum.trim().replace("'", "''");
+            wrapper.inSql("delivery_num", "SELECT delivery_num FROM sales_delivery_goods WHERE org_id='" + orgId + "' AND del_flag='0' AND goods_num LIKE '%" + safe + "%'");
+        }
+        return wrapper.orderByDesc("id");
+    }
+
+    private void validateBeforeSave(SalesDelivery delivery, String orgId, boolean isAdd) throws Exception {
+        if (delivery == null) throw new Exception("参数不能为空");
+        if (StringUtils.isAnyBlank(delivery.getDeliveryNum(), delivery.getCustomerNum())) throw new Exception("单据编号、客户编号不能为空");
+        QueryWrapper<SalesDelivery> numWrapper = new QueryWrapper<SalesDelivery>()
+                .eq("org_id", orgId).eq("delivery_num", delivery.getDeliveryNum()).eq("del_flag", "0");
+        if (!isAdd && delivery.getId() != null) numWrapper.ne("id", delivery.getId());
+        if (salesDeliveryService.count(numWrapper) > 0) throw new Exception("该单据编号已存在");
+        if (delivery.getGoods() == null || delivery.getGoods().isEmpty()) throw new Exception("货品清单不能为空");
+        if (StringUtils.isNotBlank(delivery.getEmployeeNum())) {
+            BaseEmployee emp = baseEmployeeService.getOne(new QueryWrapper<BaseEmployee>()
+                    .eq("org_id", orgId)
+                    .eq("employee_num", delivery.getEmployeeNum().trim())
+                    .eq("del_flag", "0")
+                    .last("limit 1"));
+            if (emp == null) throw new Exception("业务员编号不存在");
+        }
+    }
+
+    private void fillCustomerInfo(SalesDelivery delivery, String orgId) throws Exception {
+        BaseCustomer customer = customerService.getOne(new QueryWrapper<BaseCustomer>()
+                .eq("org_id", orgId).eq("customer_num", delivery.getCustomerNum()).eq("del_flag", "0").last("limit 1"));
+        if (customer == null) throw new Exception("客户编号不存在");
+        delivery.setCustomerName(customer.getCustomerName());
+    }
+
+    private void recalcGoodsAmountAndTotal(SalesDelivery delivery) {
+        List<SalesDeliveryGoods> goods = delivery.getGoods();
+        if (goods == null || goods.isEmpty()) {
+            delivery.setTotalAmount(null);
+            return;
+        }
+        BigDecimal sum = BigDecimal.ZERO;
+        boolean any = false;
+        for (SalesDeliveryGoods g : goods) {
+            BigDecimal p = parseMoney(g.getUnitPrice());
+            BigDecimal q = parseMoney(g.getBaseNum());
+            BigDecimal line = (p != null && q != null) ? p.multiply(q) : parseMoney(g.getAmount());
+            if (line != null) {
+                g.setAmount(formatMoney(line));
+                sum = sum.add(line);
+                any = true;
+            } else {
+                g.setAmount(null);
+            }
+        }
+        delivery.setTotalAmount(any ? formatMoney(sum) : null);
+    }
+
+    private void saveGoods(SalesDelivery delivery, String orgId, String username) {
+        List<SalesDeliveryGoods> goods = delivery.getGoods();
+        if (goods == null) goods = Collections.emptyList();
+        Date now = new Date();
+        for (SalesDeliveryGoods g : goods) {
+            g.setId(null);
+            g.setOrgId(orgId);
+            g.setDeliveryNum(delivery.getDeliveryNum());
+            g.setDelFlag("0");
+            g.setCreateBy(username);
+            g.setCreateTime(now);
+            g.setUpdateBy(null);
+            g.setUpdateTime(null);
+            salesDeliveryGoodsService.save(g);
+        }
+    }
+
+    private void enrichGoodsDisplay(List<SalesDeliveryGoods> goods, String orgId) {
+        if (goods == null || goods.isEmpty()) return;
+        for (SalesDeliveryGoods g : goods) {
+            if (StringUtils.isBlank(g.getGoodsNum())) continue;
+            BaseMaterial m = baseMaterialService.getOne(new QueryWrapper<BaseMaterial>()
+                    .eq("org_id", orgId).eq("goods_num", g.getGoodsNum()).eq("del_flag", "0").last("limit 1"));
+            if (m == null) continue;
+            g.setGoodsName(m.getGoodsName());
+            if (StringUtils.isBlank(g.getGoodsSpec())) g.setGoodsSpec(m.getGoodsSpec());
+            if (StringUtils.isBlank(g.getBaseUnit())) g.setBaseUnit(m.getUnit());
+            if (StringUtils.isBlank(g.getAssUnit())) g.setAssUnit(m.getAssistantUnit());
+            if (StringUtils.isBlank(g.getWarehouseName())) g.setWarehouseName(m.getWarehouseName());
+        }
+    }
+
+    private void enrichDeliveryDisplay(SalesDelivery delivery, String orgId) {
+        if (delivery == null) return;
+        delivery.setEmployeeName(null);
+        if (StringUtils.isNotBlank(delivery.getEmployeeNum())) {
+            BaseEmployee e = baseEmployeeService.getOne(new QueryWrapper<BaseEmployee>()
+                    .eq("org_id", orgId)
+                    .eq("employee_num", delivery.getEmployeeNum().trim())
+                    .eq("del_flag", "0")
+                    .last("limit 1"));
+            if (e != null) {
+                delivery.setEmployeeName(e.getEmployeeName());
+            }
+        }
+    }
+
+    private void deleteGoodsByDeliveryNum(String orgId, String deliveryNum) {
+        if (StringUtils.isBlank(orgId) || StringUtils.isBlank(deliveryNum)) return;
+        SalesDeliveryGoods up = new SalesDeliveryGoods();
+        up.setDelFlag("2");
+        up.setUpdateBy(getUsername());
+        up.setUpdateTime(new Date());
+        salesDeliveryGoodsService.update(up, new QueryWrapper<SalesDeliveryGoods>()
+                .eq("org_id", orgId)
+                .eq("delivery_num", deliveryNum)
+                .eq("del_flag", "0"));
+    }
+
+    private BigDecimal parseMoney(String s) {
+        if (StringUtils.isBlank(s)) return null;
+        try {
+            return new BigDecimal(s.trim().replace(",", ""));
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    private String formatMoney(BigDecimal n) {
+        if (n == null) return null;
+        return n.stripTrailingZeros().toPlainString();
+    }
+
+    private SalesDelivery getActiveById(String id) throws Exception {
+        SalesDelivery delivery = salesDeliveryService.getById(id);
+        if (delivery == null || !"0".equals(delivery.getDelFlag())) {
+            throw new Exception("销售出库单不存在");
+        }
+        return delivery;
+    }
+}

+ 728 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/controller/SalesOrderController.java

@@ -0,0 +1,728 @@
+package com.ruoyi.web.base.controller;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.utils.StringUtils;
+import com.ruoyi.common.utils.poi.ExcelUtil;
+import com.ruoyi.framework.web.service.TokenService;
+import com.ruoyi.web.base.domain.BaseCustomer;
+import com.ruoyi.web.base.domain.BaseEmployee;
+import com.ruoyi.web.base.domain.BaseLine;
+import com.ruoyi.web.base.domain.BaseMaterial;
+import com.ruoyi.web.base.domain.BaseMarket;
+import com.ruoyi.web.base.domain.RelCustomerMarket;
+import com.ruoyi.web.base.domain.SalesPrice;
+import com.ruoyi.web.base.domain.SalesPriceItem;
+import com.ruoyi.web.base.domain.SalesOrder;
+import com.ruoyi.web.base.domain.SalesOrderGoods;
+import com.ruoyi.web.base.service.IBaseCustomerService;
+import com.ruoyi.web.base.service.IBaseEmployeeService;
+import com.ruoyi.web.base.service.IBaseLineService;
+import com.ruoyi.web.base.service.IBaseMaterialService;
+import com.ruoyi.web.base.service.IBaseMarketService;
+import com.ruoyi.web.base.service.IRelCustomerMarketService;
+import com.ruoyi.web.base.service.ISalesPriceService;
+import com.ruoyi.web.base.service.ISalesPriceItemService;
+import com.ruoyi.web.base.service.ISalesOrderGoodsService;
+import com.ruoyi.web.base.service.ISalesOrderService;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import io.swagger.annotations.ApiParam;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.bind.annotation.*;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import static com.ruoyi.common.core.domain.AjaxResult.success;
+import static com.ruoyi.common.utils.SecurityUtils.getUsername;
+import static com.ruoyi.web.base.util.NumUtils.generateString;
+import static com.ruoyi.web.base.util.NumUtils.substringToInt;
+
+@RestController
+@Api(tags = "销售订单")
+@RequestMapping("/sales-order")
+public class SalesOrderController {
+
+    @Autowired
+    private ISalesOrderService salesOrderService;
+    @Autowired
+    private ISalesOrderGoodsService salesOrderGoodsService;
+    @Autowired
+    private IBaseCustomerService customerService;
+    @Autowired
+    private IRelCustomerMarketService relCustomerMarketService;
+    @Autowired
+    private IBaseMaterialService baseMaterialService;
+    @Autowired
+    private IBaseEmployeeService baseEmployeeService;
+    @Autowired
+    private IBaseLineService baseLineService;
+    @Autowired
+    private IBaseMarketService baseMarketService;
+    @Autowired
+    private ISalesPriceService salesPriceService;
+    @Autowired
+    private ISalesPriceItemService salesPriceItemService;
+    @Autowired
+    private TokenService tokenService;
+
+    @ApiOperation("销售订单新增")
+    @PostMapping("/add")
+    @Transactional
+    public AjaxResult add(@RequestBody SalesOrder order, HttpServletRequest request) throws Exception {
+        String orgId = tokenService.getLoginOrgId(request);
+        validateBeforeSave(order, orgId, true);
+        fillCustomerAndMarketInfo(order, orgId);
+        recalcGoodsSubTotalsAndOrderTotal(order);
+        order.setOrgId(orgId);
+        order.setDelFlag("0");
+        order.setAuditStatus(1);
+        String username = getUsername();
+        order.setId(null);
+        order.setCreateBy(username);
+        order.setCreateTime(new Date());
+        order.setUpdateBy(null);
+        order.setUpdateTime(null);
+        salesOrderService.save(order);
+        saveGoods(order, orgId, username);
+        return success();
+    }
+
+    @ApiOperation("销售订单修改")
+    @PostMapping("/edit")
+    @Transactional
+    public AjaxResult edit(@RequestBody SalesOrder order, HttpServletRequest request) throws Exception {
+        if (order.getId() == null) {
+            throw new Exception("id不能为空");
+        }
+        SalesOrder old = salesOrderService.getById(order.getId());
+        if (old == null || !"0".equals(old.getDelFlag())) {
+            throw new Exception("销售订单不存在");
+        }
+        if (old.getAuditStatus() != null && old.getAuditStatus() == 1) {
+            throw new Exception("已审核的销售订单不能修改,请先反审核");
+        }
+        String orgId = tokenService.getLoginOrgId(request);
+        validateBeforeSave(order, orgId, false);
+        fillCustomerAndMarketInfo(order, orgId);
+        recalcGoodsSubTotalsAndOrderTotal(order);
+        order.setOrgId(orgId);
+        order.setAuditStatus(1);
+        String username = getUsername();
+        order.setCreateBy(old.getCreateBy());
+        order.setCreateTime(old.getCreateTime());
+        order.setUpdateBy(username);
+        order.setUpdateTime(new Date());
+        salesOrderService.updateById(order);
+        /** 修改保存:先按原单据编号物理删除旧物料清单,再插入本次提交的明细 */
+        String oldOrderNum = StringUtils.isNotBlank(old.getOrderNum()) ? old.getOrderNum().trim() : null;
+        deleteSalesOrderGoodsByOrderNum(orgId, oldOrderNum);
+        saveGoods(order, orgId, username);
+        return success();
+    }
+
+    @ApiOperation("销售订单删除")
+    @PostMapping("/delete")
+    public AjaxResult delete(@RequestBody Map<String, String> params) throws Exception {
+        String ids = params.get("ids");
+        for (String id : ids.split(",")) {
+            SalesOrder order = salesOrderService.getById(id);
+            if (order == null || !"0".equals(order.getDelFlag())) {
+                continue;
+            }
+            if (order.getAuditStatus() != null && order.getAuditStatus() == 1) {
+                throw new Exception("已审核的销售订单不能删除,请先反审核");
+            }
+            // 同步删除子表明细
+            String orgId = order.getOrgId();
+            String orderNum = order.getOrderNum();
+            deleteSalesOrderGoodsByOrderNum(orgId, orderNum);
+
+            SalesOrder up = new SalesOrder();
+            up.setId(Integer.valueOf(id));
+            up.setDelFlag("2");
+            up.setUpdateBy(getUsername());
+            up.setUpdateTime(new Date());
+            salesOrderService.updateById(up);
+        }
+        return success();
+    }
+
+    @ApiOperation("销售订单详情")
+    @PostMapping("/listById")
+    public AjaxResult listById(@ApiParam("详情参数: id") @RequestBody Map<String, String> params, HttpServletRequest request) {
+        String id = params.get("id");
+        SalesOrder order = salesOrderService.getById(id);
+        if (order == null) {
+            return success(null);
+        }
+        String orgId = tokenService.getLoginOrgId(request);
+        List<SalesOrderGoods> goods = salesOrderGoodsService.list(new QueryWrapper<SalesOrderGoods>()
+                .eq("org_id", orgId)
+                .eq("order_num", order.getOrderNum())
+                .eq("del_flag", "0"));
+        enrichGoodsFromMaterial(goods, orgId);
+        order.setGoods(goods);
+        enrichSalesOrderDisplay(order, orgId);
+        return success(order);
+    }
+
+    @ApiOperation("销售订单分页")
+    @GetMapping("/page")
+    public AjaxResult page(@RequestParam("pageNum") Integer pageNum,
+                           @RequestParam("pageSize") Integer pageSize,
+                           SalesOrder query,
+                           HttpServletRequest request) {
+        String orgId = tokenService.getLoginOrgId(request);
+        QueryWrapper<SalesOrder> wrapper = buildWrapper(query, orgId);
+        Page<SalesOrder> page = salesOrderService.page(new Page<SalesOrder>(pageNum, pageSize), wrapper);
+        for (SalesOrder row : page.getRecords()) {
+            enrichSalesOrderDisplay(row, orgId);
+        }
+        return success(page);
+    }
+
+    @ApiOperation("销售订单列表")
+    @PostMapping("/list")
+    public AjaxResult list(@RequestBody SalesOrder query, HttpServletRequest request) {
+        String orgId = tokenService.getLoginOrgId(request);
+        List<SalesOrder> list = salesOrderService.list(buildWrapper(query, orgId));
+        for (SalesOrder row : list) {
+            enrichSalesOrderDisplay(row, orgId);
+        }
+        return success(list);
+    }
+
+    @ApiOperation("销售订单导出")
+    @PostMapping("/export")
+    public void export(HttpServletResponse response, SalesOrder query, HttpServletRequest request) {
+        String orgId = tokenService.getLoginOrgId(request);
+        List<SalesOrder> list = salesOrderService.list(buildWrapper(query, orgId));
+        for (SalesOrder row : list) {
+            enrichSalesOrderDisplay(row, orgId);
+        }
+        ExcelUtil<SalesOrder> util = new ExcelUtil<SalesOrder>(SalesOrder.class);
+        util.exportExcel(response, list, "销售订单");
+    }
+
+    @ApiOperation("销售订单审核")
+    @PostMapping("/audit")
+    public AjaxResult audit(@RequestBody Map<String, String> params) throws Exception {
+        String id = params.get("id");
+        SalesOrder order = getActiveById(id);
+        order.setAuditStatus(1);
+        order.setUpdateBy(getUsername());
+        order.setUpdateTime(new Date());
+        return success(salesOrderService.updateById(order));
+    }
+
+    @ApiOperation("销售订单反审核")
+    @PostMapping("/unaudit")
+    public AjaxResult unaudit(@RequestBody Map<String, String> params) throws Exception {
+        String id = params.get("id");
+        SalesOrder order = getActiveById(id);
+        order.setAuditStatus(0);
+        order.setUpdateBy(getUsername());
+        order.setUpdateTime(new Date());
+        return success(salesOrderService.updateById(order));
+    }
+
+    @ApiOperation("销售订单打印数据")
+    @PostMapping("/print")
+    public AjaxResult print(@RequestBody Map<String, String> params, HttpServletRequest request) throws Exception {
+        String id = params.get("id");
+        SalesOrder order = getActiveById(id);
+        String orgId = tokenService.getLoginOrgId(request);
+        List<SalesOrderGoods> goods = salesOrderGoodsService.list(new QueryWrapper<SalesOrderGoods>()
+                .eq("org_id", orgId)
+                .eq("order_num", order.getOrderNum())
+                .eq("del_flag", "0"));
+        enrichGoodsFromMaterial(goods, orgId);
+        order.setGoods(goods);
+        enrichSalesOrderDisplay(order, orgId);
+        return success(order);
+    }
+
+    @ApiOperation("获取最新销售订单编号")
+    @PostMapping("/getOrderNum")
+    public AjaxResult getOrderNum(HttpServletRequest request) throws Exception {
+        String orgId = tokenService.getLoginOrgId(request);
+        SalesOrder one = salesOrderService.getOne(new QueryWrapper<SalesOrder>()
+                .eq("org_id", orgId)
+                .eq("del_flag", "0")
+                .orderByDesc("id")
+                .last("limit 1"));
+        if (one == null || StringUtils.isBlank(one.getOrderNum())) {
+            return success(generateString("xs", 1));
+        }
+        return success(generateString("xs", substringToInt(one.getOrderNum())));
+    }
+
+    @ApiOperation("按货品编号、客户编号、单据日期查询物料及价格")
+    @PostMapping("/getGoodsPriceInfo")
+    public AjaxResult getGoodsPriceInfo(@RequestBody Map<String, String> params, HttpServletRequest request) throws Exception {
+        String goodsNum = params.get("goodsNum");
+        String customerNum = params.get("customerNum");
+        String documentDate = params.get("documentDate");
+        if (StringUtils.isAnyBlank(goodsNum, customerNum, documentDate)) {
+            throw new Exception("货品编号、客户编号、单据日期不能为空");
+        }
+        String orgId = tokenService.getLoginOrgId(request);
+        String safeDate = documentDate.trim().replace("'", "''");
+
+        BaseMaterial material = baseMaterialService.getOne(new QueryWrapper<BaseMaterial>()
+                .eq("org_id", orgId)
+                .eq("goods_num", goodsNum.trim())
+                .eq("del_flag", "0")
+                .last("limit 1"));
+        if (material == null) {
+            throw new Exception("未找到该货品编号对应物料");
+        }
+
+        RelCustomerMarket rel = relCustomerMarketService.getOne(new QueryWrapper<RelCustomerMarket>()
+                .eq("org_id", orgId)
+                .eq("customer_num", customerNum.trim())
+                .eq("del_flag", "0")
+                .orderByDesc("id")
+                .last("limit 1"));
+        if (rel == null || StringUtils.isBlank(rel.getMarketNum())) {
+            throw new Exception("该客户未维护市场信息");
+        }
+
+        BaseMarket market = baseMarketService.getOne(new QueryWrapper<BaseMarket>()
+                .eq("org_id", orgId)
+                .eq("market_num", rel.getMarketNum())
+                .eq("del_flag", "0")
+                .last("limit 1"));
+        if (market == null || StringUtils.isBlank(market.getPriceType())) {
+            throw new Exception("该市场未维护价格类型");
+        }
+
+        String priceType = market.getPriceType().trim();
+        SalesPrice price = salesPriceService.getOne(new QueryWrapper<SalesPrice>()
+                .eq("org_id", orgId)
+                .eq("del_flag", "0")
+                .eq("price_type", priceType)
+                .eq("effective_date", safeDate)
+                .orderByDesc("id")
+                .last("limit 1"), false);
+        SalesPriceItem priceItem = null;
+        if (price != null && StringUtils.isNotBlank(price.getPriceNum())) {
+            priceItem = salesPriceItemService.getOne(new QueryWrapper<SalesPriceItem>()
+                    .eq("org_id", orgId)
+                    .eq("price_num", price.getPriceNum())
+                    .eq("material_num", goodsNum.trim())
+                    .eq("del_flag", "0")
+                    .orderByDesc("id")
+                    .last("limit 1"), false);
+        }
+
+        Map<String, Object> data = new HashMap<String, Object>();
+        data.put("goodsNum", material.getGoodsNum());
+        data.put("goodsName", material.getGoodsName());
+        data.put("goodsSpec", material.getGoodsSpec());
+        data.put("baseUnit", material.getUnit());
+        data.put("assUnit", material.getAssistantUnit());
+        data.put("customerNum", customerNum.trim());
+        data.put("marketNum", rel.getMarketNum());
+        data.put("priceType", priceType);
+        data.put("documentDate", safeDate);
+        data.put("priceNum", priceItem == null ? null : priceItem.getPriceNum());
+        data.put("unitPrice", priceItem == null || priceItem.getUnitPrice() == null ? BigDecimal.ZERO : priceItem.getUnitPrice());
+        data.put("pickupPrice", priceItem == null ? null : priceItem.getPickupPrice());
+        return success(data);
+    }
+
+    @ApiOperation("物流线-市场-客户三级树(el-tree)")
+    @GetMapping("/lineMarketCustomer")
+    public AjaxResult lineMarketCustomerTree(HttpServletRequest request) {
+        String orgId = tokenService.getLoginOrgId(request);
+        QueryWrapper<BaseLine> lineQw = new QueryWrapper<BaseLine>()
+                .eq("org_id", orgId)
+                .and(w -> w.eq("del_flag", "0").or().isNull("del_flag"))
+                .orderByAsc("line_num")
+                .orderByAsc("id");
+        List<BaseLine> lines = baseLineService.list(lineQw);
+
+        QueryWrapper<BaseMarket> marketQw = new QueryWrapper<BaseMarket>()
+                .eq("org_id", orgId)
+                .and(w -> w.eq("del_flag", "0").or().isNull("del_flag"))
+                .orderByAsc("market_num")
+                .orderByAsc("id");
+        List<BaseMarket> allMarkets = baseMarketService.list(marketQw);
+        Map<String, List<BaseMarket>> marketsByLineNum = allMarkets.stream()
+                .filter(m -> StringUtils.isNotBlank(m.getLineNum()))
+                .collect(Collectors.groupingBy(m -> m.getLineNum().trim(), LinkedHashMap::new, Collectors.toList()));
+
+        QueryWrapper<RelCustomerMarket> relQw = new QueryWrapper<RelCustomerMarket>()
+                .eq("org_id", orgId)
+                .and(w -> w.eq("del_flag", "0").or().isNull("del_flag"))
+                .orderByDesc("id");
+        List<RelCustomerMarket> allRels = relCustomerMarketService.list(relQw);
+
+        List<BaseCustomer> customers = customerService.list(new QueryWrapper<BaseCustomer>()
+                .eq("org_id", orgId)
+                .and(w -> w.eq("del_flag", "0").or().isNull("del_flag")));
+        Map<String, String> customerNameMap = customers.stream()
+                .filter(c -> StringUtils.isNotBlank(c.getCustomerNum()))
+                .collect(Collectors.toMap(BaseCustomer::getCustomerNum,
+                        c -> c.getCustomerName() != null ? c.getCustomerName() : "",
+                        (a, b) -> a, LinkedHashMap::new));
+
+        Map<String, List<RelCustomerMarket>> relsByLineAndMarket = new LinkedHashMap<String, List<RelCustomerMarket>>();
+        for (RelCustomerMarket r : allRels) {
+            if (StringUtils.isBlank(r.getLineNum()) || StringUtils.isBlank(r.getMarketNum())) {
+                continue;
+            }
+            String key = r.getLineNum().trim() + "\0" + r.getMarketNum().trim();
+            relsByLineAndMarket.computeIfAbsent(key, k -> new ArrayList<RelCustomerMarket>()).add(r);
+        }
+
+        List<Map<String, Object>> tree = new ArrayList<Map<String, Object>>();
+        for (BaseLine line : lines) {
+            String lineNum = line.getLineNum() == null ? "" : line.getLineNum().trim();
+            Map<String, Object> lineNode = new LinkedHashMap<String, Object>();
+            lineNode.put("id", "line:" + lineNum + ":" + line.getId());
+            lineNode.put("label", StringUtils.isNotBlank(line.getLineName()) ? line.getLineName() : lineNum);
+            lineNode.put("type", "line");
+            lineNode.put("lineNum", lineNum);
+            lineNode.put("lineName", line.getLineName());
+
+            List<Map<String, Object>> marketChildren = new ArrayList<Map<String, Object>>();
+            List<BaseMarket> markets = marketsByLineNum.getOrDefault(lineNum, new ArrayList<BaseMarket>());
+            for (BaseMarket market : markets) {
+                String marketNum = market.getMarketNum() == null ? "" : market.getMarketNum().trim();
+                Map<String, Object> marketNode = new LinkedHashMap<String, Object>();
+                marketNode.put("id", "market:" + lineNum + ":" + marketNum + ":" + market.getId());
+                marketNode.put("label", StringUtils.isNotBlank(market.getMarketName()) ? market.getMarketName() : marketNum);
+                marketNode.put("type", "market");
+                marketNode.put("lineNum", lineNum);
+                marketNode.put("marketNum", marketNum);
+                marketNode.put("marketName", market.getMarketName());
+
+                String relKey = lineNum + "\0" + marketNum;
+                List<RelCustomerMarket> rels = relsByLineAndMarket.getOrDefault(relKey, new ArrayList<RelCustomerMarket>());
+                Set<String> seenCustomer = new LinkedHashSet<String>();
+                List<Map<String, Object>> customerChildren = new ArrayList<Map<String, Object>>();
+                for (RelCustomerMarket rel : rels) {
+                    String customerNum = rel.getCustomerNum() == null ? "" : rel.getCustomerNum().trim();
+                    if (StringUtils.isBlank(customerNum) || !seenCustomer.add(customerNum)) {
+                        continue;
+                    }
+                    String customerName = StringUtils.isNotBlank(rel.getCustomerName())
+                            ? rel.getCustomerName()
+                            : customerNameMap.getOrDefault(customerNum, customerNum);
+                    Map<String, Object> custNode = new LinkedHashMap<String, Object>();
+                    custNode.put("id", "customer:" + lineNum + ":" + marketNum + ":" + customerNum + ":" + rel.getId());
+                    custNode.put("label", customerName);
+                    custNode.put("type", "customer");
+                    custNode.put("lineNum", lineNum);
+                    custNode.put("marketNum", marketNum);
+                    custNode.put("customerNum", customerNum);
+                    custNode.put("customerName", customerName);
+                    custNode.put("relId", rel.getId());
+                    custNode.put("leaf", true);
+                    customerChildren.add(custNode);
+                }
+                marketNode.put("children", customerChildren);
+                marketChildren.add(marketNode);
+            }
+            lineNode.put("children", marketChildren);
+            tree.add(lineNode);
+        }
+        return success(tree);
+    }
+
+    private QueryWrapper<SalesOrder> buildWrapper(SalesOrder query, String orgId) {
+        QueryWrapper<SalesOrder> wrapper = new QueryWrapper<SalesOrder>()
+                .eq("org_id", orgId)
+                .eq("del_flag", "0");
+        if (query == null) {
+            return wrapper;
+        }
+        if (query.getDocumentDateStart() != null) {
+            wrapper.ge("document_date", query.getDocumentDateStart());
+        }
+        if (query.getDocumentDateEnd() != null) {
+            wrapper.le("document_date", query.getDocumentDateEnd());
+        }
+        if (query.getSaleDateStart() != null) {
+            wrapper.ge("sale_date", query.getSaleDateStart());
+        }
+        if (query.getSaleDateEnd() != null) {
+            wrapper.le("sale_date", query.getSaleDateEnd());
+        }
+        if (StringUtils.isNotBlank(query.getCustomerName())) {
+            wrapper.like("customer_name", query.getCustomerName());
+        }
+        if (StringUtils.isNotBlank(query.getEmployeeNum())) {
+            wrapper.like("employee_num", query.getEmployeeNum());
+        }
+        if (StringUtils.isNotBlank(query.getLineNum())) {
+            wrapper.like("line_num", query.getLineNum());
+        }
+        if (StringUtils.isNotBlank(query.getMarketNum())) {
+            wrapper.like("market_num", query.getMarketNum());
+        }
+        if (query.getAuditStatus() != null) {
+            wrapper.eq("audit_status", query.getAuditStatus());
+        }
+        wrapper.orderByDesc("id");
+        return wrapper;
+    }
+
+    private void validateBeforeSave(SalesOrder order, String orgId, boolean isAdd) throws Exception {
+        if (order == null) {
+            throw new Exception("参数不能为空");
+        }
+        if (order.getCustomerNum() != null) {
+            order.setCustomerNum(order.getCustomerNum().trim());
+        }
+        if (order.getOrderNum() != null) {
+            order.setOrderNum(order.getOrderNum().trim());
+        }
+        if (StringUtils.isAnyBlank(order.getOrderNum(), order.getCustomerNum())) {
+            throw new Exception("单据编号、客户编号不能为空");
+        }
+        QueryWrapper<SalesOrder> numWrapper = new QueryWrapper<SalesOrder>()
+                .eq("order_num", order.getOrderNum())
+                .eq("del_flag", "0");
+        if (!isAdd && order.getId() != null) {
+            numWrapper.ne("id", order.getId());
+        }
+        if (salesOrderService.count(numWrapper) > 0) {
+            throw new Exception("该单据编号已存在");
+        }
+        if (order.getGoods() == null || order.getGoods().isEmpty()) {
+            throw new Exception("货品清单不能为空");
+        }
+        BaseCustomer customer = customerService.getOne(new QueryWrapper<BaseCustomer>()
+                .eq("org_id", orgId)
+                .eq("customer_num", order.getCustomerNum())
+                .eq("del_flag", "0")
+                .last("limit 1"));
+        if (customer == null) {
+            throw new Exception("客户编号不存在");
+        }
+        if (StringUtils.isNotBlank(order.getEmployeeNum())) {
+            BaseEmployee emp = baseEmployeeService.getOne(new QueryWrapper<BaseEmployee>()
+                    .eq("org_id", orgId)
+                    .eq("employee_num", order.getEmployeeNum().trim())
+                    .eq("del_flag", "0")
+                    .last("limit 1"));
+            if (emp == null) {
+                throw new Exception("业务员编号不存在");
+            }
+        }
+    }
+
+    private void fillCustomerAndMarketInfo(SalesOrder order, String orgId) throws Exception {
+        BaseCustomer customer = customerService.getOne(new QueryWrapper<BaseCustomer>()
+                .eq("org_id", orgId)
+                .eq("customer_num", order.getCustomerNum())
+                .eq("del_flag", "0")
+                .last("limit 1"));
+        if (customer == null) {
+            throw new Exception("客户编号不存在");
+        }
+        order.setCustomerName(customer.getCustomerName());
+
+        /** 仅按客户编号解析市场、物流线(取客户市场维护表中最新一条),不依赖前端传入 */
+        RelCustomerMarket rel = relCustomerMarketService.getOne(new QueryWrapper<RelCustomerMarket>()
+                .eq("org_id", orgId)
+                .eq("customer_num", order.getCustomerNum())
+                .eq("del_flag", "0")
+                .orderByDesc("id")
+                .last("limit 1"));
+        if (rel != null) {
+            order.setMarketNum(rel.getMarketNum());
+            order.setLineNum(rel.getLineNum());
+        } else {
+            order.setMarketNum(null);
+            order.setLineNum(null);
+        }
+    }
+
+    private void enrichSalesOrderDisplay(SalesOrder order, String orgId) {
+        if (order == null) {
+            return;
+        }
+        order.setReceivableCustomer(null);
+        order.setEmployeeName(null);
+        if (StringUtils.isNotBlank(order.getCustomerNum())) {
+            BaseCustomer c = customerService.getOne(new QueryWrapper<BaseCustomer>()
+                    .eq("org_id", orgId)
+                    .eq("customer_num", order.getCustomerNum())
+                    .eq("del_flag", "0")
+                    .last("limit 1"));
+            if (c != null) {
+                order.setReceivableCustomer(c.getReceivableCustomer());
+                if (StringUtils.isBlank(order.getCustomerName())) {
+                    order.setCustomerName(c.getCustomerName());
+                }
+            }
+        }
+        if (StringUtils.isNotBlank(order.getEmployeeNum())) {
+            BaseEmployee e = baseEmployeeService.getOne(new QueryWrapper<BaseEmployee>()
+                    .eq("org_id", orgId)
+                    .eq("employee_num", order.getEmployeeNum().trim())
+                    .eq("del_flag", "0")
+                    .last("limit 1"));
+            if (e != null) {
+                order.setEmployeeName(e.getEmployeeName());
+            }
+        }
+    }
+
+    private void enrichGoodsFromMaterial(List<SalesOrderGoods> goods, String orgId) {
+        if (goods == null || goods.isEmpty()) {
+            return;
+        }
+        for (SalesOrderGoods g : goods) {
+            if (StringUtils.isBlank(g.getGoodsNum())) {
+                continue;
+            }
+            BaseMaterial m = baseMaterialService.getOne(new QueryWrapper<BaseMaterial>()
+                    .eq("org_id", orgId)
+                    .eq("goods_num", g.getGoodsNum())
+                    .eq("del_flag", "0")
+                    .last("limit 1"));
+            if (m == null) {
+                continue;
+            }
+            if (StringUtils.isBlank(g.getGoodsName())) {
+                g.setGoodsName(m.getGoodsName());
+            }
+            if (StringUtils.isBlank(g.getGoodsSpec())) {
+                g.setGoodsSpec(m.getGoodsSpec());
+            }
+            if (StringUtils.isBlank(g.getBaseUnit())) {
+                g.setBaseUnit(m.getUnit());
+            }
+            if (StringUtils.isBlank(g.getAssUnit())) {
+                g.setAssUnit(m.getAssistantUnit());
+            }
+            if (StringUtils.isBlank(g.getConversionValue())) {
+                g.setConversionValue(m.getConversionValue());
+            }
+        }
+    }
+
+    /** 小计=单价×基本计量数量;总计金额=各明细小计之和 */
+    private void recalcGoodsSubTotalsAndOrderTotal(SalesOrder order) {
+        List<SalesOrderGoods> goods = order.getGoods();
+        if (goods == null || goods.isEmpty()) {
+            order.setTotalAmount(null);
+            return;
+        }
+        BigDecimal sum = BigDecimal.ZERO;
+        boolean any = false;
+        for (SalesOrderGoods g : goods) {
+            BigDecimal line = null;
+
+            // 优先按 unitPrice * baseNum 计算
+            BigDecimal p = parseMoney(g.getUnitPrice());
+            BigDecimal q = parseMoney(g.getBaseNum());
+            if (p != null && q != null) {
+                line = p.multiply(q);
+            }
+
+            if (line != null) {
+                g.setSubTotal(formatMoney(line));
+                sum = sum.add(line);
+                any = true;
+                continue;
+            }
+
+            // unitPrice 缺失时:不要清空,直接沿用已存在的小计 sub_total
+            BigDecimal existing = parseMoney(g.getSubTotal());
+            if (existing != null) {
+                g.setSubTotal(formatMoney(existing));
+                sum = sum.add(existing);
+                any = true;
+            } else {
+                g.setSubTotal(null);
+            }
+        }
+
+        if (any) {
+            order.setTotalAmount(formatMoney(sum));
+        } else {
+            order.setTotalAmount(null);
+        }
+    }
+
+    private BigDecimal parseMoney(String s) {
+        if (StringUtils.isBlank(s)) {
+            return null;
+        }
+        try {
+            return new BigDecimal(s.trim().replace(",", ""));
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    private String formatMoney(BigDecimal n) {
+        if (n == null) {
+            return null;
+        }
+        return n.stripTrailingZeros().toPlainString();
+    }
+
+    /** 按单据编号将原明细逻辑删除(修改订单时先标记旧清单 del_flag=2) */
+    private void deleteSalesOrderGoodsByOrderNum(String orgId, String orderNum) {
+        if (StringUtils.isBlank(orgId) || StringUtils.isBlank(orderNum)) {
+            return;
+        }
+        SalesOrderGoods up = new SalesOrderGoods();
+        up.setDelFlag("2");
+        up.setUpdateBy(getUsername());
+        up.setUpdateTime(new Date());
+        salesOrderGoodsService.update(up, new QueryWrapper<SalesOrderGoods>()
+                .eq("org_id", orgId)
+                .eq("order_num", orderNum)
+                .eq("del_flag", "0"));
+    }
+
+    private void saveGoods(SalesOrder order, String orgId, String username) {
+        List<SalesOrderGoods> goods = order.getGoods();
+        if (goods == null) {
+            goods = Collections.emptyList();
+        }
+        Date now = new Date();
+        for (SalesOrderGoods g : goods) {
+            g.setId(null);
+            g.setOrgId(orgId);
+            g.setOrderNum(order.getOrderNum());
+            g.setDelFlag("0");
+            g.setCreateBy(username);
+            g.setCreateTime(now);
+            g.setUpdateBy(null);
+            g.setUpdateTime(null);
+            salesOrderGoodsService.save(g);
+        }
+    }
+
+    private SalesOrder getActiveById(String id) throws Exception {
+        SalesOrder order = salesOrderService.getById(id);
+        if (order == null || !"0".equals(order.getDelFlag())) {
+            throw new Exception("销售订单不存在");
+        }
+        return order;
+    }
+}

+ 425 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/controller/SalesPriceController.java

@@ -0,0 +1,425 @@
+package com.ruoyi.web.base.controller;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.utils.StringUtils;
+import com.ruoyi.common.utils.poi.ExcelUtil;
+import com.ruoyi.framework.web.service.TokenService;
+import com.ruoyi.web.base.domain.BaseMaterial;
+import com.ruoyi.web.base.domain.BasePriceType;
+import com.ruoyi.web.base.domain.SalesPrice;
+import com.ruoyi.web.base.domain.SalesPriceItem;
+import com.ruoyi.web.base.mapper.SalesPriceItemMapper;
+import com.ruoyi.web.base.service.IBaseMaterialService;
+import com.ruoyi.web.base.service.IBasePriceTypeService;
+import com.ruoyi.web.base.service.ISalesPriceItemService;
+import com.ruoyi.web.base.service.ISalesPriceService;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.bind.annotation.*;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import static com.ruoyi.common.core.domain.AjaxResult.success;
+import static com.ruoyi.common.utils.SecurityUtils.getUsername;
+
+@RestController
+@Api(tags = "价格管理")
+@RequestMapping("/price")
+public class SalesPriceController {
+
+    @Autowired
+    private ISalesPriceService salesPriceService;
+    @Autowired
+    private ISalesPriceItemService salesPriceItemService;
+    @Autowired
+    private SalesPriceItemMapper salesPriceItemMapper;
+    @Autowired
+    private IBaseMaterialService baseMaterialService;
+    @Autowired
+    private IBasePriceTypeService basePriceTypeService;
+    @Autowired
+    private TokenService tokenService;
+
+    @ApiOperation("价格管理新增")
+    @PreAuthorize("@ss.hasPermi('base:price:add')")
+    @PostMapping("/add")
+    @Transactional
+    public AjaxResult add(@RequestBody SalesPrice price, HttpServletRequest request) throws Exception {
+        String orgId = tokenService.getLoginOrgId(request);
+        // 新增时后台统一生成价格单编号:P + 当前时间后一天yyyyMMdd
+        price.setPriceNum(generatePriceNum());
+        validateBeforeSave(price, orgId, true);
+        price.setOrgId(orgId);
+        price.setDelFlag("0");
+        String username = getUsername();
+        price.setCreateBy(username);
+        price.setCreateTime(new Date());
+        price.setUpdateBy(null);
+        price.setUpdateTime(null);
+        salesPriceService.save(price);
+        saveItems(price, orgId, username);
+        return success();
+    }
+
+    @ApiOperation("价格管理批量新增")
+    @PreAuthorize("@ss.hasPermi('base:price:add')")
+    @PostMapping("/batchAdd")
+    @Transactional(rollbackFor = Exception.class)
+    public AjaxResult batchAdd(@RequestBody List<SalesPrice> prices, HttpServletRequest request) throws Exception {
+        if (prices == null || prices.isEmpty()) {
+            throw new Exception("数据不能为空");
+        }
+        String orgId = tokenService.getLoginOrgId(request);
+        String username = getUsername();
+        Date now = new Date();
+        for (SalesPrice price : prices) {
+            if (price == null) {
+                throw new Exception("存在空对象");
+            }
+            price.setId(null);
+            price.setPriceNum(nextAvailablePriceNum(orgId));
+            validateBeforeSave(price, orgId, true);
+            price.setOrgId(orgId);
+            price.setDelFlag("0");
+            price.setCreateBy(username);
+            price.setCreateTime(now);
+            price.setUpdateBy(null);
+            price.setUpdateTime(null);
+            salesPriceService.save(price);
+            saveItems(price, orgId, username);
+        }
+        return success();
+    }
+
+    @ApiOperation("价格管理修改")
+    @PreAuthorize("@ss.hasPermi('base:price:edit')")
+    @PostMapping("/edit")
+    @Transactional
+    public AjaxResult edit(@RequestBody SalesPrice price, HttpServletRequest request) throws Exception {
+        if (price.getId() == null) {
+            throw new Exception("id不能为空");
+        }
+        SalesPrice old = salesPriceService.getById(price.getId());
+        if (old == null || !"0".equals(old.getDelFlag())) {
+            throw new Exception("价格单不存在");
+        }
+        String orgId = tokenService.getLoginOrgId(request);
+        validateBeforeSave(price, orgId, false);
+        price.setOrgId(orgId);
+        String username = getUsername();
+        price.setCreateBy(old.getCreateBy());
+        price.setCreateTime(old.getCreateTime());
+        price.setUpdateBy(username);
+        price.setUpdateTime(new Date());
+        salesPriceService.updateById(price);
+        salesPriceItemService.remove(new QueryWrapper<SalesPriceItem>()
+                .eq("org_id", orgId)
+                .eq("price_num", old.getPriceNum()));
+        saveItems(price, orgId, username);
+        return success();
+    }
+
+    @ApiOperation("价格管理删除")
+    @PreAuthorize("@ss.hasPermi('base:price:remove')")
+    @PostMapping("/delete")
+    public AjaxResult delete(@RequestBody Map<String, String> params) throws Exception {
+        String ids = params.get("ids");
+        for (String id : ids.split(",")) {
+            SalesPrice price = salesPriceService.getById(id);
+            if (price == null || !"0".equals(price.getDelFlag())) {
+                continue;
+            }
+            SalesPrice up = new SalesPrice();
+            up.setId(Integer.valueOf(id));
+            up.setDelFlag("2");
+            up.setUpdateBy(getUsername());
+            up.setUpdateTime(new Date());
+            salesPriceService.updateById(up);
+
+            // 主表删除时,同步将子表明细逻辑删除
+            SalesPriceItem itemUp = new SalesPriceItem();
+            itemUp.setDelFlag("2");
+            itemUp.setUpdateBy(getUsername());
+            itemUp.setUpdateTime(new Date());
+            salesPriceItemService.update(itemUp, new QueryWrapper<SalesPriceItem>()
+                    .eq("org_id", price.getOrgId())
+                    .eq("price_num", price.getPriceNum())
+                    .eq("del_flag", "0"));
+        }
+        return success();
+    }
+
+    @ApiOperation("价格管理详情")
+    @PreAuthorize("@ss.hasPermi('base:price:query')")
+    @PostMapping("/listById")
+    public AjaxResult listById(@RequestBody Map<String, String> params, HttpServletRequest request) {
+        String id = params.get("id");
+        SalesPrice price = salesPriceService.getById(id);
+        if (price == null) {
+            return success(null);
+        }
+        String orgId = tokenService.getLoginOrgId(request);
+        List<SalesPriceItem> items = salesPriceItemService.list(new QueryWrapper<SalesPriceItem>()
+                .eq("org_id", orgId)
+                .eq("price_num", price.getPriceNum())
+                .eq("del_flag", "0")
+                .orderByAsc("id"));
+        enrichItemsFromMaterial(items, orgId);
+        price.setItems(items);
+        enrichSalesPriceTypeNames(Collections.singletonList(price), orgId);
+        return success(price);
+    }
+
+    @ApiOperation("价格管理分页")
+    @PreAuthorize("@ss.hasPermi('base:price:list')")
+    @GetMapping("/page")
+    public AjaxResult page(@RequestParam("pageNum") Integer pageNum,
+                           @RequestParam("pageSize") Integer pageSize,
+                           SalesPrice query,
+                           HttpServletRequest request) {
+        String orgId = tokenService.getLoginOrgId(request);
+        QueryWrapper<SalesPrice> wrapper = buildWrapper(query, orgId);
+        Page<SalesPrice> page = salesPriceService.page(new Page<SalesPrice>(pageNum, pageSize), wrapper);
+        enrichSalesPriceTypeNames(page.getRecords(), orgId);
+        return success(page);
+    }
+
+    @ApiOperation("物料价格明细列表:按价格类型编码、主表执行日期区间筛 sales_price,返回符合条件的 sales_price_item(含单价、自提价及主表类型/日期)")
+    @PreAuthorize("@ss.hasPermi('base:price:list')")
+    @GetMapping("/list")
+    public AjaxResult list(SalesPrice query, HttpServletRequest request) {
+        String orgId = tokenService.getLoginOrgId(request);
+        String priceType = query == null || StringUtils.isBlank(query.getPriceType()) ? null : query.getPriceType().trim();
+        List<SalesPriceItem> items = salesPriceItemMapper.listByJoin(
+                orgId,
+                priceType,
+                query == null ? null : query.getEffectiveDateStart(),
+                query == null ? null : query.getEffectiveDateEnd()
+        );
+        enrichItemsFromMaterial(items, orgId);
+        return success(items);
+    }
+
+    @ApiOperation("价格管理导出")
+    @PreAuthorize("@ss.hasPermi('base:price:export')")
+    @PostMapping("/export")
+    public void export(HttpServletResponse response, SalesPrice query, HttpServletRequest request) {
+        String orgId = tokenService.getLoginOrgId(request);
+        List<SalesPrice> list = salesPriceService.list(buildWrapper(query, orgId));
+        enrichSalesPriceTypeNames(list, orgId);
+        ExcelUtil<SalesPrice> util = new ExcelUtil<SalesPrice>(SalesPrice.class);
+        util.exportExcel(response, list, "价格管理");
+    }
+
+    @ApiOperation("价格管理打印数据")
+    @PreAuthorize("@ss.hasPermi('base:price:print')")
+    @PostMapping("/print")
+    public AjaxResult print(@RequestBody Map<String, String> params, HttpServletRequest request) throws Exception {
+        SalesPrice price = getActiveById(params.get("id"));
+        String orgId = tokenService.getLoginOrgId(request);
+        List<SalesPriceItem> items = salesPriceItemService.list(new QueryWrapper<SalesPriceItem>()
+                .eq("org_id", orgId)
+                .eq("price_num", price.getPriceNum())
+                .eq("del_flag", "0")
+                .orderByAsc("id"));
+        enrichItemsFromMaterial(items, orgId);
+        price.setItems(items);
+        enrichSalesPriceTypeNames(Collections.singletonList(price), orgId);
+        return success(price);
+    }
+
+    /** sales_price.price_type 与 base_price_type.price_type_num 匹配,填充 priceTypeName */
+    private void enrichSalesPriceTypeNames(List<SalesPrice> rows, String orgId) {
+        if (rows == null || rows.isEmpty() || StringUtils.isBlank(orgId)) {
+            return;
+        }
+        Set<String> nums = new LinkedHashSet<String>();
+        for (SalesPrice p : rows) {
+            if (StringUtils.isNotBlank(p.getPriceType())) {
+                nums.add(p.getPriceType().trim());
+            }
+        }
+        if (nums.isEmpty()) {
+            return;
+        }
+        List<BasePriceType> types = basePriceTypeService.list(new QueryWrapper<BasePriceType>()
+                .eq("org_id", orgId)
+                .in("price_type_num", nums));
+        Map<String, String> nameByNum = new HashMap<String, String>();
+        for (BasePriceType t : types) {
+            if (t.getPriceTypeNum() == null || StringUtils.isBlank(t.getPriceTypeName())) {
+                continue;
+            }
+            String key = t.getPriceTypeNum().trim();
+            if (!nameByNum.containsKey(key)) {
+                nameByNum.put(key, t.getPriceTypeName().trim());
+            }
+        }
+        for (SalesPrice p : rows) {
+            if (StringUtils.isBlank(p.getPriceType())) {
+                continue;
+            }
+            String nm = nameByNum.get(p.getPriceType().trim());
+            if (StringUtils.isNotBlank(nm)) {
+                p.setPriceTypeName(nm);
+            }
+        }
+    }
+
+    private QueryWrapper<SalesPrice> buildWrapper(SalesPrice query, String orgId) {
+        QueryWrapper<SalesPrice> wrapper = new QueryWrapper<SalesPrice>()
+                .eq("org_id", orgId)
+                .eq("del_flag", "0");
+        if (query == null) {
+            return wrapper.orderByDesc("id");
+        }
+        if (StringUtils.isNotBlank(query.getPriceNum())) {
+            wrapper.like("price_num", query.getPriceNum());
+        }
+        if (StringUtils.isNotBlank(query.getPriceType())) {
+            wrapper.eq("price_type", query.getPriceType().trim());
+        }
+        if (query.getEffectiveDateStart() != null) {
+            wrapper.ge("effective_date", query.getEffectiveDateStart());
+        }
+        if (query.getEffectiveDateEnd() != null) {
+            wrapper.le("effective_date", query.getEffectiveDateEnd());
+        }
+        return wrapper.orderByDesc("id");
+    }
+
+    private void validateBeforeSave(SalesPrice price, String orgId, boolean isAdd) throws Exception {
+        if (price == null) {
+            throw new Exception("参数不能为空");
+        }
+        if (!isAdd && StringUtils.isBlank(price.getPriceNum())) {
+            throw new Exception("价格单编号不能为空");
+        }
+        if (price.getEffectiveDate() == null || price.getExpireDate() == null) {
+            throw new Exception("执行日期和失效日期不能为空");
+        }
+        if (price.getExpireDate().before(price.getEffectiveDate())) {
+            throw new Exception("失效日期不能早于执行日期");
+        }
+        QueryWrapper<SalesPrice> numWrapper = new QueryWrapper<SalesPrice>()
+                .eq("org_id", orgId)
+                .eq("price_num", price.getPriceNum())
+                .eq("del_flag", "0");
+        if (!isAdd && price.getId() != null) {
+            numWrapper.ne("id", price.getId());
+        }
+        if (salesPriceService.count(numWrapper) > 0) {
+            throw new Exception("该价格单编号已存在");
+        }
+        if (price.getItems() == null || price.getItems().isEmpty()) {
+            throw new Exception("价格明细不能为空");
+        }
+        for (SalesPriceItem item : price.getItems()) {
+            if (StringUtils.isBlank(item.getMaterialNum())) {
+                throw new Exception("明细物料编号不能为空");
+            }
+            if (item.getUnitPrice() == null) {
+                throw new Exception("明细单价不能为空");
+            }
+        }
+    }
+
+    private String generatePriceNum() {
+        String ymd = LocalDate.now().plusDays(1).format(DateTimeFormatter.BASIC_ISO_DATE);
+        return "P" + ymd;
+    }
+
+    /**
+     * 生成组织内未占用的价格单编号:P+次日yyyyMMdd,若已存在则依次尝试 P...-1、P...-2…
+     */
+    private String nextAvailablePriceNum(String orgId) throws Exception {
+        String ymd = LocalDate.now().plusDays(1).format(DateTimeFormatter.BASIC_ISO_DATE);
+        String prefix = "P" + ymd;
+        for (int n = 0; n < 10000; n++) {
+            String candidate = n == 0 ? prefix : prefix + "-" + n;
+            if (salesPriceService.count(new QueryWrapper<SalesPrice>()
+                    .eq("org_id", orgId)
+                    .eq("price_num", candidate)
+                    .eq("del_flag", "0")) == 0) {
+                return candidate;
+            }
+        }
+        throw new Exception("无法生成唯一价格单编号");
+    }
+
+    private void saveItems(SalesPrice price, String orgId, String username) {
+        List<SalesPriceItem> items = price.getItems();
+        if (items == null) {
+            items = Collections.emptyList();
+        }
+        Date now = new Date();
+        for (SalesPriceItem item : items) {
+            item.setId(null);
+            item.setOrgId(orgId);
+            item.setPriceNum(price.getPriceNum());
+            item.setDelFlag("0");
+            item.setCreateBy(username);
+            item.setCreateTime(now);
+            item.setUpdateBy(null);
+            item.setUpdateTime(null);
+            salesPriceItemService.save(item);
+        }
+    }
+
+    private void enrichItemsFromMaterial(List<SalesPriceItem> items, String orgId) {
+        if (items == null || items.isEmpty()) {
+            return;
+        }
+        for (SalesPriceItem item : items) {
+            if (StringUtils.isBlank(item.getMaterialNum())) {
+                continue;
+            }
+            BaseMaterial material = baseMaterialService.getOne(new QueryWrapper<BaseMaterial>()
+                    .eq("org_id", orgId)
+                    .eq("goods_num", item.getMaterialNum())
+                    .eq("del_flag", "0")
+                    .last("limit 1"));
+            if (material != null) {
+                item.setMaterialName(material.getGoodsName());
+                item.setMaterialSpec(material.getGoodsSpec());
+                if (StringUtils.isBlank(item.getLevel())) {
+                    item.setLevel(material.getGoodsLevel());
+                }
+                if (StringUtils.isBlank(item.getVariety())) {
+                    String v = material.getVariety();
+                    if (StringUtils.isBlank(v)) {
+                        v = material.getVarietyName();
+                    }
+                    item.setVariety(v);
+                }
+                if (StringUtils.isBlank(item.getUnit())) {
+                    item.setUnit(material.getUnit());
+                }
+            }
+        }
+    }
+
+    private SalesPrice getActiveById(String id) throws Exception {
+        SalesPrice price = salesPriceService.getById(id);
+        if (price == null || !"0".equals(price.getDelFlag())) {
+            throw new Exception("价格单不存在");
+        }
+        return price;
+    }
+}

+ 530 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/controller/SalesReturnController.java

@@ -0,0 +1,530 @@
+package com.ruoyi.web.base.controller;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.utils.StringUtils;
+import com.ruoyi.common.utils.poi.ExcelUtil;
+import com.ruoyi.framework.web.service.TokenService;
+import com.ruoyi.web.base.domain.BaseCustomer;
+import com.ruoyi.web.base.domain.BaseEmployee;
+import com.ruoyi.web.base.domain.BaseMaterial;
+import com.ruoyi.web.base.domain.SalesDelivery;
+import com.ruoyi.web.base.domain.SalesDeliveryGoods;
+import com.ruoyi.web.base.domain.SalesReturn;
+import com.ruoyi.web.base.domain.SalesReturnGoods;
+import com.ruoyi.web.base.service.IBaseCustomerService;
+import com.ruoyi.web.base.service.IBaseEmployeeService;
+import com.ruoyi.web.base.service.IBaseMaterialService;
+import com.ruoyi.web.base.service.ISalesDeliveryGoodsService;
+import com.ruoyi.web.base.service.ISalesDeliveryService;
+import com.ruoyi.web.base.service.ISalesReturnGoodsService;
+import com.ruoyi.web.base.service.ISalesReturnService;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import io.swagger.annotations.ApiParam;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.bind.annotation.*;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import static com.ruoyi.common.core.domain.AjaxResult.success;
+import static com.ruoyi.common.utils.SecurityUtils.getUsername;
+import static com.ruoyi.web.base.util.NumUtils.generateString;
+import static com.ruoyi.web.base.util.NumUtils.substringToInt;
+
+@RestController
+@Api(tags = "销售退回单")
+@RequestMapping("/sales-return")
+public class SalesReturnController {
+
+    @Autowired
+    private ISalesReturnService salesReturnService;
+    @Autowired
+    private ISalesReturnGoodsService salesReturnGoodsService;
+    @Autowired
+    private ISalesDeliveryService salesDeliveryService;
+    @Autowired
+    private ISalesDeliveryGoodsService salesDeliveryGoodsService;
+    @Autowired
+    private IBaseCustomerService customerService;
+    @Autowired
+    private IBaseEmployeeService baseEmployeeService;
+    @Autowired
+    private IBaseMaterialService baseMaterialService;
+    @Autowired
+    private TokenService tokenService;
+
+    @ApiOperation("销售退回单新增")
+    @PostMapping("/add")
+    @Transactional
+    public AjaxResult add(@RequestBody SalesReturn salesReturn, HttpServletRequest request) throws Exception {
+        String orgId = tokenService.getLoginOrgId(request);
+        fillByDeliveryNumIfNeeded(salesReturn, orgId, true);
+        validateBeforeSave(salesReturn, orgId, true);
+        fillCustomerInfo(salesReturn, orgId);
+        recalcGoodsAmountAndTotal(salesReturn);
+        salesReturn.setOrgId(orgId);
+        salesReturn.setDelFlag("0");
+        salesReturn.setAuditStatus(1);
+        if (salesReturn.getOffsetStatus() == null) {
+            salesReturn.setOffsetStatus(0);
+        }
+        String username = getUsername();
+        salesReturn.setId(null);
+        salesReturn.setCreateBy(username);
+        salesReturn.setCreateTime(new Date());
+        salesReturn.setUpdateBy(null);
+        salesReturn.setUpdateTime(null);
+        salesReturnService.save(salesReturn);
+        saveGoods(salesReturn, orgId, username);
+        return success();
+    }
+
+    @ApiOperation("销售退回单修改")
+    @PostMapping("/edit")
+    @Transactional
+    public AjaxResult edit(@RequestBody SalesReturn salesReturn, HttpServletRequest request) throws Exception {
+        if (salesReturn.getId() == null) {
+            throw new Exception("id不能为空");
+        }
+        SalesReturn old = salesReturnService.getById(salesReturn.getId());
+        if (old == null || !"0".equals(old.getDelFlag())) {
+            throw new Exception("销售退回单不存在");
+        }
+        if (old.getAuditStatus() != null && old.getAuditStatus() == 1) {
+            throw new Exception("已审核的销售退回单不能修改,请先反审核");
+        }
+        if (old.getOffsetStatus() != null && old.getOffsetStatus() == 1) {
+            throw new Exception("已冲销的销售退回单不能修改");
+        }
+        String orgId = tokenService.getLoginOrgId(request);
+        fillByDeliveryNumIfNeeded(salesReturn, orgId, false);
+        validateBeforeSave(salesReturn, orgId, false);
+        fillCustomerInfo(salesReturn, orgId);
+        recalcGoodsAmountAndTotal(salesReturn);
+        salesReturn.setOrgId(orgId);
+        salesReturn.setAuditStatus(1);
+        salesReturn.setOffsetStatus(old.getOffsetStatus());
+        String username = getUsername();
+        salesReturn.setCreateBy(old.getCreateBy());
+        salesReturn.setCreateTime(old.getCreateTime());
+        salesReturn.setUpdateBy(username);
+        salesReturn.setUpdateTime(new Date());
+        salesReturnService.updateById(salesReturn);
+        String oldNum = StringUtils.isNotBlank(old.getReturnNum()) ? old.getReturnNum().trim() : null;
+        deleteGoodsByReturnNum(orgId, oldNum);
+        saveGoods(salesReturn, orgId, username);
+        return success();
+    }
+
+    @ApiOperation("销售退回单删除")
+    @PostMapping("/delete")
+    public AjaxResult delete(@RequestBody Map<String, String> params) throws Exception {
+        String ids = params.get("ids");
+        for (String id : ids.split(",")) {
+            SalesReturn salesReturn = salesReturnService.getById(id);
+            if (salesReturn == null || !"0".equals(salesReturn.getDelFlag())) {
+                continue;
+            }
+            if (salesReturn.getAuditStatus() != null && salesReturn.getAuditStatus() == 1) {
+                throw new Exception("已审核的销售退回单不能删除,请先反审核");
+            }
+            if (salesReturn.getOffsetStatus() != null && salesReturn.getOffsetStatus() == 1) {
+                throw new Exception("已冲销的销售退回单不能删除");
+            }
+            deleteGoodsByReturnNum(salesReturn.getOrgId(), salesReturn.getReturnNum());
+            SalesReturn up = new SalesReturn();
+            up.setId(Integer.valueOf(id));
+            up.setDelFlag("2");
+            up.setUpdateBy(getUsername());
+            up.setUpdateTime(new Date());
+            salesReturnService.updateById(up);
+        }
+        return success();
+    }
+
+    @ApiOperation("销售退回单详情")
+    @PostMapping("/listById")
+    public AjaxResult listById(@ApiParam("详情参数: id") @RequestBody Map<String, String> params, HttpServletRequest request) {
+        SalesReturn salesReturn = salesReturnService.getById(params.get("id"));
+        if (salesReturn == null) {
+            return success(null);
+        }
+        String orgId = tokenService.getLoginOrgId(request);
+        List<SalesReturnGoods> goods = salesReturnGoodsService.list(new QueryWrapper<SalesReturnGoods>()
+                .eq("org_id", orgId)
+                .eq("return_num", salesReturn.getReturnNum())
+                .eq("del_flag", "0"));
+        enrichGoodsDisplay(goods, orgId);
+        enrichSalesReturnDisplay(salesReturn, orgId);
+        salesReturn.setGoods(goods);
+        return success(salesReturn);
+    }
+
+    @ApiOperation("销售退回单分页")
+    @GetMapping("/page")
+    public AjaxResult page(@RequestParam("pageNum") Integer pageNum,
+                           @RequestParam("pageSize") Integer pageSize,
+                           SalesReturn query,
+                           HttpServletRequest request) {
+        String orgId = tokenService.getLoginOrgId(request);
+        QueryWrapper<SalesReturn> wrapper = buildWrapper(query, orgId);
+        Page<SalesReturn> page = salesReturnService.page(new Page<SalesReturn>(pageNum, pageSize), wrapper);
+        for (SalesReturn row : page.getRecords()) {
+            enrichSalesReturnDisplay(row, orgId);
+        }
+        return success(page);
+    }
+
+    @ApiOperation("销售退回单列表")
+    @PostMapping("/list")
+    public AjaxResult list(@RequestBody SalesReturn query, HttpServletRequest request) {
+        String orgId = tokenService.getLoginOrgId(request);
+        List<SalesReturn> list = salesReturnService.list(buildWrapper(query, orgId));
+        for (SalesReturn row : list) {
+            enrichSalesReturnDisplay(row, orgId);
+        }
+        attachGoodsForListRows(list, orgId);
+        return success(list);
+    }
+
+    @ApiOperation("销售退回单导出")
+    @PostMapping("/export")
+    public void export(HttpServletResponse response, SalesReturn query, HttpServletRequest request) {
+        String orgId = tokenService.getLoginOrgId(request);
+        List<SalesReturn> list = salesReturnService.list(buildWrapper(query, orgId));
+        for (SalesReturn row : list) {
+            enrichSalesReturnDisplay(row, orgId);
+        }
+        ExcelUtil<SalesReturn> util = new ExcelUtil<SalesReturn>(SalesReturn.class);
+        util.exportExcel(response, list, "销售退回单");
+    }
+
+    @ApiOperation("销售退回单审核")
+    @PostMapping("/audit")
+    public AjaxResult audit(@RequestBody Map<String, String> params) throws Exception {
+        SalesReturn salesReturn = getActiveById(params.get("id"));
+        salesReturn.setAuditStatus(1);
+        salesReturn.setUpdateBy(getUsername());
+        salesReturn.setUpdateTime(new Date());
+        return success(salesReturnService.updateById(salesReturn));
+    }
+
+    @ApiOperation("销售退回单反审核")
+    @PostMapping("/unaudit")
+    public AjaxResult unaudit(@RequestBody Map<String, String> params) throws Exception {
+        SalesReturn salesReturn = getActiveById(params.get("id"));
+        if (salesReturn.getOffsetStatus() != null && salesReturn.getOffsetStatus() == 1) {
+            throw new Exception("已做冲销的销售退回单不可反审核");
+        }
+        salesReturn.setAuditStatus(0);
+        salesReturn.setUpdateBy(getUsername());
+        salesReturn.setUpdateTime(new Date());
+        return success(salesReturnService.updateById(salesReturn));
+    }
+
+    @ApiOperation("销售退回单打印数据")
+    @PostMapping("/print")
+    public AjaxResult print(@RequestBody Map<String, String> params, HttpServletRequest request) throws Exception {
+        SalesReturn salesReturn = getActiveById(params.get("id"));
+        String orgId = tokenService.getLoginOrgId(request);
+        List<SalesReturnGoods> goods = salesReturnGoodsService.list(new QueryWrapper<SalesReturnGoods>()
+                .eq("org_id", orgId)
+                .eq("return_num", salesReturn.getReturnNum())
+                .eq("del_flag", "0"));
+        enrichGoodsDisplay(goods, orgId);
+        enrichSalesReturnDisplay(salesReturn, orgId);
+        salesReturn.setGoods(goods);
+        return success(salesReturn);
+    }
+
+    @ApiOperation("获取最新销售退回单编号")
+    @PostMapping("/getReturnNum")
+    public AjaxResult getReturnNum(HttpServletRequest request) throws Exception {
+        String orgId = tokenService.getLoginOrgId(request);
+        SalesReturn one = salesReturnService.getOne(new QueryWrapper<SalesReturn>()
+                .eq("org_id", orgId).eq("del_flag", "0").orderByDesc("id").last("limit 1"));
+        if (one == null || StringUtils.isBlank(one.getReturnNum())) {
+            return success(generateString("xt", 1));
+        }
+        return success(generateString("xt", substringToInt(one.getReturnNum())));
+    }
+
+    @ApiOperation("按销售出库单编号回填退回单")
+    @PostMapping("/fillByDeliveryNum")
+    public AjaxResult fillByDeliveryNum(@RequestBody Map<String, String> params, HttpServletRequest request) throws Exception {
+        String deliveryNum = params.get("deliveryNum");
+        if (StringUtils.isBlank(deliveryNum)) {
+            throw new Exception("销售出库单编号不能为空");
+        }
+        String orgId = tokenService.getLoginOrgId(request);
+        SalesReturn salesReturn = new SalesReturn();
+        salesReturn.setDeliveryNum(deliveryNum.trim());
+        salesReturn.setGoods(new ArrayList<SalesReturnGoods>());
+        fillByDeliveryNumIfNeeded(salesReturn, orgId, true);
+        Map<String, Object> data = new HashMap<String, Object>();
+        data.put("deliveryNum", salesReturn.getDeliveryNum());
+        data.put("customerNum", salesReturn.getCustomerNum());
+        data.put("customerName", salesReturn.getCustomerName());
+        data.put("receivableCustomer", salesReturn.getReceivableCustomer());
+        data.put("employeeNum", salesReturn.getEmployeeNum());
+        data.put("remark", salesReturn.getRemark());
+        data.put("goods", salesReturn.getGoods());
+        return success(data);
+    }
+
+    private QueryWrapper<SalesReturn> buildWrapper(SalesReturn query, String orgId) {
+        QueryWrapper<SalesReturn> wrapper = new QueryWrapper<SalesReturn>()
+                .eq("org_id", orgId).eq("del_flag", "0");
+        if (query == null) return wrapper.orderByDesc("id");
+        if (query.getDocumentDateStart() != null) wrapper.ge("document_date", query.getDocumentDateStart());
+        if (query.getDocumentDateEnd() != null) wrapper.le("document_date", query.getDocumentDateEnd());
+        if (StringUtils.isNotBlank(query.getReturnNum())) wrapper.like("return_num", query.getReturnNum());
+        if (StringUtils.isNotBlank(query.getDeliveryNum())) wrapper.like("delivery_num", query.getDeliveryNum());
+        if (StringUtils.isNotBlank(query.getCustomerNum())) wrapper.like("customer_num", query.getCustomerNum());
+        if (StringUtils.isNotBlank(query.getCustomerName())) {
+            String safe = query.getCustomerName().trim().replace("'", "''");
+            wrapper.inSql("customer_num", "SELECT customer_num FROM base_customer WHERE org_id='" + orgId + "' AND del_flag='0' AND customer_name LIKE '%" + safe + "%'");
+        }
+        if (StringUtils.isNotBlank(query.getRemark())) wrapper.like("remark", query.getRemark());
+        if (StringUtils.isNotBlank(query.getEmployeeNum())) wrapper.like("employee_num", query.getEmployeeNum());
+        if (query.getAuditStatus() != null) wrapper.eq("audit_status", query.getAuditStatus());
+        if (StringUtils.isNotBlank(query.getGoodsNum())) {
+            String safe = query.getGoodsNum().trim().replace("'", "''");
+            wrapper.inSql("return_num", "SELECT return_num FROM sales_return_goods WHERE org_id='" + orgId + "' AND del_flag='0' AND goods_num LIKE '%" + safe + "%'");
+        }
+        if (StringUtils.isNotBlank(query.getWarehouseNum())) {
+            String safe = query.getWarehouseNum().trim().replace("'", "''");
+            wrapper.inSql("return_num", "SELECT return_num FROM sales_return_goods WHERE org_id='" + orgId + "' AND del_flag='0' AND warehouse_num LIKE '%" + safe + "%'");
+        }
+        if (StringUtils.isNotBlank(query.getGoodsName())) {
+            String safe = query.getGoodsName().trim().replace("'", "''");
+            wrapper.inSql("return_num", "SELECT g.return_num FROM sales_return_goods g LEFT JOIN base_material m ON m.org_id=g.org_id AND m.goods_num=g.goods_num WHERE g.org_id='" + orgId + "' AND g.del_flag='0' AND m.goods_name LIKE '%" + safe + "%'");
+        }
+        if (StringUtils.isNotBlank(query.getWarehouseName())) {
+            String safe = query.getWarehouseName().trim().replace("'", "''");
+            wrapper.inSql("return_num", "SELECT g.return_num FROM sales_return_goods g LEFT JOIN base_material m ON m.org_id=g.org_id AND m.goods_num=g.goods_num WHERE g.org_id='" + orgId + "' AND g.del_flag='0' AND m.warehouse_name LIKE '%" + safe + "%'");
+        }
+        return wrapper.orderByDesc("id");
+    }
+
+    private void validateBeforeSave(SalesReturn salesReturn, String orgId, boolean isAdd) throws Exception {
+        if (salesReturn == null) throw new Exception("参数不能为空");
+        if (StringUtils.isAnyBlank(salesReturn.getReturnNum(), salesReturn.getCustomerNum())) throw new Exception("单据编号、客户编号不能为空");
+        QueryWrapper<SalesReturn> numWrapper = new QueryWrapper<SalesReturn>()
+                .eq("org_id", orgId).eq("return_num", salesReturn.getReturnNum()).eq("del_flag", "0");
+        if (!isAdd && salesReturn.getId() != null) numWrapper.ne("id", salesReturn.getId());
+        if (salesReturnService.count(numWrapper) > 0) throw new Exception("该单据编号已存在");
+        if (salesReturn.getGoods() == null || salesReturn.getGoods().isEmpty()) throw new Exception("货品清单不能为空");
+        if (StringUtils.isNotBlank(salesReturn.getEmployeeNum())) {
+            BaseEmployee emp = baseEmployeeService.getOne(new QueryWrapper<BaseEmployee>()
+                    .eq("org_id", orgId).eq("employee_num", salesReturn.getEmployeeNum().trim()).eq("del_flag", "0").last("limit 1"));
+            if (emp == null) throw new Exception("业务员编号不存在");
+        }
+    }
+
+    private void fillCustomerInfo(SalesReturn salesReturn, String orgId) throws Exception {
+        BaseCustomer customer = customerService.getOne(new QueryWrapper<BaseCustomer>()
+                .eq("org_id", orgId).eq("customer_num", salesReturn.getCustomerNum()).eq("del_flag", "0").last("limit 1"));
+        if (customer == null) throw new Exception("客户编号不存在");
+        salesReturn.setCustomerName(customer.getCustomerName());
+        salesReturn.setReceivableCustomer(customer.getReceivableCustomer());
+    }
+
+    private void fillByDeliveryNumIfNeeded(SalesReturn salesReturn, String orgId, boolean fillGoods) throws Exception {
+        if (salesReturn == null || StringUtils.isBlank(salesReturn.getDeliveryNum())) return;
+        SalesDelivery delivery = salesDeliveryService.getOne(new QueryWrapper<SalesDelivery>()
+                .eq("org_id", orgId).eq("delivery_num", salesReturn.getDeliveryNum().trim()).eq("del_flag", "0").last("limit 1"));
+        if (delivery == null) throw new Exception("销货转入单据不存在");
+        if (StringUtils.isBlank(salesReturn.getCustomerNum())) salesReturn.setCustomerNum(delivery.getCustomerNum());
+        if (StringUtils.isBlank(salesReturn.getEmployeeNum())) salesReturn.setEmployeeNum(delivery.getEmployeeNum());
+        if (StringUtils.isBlank(salesReturn.getRemark())) salesReturn.setRemark(delivery.getRemark());
+        if (fillGoods && (salesReturn.getGoods() == null || salesReturn.getGoods().isEmpty())) {
+            List<SalesDeliveryGoods> deliveryGoods = salesDeliveryGoodsService.list(new QueryWrapper<SalesDeliveryGoods>()
+                    .eq("org_id", orgId).eq("delivery_num", delivery.getDeliveryNum()).eq("del_flag", "0"));
+            List<SalesReturnGoods> goods = new ArrayList<SalesReturnGoods>();
+            for (SalesDeliveryGoods g : deliveryGoods) {
+                SalesReturnGoods item = new SalesReturnGoods();
+                item.setGoodsNum(g.getGoodsNum());
+                item.setWarehouseNum(g.getWarehouseNum());
+                item.setGoodsSpec(g.getGoodsSpec());
+                item.setBatchNum(g.getBatchNum());
+                item.setBaseNum(g.getBaseNum());
+                item.setBaseUnit(g.getBaseUnit());
+                item.setAssNum(g.getAssNum());
+                item.setAssUnit(g.getAssUnit());
+                item.setGoodsName(g.getGoodsName());
+                item.setWarehouseName(g.getWarehouseName());
+                item.setUnitPrice(g.getUnitPrice());
+                item.setAmount(g.getAmount());
+                item.setGoodsRemark(g.getGoodsRemark());
+                goods.add(item);
+            }
+            salesReturn.setGoods(goods);
+        }
+    }
+
+    private void recalcGoodsAmountAndTotal(SalesReturn salesReturn) {
+        List<SalesReturnGoods> goods = salesReturn.getGoods();
+        if (goods == null || goods.isEmpty()) {
+            salesReturn.setTotalAmount(null);
+            return;
+        }
+        BigDecimal sum = BigDecimal.ZERO;
+        boolean any = false;
+        for (SalesReturnGoods g : goods) {
+            BigDecimal p = parseMoney(g.getUnitPrice());
+            BigDecimal q = parseMoney(g.getBaseNum());
+            BigDecimal line = (p != null && q != null) ? p.multiply(q) : parseMoney(g.getAmount());
+            if (line != null) {
+                g.setAmount(formatMoney(line));
+                sum = sum.add(line);
+                any = true;
+            } else {
+                g.setAmount(null);
+            }
+        }
+        salesReturn.setTotalAmount(any ? formatMoney(sum) : null);
+    }
+
+    private void saveGoods(SalesReturn salesReturn, String orgId, String username) {
+        List<SalesReturnGoods> goods = salesReturn.getGoods();
+        if (goods == null) goods = Collections.emptyList();
+        Date now = new Date();
+        for (SalesReturnGoods g : goods) {
+            g.setId(null);
+            g.setOrgId(orgId);
+            g.setReturnNum(salesReturn.getReturnNum());
+            g.setDelFlag("0");
+            g.setCreateBy(username);
+            g.setCreateTime(now);
+            g.setUpdateBy(null);
+            g.setUpdateTime(null);
+            salesReturnGoodsService.save(g);
+        }
+    }
+
+    /**
+     * 列表接口需带出货品清单:按退回单号批量查询子表,避免逐单 N+1。
+     */
+    private void attachGoodsForListRows(List<SalesReturn> list, String orgId) {
+        if (list == null || list.isEmpty()) {
+            return;
+        }
+        List<String> returnNums = list.stream()
+                .filter(r -> r != null && StringUtils.isNotBlank(r.getReturnNum()))
+                .map(r -> r.getReturnNum().trim())
+                .distinct()
+                .collect(Collectors.toList());
+        if (returnNums.isEmpty()) {
+            return;
+        }
+        List<SalesReturnGoods> allGoods = salesReturnGoodsService.list(new QueryWrapper<SalesReturnGoods>()
+                .eq("org_id", orgId)
+                .in("return_num", returnNums)
+                .eq("del_flag", "0")
+                .orderByAsc("id"));
+        Map<String, List<SalesReturnGoods>> byReturnNum = new HashMap<String, List<SalesReturnGoods>>();
+        for (SalesReturnGoods g : allGoods) {
+            if (g == null || StringUtils.isBlank(g.getReturnNum())) {
+                continue;
+            }
+            String key = g.getReturnNum().trim();
+            byReturnNum.computeIfAbsent(key, k -> new ArrayList<SalesReturnGoods>()).add(g);
+        }
+        for (SalesReturn row : list) {
+            if (row == null || StringUtils.isBlank(row.getReturnNum())) {
+                continue;
+            }
+            List<SalesReturnGoods> goods = byReturnNum.getOrDefault(row.getReturnNum().trim(), Collections.<SalesReturnGoods>emptyList());
+            List<SalesReturnGoods> copy = new ArrayList<SalesReturnGoods>(goods);
+            enrichGoodsDisplay(copy, orgId);
+            row.setGoods(copy);
+        }
+    }
+
+    private void enrichSalesReturnDisplay(SalesReturn salesReturn, String orgId) {
+        if (salesReturn == null) return;
+        salesReturn.setCustomerName(null);
+        salesReturn.setEmployeeName(null);
+        if (StringUtils.isNotBlank(salesReturn.getCustomerNum())) {
+            BaseCustomer c = customerService.getOne(new QueryWrapper<BaseCustomer>()
+                    .eq("org_id", orgId).eq("customer_num", salesReturn.getCustomerNum().trim()).eq("del_flag", "0").last("limit 1"));
+            if (c != null) {
+                salesReturn.setCustomerName(c.getCustomerName());
+                if (StringUtils.isBlank(salesReturn.getReceivableCustomer())) {
+                    salesReturn.setReceivableCustomer(c.getReceivableCustomer());
+                }
+            }
+        }
+        if (StringUtils.isNotBlank(salesReturn.getEmployeeNum())) {
+            BaseEmployee e = baseEmployeeService.getOne(new QueryWrapper<BaseEmployee>()
+                    .eq("org_id", orgId).eq("employee_num", salesReturn.getEmployeeNum().trim()).eq("del_flag", "0").last("limit 1"));
+            if (e != null) salesReturn.setEmployeeName(e.getEmployeeName());
+        }
+    }
+
+    private void enrichGoodsDisplay(List<SalesReturnGoods> goods, String orgId) {
+        if (goods == null || goods.isEmpty()) return;
+        for (SalesReturnGoods g : goods) {
+            if (StringUtils.isBlank(g.getGoodsNum())) continue;
+            BaseMaterial m = baseMaterialService.getOne(new QueryWrapper<BaseMaterial>()
+                    .eq("org_id", orgId).eq("goods_num", g.getGoodsNum()).eq("del_flag", "0").last("limit 1"));
+            if (m == null) continue;
+            g.setGoodsName(m.getGoodsName());
+            if (StringUtils.isBlank(g.getGoodsSpec())) g.setGoodsSpec(m.getGoodsSpec());
+            if (StringUtils.isBlank(g.getBaseUnit())) g.setBaseUnit(m.getUnit());
+            if (StringUtils.isBlank(g.getAssUnit())) g.setAssUnit(m.getAssistantUnit());
+            if (StringUtils.isBlank(g.getWarehouseName())) g.setWarehouseName(m.getWarehouseName());
+            if (StringUtils.isBlank(g.getGoodsName())) g.setGoodsName(m.getGoodsName());
+            BigDecimal p = parseMoney(g.getUnitPrice());
+            BigDecimal q = parseMoney(g.getBaseNum());
+            g.setAmount((p != null && q != null) ? formatMoney(p.multiply(q)) : null);
+        }
+    }
+
+    private void deleteGoodsByReturnNum(String orgId, String returnNum) {
+        if (StringUtils.isBlank(orgId) || StringUtils.isBlank(returnNum)) return;
+        SalesReturnGoods up = new SalesReturnGoods();
+        up.setDelFlag("2");
+        up.setUpdateBy(getUsername());
+        up.setUpdateTime(new Date());
+        salesReturnGoodsService.update(up, new QueryWrapper<SalesReturnGoods>()
+                .eq("org_id", orgId)
+                .eq("return_num", returnNum)
+                .eq("del_flag", "0"));
+    }
+
+    private BigDecimal parseMoney(String s) {
+        if (StringUtils.isBlank(s)) return null;
+        try {
+            return new BigDecimal(s.trim().replace(",", ""));
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    private String formatMoney(BigDecimal n) {
+        if (n == null) return null;
+        return n.stripTrailingZeros().toPlainString();
+    }
+
+    private SalesReturn getActiveById(String id) throws Exception {
+        SalesReturn salesReturn = salesReturnService.getById(id);
+        if (salesReturn == null || !"0".equals(salesReturn.getDelFlag())) {
+            throw new Exception("销售退回单不存在");
+        }
+        return salesReturn;
+    }
+}

+ 111 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/domain/BaseCustomer.java

@@ -0,0 +1,111 @@
+package com.ruoyi.web.base.domain;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.ruoyi.common.annotation.Excel;
+import com.ruoyi.web.base.domain.base.Base;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.experimental.Accessors;
+
+/**
+ * 客户管理对象
+ */
+@Data
+@EqualsAndHashCode(callSuper = false)
+@Accessors(chain = true)
+@TableName("base_customer")
+@ApiModel(value = "BaseCustomer对象", description = "客户管理")
+public class BaseCustomer extends Base {
+
+    private static final long serialVersionUID = 1L;
+
+    @Excel(name = "客户编号")
+    @ApiModelProperty(value = "客户编号")
+    private String customerNum;
+
+    @Excel(name = "客户名称")
+    @ApiModelProperty(value = "客户名称")
+    private String customerName;
+
+    @Excel(name = "客户地址")
+    @ApiModelProperty(value = "客户地址")
+    private String customerAddress;
+
+    @Excel(name = "客户等级", readConverterExp = "1=1级,2=2级,3=3级,4=4级,5=5级")
+    @ApiModelProperty(value = "客户等级")
+    private Integer customerLevel;
+
+    @Excel(name = "联络人")
+    @ApiModelProperty(value = "联络人")
+    private String contactPerson;
+
+    @Excel(name = "联系电话")
+    @ApiModelProperty(value = "联系电话")
+    private String contactPhone;
+
+    @Excel(name = "应收客户")
+    @ApiModelProperty(value = "应收客户")
+    private String receivableCustomer;
+
+    @Excel(name = "收款客户")
+    @ApiModelProperty(value = "收款客户")
+    private String payeeCustomer;
+
+    @Excel(name = "送货客户")
+    @ApiModelProperty(value = "送货客户")
+    private String deliveryCustomer;
+
+    @Excel(name = "信用控制", readConverterExp = "0=空白,1=启用,2=不启用")
+    @ApiModelProperty(value = "信用控制")
+    private Integer creditControl;
+
+    @Excel(name = "开户银行")
+    @ApiModelProperty(value = "开户银行")
+    private String bankName;
+
+    @Excel(name = "银行账号")
+    @ApiModelProperty(value = "银行账号")
+    private String bankAccount;
+
+    @Excel(name = "开票类型", readConverterExp = "0=空白,1=普票,2=增票")
+    @ApiModelProperty(value = "开票类型")
+    private Integer invoiceType;
+
+    @Excel(name = "客户渠道")
+    @ApiModelProperty(value = "客户渠道")
+    private String customerChannel;
+
+    @Excel(name = "检疫统计类型", readConverterExp = "0=无,1=按客户,2=按市场")
+    @ApiModelProperty(value = "检疫统计类型")
+    private Integer quarantineStatType;
+
+    @Excel(name = "品牌")
+    @ApiModelProperty(value = "品牌")
+    private String brand;
+
+    @Excel(name = "盖章颜色")
+    @ApiModelProperty(value = "盖章颜色")
+    private String stampColor;
+
+    @Excel(name = "状态", readConverterExp = "0=停用,1=启用")
+    @ApiModelProperty(value = "状态")
+    private Integer status;
+
+    @Excel(name = "是否浙食链客户")
+    @ApiModelProperty(value = "是否浙食链客户")
+    private Integer zslCustomer;
+
+    @Excel(name = "生产/经营许可证")
+    @ApiModelProperty(value = "生产/经营许可证")
+    private String businessPermit;
+
+    @Excel(name = "营业执照")
+    @ApiModelProperty(value = "营业执照")
+    private String businessLicense;
+
+    @Excel(name = "税号")
+    @ApiModelProperty(value = "税号")
+    private String taxNumber;
+}

+ 72 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/domain/RelCustomerMarket.java

@@ -0,0 +1,72 @@
+package com.ruoyi.web.base.domain;
+
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.ruoyi.common.annotation.Excel;
+import com.ruoyi.web.base.domain.base.Base;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.experimental.Accessors;
+
+@Data
+@EqualsAndHashCode(callSuper = false)
+@Accessors(chain = true)
+@TableName("rel_customer_market")
+@ApiModel(value = "RelCustomerMarket对象", description = "客户市场维护")
+public class RelCustomerMarket extends Base {
+    private static final long serialVersionUID = 1L;
+
+    @Excel(name = "物流线编号")
+    @ApiModelProperty(value = "物流线编号")
+    private String lineNum;
+
+    @TableField(exist = false)
+    @Excel(name = "物流线名称")
+    @ApiModelProperty(value = "物流线名称")
+    private String lineName;
+
+    @Excel(name = "市场编号")
+    @ApiModelProperty(value = "市场编号")
+    private String marketNum;
+
+    @TableField(exist = false)
+    @Excel(name = "市场名称")
+    @ApiModelProperty(value = "市场名称")
+    private String marketName;
+
+    @Excel(name = "客户编号")
+    @ApiModelProperty(value = "客户编号")
+    private String customerNum;
+
+    @TableField(exist = false)
+    @Excel(name = "客户名称")
+    @ApiModelProperty(value = "客户名称")
+    private String customerName;
+
+    @TableField(exist = false)
+    @Excel(name = "客户等级", readConverterExp = "1=1级,2=2级,3=3级,4=4级,5=5级")
+    @ApiModelProperty(value = "客户等级")
+    private Integer customerLevel;
+
+    @TableField(exist = false)
+    @Excel(name = "应收客户")
+    @ApiModelProperty(value = "应收客户")
+    private String receivableCustomer;
+
+    @TableField(exist = false)
+    @Excel(name = "收款客户")
+    @ApiModelProperty(value = "收款客户")
+    private String payeeCustomer;
+
+    @TableField(exist = false)
+    @Excel(name = "信用控制", readConverterExp = "0= ,1=启用,2=不启用")
+    @ApiModelProperty(value = "信用控制")
+    private Integer creditControl;
+
+    @TableField(exist = false)
+    @Excel(name = "客户渠道")
+    @ApiModelProperty(value = "客户渠道")
+    private String customerChannel;
+}

+ 88 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/domain/SalesAllowance.java

@@ -0,0 +1,88 @@
+package com.ruoyi.web.base.domain;
+
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.ruoyi.common.annotation.Excel;
+import com.ruoyi.web.base.domain.base.Base;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.experimental.Accessors;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.util.Date;
+import java.util.List;
+
+@Data
+@EqualsAndHashCode(callSuper = false)
+@Accessors(chain = true)
+@TableName("sales_allowance")
+@ApiModel(value = "SalesAllowance对象", description = "销售折让单")
+public class SalesAllowance extends Base {
+
+    private static final long serialVersionUID = 1L;
+
+    @ApiModelProperty(value = "折让日期")
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    @DateTimeFormat(pattern = "yyyy-MM-dd")
+    @Excel(name = "折让日期", width = 30, dateFormat = "yyyy-MM-dd")
+    private Date allowanceDate;
+
+    @ApiModelProperty(value = "折让编号")
+    @Excel(name = "折让编号")
+    private String allowanceNum;
+
+    @ApiModelProperty(value = "客户编号")
+    @Excel(name = "客户编号")
+    private String customerNum;
+
+    @TableField(exist = false)
+    @ApiModelProperty(value = "客户名称")
+    @Excel(name = "客户名称")
+    private String customerName;
+
+    @ApiModelProperty(value = "业务员编号")
+    @Excel(name = "业务员编号")
+    private String employeeNum;
+
+    @TableField(exist = false)
+    @ApiModelProperty(value = "业务员姓名")
+    private String employeeName;
+
+    @ApiModelProperty(value = "部门编号")
+    @Excel(name = "部门编号")
+    private String departmentNum;
+
+    @TableField(exist = false)
+    @ApiModelProperty(value = "部门名称")
+    private String departmentName;
+
+    @ApiModelProperty(value = "总计金额")
+    @Excel(name = "总计金额")
+    private String totalAmount;
+
+    @ApiModelProperty(value = "审核状态")
+    @Excel(name = "审核状态", readConverterExp = "0=未审核,1=已审核")
+    private Integer auditStatus;
+
+    @ApiModelProperty(value = "冲销状态")
+    private Integer offsetStatus;
+
+    @TableField(exist = false)
+    @ApiModelProperty(value = "出库清单")
+    private List<SalesAllowanceItem> items;
+
+    @TableField(exist = false)
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    @DateTimeFormat(pattern = "yyyy-MM-dd")
+    @ApiModelProperty(value = "折让日期开始")
+    private Date allowanceDateStart;
+
+    @TableField(exist = false)
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    @DateTimeFormat(pattern = "yyyy-MM-dd")
+    @ApiModelProperty(value = "折让日期结束")
+    private Date allowanceDateEnd;
+}

+ 65 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/domain/SalesAllowanceItem.java

@@ -0,0 +1,65 @@
+package com.ruoyi.web.base.domain;
+
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.ruoyi.common.annotation.Excel;
+import com.ruoyi.web.base.domain.base.Base;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.experimental.Accessors;
+
+import java.util.Date;
+
+@Data
+@EqualsAndHashCode(callSuper = false)
+@Accessors(chain = false)
+@TableName("sales_allowance_item")
+@ApiModel(value = "SalesAllowanceItem对象", description = "销售折让单出库清单")
+public class SalesAllowanceItem extends Base {
+
+    private static final long serialVersionUID = 1L;
+
+    @TableField(exist = false)
+    private String remark;
+
+    @ApiModelProperty(value = "折让编号")
+    private String allowanceNum;
+
+    @ApiModelProperty(value = "销售单号(销售出库单编号)")
+    @Excel(name = "销售单号")
+    private String deliveryNum;
+
+    @ApiModelProperty(value = "销售日期")
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    @Excel(name = "销售日期", width = 30, dateFormat = "yyyy-MM-dd")
+    private Date salesDate;
+
+    @ApiModelProperty(value = "批次")
+    private String batchNum;
+
+    @ApiModelProperty(value = "品名")
+    @Excel(name = "品名")
+    private String goodsName;
+
+    @ApiModelProperty(value = "数量")
+    private String quantity;
+
+    @ApiModelProperty(value = "单位")
+    private String unit;
+
+    @ApiModelProperty(value = "单价")
+    private String unitPrice;
+
+    @ApiModelProperty(value = "金额")
+    private String amount;
+
+    @ApiModelProperty(value = "备注")
+    private String goodsRemark;
+
+    @TableField(exist = false)
+    @ApiModelProperty(value = "货品编号(录入辅助)")
+    private String goodsNum;
+}

+ 83 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/domain/SalesDelivery.java

@@ -0,0 +1,83 @@
+package com.ruoyi.web.base.domain;
+
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.ruoyi.common.annotation.Excel;
+import com.ruoyi.web.base.domain.base.Base;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.experimental.Accessors;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.util.Date;
+import java.util.List;
+
+@Data
+@EqualsAndHashCode(callSuper = false)
+@Accessors(chain = true)
+@TableName("sales_delivery")
+@ApiModel(value = "SalesDelivery对象", description = "销售出库单")
+public class SalesDelivery extends Base {
+
+    private static final long serialVersionUID = 1L;
+
+    @ApiModelProperty(value = "单据日期")
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    @DateTimeFormat(pattern = "yyyy-MM-dd")
+    @Excel(name = "单据日期", width = 30, dateFormat = "yyyy-MM-dd")
+    private Date documentDate;
+
+    @ApiModelProperty(value = "单据编号")
+    @Excel(name = "单据编号")
+    private String deliveryNum;
+
+    @ApiModelProperty(value = "客户编号")
+    @Excel(name = "客户编号")
+    private String customerNum;
+
+    @ApiModelProperty(value = "客户名称")
+    @Excel(name = "客户名称")
+    private String customerName;
+
+    @ApiModelProperty(value = "业务员")
+    @Excel(name = "业务员")
+    private String employeeNum;
+
+    @TableField(exist = false)
+    @ApiModelProperty(value = "业务员姓名")
+    private String employeeName;
+
+    @ApiModelProperty(value = "总计金额")
+    @Excel(name = "总计金额")
+    private String totalAmount;
+
+    @ApiModelProperty(value = "审核状态")
+    @Excel(name = "审核状态", readConverterExp = "0=未审核,1=已审核")
+    private Integer auditStatus;
+
+    @ApiModelProperty(value = "冲销状态")
+    private Integer offsetStatus;
+
+    @TableField(exist = false)
+    @ApiModelProperty(value = "货品清单")
+    private List<SalesDeliveryGoods> goods;
+
+    @TableField(exist = false)
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    @DateTimeFormat(pattern = "yyyy-MM-dd")
+    @ApiModelProperty(value = "单据日期开始")
+    private Date documentDateStart;
+
+    @TableField(exist = false)
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    @DateTimeFormat(pattern = "yyyy-MM-dd")
+    @ApiModelProperty(value = "单据日期结束")
+    private Date documentDateEnd;
+
+    @TableField(exist = false)
+    @ApiModelProperty(value = "查询-货品编号")
+    private String goodsNum;
+}

+ 74 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/domain/SalesDeliveryGoods.java

@@ -0,0 +1,74 @@
+package com.ruoyi.web.base.domain;
+
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.ruoyi.common.annotation.Excel;
+import com.ruoyi.web.base.domain.base.Base;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.experimental.Accessors;
+
+@Data
+@EqualsAndHashCode(callSuper = false)
+@Accessors(chain = false)
+@TableName("sales_delivery_goods")
+@ApiModel(value = "SalesDeliveryGoods对象", description = "销售出库单货品清单")
+public class SalesDeliveryGoods extends Base {
+
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * sales_delivery_goods 未使用通用备注列 remark。
+     * Base 类中存在 remark 字段,这里显式屏蔽,避免查询报列不存在。
+     */
+    @TableField(exist = false)
+    private String remark;
+
+    @ApiModelProperty(value = "单据编号")
+    private String deliveryNum;
+
+    @ApiModelProperty(value = "货品编号")
+    @Excel(name = "货品编号")
+    private String goodsNum;
+
+    @ApiModelProperty(value = "仓库编号")
+    private String warehouseNum;
+
+    @ApiModelProperty(value = "规格")
+    private String goodsSpec;
+
+    @ApiModelProperty(value = "批次")
+    private String batchNum;
+
+    @ApiModelProperty(value = "基本计量数量")
+    private String baseNum;
+
+    @ApiModelProperty(value = "销售重量")
+    private String salesWeight;
+
+    @ApiModelProperty(value = "基本计量单位")
+    private String baseUnit;
+
+    @ApiModelProperty(value = "辅助计量数量")
+    private String assNum;
+
+    @ApiModelProperty(value = "辅助计量单位")
+    private String assUnit;
+
+    @ApiModelProperty(value = "单价")
+    private String unitPrice;
+
+    @ApiModelProperty(value = "金额")
+    private String amount;
+
+    @ApiModelProperty(value = "货品备注")
+    private String goodsRemark;
+
+    @ApiModelProperty(value = "货品名称")
+    private String goodsName;
+
+    @ApiModelProperty(value = "仓库名称")
+    private String warehouseName;
+}

+ 118 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/domain/SalesOrder.java

@@ -0,0 +1,118 @@
+package com.ruoyi.web.base.domain;
+
+import com.baomidou.mybatisplus.annotation.FieldStrategy;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.ruoyi.common.annotation.Excel;
+import com.ruoyi.web.base.domain.base.Base;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import org.springframework.format.annotation.DateTimeFormat;
+import lombok.EqualsAndHashCode;
+import lombok.experimental.Accessors;
+
+import java.util.Date;
+import java.util.List;
+
+@Data
+@EqualsAndHashCode(callSuper = false)
+@Accessors(chain = true)
+@TableName("sales_order")
+@ApiModel(value = "SalesOrder对象", description = "销售订单")
+public class SalesOrder extends Base {
+
+    private static final long serialVersionUID = 1L;
+
+    @ApiModelProperty(value = "单据日期")
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    @DateTimeFormat(pattern = "yyyy-MM-dd")
+    @Excel(name = "单据日期", width = 30, dateFormat = "yyyy-MM-dd")
+    private Date documentDate;
+
+    @ApiModelProperty(value = "单据编号")
+    @Excel(name = "单据编号")
+    private String orderNum;
+
+    @ApiModelProperty(value = "销售日期")
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    @DateTimeFormat(pattern = "yyyy-MM-dd")
+    @Excel(name = "销售日期", width = 30, dateFormat = "yyyy-MM-dd")
+    private Date saleDate;
+
+    @ApiModelProperty(value = "客户编号")
+    @Excel(name = "客户编号")
+    private String customerNum;
+
+    @ApiModelProperty(value = "客户名称")
+    @Excel(name = "客户名称")
+    private String customerName;
+
+    @ApiModelProperty(value = "市场编号")
+    @Excel(name = "市场编号")
+    @TableField(updateStrategy = FieldStrategy.IGNORED)
+    private String marketNum;
+
+    @ApiModelProperty(value = "物流线编号")
+    @Excel(name = "物流线编号")
+    @TableField(updateStrategy = FieldStrategy.IGNORED)
+    private String lineNum;
+
+    @ApiModelProperty(value = "业务员编号")
+    @Excel(name = "业务员编号")
+    private String employeeNum;
+
+    /** 自 base_employee 带出,不落库 */
+    @TableField(exist = false)
+    @ApiModelProperty(value = "业务员姓名")
+    @Excel(name = "业务员")
+    private String employeeName;
+
+    /** 自 base_customer 带出,不落库 */
+    @TableField(exist = false)
+    @ApiModelProperty(value = "应收客户")
+    @Excel(name = "应收客户")
+    private String receivableCustomer;
+
+    @ApiModelProperty(value = "总计金额")
+    @Excel(name = "总计金额")
+    @TableField(value = "total_amount", insertStrategy = FieldStrategy.NOT_NULL, updateStrategy = FieldStrategy.NOT_NULL)
+    private String totalAmount;
+
+    @ApiModelProperty(value = "备注1")
+    @Excel(name = "备注1")
+    private String remark1;
+
+    @ApiModelProperty(value = "审核状态")
+    @Excel(name = "审核状态", readConverterExp = "0=未审核,1=已审核")
+    private Integer auditStatus;
+
+    @TableField(exist = false)
+    @ApiModelProperty(value = "货品清单")
+    private List<SalesOrderGoods> goods;
+
+    @TableField(exist = false)
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    @DateTimeFormat(pattern = "yyyy-MM-dd")
+    @ApiModelProperty(value = "单据日期开始")
+    private Date documentDateStart;
+
+    @TableField(exist = false)
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    @DateTimeFormat(pattern = "yyyy-MM-dd")
+    @ApiModelProperty(value = "单据日期结束")
+    private Date documentDateEnd;
+
+    @TableField(exist = false)
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    @DateTimeFormat(pattern = "yyyy-MM-dd")
+    @ApiModelProperty(value = "销售日期开始")
+    private Date saleDateStart;
+
+    @TableField(exist = false)
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    @DateTimeFormat(pattern = "yyyy-MM-dd")
+    @ApiModelProperty(value = "销售日期结束")
+    private Date saleDateEnd;
+}

+ 66 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/domain/SalesOrderGoods.java

@@ -0,0 +1,66 @@
+package com.ruoyi.web.base.domain;
+
+import com.baomidou.mybatisplus.annotation.FieldStrategy;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.ruoyi.common.annotation.Excel;
+import com.ruoyi.web.base.domain.base.Base;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.experimental.Accessors;
+
+@Data
+@EqualsAndHashCode(callSuper = false)
+@Accessors(chain = false)
+@TableName("sales_order_goods")
+@ApiModel(value = "SalesOrderGoods对象", description = "销售订单货品清单")
+public class SalesOrderGoods extends Base {
+
+    private static final long serialVersionUID = 1L;
+
+    @ApiModelProperty(value = "单据编号")
+    private String orderNum;
+
+    @ApiModelProperty(value = "货品编号")
+    @Excel(name = "货品编号")
+    private String goodsNum;
+
+    @ApiModelProperty(value = "货品名称")
+    @Excel(name = "货品名称")
+    private String goodsName;
+
+    @ApiModelProperty(value = "规格")
+    @Excel(name = "规格")
+    private String goodsSpec;
+
+    @ApiModelProperty(value = "基本计量数量")
+    @Excel(name = "基本计量数量")
+    private String baseNum;
+
+    @ApiModelProperty(value = "辅助计量数量")
+    @Excel(name = "辅助计量数量")
+    private String assNum;
+
+    @ApiModelProperty(value = "基本计量单位")
+    @Excel(name = "基本计量单位")
+    private String baseUnit;
+
+    @ApiModelProperty(value = "辅助计量单位")
+    @Excel(name = "辅助计量单位")
+    private String assUnit;
+
+    @ApiModelProperty(value = "单价")
+    @Excel(name = "单价")
+    private String unitPrice;
+
+    @ApiModelProperty(value = "换算值")
+    @Excel(name = "换算值")
+    private String conversionValue;
+
+    @ApiModelProperty(value = "小计")
+    @Excel(name = "小计")
+    @TableField(value = "sub_total", insertStrategy = FieldStrategy.NOT_NULL, updateStrategy = FieldStrategy.NOT_NULL)
+    private String subTotal;
+}

+ 67 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/domain/SalesPrice.java

@@ -0,0 +1,67 @@
+package com.ruoyi.web.base.domain;
+
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.ruoyi.common.annotation.Excel;
+import com.ruoyi.web.base.domain.base.Base;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.experimental.Accessors;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.util.Date;
+import java.util.List;
+
+@Data
+@EqualsAndHashCode(callSuper = false)
+@Accessors(chain = true)
+@TableName("sales_price")
+@ApiModel(value = "SalesPrice对象", description = "销售价格主表")
+public class SalesPrice extends Base {
+
+    private static final long serialVersionUID = 1L;
+
+    @ApiModelProperty(value = "价格单编号")
+    @Excel(name = "价格单编号")
+    private String priceNum;
+
+    @ApiModelProperty(value = "价格类型(编码,对应 base_price_type.price_type_num)")
+    @Excel(name = "价格类型编码")
+    private String priceType;
+
+    @TableField(exist = false)
+    @ApiModelProperty(value = "价格类型名称(自 base_price_type 带出,不落库)")
+    @Excel(name = "价格类型名称")
+    private String priceTypeName;
+
+    @ApiModelProperty(value = "执行日期")
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    @DateTimeFormat(pattern = "yyyy-MM-dd")
+    @Excel(name = "执行日期", width = 30, dateFormat = "yyyy-MM-dd")
+    private Date effectiveDate;
+
+    @ApiModelProperty(value = "失效日期")
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    @DateTimeFormat(pattern = "yyyy-MM-dd")
+    @Excel(name = "失效日期", width = 30, dateFormat = "yyyy-MM-dd")
+    private Date expireDate;
+
+    @TableField(exist = false)
+    @ApiModelProperty(value = "明细清单")
+    private List<SalesPriceItem> items;
+
+    @TableField(exist = false)
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    @DateTimeFormat(pattern = "yyyy-MM-dd")
+    @ApiModelProperty(value = "执行日期开始")
+    private Date effectiveDateStart;
+
+    @TableField(exist = false)
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    @DateTimeFormat(pattern = "yyyy-MM-dd")
+    @ApiModelProperty(value = "执行日期结束")
+    private Date effectiveDateEnd;
+}

+ 81 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/domain/SalesPriceItem.java

@@ -0,0 +1,81 @@
+package com.ruoyi.web.base.domain;
+
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.ruoyi.common.annotation.Excel;
+import com.ruoyi.web.base.domain.base.Base;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.experimental.Accessors;
+
+import java.util.Date;
+
+@Data
+@EqualsAndHashCode(callSuper = false)
+@Accessors(chain = true)
+@TableName("sales_price_item")
+@ApiModel(value = "SalesPriceItem对象", description = "销售价格明细")
+public class SalesPriceItem extends Base {
+
+    private static final long serialVersionUID = 1L;
+
+    @ApiModelProperty(value = "价格单编号")
+    private String priceNum;
+
+    @ApiModelProperty(value = "物料编号")
+    @Excel(name = "物料编号")
+    private String materialNum;
+
+    @ApiModelProperty(value = "级别")
+    @Excel(name = "级别")
+    @TableField("level")
+    private String level;
+
+    @ApiModelProperty(value = "品种")
+    @Excel(name = "品种")
+    private String variety;
+
+    @ApiModelProperty(value = "单位")
+    @Excel(name = "单位")
+    private String unit;
+
+    @ApiModelProperty(value = "单价")
+    @Excel(name = "单价")
+    private java.math.BigDecimal unitPrice;
+
+    @ApiModelProperty(value = "自提价")
+    @Excel(name = "自提价")
+    private java.math.BigDecimal pickupPrice;
+
+    @TableField(exist = false)
+    @ApiModelProperty(value = "物料名称")
+    @Excel(name = "物料名称")
+    private String materialName;
+
+    @TableField(exist = false)
+    @ApiModelProperty(value = "物料规格")
+    @Excel(name = "物料规格")
+    private String materialSpec;
+
+    /** 以下自 sales_price 主表带出(/price/list 等),不落库 */
+    @TableField(exist = false)
+    @ApiModelProperty(value = "价格类型编码")
+    private String priceType;
+
+    @TableField(exist = false)
+    @ApiModelProperty(value = "价格类型名称")
+    private String priceTypeName;
+
+    @TableField(exist = false)
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    @ApiModelProperty(value = "主表执行日期")
+    private Date effectiveDate;
+
+    @TableField(exist = false)
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    @ApiModelProperty(value = "主表失效日期")
+    private Date expireDate;
+}

+ 104 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/domain/SalesReturn.java

@@ -0,0 +1,104 @@
+package com.ruoyi.web.base.domain;
+
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.ruoyi.common.annotation.Excel;
+import com.ruoyi.web.base.domain.base.Base;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.experimental.Accessors;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.util.Date;
+import java.util.List;
+
+@Data
+@EqualsAndHashCode(callSuper = false)
+@Accessors(chain = true)
+@TableName("sales_return")
+@ApiModel(value = "SalesReturn对象", description = "销售退回单")
+public class SalesReturn extends Base {
+
+    private static final long serialVersionUID = 1L;
+
+    @ApiModelProperty(value = "单据日期")
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    @DateTimeFormat(pattern = "yyyy-MM-dd")
+    @Excel(name = "单据日期", width = 30, dateFormat = "yyyy-MM-dd")
+    private Date documentDate;
+
+    @ApiModelProperty(value = "单据编号")
+    @Excel(name = "单据编号")
+    private String returnNum;
+
+    @ApiModelProperty(value = "销货转入(销售出库单编号)")
+    @Excel(name = "销货转入")
+    private String deliveryNum;
+
+    @ApiModelProperty(value = "客户编号")
+    @Excel(name = "客户编号")
+    private String customerNum;
+
+    @ApiModelProperty(value = "客户名称")
+    @Excel(name = "客户名称")
+    @TableField(exist = false)
+    private String customerName;
+
+    @ApiModelProperty(value = "应收客户")
+    @Excel(name = "应收客户")
+    private String receivableCustomer;
+
+    @ApiModelProperty(value = "业务员编号")
+    @Excel(name = "业务员编号")
+    private String employeeNum;
+
+    @TableField(exist = false)
+    @ApiModelProperty(value = "业务员姓名")
+    private String employeeName;
+
+    @ApiModelProperty(value = "总计金额")
+    @Excel(name = "总计金额")
+    private String totalAmount;
+
+    @ApiModelProperty(value = "审核状态")
+    @Excel(name = "审核状态", readConverterExp = "0=未审核,1=已审核")
+    private Integer auditStatus;
+
+    @ApiModelProperty(value = "冲销状态")
+    private Integer offsetStatus;
+
+    @TableField(exist = false)
+    @ApiModelProperty(value = "货品清单")
+    private List<SalesReturnGoods> goods;
+
+    @TableField(exist = false)
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    @DateTimeFormat(pattern = "yyyy-MM-dd")
+    @ApiModelProperty(value = "单据日期开始")
+    private Date documentDateStart;
+
+    @TableField(exist = false)
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    @DateTimeFormat(pattern = "yyyy-MM-dd")
+    @ApiModelProperty(value = "单据日期结束")
+    private Date documentDateEnd;
+
+    @TableField(exist = false)
+    @ApiModelProperty(value = "查询-货品编号")
+    private String goodsNum;
+
+    @TableField(exist = false)
+    @ApiModelProperty(value = "查询-货品名称")
+    private String goodsName;
+
+    @TableField(exist = false)
+    @ApiModelProperty(value = "查询-仓库编号")
+    private String warehouseNum;
+
+    @TableField(exist = false)
+    @ApiModelProperty(value = "查询-仓库名称")
+    private String warehouseName;
+}

+ 67 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/domain/SalesReturnGoods.java

@@ -0,0 +1,67 @@
+package com.ruoyi.web.base.domain;
+
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.ruoyi.common.annotation.Excel;
+import com.ruoyi.web.base.domain.base.Base;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.experimental.Accessors;
+
+@Data
+@EqualsAndHashCode(callSuper = false)
+@Accessors(chain = false)
+@TableName("sales_return_goods")
+@ApiModel(value = "SalesReturnGoods对象", description = "销售退回单货品清单")
+public class SalesReturnGoods extends Base {
+
+    private static final long serialVersionUID = 1L;
+
+    @TableField(exist = false)
+    private String remark;
+
+    @ApiModelProperty(value = "单据编号")
+    private String returnNum;
+
+    @ApiModelProperty(value = "货品编号")
+    @Excel(name = "货品编号")
+    private String goodsNum;
+
+    @ApiModelProperty(value = "仓库编号")
+    private String warehouseNum;
+
+    @ApiModelProperty(value = "规格")
+    private String goodsSpec;
+
+    @ApiModelProperty(value = "批次")
+    private String batchNum;
+
+    @ApiModelProperty(value = "基本计量数量")
+    private String baseNum;
+
+    @ApiModelProperty(value = "基本计量单位")
+    private String baseUnit;
+
+    @ApiModelProperty(value = "辅助计量数量")
+    private String assNum;
+
+    @ApiModelProperty(value = "辅助计量单位")
+    private String assUnit;
+
+    @ApiModelProperty(value = "单价")
+    private String unitPrice;
+
+    @ApiModelProperty(value = "金额")
+    private String amount;
+
+    @ApiModelProperty(value = "货品备注")
+    private String goodsRemark;
+
+    @ApiModelProperty(value = "货品名称")
+    private String goodsName;
+
+    @ApiModelProperty(value = "仓库名称")
+    private String warehouseName;
+}

+ 7 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/mapper/BaseCustomerMapper.java

@@ -0,0 +1,7 @@
+package com.ruoyi.web.base.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.web.base.domain.BaseCustomer;
+
+public interface BaseCustomerMapper extends BaseMapper<BaseCustomer> {
+}

+ 18 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/mapper/RelCustomerMarketMapper.java

@@ -0,0 +1,18 @@
+package com.ruoyi.web.base.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.ruoyi.web.base.domain.RelCustomerMarket;
+import org.apache.ibatis.annotations.Param;
+
+public interface RelCustomerMarketMapper extends BaseMapper<RelCustomerMarket> {
+
+    /**
+     * 分页查询(单次 SQL JOIN,适配大数据量表)
+     */
+    IPage<RelCustomerMarket> selectCustomerMarketPage(Page<RelCustomerMarket> page,
+                                                      @Param("orgId") String orgId,
+                                                      @Param("lineNum") String lineNum,
+                                                      @Param("marketNum") String marketNum);
+}

+ 7 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/mapper/SalesAllowanceItemMapper.java

@@ -0,0 +1,7 @@
+package com.ruoyi.web.base.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.web.base.domain.SalesAllowanceItem;
+
+public interface SalesAllowanceItemMapper extends BaseMapper<SalesAllowanceItem> {
+}

+ 7 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/mapper/SalesAllowanceMapper.java

@@ -0,0 +1,7 @@
+package com.ruoyi.web.base.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.web.base.domain.SalesAllowance;
+
+public interface SalesAllowanceMapper extends BaseMapper<SalesAllowance> {
+}

+ 7 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/mapper/SalesDeliveryGoodsMapper.java

@@ -0,0 +1,7 @@
+package com.ruoyi.web.base.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.web.base.domain.SalesDeliveryGoods;
+
+public interface SalesDeliveryGoodsMapper extends BaseMapper<SalesDeliveryGoods> {
+}

+ 7 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/mapper/SalesDeliveryMapper.java

@@ -0,0 +1,7 @@
+package com.ruoyi.web.base.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.web.base.domain.SalesDelivery;
+
+public interface SalesDeliveryMapper extends BaseMapper<SalesDelivery> {
+}

+ 7 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/mapper/SalesOrderGoodsMapper.java

@@ -0,0 +1,7 @@
+package com.ruoyi.web.base.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.web.base.domain.SalesOrderGoods;
+
+public interface SalesOrderGoodsMapper extends BaseMapper<SalesOrderGoods> {
+}

+ 7 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/mapper/SalesOrderMapper.java

@@ -0,0 +1,7 @@
+package com.ruoyi.web.base.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.web.base.domain.SalesOrder;
+
+public interface SalesOrderMapper extends BaseMapper<SalesOrder> {
+}

+ 47 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/mapper/SalesPriceItemMapper.java

@@ -0,0 +1,47 @@
+package com.ruoyi.web.base.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.web.base.domain.SalesPriceItem;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+import java.util.Date;
+import java.util.List;
+
+public interface SalesPriceItemMapper extends BaseMapper<SalesPriceItem> {
+    @Select({
+            "<script>",
+            "SELECT",
+            "  spi.id, spi.org_id, spi.price_num, spi.material_num, spi.level, spi.variety, spi.unit,",
+            "  spi.unit_price, spi.pickup_price, spi.remark, spi.del_flag, spi.create_by, spi.create_time, spi.update_by, spi.update_time,",
+            "  sp.price_type AS priceType,",
+            "  bpt.price_type_name AS priceTypeName,",
+            "  sp.effective_date AS effectiveDate,",
+            "  sp.expire_date AS expireDate",
+            "FROM sales_price_item spi",
+            "JOIN sales_price sp",
+            "  ON sp.org_id = spi.org_id",
+            " AND sp.price_num = spi.price_num",
+            "LEFT JOIN base_price_type bpt",
+            "  ON bpt.org_id = sp.org_id",
+            " AND bpt.price_type_num = sp.price_type",
+            "WHERE spi.org_id = #{orgId}",
+            "  AND spi.del_flag = '0'",
+            "  AND sp.del_flag = '0'",
+            "<if test='priceType != null and priceType != \"\"'>",
+            "  AND sp.price_type = #{priceType}",
+            "</if>",
+            "<if test='effectiveDateStart != null'>",
+            "  AND sp.effective_date &gt;= #{effectiveDateStart}",
+            "</if>",
+            "<if test='effectiveDateEnd != null'>",
+            "  AND sp.effective_date &lt;= #{effectiveDateEnd}",
+            "</if>",
+            "ORDER BY sp.effective_date DESC, sp.id DESC, spi.id ASC",
+            "</script>"
+    })
+    List<SalesPriceItem> listByJoin(@Param("orgId") String orgId,
+                                    @Param("priceType") String priceType,
+                                    @Param("effectiveDateStart") Date effectiveDateStart,
+                                    @Param("effectiveDateEnd") Date effectiveDateEnd);
+}

+ 7 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/mapper/SalesPriceMapper.java

@@ -0,0 +1,7 @@
+package com.ruoyi.web.base.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.web.base.domain.SalesPrice;
+
+public interface SalesPriceMapper extends BaseMapper<SalesPrice> {
+}

+ 7 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/mapper/SalesReturnGoodsMapper.java

@@ -0,0 +1,7 @@
+package com.ruoyi.web.base.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.web.base.domain.SalesReturnGoods;
+
+public interface SalesReturnGoodsMapper extends BaseMapper<SalesReturnGoods> {
+}

+ 7 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/mapper/SalesReturnMapper.java

@@ -0,0 +1,7 @@
+package com.ruoyi.web.base.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.web.base.domain.SalesReturn;
+
+public interface SalesReturnMapper extends BaseMapper<SalesReturn> {
+}

+ 7 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/service/IBaseCustomerService.java

@@ -0,0 +1,7 @@
+package com.ruoyi.web.base.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.ruoyi.web.base.domain.BaseCustomer;
+
+public interface IBaseCustomerService extends IService<BaseCustomer> {
+}

+ 14 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/service/IRelCustomerMarketService.java

@@ -0,0 +1,14 @@
+package com.ruoyi.web.base.service;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.ruoyi.web.base.domain.RelCustomerMarket;
+
+public interface IRelCustomerMarketService extends IService<RelCustomerMarket> {
+
+    /**
+     * 客户市场维护分页(JOIN 一次查出展示字段)
+     */
+    IPage<RelCustomerMarket> pageWithDetail(Page<RelCustomerMarket> page, String orgId, String lineNum, String marketNum);
+}

+ 7 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/service/ISalesAllowanceItemService.java

@@ -0,0 +1,7 @@
+package com.ruoyi.web.base.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.ruoyi.web.base.domain.SalesAllowanceItem;
+
+public interface ISalesAllowanceItemService extends IService<SalesAllowanceItem> {
+}

+ 7 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/service/ISalesAllowanceService.java

@@ -0,0 +1,7 @@
+package com.ruoyi.web.base.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.ruoyi.web.base.domain.SalesAllowance;
+
+public interface ISalesAllowanceService extends IService<SalesAllowance> {
+}

+ 7 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/service/ISalesDeliveryGoodsService.java

@@ -0,0 +1,7 @@
+package com.ruoyi.web.base.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.ruoyi.web.base.domain.SalesDeliveryGoods;
+
+public interface ISalesDeliveryGoodsService extends IService<SalesDeliveryGoods> {
+}

+ 7 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/service/ISalesDeliveryService.java

@@ -0,0 +1,7 @@
+package com.ruoyi.web.base.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.ruoyi.web.base.domain.SalesDelivery;
+
+public interface ISalesDeliveryService extends IService<SalesDelivery> {
+}

+ 7 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/service/ISalesOrderGoodsService.java

@@ -0,0 +1,7 @@
+package com.ruoyi.web.base.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.ruoyi.web.base.domain.SalesOrderGoods;
+
+public interface ISalesOrderGoodsService extends IService<SalesOrderGoods> {
+}

+ 7 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/service/ISalesOrderService.java

@@ -0,0 +1,7 @@
+package com.ruoyi.web.base.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.ruoyi.web.base.domain.SalesOrder;
+
+public interface ISalesOrderService extends IService<SalesOrder> {
+}

+ 7 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/service/ISalesPriceItemService.java

@@ -0,0 +1,7 @@
+package com.ruoyi.web.base.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.ruoyi.web.base.domain.SalesPriceItem;
+
+public interface ISalesPriceItemService extends IService<SalesPriceItem> {
+}

+ 7 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/service/ISalesPriceService.java

@@ -0,0 +1,7 @@
+package com.ruoyi.web.base.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.ruoyi.web.base.domain.SalesPrice;
+
+public interface ISalesPriceService extends IService<SalesPrice> {
+}

+ 7 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/service/ISalesReturnGoodsService.java

@@ -0,0 +1,7 @@
+package com.ruoyi.web.base.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.ruoyi.web.base.domain.SalesReturnGoods;
+
+public interface ISalesReturnGoodsService extends IService<SalesReturnGoods> {
+}

+ 7 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/service/ISalesReturnService.java

@@ -0,0 +1,7 @@
+package com.ruoyi.web.base.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.ruoyi.web.base.domain.SalesReturn;
+
+public interface ISalesReturnService extends IService<SalesReturn> {
+}

+ 11 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/service/impl/BaseCustomerServiceImpl.java

@@ -0,0 +1,11 @@
+package com.ruoyi.web.base.service.impl;
+
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.ruoyi.web.base.domain.BaseCustomer;
+import com.ruoyi.web.base.mapper.BaseCustomerMapper;
+import com.ruoyi.web.base.service.IBaseCustomerService;
+import org.springframework.stereotype.Service;
+
+@Service
+public class BaseCustomerServiceImpl extends ServiceImpl<BaseCustomerMapper, BaseCustomer> implements IBaseCustomerService {
+}

+ 18 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/service/impl/RelCustomerMarketServiceImpl.java

@@ -0,0 +1,18 @@
+package com.ruoyi.web.base.service.impl;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.ruoyi.web.base.domain.RelCustomerMarket;
+import com.ruoyi.web.base.mapper.RelCustomerMarketMapper;
+import com.ruoyi.web.base.service.IRelCustomerMarketService;
+import org.springframework.stereotype.Service;
+
+@Service
+public class RelCustomerMarketServiceImpl extends ServiceImpl<RelCustomerMarketMapper, RelCustomerMarket> implements IRelCustomerMarketService {
+
+    @Override
+    public IPage<RelCustomerMarket> pageWithDetail(Page<RelCustomerMarket> page, String orgId, String lineNum, String marketNum) {
+        return baseMapper.selectCustomerMarketPage(page, orgId, lineNum, marketNum);
+    }
+}

+ 11 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/service/impl/SalesAllowanceItemServiceImpl.java

@@ -0,0 +1,11 @@
+package com.ruoyi.web.base.service.impl;
+
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.ruoyi.web.base.domain.SalesAllowanceItem;
+import com.ruoyi.web.base.mapper.SalesAllowanceItemMapper;
+import com.ruoyi.web.base.service.ISalesAllowanceItemService;
+import org.springframework.stereotype.Service;
+
+@Service
+public class SalesAllowanceItemServiceImpl extends ServiceImpl<SalesAllowanceItemMapper, SalesAllowanceItem> implements ISalesAllowanceItemService {
+}

+ 11 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/service/impl/SalesAllowanceServiceImpl.java

@@ -0,0 +1,11 @@
+package com.ruoyi.web.base.service.impl;
+
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.ruoyi.web.base.domain.SalesAllowance;
+import com.ruoyi.web.base.mapper.SalesAllowanceMapper;
+import com.ruoyi.web.base.service.ISalesAllowanceService;
+import org.springframework.stereotype.Service;
+
+@Service
+public class SalesAllowanceServiceImpl extends ServiceImpl<SalesAllowanceMapper, SalesAllowance> implements ISalesAllowanceService {
+}

+ 11 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/service/impl/SalesDeliveryGoodsServiceImpl.java

@@ -0,0 +1,11 @@
+package com.ruoyi.web.base.service.impl;
+
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.ruoyi.web.base.domain.SalesDeliveryGoods;
+import com.ruoyi.web.base.mapper.SalesDeliveryGoodsMapper;
+import com.ruoyi.web.base.service.ISalesDeliveryGoodsService;
+import org.springframework.stereotype.Service;
+
+@Service
+public class SalesDeliveryGoodsServiceImpl extends ServiceImpl<SalesDeliveryGoodsMapper, SalesDeliveryGoods> implements ISalesDeliveryGoodsService {
+}

+ 11 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/service/impl/SalesDeliveryServiceImpl.java

@@ -0,0 +1,11 @@
+package com.ruoyi.web.base.service.impl;
+
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.ruoyi.web.base.domain.SalesDelivery;
+import com.ruoyi.web.base.mapper.SalesDeliveryMapper;
+import com.ruoyi.web.base.service.ISalesDeliveryService;
+import org.springframework.stereotype.Service;
+
+@Service
+public class SalesDeliveryServiceImpl extends ServiceImpl<SalesDeliveryMapper, SalesDelivery> implements ISalesDeliveryService {
+}

+ 11 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/service/impl/SalesOrderGoodsServiceImpl.java

@@ -0,0 +1,11 @@
+package com.ruoyi.web.base.service.impl;
+
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.ruoyi.web.base.domain.SalesOrderGoods;
+import com.ruoyi.web.base.mapper.SalesOrderGoodsMapper;
+import com.ruoyi.web.base.service.ISalesOrderGoodsService;
+import org.springframework.stereotype.Service;
+
+@Service
+public class SalesOrderGoodsServiceImpl extends ServiceImpl<SalesOrderGoodsMapper, SalesOrderGoods> implements ISalesOrderGoodsService {
+}

+ 11 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/service/impl/SalesOrderServiceImpl.java

@@ -0,0 +1,11 @@
+package com.ruoyi.web.base.service.impl;
+
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.ruoyi.web.base.domain.SalesOrder;
+import com.ruoyi.web.base.mapper.SalesOrderMapper;
+import com.ruoyi.web.base.service.ISalesOrderService;
+import org.springframework.stereotype.Service;
+
+@Service
+public class SalesOrderServiceImpl extends ServiceImpl<SalesOrderMapper, SalesOrder> implements ISalesOrderService {
+}

+ 11 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/service/impl/SalesPriceItemServiceImpl.java

@@ -0,0 +1,11 @@
+package com.ruoyi.web.base.service.impl;
+
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.ruoyi.web.base.domain.SalesPriceItem;
+import com.ruoyi.web.base.mapper.SalesPriceItemMapper;
+import com.ruoyi.web.base.service.ISalesPriceItemService;
+import org.springframework.stereotype.Service;
+
+@Service
+public class SalesPriceItemServiceImpl extends ServiceImpl<SalesPriceItemMapper, SalesPriceItem> implements ISalesPriceItemService {
+}

+ 11 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/service/impl/SalesPriceServiceImpl.java

@@ -0,0 +1,11 @@
+package com.ruoyi.web.base.service.impl;
+
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.ruoyi.web.base.domain.SalesPrice;
+import com.ruoyi.web.base.mapper.SalesPriceMapper;
+import com.ruoyi.web.base.service.ISalesPriceService;
+import org.springframework.stereotype.Service;
+
+@Service
+public class SalesPriceServiceImpl extends ServiceImpl<SalesPriceMapper, SalesPrice> implements ISalesPriceService {
+}

+ 11 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/service/impl/SalesReturnGoodsServiceImpl.java

@@ -0,0 +1,11 @@
+package com.ruoyi.web.base.service.impl;
+
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.ruoyi.web.base.domain.SalesReturnGoods;
+import com.ruoyi.web.base.mapper.SalesReturnGoodsMapper;
+import com.ruoyi.web.base.service.ISalesReturnGoodsService;
+import org.springframework.stereotype.Service;
+
+@Service
+public class SalesReturnGoodsServiceImpl extends ServiceImpl<SalesReturnGoodsMapper, SalesReturnGoods> implements ISalesReturnGoodsService {
+}

+ 11 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/base/service/impl/SalesReturnServiceImpl.java

@@ -0,0 +1,11 @@
+package com.ruoyi.web.base.service.impl;
+
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.ruoyi.web.base.domain.SalesReturn;
+import com.ruoyi.web.base.mapper.SalesReturnMapper;
+import com.ruoyi.web.base.service.ISalesReturnService;
+import org.springframework.stereotype.Service;
+
+@Service
+public class SalesReturnServiceImpl extends ServiceImpl<SalesReturnMapper, SalesReturn> implements ISalesReturnService {
+}

+ 54 - 0
ruoyi-admin/src/main/resources/mapper/RelCustomerMarketMapper.xml

@@ -0,0 +1,54 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.ruoyi.web.base.mapper.RelCustomerMarketMapper">
+
+    <!--
+      分页一次 JOIN 出展示字段,避免主表分页后再对 customer/line/market 多次 IN 查询。
+      建议在 rel_customer_market 上建立组合索引,见 sql/rel_customer_market_index_page.sql
+    -->
+    <select id="selectCustomerMarketPage" resultType="com.ruoyi.web.base.domain.RelCustomerMarket">
+        SELECT
+            r.id,
+            r.org_id AS orgId,
+            r.line_num AS lineNum,
+            r.market_num AS marketNum,
+            r.customer_num AS customerNum,
+            CASE WHEN c.id IS NOT NULL THEN c.remark ELSE r.remark END AS remark,
+            r.del_flag AS delFlag,
+            r.create_by AS createBy,
+            r.create_time AS createTime,
+            r.update_by AS updateBy,
+            r.update_time AS updateTime,
+            c.customer_name AS customerName,
+            c.customer_level AS customerLevel,
+            c.receivable_customer AS receivableCustomer,
+            c.payee_customer AS payeeCustomer,
+            c.credit_control AS creditControl,
+            c.customer_channel AS customerChannel,
+            l.line_name AS lineName,
+            m.market_name AS marketName
+        FROM rel_customer_market r
+        LEFT JOIN base_customer c
+            ON c.org_id = r.org_id
+            AND c.customer_num = r.customer_num
+            AND c.del_flag = '0'
+        LEFT JOIN base_line l
+            ON l.org_id = r.org_id
+            AND l.line_num = r.line_num
+            AND l.del_flag = '0'
+        LEFT JOIN base_market m
+            ON m.org_id = r.org_id
+            AND m.market_num = r.market_num
+            AND m.del_flag = '0'
+        WHERE r.org_id = #{orgId}
+          AND r.del_flag = '0'
+        <if test="lineNum != null and lineNum != ''">
+            AND r.line_num = #{lineNum}
+        </if>
+        <if test="marketNum != null and marketNum != ''">
+            AND r.market_num = #{marketNum}
+        </if>
+        ORDER BY r.id DESC
+    </select>
+
+</mapper>

+ 38 - 0
sql/base_customer.sql

@@ -0,0 +1,38 @@
+-- 客户管理建表脚本(仅表结构,不包含菜单权限初始化)
+
+CREATE TABLE IF NOT EXISTS `base_customer` (
+  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `org_id` varchar(64) DEFAULT NULL COMMENT '组织ID',
+  `customer_num` varchar(64) NOT NULL COMMENT '客户编号',
+  `customer_name` varchar(128) NOT NULL COMMENT '客户名称',
+  `customer_address` varchar(255) DEFAULT NULL COMMENT '客户地址',
+  `customer_level` tinyint(1) DEFAULT 1 COMMENT '客户等级(1-5对应1级-5级)',
+  `contact_person` varchar(64) DEFAULT NULL COMMENT '联络人',
+  `contact_phone` varchar(64) DEFAULT NULL COMMENT '联系电话',
+  `receivable_customer` varchar(128) DEFAULT NULL COMMENT '应收客户',
+  `payee_customer` varchar(128) DEFAULT NULL COMMENT '收款客户',
+  `delivery_customer` varchar(128) DEFAULT NULL COMMENT '送货客户',
+  `credit_control` tinyint(1) DEFAULT 0 COMMENT '信用控制(0'' ''1启用2不启用)',
+  `bank_name` varchar(128) DEFAULT NULL COMMENT '开户银行',
+  `bank_account` varchar(128) DEFAULT NULL COMMENT '银行账号',
+  `invoice_type` tinyint(1) DEFAULT 0 COMMENT '开票类型(0'' ''1普票2增票)',
+  `customer_channel` varchar(64) DEFAULT NULL COMMENT '客户渠道',
+  `quarantine_stat_type` tinyint(1) DEFAULT 0 COMMENT '检疫统计类型(0无1按客户2按市场)',
+  `brand` varchar(64) DEFAULT NULL COMMENT '品牌',
+  `stamp_color` varchar(64) DEFAULT NULL COMMENT '盖章颜色',
+  `status` tinyint(1) DEFAULT 1 COMMENT '状态(0停用1启用)',
+  `zsl_customer` tinyint(1) DEFAULT 0 COMMENT '是否浙食链客户(0否1是)',
+  `business_permit` varchar(255) DEFAULT NULL COMMENT '生产/经营许可证',
+  `business_license` varchar(255) DEFAULT NULL COMMENT '营业执照',
+  `tax_number` varchar(128) DEFAULT NULL COMMENT '税号',
+  `remark` varchar(500) DEFAULT NULL COMMENT '备注',
+  `del_flag` char(1) DEFAULT '0' COMMENT '删除标志(0代表存在 2代表删除)',
+  `create_by` varchar(64) DEFAULT '' COMMENT '创建者',
+  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+  `update_by` varchar(64) DEFAULT '' COMMENT '更新者',
+  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_customer_num_del_flag` (`customer_num`, `del_flag`),
+  KEY `idx_customer_name` (`customer_name`),
+  KEY `idx_contact_phone` (`contact_phone`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客户管理';

+ 22 - 0
sql/rel_customer_market.sql

@@ -0,0 +1,22 @@
+-- 客户市场维护建表脚本(仅表结构)
+
+CREATE TABLE IF NOT EXISTS `rel_customer_market` (
+  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `org_id` varchar(64) DEFAULT NULL COMMENT '组织ID',
+  `line_num` varchar(64) NOT NULL COMMENT '物流线编号',
+  `market_num` varchar(64) NOT NULL COMMENT '市场编号',
+  `customer_num` varchar(64) NOT NULL COMMENT '客户编号',
+  `remark` varchar(500) DEFAULT NULL COMMENT '备注',
+  `del_flag` char(1) DEFAULT '0' COMMENT '删除标志(0代表存在 2代表删除)',
+  `create_by` varchar(64) DEFAULT '' COMMENT '创建者',
+  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+  `update_by` varchar(64) DEFAULT '' COMMENT '更新者',
+  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  KEY `idx_line_num` (`line_num`),
+  KEY `idx_market_num` (`market_num`),
+  KEY `idx_customer_num` (`customer_num`),
+  KEY `idx_org_del_id` (`org_id`, `del_flag`, `id`),
+  KEY `idx_org_del_line_id` (`org_id`, `del_flag`, `line_num`, `id`),
+  KEY `idx_org_del_market_id` (`org_id`, `del_flag`, `market_num`, `id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客户市场维护';

+ 49 - 0
sql/sales_allowance.sql

@@ -0,0 +1,49 @@
+-- 销售折让单建表脚本(仅表结构)
+
+CREATE TABLE IF NOT EXISTS `sales_allowance` (
+  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `org_id` varchar(64) DEFAULT NULL COMMENT '组织ID',
+  `allowance_date` date DEFAULT NULL COMMENT '折让日期',
+  `allowance_num` varchar(64) NOT NULL COMMENT '折让编号',
+  `customer_num` varchar(64) DEFAULT NULL COMMENT '客户编号',
+  `employee_num` varchar(64) DEFAULT NULL COMMENT '业务员编号',
+  `department_num` varchar(64) DEFAULT NULL COMMENT '部门编号',
+  `total_amount` varchar(64) DEFAULT NULL COMMENT '总计金额',
+  `audit_status` tinyint(1) DEFAULT 1 COMMENT '审核状态(0未审核1已审核)',
+  `offset_status` tinyint(1) DEFAULT 0 COMMENT '冲销状态(0未冲销1已冲销)',
+  `remark` varchar(500) DEFAULT NULL COMMENT '备注',
+  `del_flag` char(1) DEFAULT '0' COMMENT '删除标志(0代表存在 2代表删除)',
+  `create_by` varchar(64) DEFAULT '' COMMENT '创建者',
+  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+  `update_by` varchar(64) DEFAULT '' COMMENT '更新者',
+  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  KEY `idx_allowance_num` (`allowance_num`),
+  KEY `idx_customer_num` (`customer_num`),
+  KEY `idx_employee_num` (`employee_num`),
+  KEY `idx_department_num` (`department_num`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='销售折让单';
+
+CREATE TABLE IF NOT EXISTS `sales_allowance_item` (
+  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `org_id` varchar(64) DEFAULT NULL COMMENT '组织ID',
+  `allowance_num` varchar(64) NOT NULL COMMENT '折让编号',
+  `delivery_num` varchar(64) DEFAULT NULL COMMENT '销售单号(销售出库单编号)',
+  `sales_date` date DEFAULT NULL COMMENT '销售日期',
+  `batch_num` varchar(64) DEFAULT NULL COMMENT '批次',
+  `goods_name` varchar(256) DEFAULT NULL COMMENT '品名',
+  `quantity` varchar(64) DEFAULT NULL COMMENT '数量',
+  `unit` varchar(32) DEFAULT NULL COMMENT '单位',
+  `unit_price` varchar(64) DEFAULT NULL COMMENT '单价',
+  `amount` varchar(64) DEFAULT NULL COMMENT '金额',
+  `goods_remark` varchar(500) DEFAULT NULL COMMENT '备注',
+  `del_flag` char(1) DEFAULT '0' COMMENT '删除标志(0代表存在 2代表删除)',
+  `create_by` varchar(64) DEFAULT '' COMMENT '创建者',
+  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+  `update_by` varchar(64) DEFAULT '' COMMENT '更新者',
+  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  KEY `idx_allowance_num` (`allowance_num`),
+  KEY `idx_delivery_num` (`delivery_num`),
+  KEY `idx_allowance_del` (`allowance_num`,`del_flag`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='销售折让单出库清单';

+ 53 - 0
sql/sales_delivery.sql

@@ -0,0 +1,53 @@
+-- 销售出库单建表脚本(仅表结构)
+
+CREATE TABLE IF NOT EXISTS `sales_delivery` (
+  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `org_id` varchar(64) DEFAULT NULL COMMENT '组织ID',
+  `document_date` date DEFAULT NULL COMMENT '单据日期',
+  `delivery_num` varchar(64) NOT NULL COMMENT '单据编号',
+  `customer_num` varchar(64) DEFAULT NULL COMMENT '客户编号',
+  `customer_name` varchar(128) DEFAULT NULL COMMENT '客户名称',
+  `employee_num` varchar(64) DEFAULT NULL COMMENT '业务员编号',
+  `total_amount` varchar(64) DEFAULT NULL COMMENT '总计金额',
+  `audit_status` tinyint(1) DEFAULT 1 COMMENT '审核状态(0未审核1已审核)',
+  `offset_status` tinyint(1) DEFAULT 0 COMMENT '冲销状态(0未冲销1已冲销)',
+  `remark` varchar(500) DEFAULT NULL COMMENT '备注',
+  `del_flag` char(1) DEFAULT '0' COMMENT '删除标志(0代表存在 2代表删除)',
+  `create_by` varchar(64) DEFAULT '' COMMENT '创建者',
+  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+  `update_by` varchar(64) DEFAULT '' COMMENT '更新者',
+  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  KEY `idx_customer_num` (`customer_num`),
+  KEY `idx_employee_num` (`employee_num`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='销售出库单';
+
+CREATE TABLE IF NOT EXISTS `sales_delivery_goods` (
+  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `org_id` varchar(64) DEFAULT NULL COMMENT '组织ID',
+  `delivery_num` varchar(64) NOT NULL COMMENT '单据编号',
+  `goods_num` varchar(64) DEFAULT NULL COMMENT '货品编号',
+  `goods_name` varchar(128) DEFAULT NULL COMMENT '货品名称',
+  `warehouse_num` varchar(64) DEFAULT NULL COMMENT '仓库编号',
+  `warehouse_name` varchar(128) DEFAULT NULL COMMENT '仓库名称',
+  `goods_spec` varchar(128) DEFAULT NULL COMMENT '规格',
+  `batch_num` varchar(64) DEFAULT NULL COMMENT '批次',
+  `base_num` varchar(64) DEFAULT NULL COMMENT '基本计量数量',
+  `sales_weight` varchar(64) DEFAULT NULL COMMENT '销售重量',
+  `base_unit` varchar(32) DEFAULT NULL COMMENT '基本计量单位',
+  `ass_num` varchar(64) DEFAULT NULL COMMENT '辅助计量数量',
+  `ass_unit` varchar(32) DEFAULT NULL COMMENT '辅助计量单位',
+  `unit_price` varchar(64) DEFAULT NULL COMMENT '单价',
+  `amount` varchar(64) DEFAULT NULL COMMENT '金额',
+  `goods_remark` varchar(500) DEFAULT NULL COMMENT '货品备注',
+  `del_flag` char(1) DEFAULT '0' COMMENT '删除标志(0代表存在 2代表删除)',
+  `create_by` varchar(64) DEFAULT '' COMMENT '创建者',
+  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+  `update_by` varchar(64) DEFAULT '' COMMENT '更新者',
+  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  KEY `idx_delivery_num` (`delivery_num`),
+  KEY `idx_goods_num` (`goods_num`),
+  KEY `idx_warehouse_num` (`warehouse_num`),
+  KEY `idx_delivery_del` (`delivery_num`,`del_flag`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='销售出库单货品清单';

+ 54 - 0
sql/sales_order.sql

@@ -0,0 +1,54 @@
+-- 销售订单建表脚本(仅表结构)
+
+CREATE TABLE IF NOT EXISTS `sales_order` (
+  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `org_id` varchar(64) DEFAULT NULL COMMENT '组织ID',
+  `document_date` date DEFAULT NULL COMMENT '单据日期',
+  `order_num` varchar(64) NOT NULL COMMENT '单据编号',
+  `sale_date` date DEFAULT NULL COMMENT '销售日期',
+  `customer_num` varchar(64) DEFAULT NULL COMMENT '客户编号',
+  `customer_name` varchar(128) DEFAULT NULL COMMENT '客户名称',
+  `market_num` varchar(64) DEFAULT NULL COMMENT '市场编号',
+  `line_num` varchar(64) DEFAULT NULL COMMENT '物流线编号',
+  `employee_num` varchar(64) DEFAULT NULL COMMENT '业务员编号',
+  `total_amount` varchar(64) DEFAULT NULL COMMENT '总计金额',
+  `remark1` varchar(500) DEFAULT NULL COMMENT '备注1',
+  `audit_status` tinyint(1) DEFAULT 1 COMMENT '审核状态(0未审核1已审核)',
+  `remark` varchar(500) DEFAULT NULL COMMENT '备注',
+  `del_flag` char(1) DEFAULT '0' COMMENT '删除标志(0代表存在 2代表删除)',
+  `create_by` varchar(64) DEFAULT '' COMMENT '创建者',
+  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+  `update_by` varchar(64) DEFAULT '' COMMENT '更新者',
+  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_order_num_del_flag` (`order_num`, `del_flag`),
+  KEY `idx_customer_num` (`customer_num`),
+  KEY `idx_market_num` (`market_num`),
+  KEY `idx_line_num` (`line_num`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='销售订单';
+
+CREATE TABLE IF NOT EXISTS `sales_order_goods` (
+  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `org_id` varchar(64) DEFAULT NULL COMMENT '组织ID',
+  `order_num` varchar(64) NOT NULL COMMENT '单据编号',
+  `goods_num` varchar(64) DEFAULT NULL COMMENT '货品编号',
+  `goods_name` varchar(128) DEFAULT NULL COMMENT '货品名称',
+  `goods_spec` varchar(256) DEFAULT NULL COMMENT '规格',
+  `base_num` varchar(64) DEFAULT NULL COMMENT '基本计量数量',
+  `ass_num` varchar(64) DEFAULT NULL COMMENT '辅助计量数量',
+  `base_unit` varchar(32) DEFAULT NULL COMMENT '基本计量单位',
+  `ass_unit` varchar(32) DEFAULT NULL COMMENT '辅助计量单位',
+  `unit_price` varchar(64) DEFAULT NULL COMMENT '单价',
+  `conversion_value` varchar(64) DEFAULT NULL COMMENT '换算值',
+  `sub_total` varchar(64) DEFAULT NULL COMMENT '小计',
+  `remark` varchar(500) DEFAULT NULL COMMENT '备注',
+  `del_flag` char(1) NOT NULL DEFAULT '0' COMMENT '删除标志(0代表存在 2代表删除)',
+  `create_by` varchar(64) DEFAULT '' COMMENT '创建者',
+  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+  `update_by` varchar(64) DEFAULT '' COMMENT '更新者',
+  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  KEY `idx_order_num` (`order_num`),
+  KEY `idx_goods_num` (`goods_num`),
+  KEY `idx_order_del` (`order_num`, `del_flag`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='销售订单货品清单';

+ 39 - 0
sql/sales_price.sql

@@ -0,0 +1,39 @@
+-- 物料销售价格管理:建表脚本
+
+CREATE TABLE IF NOT EXISTS `sales_price` (
+  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `org_id` varchar(64) DEFAULT NULL COMMENT '组织ID',
+  `price_num` varchar(64) NOT NULL COMMENT '价格单编号',
+  `price_type` varchar(32) NOT NULL DEFAULT '' COMMENT '价格类型',
+  `effective_date` date NOT NULL COMMENT '执行日期',
+  `expire_date` date NOT NULL COMMENT '失效日期',
+  `remark` varchar(500) DEFAULT NULL COMMENT '备注',
+  `del_flag` char(1) DEFAULT '0' COMMENT '删除标志(0代表存在 2代表删除)',
+  `create_by` varchar(64) DEFAULT '' COMMENT '创建者',
+  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+  `update_by` varchar(64) DEFAULT '' COMMENT '更新者',
+  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  KEY `idx_price_date` (`effective_date`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='销售价格主表';
+
+CREATE TABLE IF NOT EXISTS `sales_price_item` (
+  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `org_id` varchar(64) DEFAULT NULL COMMENT '组织ID',
+  `price_num` varchar(64) NOT NULL COMMENT '价格单编号',
+  `material_num` varchar(64) NOT NULL COMMENT '物料编号',
+  `level` varchar(64) DEFAULT NULL COMMENT '级别',
+  `variety` varchar(128) DEFAULT NULL COMMENT '品种',
+  `unit` varchar(32) DEFAULT NULL COMMENT '单位',
+  `unit_price` decimal(18,4) NOT NULL COMMENT '单价',
+  `pickup_price` decimal(18,4) DEFAULT NULL COMMENT '自提价',
+  `remark` varchar(500) DEFAULT NULL COMMENT '备注',
+  `del_flag` char(1) DEFAULT '0' COMMENT '删除标志(0代表存在 2代表删除)',
+  `create_by` varchar(64) DEFAULT '' COMMENT '创建者',
+  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+  `update_by` varchar(64) DEFAULT '' COMMENT '更新者',
+  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  KEY `idx_price_num` (`price_num`),
+  KEY `idx_material_num` (`material_num`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='销售价格明细表';

+ 54 - 0
sql/sales_return.sql

@@ -0,0 +1,54 @@
+-- 销售退回单建表脚本(仅表结构)
+
+CREATE TABLE IF NOT EXISTS `sales_return` (
+  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `org_id` varchar(64) DEFAULT NULL COMMENT '组织ID',
+  `document_date` date DEFAULT NULL COMMENT '单据日期',
+  `return_num` varchar(64) NOT NULL COMMENT '单据编号',
+  `delivery_num` varchar(64) DEFAULT NULL COMMENT '销货转入(销售出库单编号)',
+  `customer_num` varchar(64) DEFAULT NULL COMMENT '客户编号',
+  `receivable_customer` varchar(128) DEFAULT NULL COMMENT '应收客户',
+  `employee_num` varchar(64) DEFAULT NULL COMMENT '业务员编号',
+  `total_amount` varchar(64) DEFAULT NULL COMMENT '总计金额',
+  `audit_status` tinyint(1) DEFAULT 1 COMMENT '审核状态(0未审核1已审核)',
+  `offset_status` tinyint(1) DEFAULT 0 COMMENT '冲销状态(0未冲销1已冲销)',
+  `remark` varchar(500) DEFAULT NULL COMMENT '备注',
+  `del_flag` char(1) DEFAULT '0' COMMENT '删除标志(0代表存在 2代表删除)',
+  `create_by` varchar(64) DEFAULT '' COMMENT '创建者',
+  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+  `update_by` varchar(64) DEFAULT '' COMMENT '更新者',
+  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  KEY `idx_return_num` (`return_num`),
+  KEY `idx_delivery_num` (`delivery_num`),
+  KEY `idx_customer_num` (`customer_num`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='销售退回单';
+
+CREATE TABLE IF NOT EXISTS `sales_return_goods` (
+  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `org_id` varchar(64) DEFAULT NULL COMMENT '组织ID',
+  `return_num` varchar(64) NOT NULL COMMENT '单据编号',
+  `goods_num` varchar(64) DEFAULT NULL COMMENT '货品编号',
+  `goods_name` varchar(128) DEFAULT NULL COMMENT '货品名称',
+  `warehouse_num` varchar(64) DEFAULT NULL COMMENT '仓库编号',
+  `warehouse_name` varchar(128) DEFAULT NULL COMMENT '仓库名称',
+  `goods_spec` varchar(128) DEFAULT NULL COMMENT '规格',
+  `batch_num` varchar(64) DEFAULT NULL COMMENT '批次',
+  `base_num` varchar(64) DEFAULT NULL COMMENT '基本计量数量',
+  `base_unit` varchar(32) DEFAULT NULL COMMENT '基本计量单位',
+  `ass_num` varchar(64) DEFAULT NULL COMMENT '辅助计量数量',
+  `ass_unit` varchar(32) DEFAULT NULL COMMENT '辅助计量单位',
+  `unit_price` varchar(64) DEFAULT NULL COMMENT '单价',
+  `amount` varchar(64) DEFAULT NULL COMMENT '金额',
+  `goods_remark` varchar(500) DEFAULT NULL COMMENT '货品备注',
+  `del_flag` char(1) DEFAULT '0' COMMENT '删除标志(0代表存在 2代表删除)',
+  `create_by` varchar(64) DEFAULT '' COMMENT '创建者',
+  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+  `update_by` varchar(64) DEFAULT '' COMMENT '更新者',
+  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  KEY `idx_return_num` (`return_num`),
+  KEY `idx_goods_num` (`goods_num`),
+  KEY `idx_warehouse_num` (`warehouse_num`),
+  KEY `idx_return_del` (`return_num`,`del_flag`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='销售退回单货品清单';