Compare commits

5 Commits

Author SHA1 Message Date
wuweihua 1d47d5781b Merge branch 'test' of http://git.lionquexpress.com/wuweihua/BMS into test 2026-10-28 22:22:48 +08:00
wuweihua a5e9511857 去掉套餐用户绑定功能逻辑 2026-10-28 22:14:09 +08:00
wuweihua 56340d0ea0 优化套餐新建版本的逻辑 2026-10-28 22:09:30 +08:00
wuweihua 786feec859 Merge branch 'test' of http://git.lionquexpress.com/wuweihua/BMS into test 2026-10-28 21:42:55 +08:00
wuweihua 1eb0e45513 增加建立新版本 2026-10-28 21:42:45 +08:00
22 changed files with 289 additions and 507 deletions
@@ -27,7 +27,6 @@ import com.bms.common.enums.BusinessType;
import com.bms.common.utils.MessageUtils; import com.bms.common.utils.MessageUtils;
import com.bms.common.utils.StringUtils; import com.bms.common.utils.StringUtils;
import com.bms.price.domain.PrcCustomerPriceSet; import com.bms.price.domain.PrcCustomerPriceSet;
import com.bms.price.domain.PrcPriceSetUser;
import com.bms.price.domain.PrcPriceExcelFile; import com.bms.price.domain.PrcPriceExcelFile;
import com.bms.price.domain.PrcPriceSet; import com.bms.price.domain.PrcPriceSet;
import com.bms.price.domain.PrcPriceSetDetail; import com.bms.price.domain.PrcPriceSetDetail;
@@ -388,29 +387,4 @@ public class PriceSetController extends BaseController
return toAjax(priceSetService.deleteCustomerByIds(customerPricesetIds)); return toAjax(priceSetService.deleteCustomerByIds(customerPricesetIds));
} }
@PreAuthorize("@ss.hasPermi('price:set:list')")
@GetMapping("/viewer/list")
public TableDataInfo viewerList(PrcPriceSetUser query)
{
startPage();
List<PrcPriceSetUser> list = priceSetService.selectViewerList(query);
return getDataTable(list);
}
@PreAuthorize("@ss.hasPermi('price:set:edit')")
@Log(title = "Package viewer bind", businessType = BusinessType.INSERT)
@PostMapping("/viewer")
public AjaxResult addViewer(@RequestBody PrcPriceSetUser row)
{
row.setCreateBy(getUsername());
return toAjax(priceSetService.insertViewer(row));
}
@PreAuthorize("@ss.hasPermi('price:set:edit')")
@Log(title = "Package viewer bind", businessType = BusinessType.DELETE)
@DeleteMapping("/viewer/{psuIds}")
public AjaxResult removeViewer(@PathVariable Long[] psuIds)
{
return toAjax(priceSetService.deleteViewerByIds(psuIds));
}
} }
@@ -205,6 +205,7 @@ price.bill.fee.idRequired=费用ID不能为空
price.set.version.effectiveTime.empty=版本生效时间不能为空 price.set.version.effectiveTime.empty=版本生效时间不能为空
price.set.version.effectiveTime.beforePrev=生效时间不能早于上一版本的生效时间 price.set.version.effectiveTime.beforePrev=生效时间不能早于上一版本的生效时间
price.set.version.effectiveTime.hitBoundary=生效时间不能与已有版本的生效时间或失效时间相同
price.set.version.notExists=套餐版本不存在 price.set.version.notExists=套餐版本不存在
price.set.version.activate.statusNotAllowed=仅草稿或待生效版本可设置生效时间 price.set.version.activate.statusNotAllowed=仅草稿或待生效版本可设置生效时间
price.set.version.delete.statusNotAllowed=仅草稿、待生效或已生效版本可删除 price.set.version.delete.statusNotAllowed=仅草稿、待生效或已生效版本可删除
@@ -42,8 +42,6 @@ public class PrcPriceSet extends BaseEntity
private List<PrcPriceSetDetail> details; private List<PrcPriceSetDetail> details;
private List<PrcPriceSetVersion> versions; private List<PrcPriceSetVersion> versions;
private List<PrcCustomerPriceSet> customers; private List<PrcCustomerPriceSet> customers;
/** viewers explicitly bound to this package */
private List<PrcPriceSetUser> viewers;
/** async save task id (not persisted) */ /** async save task id (not persisted) */
private String saveTaskId; private String saveTaskId;
/** Y: ignored on save; library pull only via /detail/pullFromLibrary */ /** Y: ignored on save; library pull only via /detail/pullFromLibrary */
@@ -1,21 +0,0 @@
package com.bms.price.domain;
import com.bms.common.core.domain.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
/** 套餐可查看人绑定:prc_price_set_user */
@Data
@EqualsAndHashCode(callSuper = true)
public class PrcPriceSetUser extends BaseEntity
{
private static final long serialVersionUID = 1L;
private Long psuId;
private Long priceSetId;
private Long userId;
/** login account */
private String userName;
/** display only, not a table column */
private String nickName;
}
@@ -29,7 +29,10 @@ public class PrcPriceSetVersion extends BaseEntity
/** 0 draft / 3 pending / 1 active / 2 expired */ /** 0 draft / 3 pending / 1 active / 2 expired */
private String status; private String status;
/** /**
* Create-price mode for this version: PULL = from product library, IMPORT = Excel. * Create-price mode for this version:
* PULL = from product library,
* IMPORT = Excel with prior version preload,
* IMPORT_FRESH = Excel without prior version data.
* Column: price_build_mode * Column: price_build_mode
*/ */
private String priceBuildMode; private String priceBuildMode;
@@ -1,20 +0,0 @@
package com.bms.price.mapper;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.bms.price.domain.PrcPriceSetUser;
public interface PrcPriceSetUserMapper
{
PrcPriceSetUser selectPrcPriceSetUserById(Long psuId);
List<PrcPriceSetUser> selectPrcPriceSetUserList(PrcPriceSetUser query);
int insertPrcPriceSetUser(PrcPriceSetUser row);
int deletePrcPriceSetUserByIds(Long[] psuIds);
int deleteByPriceSetId(Long priceSetId);
int countBySetAndUser(@Param("priceSetId") Long priceSetId, @Param("userId") Long userId);
}
@@ -9,7 +9,6 @@ import com.bms.price.domain.PrcPriceSet;
import com.bms.price.domain.PrcPriceSetCheckItem; import com.bms.price.domain.PrcPriceSetCheckItem;
import com.bms.price.domain.PrcPriceSetDetail; import com.bms.price.domain.PrcPriceSetDetail;
import com.bms.price.domain.PrcPriceSetSaveTask; import com.bms.price.domain.PrcPriceSetSaveTask;
import com.bms.price.domain.PrcPriceSetUser;
/** /**
* 价格套餐 服务层 * 价格套餐 服务层
@@ -92,12 +91,6 @@ public interface IPrcPriceSetService
public int deleteCustomerByIds(Long[] customerPricesetIds); public int deleteCustomerByIds(Long[] customerPricesetIds);
public List<PrcPriceSetUser> selectViewerList(PrcPriceSetUser query);
public int insertViewer(PrcPriceSetUser row);
public int deleteViewerByIds(Long[] psuIds);
/** chain expire_time = next version effective_time, then refresh 0/3/1/2 */ /** chain expire_time = next version effective_time, then refresh 0/3/1/2 */
public int refreshAllVersionStatus(); public int refreshAllVersionStatus();
@@ -30,7 +30,6 @@ import com.bms.price.domain.BmsProductCountry;
import com.bms.price.domain.PrcCustomerPriceSet; import com.bms.price.domain.PrcCustomerPriceSet;
import com.bms.price.domain.PrcPriceExcelFile; import com.bms.price.domain.PrcPriceExcelFile;
import com.bms.price.domain.PrcPriceSet; import com.bms.price.domain.PrcPriceSet;
import com.bms.price.domain.PrcPriceSetUser;
import com.bms.price.domain.PrcPriceSetCheckItem; import com.bms.price.domain.PrcPriceSetCheckItem;
import com.bms.price.domain.PrcPriceSetDetail; import com.bms.price.domain.PrcPriceSetDetail;
import com.bms.price.domain.PrcPriceSetProduct; import com.bms.price.domain.PrcPriceSetProduct;
@@ -44,12 +43,9 @@ import com.bms.price.mapper.PrcCustomerPriceSetMapper;
import com.bms.price.mapper.PrcPriceSetDetailMapper; import com.bms.price.mapper.PrcPriceSetDetailMapper;
import com.bms.price.mapper.PrcPriceSetMapper; import com.bms.price.mapper.PrcPriceSetMapper;
import com.bms.price.mapper.PrcPriceSetProductMapper; import com.bms.price.mapper.PrcPriceSetProductMapper;
import com.bms.price.mapper.PrcPriceSetUserMapper;
import com.bms.price.mapper.PrcPriceSetVersionMapper; import com.bms.price.mapper.PrcPriceSetVersionMapper;
import com.bms.price.mapper.PrcSurchargeMapper; import com.bms.price.mapper.PrcSurchargeMapper;
import com.bms.price.mapper.PrcSurchargeRelMapper; import com.bms.price.mapper.PrcSurchargeRelMapper;
import com.bms.common.core.domain.entity.SysUser;
import com.bms.system.mapper.SysUserMapper;
import com.bms.price.service.IBmsProductService; import com.bms.price.service.IBmsProductService;
import com.bms.price.search.IBillSearchStore; import com.bms.price.search.IBillSearchStore;
import com.bms.price.search.PriceChangeEsLog; import com.bms.price.search.PriceChangeEsLog;
@@ -105,12 +101,6 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
@Autowired @Autowired
private PrcCustomerPriceSetMapper customerMapper; private PrcCustomerPriceSetMapper customerMapper;
@Autowired
private PrcPriceSetUserMapper priceSetUserMapper;
@Autowired
private SysUserMapper sysUserMapper;
@Autowired @Autowired
private BmsProductCountryMapper productCountryMapper; private BmsProductCountryMapper productCountryMapper;
@@ -149,18 +139,14 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
} }
/** /**
* Price-set list data scope: bound customer's sales user (same as customer list), * Price-set list data scope: bound customer's sales user (same as customer list)
* package creator, or explicit viewer bind. Super admin skips filter in DataScopeAspect. * or package creator. Super admin skips filter in DataScopeAspect.
*/ */
@Override @Override
@DataScope(deptAlias = "su", deptField = "dept_id", userAlias = "su", userField = "user_id", @DataScope(deptAlias = "su", deptField = "dept_id", userAlias = "su", userField = "user_id",
permission = "price:set:list") permission = "price:set:list")
public List<PrcPriceSet> selectPrcPriceSetList(PrcPriceSet set) public List<PrcPriceSet> selectPrcPriceSetList(PrcPriceSet set)
{ {
if (set != null)
{
set.getParams().put("viewerUserId", SecurityUtils.getUserId());
}
List<PrcPriceSet> list = setMapper.selectPrcPriceSetList(set); List<PrcPriceSet> list = setMapper.selectPrcPriceSetList(set);
for (PrcPriceSet item : list) for (PrcPriceSet item : list)
{ {
@@ -401,10 +387,20 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
{ {
throw new ServiceException(MessageUtils.message("price.set.edit.stopped")); throw new ServiceException(MessageUtils.message("price.set.edit.stopped"));
} }
Date prevEffective = findPrevVersionEffectiveTime(version); boolean importFresh = "IMPORT_FRESH".equalsIgnoreCase(StringUtils.trim(version.getPriceBuildMode()));
if (prevEffective != null && effectiveTime.before(prevEffective)) if (importFresh)
{ {
throw new ServiceException(MessageUtils.message("price.set.version.effectiveTime.beforePrev")); // May start inside a prior version window; forbid equal to any other version's eff/exp.
// refreshVersionStatus then sets B.expire=A.eff, A.expire=B.oldExpire and syncs children.
validateImportFreshActivateTime(version, effectiveTime);
}
else
{
Date prevEffective = findPrevVersionEffectiveTime(version);
if (prevEffective != null && effectiveTime.before(prevEffective))
{
throw new ServiceException(MessageUtils.message("price.set.version.effectiveTime.beforePrev"));
}
} }
// Set schedule; refreshVersionStatus decides PENDING(3) vs EFFECT(1) and chains expire_time // Set schedule; refreshVersionStatus decides PENDING(3) vs EFFECT(1) and chains expire_time
version.setEffectiveTime(effectiveTime); version.setEffectiveTime(effectiveTime);
@@ -415,6 +411,41 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
return 1; return 1;
} }
/**
* IMPORT_FRESH activate: allow effective time earlier than prev-by-seq (incl. inside prior window B).
* Must not equal any other timed version's effectiveTime or expireTime.
*/
private void validateImportFreshActivateTime(PrcPriceSetVersion version, Date effectiveTime)
{
if (version == null || version.getPriceSetId() == null || effectiveTime == null)
{
return;
}
PrcPriceSetVersion q = new PrcPriceSetVersion();
q.setPriceSetId(version.getPriceSetId());
q.setPriceType(com.bms.price.domain.PriceType.SALES);
List<PrcPriceSetVersion> list = versionMapper.selectPrcPriceSetVersionList(q);
if (list == null || list.isEmpty())
{
return;
}
for (PrcPriceSetVersion v : list)
{
if (v == null)
{
continue;
}
if (version.getPsvId() != null && version.getPsvId().equals(v.getPsvId()))
{
continue;
}
if (sameTime(v.getEffectiveTime(), effectiveTime) || sameTime(v.getExpireTime(), effectiveTime))
{
throw new ServiceException(MessageUtils.message("price.set.version.effectiveTime.hitBoundary"));
}
}
}
/** Previous version (lower version_seq) effective_time; null if none. */ /** Previous version (lower version_seq) effective_time; null if none. */
private Date findPrevVersionEffectiveTime(PrcPriceSetVersion version) private Date findPrevVersionEffectiveTime(PrcPriceSetVersion version)
{ {
@@ -2423,49 +2454,6 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
return customerMapper.deletePrcCustomerPriceSetByIds(customerPricesetIds); return customerMapper.deletePrcCustomerPriceSetByIds(customerPricesetIds);
} }
@Override
public List<PrcPriceSetUser> selectViewerList(PrcPriceSetUser query)
{
return priceSetUserMapper.selectPrcPriceSetUserList(query);
}
@Override
@Transactional
public int insertViewer(PrcPriceSetUser row)
{
if (row == null || row.getPriceSetId() == null || row.getUserId() == null)
{
throw new ServiceException("price set user bind requires priceSetId and userId");
}
requireSet(row.getPriceSetId());
if (priceSetUserMapper.countBySetAndUser(row.getPriceSetId(), row.getUserId()) > 0)
{
throw new ServiceException("user already bound to this price set");
}
SysUser user = sysUserMapper.selectUserById(row.getUserId());
if (user == null)
{
throw new ServiceException("user not found");
}
row.setUserName(user.getUserName());
if (StringUtils.isEmpty(row.getCreateBy()))
{
row.setCreateBy(SecurityUtils.getUsername());
}
return priceSetUserMapper.insertPrcPriceSetUser(row);
}
@Override
@Transactional
public int deleteViewerByIds(Long[] psuIds)
{
if (psuIds == null || psuIds.length == 0)
{
return 0;
}
return priceSetUserMapper.deletePrcPriceSetUserByIds(psuIds);
}
private void replaceChildren(PrcPriceSet set) private void replaceChildren(PrcPriceSet set)
{ {
saveOrUpdateVersion(set); saveOrUpdateVersion(set);
@@ -3232,9 +3220,6 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
PrcCustomerPriceSet cquery = new PrcCustomerPriceSet(); PrcCustomerPriceSet cquery = new PrcCustomerPriceSet();
cquery.setPriceSetId(priceSetId); cquery.setPriceSetId(priceSetId);
set.setCustomers(customerMapper.selectPrcCustomerPriceSetList(cquery)); set.setCustomers(customerMapper.selectPrcCustomerPriceSetList(cquery));
PrcPriceSetUser uquery = new PrcPriceSetUser();
uquery.setPriceSetId(priceSetId);
set.setViewers(priceSetUserMapper.selectPrcPriceSetUserList(uquery));
} }
private void fillVersionChildren(PrcPriceSetVersion version) private void fillVersionChildren(PrcPriceSetVersion version)
@@ -3361,9 +3346,10 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
} }
/** /**
* Draft only: when priceBuildMode changes (PULL/IMPORT), delete this version's * Draft only: when priceBuildMode changes (PULL/IMPORT/IMPORT_FRESH), delete this version's
* product / detail / SALES surcharge rel rows. Same mode or non-draft: no-op. * product / detail / SALES surcharge rel rows. Same mode or non-draft: no-op.
* IMPORT: after wipe, copy products+details from latest prior version (if any). * IMPORT: after wipe, copy products+details from latest prior version (if any).
* IMPORT_FRESH: wipe only, do not copy prior version (blank Excel build).
*/ */
private void clearDraftVersionChildrenIfBuildModeChanged(PrcPriceSetVersion db, PrcPriceSetVersion incoming) private void clearDraftVersionChildrenIfBuildModeChanged(PrcPriceSetVersion db, PrcPriceSetVersion incoming)
{ {
@@ -3387,6 +3373,7 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
} }
Long psvId = db.getPsvId(); Long psvId = db.getPsvId();
wipeVersionChildren(psvId, db.getStatus()); wipeVersionChildren(psvId, db.getStatus());
// IMPORT inherits prior version; IMPORT_FRESH starts empty for Excel import
if ("IMPORT".equalsIgnoreCase(newMode)) if ("IMPORT".equalsIgnoreCase(newMode))
{ {
copyLatestPriorVersionChildren(db.getPriceSetId(), psvId); copyLatestPriorVersionChildren(db.getPriceSetId(), psvId);
@@ -104,14 +104,6 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
AND su.user_name = s.created_by COLLATE utf8mb4_general_ci AND su.user_name = s.created_by COLLATE utf8mb4_general_ci
${params.dataScope} ${params.dataScope}
) )
<if test="params.viewerUserId != null">
OR EXISTS (
SELECT 1
FROM prc_price_set_user psu
WHERE psu.price_set_id = s.price_set_id
AND psu.user_id = #{params.viewerUserId}
)
</if>
) )
</if> </if>
</where> </where>
@@ -1,56 +0,0 @@
<?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.bms.price.mapper.PrcPriceSetUserMapper">
<resultMap type="PrcPriceSetUser" id="PrcPriceSetUserResult">
<id property="psuId" column="psu_id" />
<result property="priceSetId" column="price_set_id" />
<result property="userId" column="user_id" />
<result property="userName" column="user_name" />
<result property="nickName" column="nick_name" />
<result property="createBy" column="created_by" />
<result property="createTime" column="create_time" />
</resultMap>
<sql id="selectVo">
select psu.psu_id, psu.price_set_id, psu.user_id, psu.user_name,
u.nick_name, psu.created_by, psu.create_time
from prc_price_set_user psu
left join sys_user u on u.user_id = psu.user_id
</sql>
<select id="selectPrcPriceSetUserById" parameterType="Long" resultMap="PrcPriceSetUserResult">
<include refid="selectVo"/>
where psu.psu_id = #{psuId}
</select>
<select id="selectPrcPriceSetUserList" parameterType="PrcPriceSetUser" resultMap="PrcPriceSetUserResult">
<include refid="selectVo"/>
<where>
<if test="priceSetId != null">AND psu.price_set_id = #{priceSetId}</if>
<if test="userId != null">AND psu.user_id = #{userId}</if>
<if test="userName != null and userName != ''">AND psu.user_name = #{userName}</if>
</where>
order by psu.psu_id asc
</select>
<select id="countBySetAndUser" resultType="int">
select count(1) from prc_price_set_user
where price_set_id = #{priceSetId} and user_id = #{userId}
</select>
<insert id="insertPrcPriceSetUser" parameterType="PrcPriceSetUser" useGeneratedKeys="true" keyProperty="psuId">
insert into prc_price_set_user (price_set_id, user_id, user_name, created_by, create_time)
values (#{priceSetId}, #{userId}, #{userName}, #{createBy}, sysdate())
</insert>
<delete id="deletePrcPriceSetUserByIds" parameterType="Long">
delete from prc_price_set_user where psu_id in
<foreach collection="array" item="id" open="(" separator="," close=")">#{id}</foreach>
</delete>
<delete id="deleteByPriceSetId" parameterType="Long">
delete from prc_price_set_user where price_set_id = #{priceSetId}
</delete>
</mapper>
+1 -18
View File
@@ -807,23 +807,6 @@ CREATE TABLE `prc_customer_price_set` (
CONSTRAINT `fk_customer_price_set` FOREIGN KEY (`price_set_id`) REFERENCES `prc_price_set` (`price_set_id`) CONSTRAINT `fk_customer_price_set` FOREIGN KEY (`price_set_id`) REFERENCES `prc_price_set` (`price_set_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='客户套餐表-客户某时间段内使用的套餐'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='客户套餐表-客户某时间段内使用的套餐';
-- ----------------------------
-- 表结构:`prc_price_set_user`
-- ----------------------------
CREATE TABLE `prc_price_set_user` (
`psu_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
`price_set_id` bigint(20) NOT NULL COMMENT '价格套餐id',
`user_id` bigint(20) NOT NULL COMMENT '用户id',
`user_name` varchar(64) NOT NULL DEFAULT '' COMMENT '登录账号',
`created_by` varchar(64) DEFAULT '' COMMENT '创建人',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (`psu_id`),
UNIQUE KEY `uk_psu_set_user` (`price_set_id`,`user_id`),
KEY `idx_psu_user` (`user_id`),
KEY `idx_psu_set` (`price_set_id`),
CONSTRAINT `fk_psu_set` FOREIGN KEY (`price_set_id`) REFERENCES `prc_price_set` (`price_set_id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='套餐可见用户绑定';
-- ---------------------------- -- ----------------------------
-- 表结构:`prc_income_bill` -- 表结构:`prc_income_bill`
-- ---------------------------- -- ----------------------------
@@ -1315,7 +1298,7 @@ CREATE TABLE `prc_price_set_version` (
`effective_time` datetime DEFAULT NULL COMMENT '生效时间', `effective_time` datetime DEFAULT NULL COMMENT '生效时间',
`expire_time` datetime DEFAULT NULL COMMENT '失效时间(空=长期;下一版本启动时写入该启动时间)', `expire_time` datetime DEFAULT NULL COMMENT '失效时间(空=长期;下一版本启动时写入该启动时间)',
`status` char(1) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '0' COMMENT '0 draft 3 pending 1 effect 2 expired', `status` char(1) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '0' COMMENT '0 draft 3 pending 1 effect 2 expired',
`price_build_mode` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建价格方式 PULL=拉取产品库 IMPORT=Excel导入', `price_build_mode` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建价格方式 PULL=拉取产品库 IMPORT=Excel继承上一版 IMPORT_FRESH=Excel重新建价',
`is_del` char(1) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '0' COMMENT '是否删除 0未删除 1已删除', `is_del` char(1) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '0' COMMENT '是否删除 0未删除 1已删除',
`created_by` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '创建人', `created_by` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '创建人',
`create_time` datetime DEFAULT NULL COMMENT '创建时间', `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+2 -14
View File
@@ -1,16 +1,4 @@
-- Price set viewer bind -- Drop package viewer bind table (feature removed)
SET NAMES utf8mb4; SET NAMES utf8mb4;
CREATE TABLE IF NOT EXISTS prc_price_set_user ( DROP TABLE IF EXISTS prc_price_set_user;
psu_id bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
price_set_id bigint(20) NOT NULL COMMENT '价格套餐id',
user_id bigint(20) NOT NULL COMMENT '用户id',
user_name varchar(64) NOT NULL DEFAULT '' COMMENT '登录账号',
created_by varchar(64) DEFAULT '' COMMENT '创建人',
create_time datetime DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (psu_id),
UNIQUE KEY uk_psu_set_user (price_set_id, user_id),
KEY idx_psu_user (user_id),
KEY idx_psu_set (price_set_id),
CONSTRAINT fk_psu_set FOREIGN KEY (price_set_id) REFERENCES prc_price_set (price_set_id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='套餐可见用户绑定';
@@ -1,3 +1,3 @@
-- Add create-price mode on package version: PULL / IMPORT -- Add create-price mode on package version: PULL / IMPORT
ALTER TABLE prc_price_set_version ALTER TABLE prc_price_set_version
ADD COLUMN price_build_mode varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建价格方式 PULL=拉取产品库 IMPORT=Excel导入' AFTER status; ADD COLUMN price_build_mode varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建价格方式 PULL=拉取产品库 IMPORT=Excel继承上一版 IMPORT_FRESH=Excel重新建价' AFTER status;
-23
View File
@@ -412,26 +412,3 @@ export function delSetCustomer(ids) {
method: 'delete' method: 'delete'
}) })
} }
export function listSetViewer(query) {
return request({
url: '/price/set/viewer/list',
method: 'get',
params: query
})
}
export function addSetViewer(data) {
return request({
url: '/price/set/viewer',
method: 'post',
data: data
})
}
export function delSetViewer(ids) {
return request({
url: '/price/set/viewer/' + ids,
method: 'delete'
})
}
@@ -201,6 +201,9 @@ async function finish() {
// Backend copies latest prior version products/details into this draft // Backend copies latest prior version products/details into this draft
await reloadProductsAfterImportModeSave() await reloadProductsAfterImportModeSave()
proxy.$modal.msgSuccess("建价方式已保存,已带出上一版产品,可导入 Excel 按产品+国家覆盖或新增") proxy.$modal.msgSuccess("建价方式已保存,已带出上一版产品,可导入 Excel 按产品+国家覆盖或新增")
} else if (version.priceBuildMode === 'IMPORT_FRESH') {
savedProductsSnapshot.value = (products.value || []).map(p => ({ ...p }))
proxy.$modal.msgSuccess("建价方式已保存并清空旧明细,可直接导入 Excel 建价(不延用以前的产品价格)")
} else { } else {
savedProductsSnapshot.value = (products.value || []).map(p => ({ ...p })) savedProductsSnapshot.value = (products.value || []).map(p => ({ ...p }))
proxy.$modal.msgSuccess("建价方式已保存并清空旧明细,可导入 Excel 建价") proxy.$modal.msgSuccess("建价方式已保存并清空旧明细,可导入 Excel 建价")
@@ -51,21 +51,32 @@
type="button" type="button"
class="build-card build-card--pull" class="build-card build-card--pull"
:class="{ selected: isPullBuildMode(priceBuildMode) }" :class="{ selected: isPullBuildMode(priceBuildMode) }"
:disabled="buildModeLocked || choosingMode" :disabled="!canChooseBuildMode"
@click="chooseBuildMode('PULL')" @click="chooseBuildMode('PULL')"
> >
<div class="build-card__title">按产品价建价</div> <div class="build-card__title">按产品价建价</div>
<div class="build-card__desc">按产品库基础价生成套餐产品列表,可再导入 Excel 修改价格</div> <div class="build-card__desc">按产品库基础价生成套餐产品列表,可再导入 Excel 修改价格</div>
</button> </button>
<button <button
v-if="showImportInherit"
type="button" type="button"
class="build-card build-card--import" class="build-card build-card--import"
:class="{ selected: isImportBuildMode(priceBuildMode) }" :class="{ selected: isImportBuildMode(priceBuildMode) }"
:disabled="buildModeLocked || !isFixedType(setType) || choosingMode" :disabled="!canChooseBuildMode || !isFixedType(setType)"
@click="chooseBuildMode('IMPORT')" @click="chooseBuildMode('IMPORT')"
> >
<div class="build-card__title">Excel 导入建价</div> <div class="build-card__title">按上一版 Excel 建价</div>
<div class="build-card__desc">上传威狮 B2C 小包价表,直接生成套餐销售价</div> <div class="build-card__desc">延用以前的产品价格,带出上一版后再导入 Excel 按产品+国家覆盖或新增</div>
</button>
<button
type="button"
class="build-card build-card--import-fresh"
:class="{ selected: isImportFreshBuildMode(priceBuildMode) }"
:disabled="!canChooseBuildMode || !isFixedType(setType)"
@click="chooseBuildMode('IMPORT_FRESH')"
>
<div class="build-card__title">Excel 重新建价</div>
<div class="build-card__desc">不延用以前的产品价格,不带出上一版,上传 Excel 直接生成套餐销售价</div>
</button> </button>
</div> </div>
</div> </div>
@@ -207,7 +218,9 @@ import {
isBuildModeLockedVersion, isBuildModeLockedVersion,
isDiscountType, isDiscountType,
isFixedType, isFixedType,
isExcelBuildMode,
isImportBuildMode, isImportBuildMode,
isImportFreshBuildMode,
isPullBuildMode, isPullBuildMode,
pickLatestPriorVersion, pickLatestPriorVersion,
setTypeLabel, setTypeLabel,
@@ -233,7 +246,14 @@ const props = defineProps({
/** Version status: draft can change build mode; started versions lock it */ /** Version status: draft can change build mode; started versions lock it */
versionStatus: { type: [String, Number], default: undefined }, versionStatus: { type: [String, Number], default: undefined },
/** True when build mode changed locally and not saved yet */ /** True when build mode changed locally and not saved yet */
buildModeDirty: { type: Boolean, default: false } buildModeDirty: { type: Boolean, default: false },
/** False while parent still loading package data — avoid first click being wiped by applySetData */
buildModeReady: { type: Boolean, default: true },
/**
* Show「按上一版 Excel 建价」(IMPORT). Hidden for brand-new package create
* (no prior version); keep for newVersion / copy / edit.
*/
showImportInherit: { type: Boolean, default: true }
}) })
const emit = defineEmits(['update:psvId', 'update:priceBuildMode', 'update:pefId']) const emit = defineEmits(['update:psvId', 'update:priceBuildMode', 'update:pefId'])
@@ -279,7 +299,7 @@ const regionOptions = ref([])
const countryImportDesc = [ const countryImportDesc = [
'选择目的国家后上传专线成本价表(含「产品目录」),目录中的产品都会记到该国家。', '选择目的国家后上传专线成本价表(含「产品目录」),目录中的产品都会记到该国家。',
'按产品+国家覆盖:仅替换已选国家的明细,其它产品/国家保留。', '按产品+国家覆盖:仅替换已选国家的明细,其它产品/国家保留。',
'邮编、赔偿标准等 sheet 不导入。仅套餐「Excel 导入建价」可用。' '邮编、赔偿标准等 sheet 不导入。仅套餐 Excel 建价方式可用。'
] ]
const importProgressVisible = ref(false) const importProgressVisible = ref(false)
const importProgressPercent = ref(0) const importProgressPercent = ref(0)
@@ -287,16 +307,19 @@ const importProgressMessage = ref('')
const importProgressStatus = ref(undefined) const importProgressStatus = ref(undefined)
const progressDialogTitle = ref('正在导入价格') const progressDialogTitle = ref('正在导入价格')
const hasBuildMode = computed(() => { const hasBuildMode = computed(() => {
if (isPullBuildMode(props.priceBuildMode) || isImportBuildMode(props.priceBuildMode)) return true if (isPullBuildMode(props.priceBuildMode) || isExcelBuildMode(props.priceBuildMode)) return true
// Legacy / already has products: treat as build mode ready // Legacy / already has products: treat as build mode ready
return (props.products || []).some(p => p && p.productCode) return (props.products || []).some(p => p && p.productCode)
}) })
/** Started (non-draft) versions: show cards but disable switching */ /** Started (non-draft) versions: show cards but disable switching */
const buildModeLocked = computed(() => isBuildModeLockedVersion(props.versionStatus)) const buildModeLocked = computed(() => isBuildModeLockedVersion(props.versionStatus))
const choosingMode = ref(false) const choosingMode = ref(false)
const canChooseBuildMode = computed(() =>
!!props.buildModeReady && !buildModeLocked.value && !choosingMode.value
)
const isModifyImport = computed(() => { const isModifyImport = computed(() => {
if (isPullBuildMode(props.priceBuildMode)) return true if (isPullBuildMode(props.priceBuildMode)) return true
if (isImportBuildMode(props.priceBuildMode)) return false if (isExcelBuildMode(props.priceBuildMode)) return false
return (props.products || []).some(p => p && p.productCode) return (props.products || []).some(p => p && p.productCode)
}) })
const importActionLabel = computed(() => const importActionLabel = computed(() =>
@@ -311,27 +334,36 @@ const importDesc = computed(() => {
'仅固定价套餐可导入;未匹配行不会新增明细。' '仅固定价套餐可导入;未匹配行不会新增明细。'
] ]
} }
if (isImportFreshBuildMode(props.priceBuildMode)) {
return [
'适用威狮B2C小包价表(含「产品目录」)。',
'不延用以前的产品价格;导入按「产品+国家」写入套餐销售价,直接生成产品明细。',
'同一产品下重复国家+重量段将整单失败。仅固定价套餐「Excel 重新建价」可用。'
]
}
return [ return [
'适用威狮B2C小包价表(含「产品目录」)。', '适用威狮B2C小包价表(含「产品目录」)。',
'先带出上一版全部产品;导入按「产品+国家」匹配:命中则覆盖该国家明细,未命中则新增;其它产品/国家保留。', '延用以前的产品价格:先带出上一版全部产品;导入按「产品+国家」匹配:命中则覆盖该国家明细,未命中则新增;其它产品/国家保留。',
'同一产品下重复国家+重量段将整单失败。仅固定价套餐「导入 Excel 建价」可用。' '同一产品下重复国家+重量段将整单失败。仅固定价套餐「按上一版 Excel 建价」可用。'
] ]
}) })
const typeHint = computed(() => { const typeHint = computed(() => {
if (isPullBuildMode(props.priceBuildMode)) return '· 按产品价建价' if (isPullBuildMode(props.priceBuildMode)) return '· 按产品价建价'
if (isImportBuildMode(props.priceBuildMode)) return '· Excel 导入' if (isImportBuildMode(props.priceBuildMode)) return '· 按上一版 Excel 建价'
if (isImportFreshBuildMode(props.priceBuildMode)) return '· Excel 重新建价'
if (hasBuildMode.value) return '· 当前:' + setTypeLabel(props.setType) if (hasBuildMode.value) return '· 当前:' + setTypeLabel(props.setType)
return '· 请选择创建价格方式' return '· 请选择创建价格方式'
}) })
// PULL: hide pull btn (already pulled), show modify-import. // PULL: hide pull btn (already pulled), show modify-import.
// IMPORT: show create-import. // IMPORT / IMPORT_FRESH: show create-import.
const showPullAction = computed(() => false) const showPullAction = computed(() => false)
const showImportAction = computed(() => hasBuildMode.value && isFixedType(props.setType)) const showImportAction = computed(() => hasBuildMode.value && isFixedType(props.setType))
const emptyTip = computed(() => { const emptyTip = computed(() => {
if (props.livePersist) return '暂无产品' if (props.livePersist) return '暂无产品'
if (isPullBuildMode(props.priceBuildMode)) return '暂无产品,请先返回上一步重新拉取或等待拉取完成' if (isPullBuildMode(props.priceBuildMode)) return '暂无产品,请先返回上一步重新拉取或等待拉取完成'
if (isImportBuildMode(props.priceBuildMode)) return '暂无上一版产品,请点击右上角导入 Excel 建价(按产品+国家覆盖或新增)' if (isImportBuildMode(props.priceBuildMode)) return '暂无上一版产品,请点击右上角导入 Excel 建价(按产品+国家覆盖或新增)'
if (isImportFreshBuildMode(props.priceBuildMode)) return '暂无产品,请点击右上角导入 Excel 建价'
return '暂无产品,请先选择创建价格方式' return '暂无产品,请先选择创建价格方式'
}) })
@@ -467,7 +499,7 @@ const pulling = ref(false)
function noop() {} function noop() {}
async function chooseBuildMode(mode) { async function chooseBuildMode(mode) {
if (!mode || choosingMode.value || buildModeLocked.value) return if (!mode || !canChooseBuildMode.value) return
if (mode === props.priceBuildMode) return if (mode === props.priceBuildMode) return
choosingMode.value = true choosingMode.value = true
try { try {
@@ -1103,6 +1135,7 @@ defineExpose({ pullAllFromLibrary, clearLocalDraft, loadLatestPriorVersionDraft
} }
.build-card--pull:hover:not(:disabled) { border-color: #67c23a; } .build-card--pull:hover:not(:disabled) { border-color: #67c23a; }
.build-card--import:hover:not(:disabled) { border-color: #409eff; } .build-card--import:hover:not(:disabled) { border-color: #409eff; }
.build-card--import-fresh:hover:not(:disabled) { border-color: #e6a23c; }
.build-card__title { .build-card__title {
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
@@ -1111,6 +1144,7 @@ defineExpose({ pullAllFromLibrary, clearLocalDraft, loadLatestPriorVersionDraft
} }
.build-card--pull .build-card__title { color: #67c23a; } .build-card--pull .build-card__title { color: #67c23a; }
.build-card--import .build-card__title { color: #409eff; } .build-card--import .build-card__title { color: #409eff; }
.build-card--import-fresh .build-card__title { color: #e6a23c; }
.build-card__desc { .build-card__desc {
font-size: 13px; font-size: 13px;
line-height: 1.6; line-height: 1.6;
@@ -1,174 +0,0 @@
<template>
<set-section-card
title="绑定用户"
tone="orange"
:count="viewers.length"
sub="指定用户可查看本套餐(不受销售数据权限限制)"
>
<template #action>
<el-button
type="primary"
plain
icon="Plus"
size="small"
v-hasPermi="['price:set:edit']"
@click="openBind"
>绑定用户</el-button>
</template>
<el-table :data="viewers" border class="user-table" empty-text="暂未绑定用户">
<el-table-column label="账号" prop="userName" min-width="140" show-overflow-tooltip>
<template #default="{ row }">
<span class="u-code">{{ row.userName }}</span>
</template>
</el-table-column>
<el-table-column label="姓名" prop="nickName" min-width="140" show-overflow-tooltip>
<template #default="{ row }">
{{ row.nickName || '-' }}
</template>
</el-table-column>
<el-table-column label="操作" width="100" align="center" fixed="right">
<template #default="{ row }">
<el-button
link
type="danger"
v-hasPermi="['price:set:edit']"
@click="onUnbind(row)"
>解绑</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog
v-model="bindVisible"
title="绑定用户"
width="480px"
append-to-body
@closed="onClosed"
>
<el-form label-width="80px">
<el-form-item label="用户" required>
<el-select
v-model="selectedUserIds"
multiple
filterable
collapse-tags
collapse-tags-tooltip
placeholder="请选择用户"
style="width: 100%"
:loading="userLoading"
>
<el-option
v-for="u in userOptions"
:key="u.userId"
:label="formatUser(u)"
:value="u.userId"
:disabled="u.status === '1' || boundUserIdSet.has(String(u.userId))"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="bindVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submitBind">确定</el-button>
</template>
</el-dialog>
</set-section-card>
</template>
<script setup name="SetUserTable">
import { ElMessage, ElMessageBox } from 'element-plus'
import SetSectionCard from './SetSectionCard.vue'
import { useUsers } from '@/components/UserSelect/useUsers'
import { addSetViewer, delSetViewer } from '@/api/price/set'
const props = defineProps({
set: { type: Object, required: true }
})
const emit = defineEmits(['changed'])
const { userList, ensureUsers } = useUsers()
const viewers = computed(() => props.set.viewers || [])
const boundUserIdSet = computed(() => new Set(viewers.value.map(v => String(v.userId))))
const bindVisible = ref(false)
const selectedUserIds = ref([])
const userLoading = ref(false)
const submitting = ref(false)
const userOptions = computed(() => userList.value || [])
function formatUser(u) {
const name = u.nickName || u.userName || ''
return u.userName ? (name + ' (' + u.userName + ')') : name
}
function openBind() {
selectedUserIds.value = []
bindVisible.value = true
userLoading.value = true
ensureUsers().finally(() => { userLoading.value = false })
}
function onClosed() {
selectedUserIds.value = []
}
async function submitBind() {
const ids = (selectedUserIds.value || []).filter(id => !boundUserIdSet.value.has(String(id)))
if (!ids.length) {
ElMessage.warning('请选择要绑定的用户')
return
}
if (!props.set.priceSetId) {
ElMessage.warning('套餐不存在')
return
}
submitting.value = true
let ok = 0
let fail = 0
try {
for (const userId of ids) {
try {
await addSetViewer({ priceSetId: props.set.priceSetId, userId })
ok++
} catch (e) {
fail++
}
}
if (ok) ElMessage.success('绑定成功 ' + ok + (fail ? (',失败 ' + fail) : ''))
else ElMessage.error('绑定失败')
bindVisible.value = false
emit('changed')
} finally {
submitting.value = false
}
}
function onUnbind(row) {
if (!row || !row.psuId) return
ElMessageBox.confirm(
'确认解绑用户 ' + (row.nickName || row.userName || '') + ' ?',
'解绑确认',
{ type: 'warning' }
).then(() => delSetViewer(row.psuId)).then(() => {
ElMessage.success('已解绑')
emit('changed')
}).catch(() => {})
}
</script>
<style scoped>
.user-table {
border-radius: 6px;
}
.user-table :deep(th.el-table__cell) {
background: #fafbfc;
color: #606266;
font-weight: 600;
}
.u-code {
font-family: Consolas, Monaco, monospace;
font-size: 12px;
color: #606266;
}
</style>
@@ -173,7 +173,8 @@ const noneSc = '无'
const isFixed = computed(() => isFixedType(props.setType)) const isFixed = computed(() => isFixedType(props.setType))
const title = computed(() => { const title = computed(() => {
const v = props.version || {} const v = props.version || {}
return (v.versionNo || '-') + ' · 版本明细' const eff = v.effectiveTime || '未设置生效时间'
return eff + ' · 版本明细'
}) })
const detailTitleText = computed(() => { const detailTitleText = computed(() => {
const p = activeProduct.value || {} const p = activeProduct.value || {}
@@ -19,13 +19,10 @@
</el-tabs> </el-tabs>
<el-table :data="filteredVersions" border empty-text="暂无版本"> <el-table :data="filteredVersions" border empty-text="暂无版本">
<el-table-column :label="colNo" width="100">
<template #default="{ row }">
<el-button link type="primary" @click="openDetails(row)">{{ row.versionNo || '-' }}</el-button>
</template>
</el-table-column>
<el-table-column :label="colEff" min-width="170"> <el-table-column :label="colEff" min-width="170">
<template #default="{ row }">{{ row.effectiveTime || '-' }}</template> <template #default="{ row }">
<el-button link type="primary" @click="openDetails(row)">{{ row.effectiveTime || '未设置' }}</el-button>
</template>
</el-table-column> </el-table-column>
<el-table-column :label="colExp" min-width="170"> <el-table-column :label="colExp" min-width="170">
<template #default="{ row }">{{ row.expireTime || '-' }}</template> <template #default="{ row }">{{ row.expireTime || '-' }}</template>
@@ -90,7 +87,6 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue', 'create-version', 'open-details', 'view-import-attach', 'activate', 'reschedule', 'edit', 'remove']) const emit = defineEmits(['update:modelValue', 'create-version', 'open-details', 'view-import-attach', 'activate', 'reschedule', 'edit', 'remove'])
const title = '套餐版本' const title = '套餐版本'
const colNo = '版本号'
const colEff = '生效时间' const colEff = '生效时间'
const colExp = '失效时间' const colExp = '失效时间'
const colStatus = '状态' const colStatus = '状态'
@@ -121,10 +117,33 @@ function tabLabel(key) {
const hasDraft = computed(() => (props.versions || []).some(v => isDraftVersion(v))) const hasDraft = computed(() => (props.versions || []).some(v => isDraftVersion(v)))
function toEffMs(v) {
if (!v || !v.effectiveTime) return NaN
const t = new Date(String(v.effectiveTime).replace(/-/g, '/')).getTime()
return Number.isNaN(t) ? NaN : t
}
/** Sort by effective time descending; drafts without time last. */
function sortByEffectiveTime(list) {
return (list || []).slice().sort((a, b) => {
const ta = toEffMs(a)
const tb = toEffMs(b)
const aNull = Number.isNaN(ta)
const bNull = Number.isNaN(tb)
if (aNull && bNull) return (Number(b.versionSeq) || 0) - (Number(a.versionSeq) || 0)
if (aNull) return 1
if (bNull) return -1
if (tb !== ta) return tb - ta
return (Number(b.versionSeq) || 0) - (Number(a.versionSeq) || 0)
})
}
const filteredVersions = computed(() => { const filteredVersions = computed(() => {
const list = props.versions || [] const list = props.versions || []
if (statusTab.value === 'all') return list const filtered = statusTab.value === 'all'
return list.filter(v => String(v.status) === statusTab.value) ? list
: list.filter(v => String(v.status) === statusTab.value)
return sortByEffectiveTime(filtered)
}) })
function detailCount(row) { function detailCount(row) {
+44 -11
View File
@@ -21,6 +21,8 @@
:ref-data="refData" :ref-data="refData"
:live-persist="livePersist" :live-persist="livePersist"
:build-mode-dirty="buildModeDirty" :build-mode-dirty="buildModeDirty"
:build-mode-ready="pageReady"
:show-import-inherit="showImportInherit"
:header-title="detailsHeaderTitle" :header-title="detailsHeaderTitle"
/> />
</div> </div>
@@ -81,6 +83,8 @@ const deferPersist = computed(() => copyMode.value || newVersionMode.value)
const showBasic = computed(() => !deferPersist.value && String(route.query.step || '') !== '2') const showBasic = computed(() => !deferPersist.value && String(route.query.step || '') !== '2')
/** Edit existing version: live PUT/pull/import. New create / copy / newVersion: defer until finish. */ /** Edit existing version: live PUT/pull/import. New create / copy / newVersion: defer until finish. */
const livePersist = computed(() => !!form.priceSetId && !deferPersist.value) const livePersist = computed(() => !!form.priceSetId && !deferPersist.value)
/** Brand-new package has no prior version — hide IMPORT inherit card. */
const showImportInherit = computed(() => newVersionMode.value || copyMode.value)
const detailsHeaderTitle = '编辑产品价格' const detailsHeaderTitle = '编辑产品价格'
@@ -100,6 +104,8 @@ const savedProductsSnapshot = ref([])
const buildModeDirty = computed(() => const buildModeDirty = computed(() =>
String(form.version.priceBuildMode || '') !== String(savedPriceBuildMode.value || '') String(form.version.priceBuildMode || '') !== String(savedPriceBuildMode.value || '')
) )
/** Gate build-mode cards until package payload applied (avoids first click being wiped). */
const pageReady = ref(!route.query.priceSetId)
function mapSaveProgressMessage(message) { function mapSaveProgressMessage(message) {
const m = message || '' const m = message || ''
@@ -182,6 +188,9 @@ async function finishEdit() {
// Backend copied prior version into this draft; refresh product list from current psv // Backend copied prior version into this draft; refresh product list from current psv
await refreshProductsFromCurrentVersion() await refreshProductsFromCurrentVersion()
proxy.$modal.msgSuccess("建价方式已保存,已带出上一版产品") proxy.$modal.msgSuccess("建价方式已保存,已带出上一版产品")
} else if (form.version.priceBuildMode === 'IMPORT_FRESH') {
savedProductsSnapshot.value = (form.products || []).map(p => ({ ...p }))
proxy.$modal.msgSuccess("建价方式已保存,可直接导入 Excel 建价(不延用以前的产品价格)")
} else { } else {
savedProductsSnapshot.value = (form.products || []).map(p => ({ ...p })) savedProductsSnapshot.value = (form.products || []).map(p => ({ ...p }))
proxy.$modal.msgSuccess("建价方式已保存") proxy.$modal.msgSuccess("建价方式已保存")
@@ -335,7 +344,7 @@ function onPefIdUpdate(pefId) {
} }
} }
/** Step2 chose PULL / IMPORT: edit live-saves mode; create only local + optional preview pull. */ /** Step2 chose PULL / IMPORT / IMPORT_FRESH: edit live-saves mode; create only local + optional preview pull. */
function onPriceBuildModeUpdate(mode) { function onPriceBuildModeUpdate(mode) {
if (!mode) return if (!mode) return
if (isBuildModeLockedVersion(form.version.status)) return if (isBuildModeLockedVersion(form.version.status)) return
@@ -346,7 +355,7 @@ function onPriceBuildModeUpdate(mode) {
savedProductsSnapshot.value = (form.products || []).map(p => ({ ...p })) savedProductsSnapshot.value = (form.products || []).map(p => ({ ...p }))
} }
form.version.priceBuildMode = mode form.version.priceBuildMode = mode
if (mode !== 'IMPORT') { if (mode !== 'IMPORT' && mode !== 'IMPORT_FRESH') {
draftPefId.value = undefined draftPefId.value = undefined
draftPefIds.value = [] draftPefIds.value = []
} }
@@ -376,7 +385,7 @@ function onPriceBuildModeUpdate(mode) {
proxy.$modal.msgSuccess("已拉取产品列表,可继续编辑价格") proxy.$modal.msgSuccess("已拉取产品列表,可继续编辑价格")
}).catch(() => {}) }).catch(() => {})
}) })
} else if (mode === 'IMPORT') { } else if (mode === 'IMPORT' && showImportInherit.value) {
nextTick(async () => { nextTick(async () => {
// newVersion/copy: wait until loadExisting fills priceSetId // newVersion/copy: wait until loadExisting fills priceSetId
const qid = route.query.priceSetId const qid = route.query.priceSetId
@@ -391,7 +400,7 @@ function onPriceBuildModeUpdate(mode) {
return return
} }
if (!form.priceSetId) { if (!form.priceSetId) {
proxy.$modal.msgSuccess("新建套餐无历史版本可带出,请直接导入 Excel 建价") proxy.$modal.msgSuccess("请直接导入 Excel 建价")
return return
} }
try { try {
@@ -403,6 +412,8 @@ function onPriceBuildModeUpdate(mode) {
proxy.$modal.msgSuccess("请导入 Excel 建价") proxy.$modal.msgSuccess("请导入 Excel 建价")
} }
}) })
} else if (mode === 'IMPORT_FRESH') {
proxy.$modal.msgSuccess("请直接导入 Excel 建价(不延用以前的产品价格)")
} }
} }
@@ -448,31 +459,48 @@ function applySetData(data, asCopy, asNewVersion) {
const fromData = (data.version && data.version.psvId) ? data.version : null const fromData = (data.version && data.version.psvId) ? data.version : null
const current = byQuery || fromData || versions[0] || {} const current = byQuery || fromData || versions[0] || {}
if (asCopy) { if (asCopy) {
const keptMode = form.version.priceBuildMode
const keptProducts = keptMode ? form.products.slice() : []
const keptDetails = keptMode ? form.details.slice() : []
form.version.psvId = undefined form.version.psvId = undefined
form.version.versionNo = nextVersionNo(versions) form.version.versionNo = nextVersionNo(versions)
form.version.effectiveTime = undefined form.version.effectiveTime = undefined
form.version.expireTime = undefined form.version.expireTime = undefined
form.version.status = undefined form.version.status = undefined
form.version.priceBuildMode = undefined form.version.priceBuildMode = keptMode || undefined
draftPefId.value = undefined draftPefId.value = undefined
draftPefIds.value = [] draftPefIds.value = []
savedPriceBuildMode.value = undefined savedPriceBuildMode.value = undefined
form.details = [] if (keptMode) {
form.products = [] form.products.splice(0, form.products.length, ...keptProducts)
form.details.splice(0, form.details.length, ...keptDetails)
} else {
form.details = []
form.products = []
}
} else if (asNewVersion) { } else if (asNewVersion) {
// Draft is created on finish only. Resume only when query has psvId (legacy empty draft). // Draft is created on finish only. Resume only when query has psvId (legacy empty draft).
// Keep mode/products if user already chose a build mode before this response landed.
const keptMode = form.version.priceBuildMode
const keptProducts = keptMode ? form.products.slice() : []
const keptDetails = keptMode ? form.details.slice() : []
const resume = qPsv != null ? byQuery : null const resume = qPsv != null ? byQuery : null
form.version.psvId = resume ? resume.psvId : undefined form.version.psvId = resume ? resume.psvId : undefined
form.version.versionNo = (resume && resume.versionNo) || nextVersionNo(versions) form.version.versionNo = (resume && resume.versionNo) || nextVersionNo(versions)
form.version.effectiveTime = undefined form.version.effectiveTime = undefined
form.version.expireTime = undefined form.version.expireTime = undefined
form.version.status = (resume && resume.status) || '0' form.version.status = (resume && resume.status) || '0'
form.version.priceBuildMode = undefined form.version.priceBuildMode = keptMode || undefined
draftPefId.value = undefined draftPefId.value = undefined
draftPefIds.value = [] draftPefIds.value = []
savedPriceBuildMode.value = undefined savedPriceBuildMode.value = undefined
form.details = [] if (keptMode) {
form.products = [] form.products.splice(0, form.products.length, ...keptProducts)
form.details.splice(0, form.details.length, ...keptDetails)
} else {
form.details = []
form.products = []
}
} else { } else {
form.version.psvId = current.psvId form.version.psvId = current.psvId
form.version.versionNo = current.versionNo || nextVersionNo(versions) form.version.versionNo = current.versionNo || nextVersionNo(versions)
@@ -491,8 +519,11 @@ function applySetData(data, asCopy, asNewVersion) {
} }
function loadExisting(id, asCopy, asNewVersion) { function loadExisting(id, asCopy, asNewVersion) {
getPriceSet(id).then(res => { pageReady.value = false
return getPriceSet(id).then(res => {
applySetData(res.data || {}, asCopy, asNewVersion) applySetData(res.data || {}, asCopy, asNewVersion)
}).finally(() => {
pageReady.value = true
}) })
} }
@@ -501,6 +532,8 @@ onMounted(() => {
const id = route.query.priceSetId const id = route.query.priceSetId
if (id) { if (id) {
loadExisting(id, copyMode.value, newVersionMode.value) loadExisting(id, copyMode.value, newVersionMode.value)
} else {
pageReady.value = true
} }
}) })
}) })
+86 -34
View File
@@ -20,8 +20,6 @@
<set-customer-table :set="set" /> <set-customer-table :set="set" />
<set-user-table :set="set" @changed="load" />
<set-version-table <set-version-table
v-model="currentPsvId" v-model="currentPsvId"
:versions="set.versions || []" :versions="set.versions || []"
@@ -50,9 +48,6 @@
@closed="onActivateClosed" @closed="onActivateClosed"
> >
<el-form ref="activateFormRef" :model="activateForm" :rules="activateRules" label-width="100px"> <el-form ref="activateFormRef" :model="activateForm" :rules="activateRules" label-width="100px">
<el-form-item label="版本号">
<span>{{ activateForm.versionNo || '-' }}</span>
</el-form-item>
<el-form-item label="生效时间" prop="effectiveTime"> <el-form-item label="生效时间" prop="effectiveTime">
<el-date-picker <el-date-picker
v-model="activateForm.effectiveTime" v-model="activateForm.effectiveTime"
@@ -83,7 +78,6 @@
import SetDetailHeader from './components/detail/SetDetailHeader.vue' import SetDetailHeader from './components/detail/SetDetailHeader.vue'
import SetBasicInfoCard from './components/detail/SetBasicInfoCard.vue' import SetBasicInfoCard from './components/detail/SetBasicInfoCard.vue'
import SetCustomerTable from './components/detail/SetCustomerTable.vue' import SetCustomerTable from './components/detail/SetCustomerTable.vue'
import SetUserTable from './components/detail/SetUserTable.vue'
import SetVersionTable from './components/detail/SetVersionTable.vue' import SetVersionTable from './components/detail/SetVersionTable.vue'
import SetVersionDetailDrawer from './components/detail/SetVersionDetailDrawer.vue' import SetVersionDetailDrawer from './components/detail/SetVersionDetailDrawer.vue'
import SetVersionExcelFileDrawer from './components/detail/SetVersionExcelFileDrawer.vue' import SetVersionExcelFileDrawer from './components/detail/SetVersionExcelFileDrawer.vue'
@@ -92,7 +86,7 @@ import { usePriceSetRefData } from './composables/usePriceSetRefData'
import { import {
getPriceSet, disablePriceSet, enablePriceSet, activatePriceSetVersion, deletePriceSetVersion getPriceSet, disablePriceSet, enablePriceSet, activatePriceSetVersion, deletePriceSetVersion
} from '@/api/price/set' } from '@/api/price/set'
import { isDraftVersion, isPendingVersion, isEffectVersion } from './utils/priceSet' import { isDraftVersion, isPendingVersion, isEffectVersion, isImportFreshBuildMode } from './utils/priceSet'
const { proxy } = getCurrentInstance() const { proxy } = getCurrentInstance()
const route = useRoute() const route = useRoute()
@@ -233,8 +227,18 @@ const activateConfirmText = computed(() =>
activateMode.value === 'reschedule' ? '确认修改' : '确认启动' activateMode.value === 'reschedule' ? '确认修改' : '确认启动'
) )
const activatePrevEffectiveTime = ref(undefined) const activatePrevEffectiveTime = ref(undefined)
const activateIsImportFresh = ref(false)
/** Other versions' effective/expire times (hour) — forbidden for IMPORT_FRESH */
const activateBoundaryTimes = ref([])
const activateEffBeforePrevMsg = '生效时间不能早于上一版本的生效时间' const activateEffBeforePrevMsg = '生效时间不能早于上一版本的生效时间'
const activateEffHitBoundaryMsg = '生效时间不能与已有版本的生效时间或失效时间相同'
const activateDialogHint = computed(() => { const activateDialogHint = computed(() => {
if (activateIsImportFresh.value) {
const base = activateMode.value === 'reschedule'
? 'Excel 重新建价:生效时间可落在历史版本区间内,将自动截断该版本失效时间并同步产品/明细。'
: 'Excel 重新建价:生效时间可落在历史版本区间内;落在版本 B 区间时,B 失效时间改为本次启动时间,本版本失效时间取 B 原失效时间,并同步双方产品/明细。'
return base + '不可与已有版本的生效时间或失效时间相同。'
}
const base = activateMode.value === 'reschedule' const base = activateMode.value === 'reschedule'
? '仅待生效版本可修改;若时间晚于当前仍为待生效,到时自动生效并使上一版本失效。' ? '仅待生效版本可修改;若时间晚于当前仍为待生效,到时自动生效并使上一版本失效。'
: '若生效时间晚于当前,版本将进入「待生效」;到时自动生效并使上一版本失效。' : '若生效时间晚于当前,版本将进入「待生效」;到时自动生效并使上一版本失效。'
@@ -242,20 +246,27 @@ const activateDialogHint = computed(() => {
if (!prev) return base if (!prev) return base
return base + '不得早于上一版:' + prev + '。' return base + '不得早于上一版:' + prev + '。'
}) })
const activateRules = { const activateRules = computed(() => ({
effectiveTime: [ effectiveTime: [
{ required: true, message: '请选择生效时间', trigger: 'change' }, { required: true, message: '请选择生效时间', trigger: 'change' },
{ {
validator: (_r, v, cb) => { validator: (_r, v, cb) => {
if (!v) return cb()
if (activateIsImportFresh.value) {
const ms = toTimeMs(v)
if (activateBoundaryTimes.value.some(b => toTimeMs(b) === ms)) {
return cb(new Error(activateEffHitBoundaryMsg))
}
return cb()
}
const prev = activatePrevEffectiveTime.value const prev = activatePrevEffectiveTime.value
if (!v || !prev) return cb() if (prev && toTimeMs(v) < toTimeMs(prev)) return cb(new Error(activateEffBeforePrevMsg))
if (toTimeMs(v) < toTimeMs(prev)) return cb(new Error(activateEffBeforePrevMsg))
cb() cb()
}, },
trigger: 'change' trigger: 'change'
} }
] ]
} }))
function toTimeMs(val) { function toTimeMs(val) {
if (val == null || val === '') return NaN if (val == null || val === '') return NaN
@@ -278,13 +289,30 @@ function findPrevVersionEffectiveTime(row) {
return prev ? normalizeToHour(prev.effectiveTime) : undefined return prev ? normalizeToHour(prev.effectiveTime) : undefined
} }
function pickActivateDefaultTime(prev) { /** Collect other versions' effective/expire hour times (forbidden for IMPORT_FRESH). */
function collectActivateBoundaryTimes(excludePsvId) {
const out = []
const seen = new Set()
for (const v of set.value.versions || []) {
if (!v || v.psvId == null || String(v.psvId) === String(excludePsvId)) continue
;[v.effectiveTime, v.expireTime].forEach(t => {
const n = t ? normalizeToHour(t) : ''
if (!n || seen.has(n)) return
seen.add(n)
out.push(n)
})
}
return out
}
function pickActivateDefaultTime(prev, importFresh) {
const now = formatNow() const now = formatNow()
if (!prev) return now if (importFresh || !prev) return now
return toTimeMs(now) >= toTimeMs(prev) ? now : prev return toTimeMs(now) >= toTimeMs(prev) ? now : prev
} }
function disabledActivateDate(date) { function disabledActivateDate(date) {
if (activateIsImportFresh.value) return false
const prev = activatePrevEffectiveTime.value const prev = activatePrevEffectiveTime.value
if (!prev || !date) return false if (!prev || !date) return false
const prevDay = new Date(String(prev).replace(/-/g, '/')) const prevDay = new Date(String(prev).replace(/-/g, '/'))
@@ -295,13 +323,27 @@ function disabledActivateDate(date) {
} }
function disabledActivateHours() { function disabledActivateHours() {
const prev = activatePrevEffectiveTime.value
const cur = activateForm.effectiveTime const cur = activateForm.effectiveTime
if (!prev) return []
const prevD = new Date(String(prev).replace(/-/g, '/'))
const curD = cur const curD = cur
? new Date(String(normalizeToHour(cur)).replace(/-/g, '/')) ? new Date(String(normalizeToHour(cur)).replace(/-/g, '/'))
: new Date() : new Date()
if (activateIsImportFresh.value) {
const hours = new Set()
for (const b of activateBoundaryTimes.value) {
const bd = new Date(String(b).replace(/-/g, '/'))
if (
bd.getFullYear() === curD.getFullYear() &&
bd.getMonth() === curD.getMonth() &&
bd.getDate() === curD.getDate()
) {
hours.add(bd.getHours())
}
}
return [...hours]
}
const prev = activatePrevEffectiveTime.value
if (!prev) return []
const prevD = new Date(String(prev).replace(/-/g, '/'))
if ( if (
prevD.getFullYear() !== curD.getFullYear() || prevD.getFullYear() !== curD.getFullYear() ||
prevD.getMonth() !== curD.getMonth() || prevD.getMonth() !== curD.getMonth() ||
@@ -321,30 +363,38 @@ function onActivatePickerVisible(visible) {
} }
} }
function onActivateVersion(row) { function openActivateDialog(row, mode) {
if (!row || row.psvId == null) return if (!row || row.psvId == null) return
activateMode.value = 'activate' activateMode.value = mode
activateForm.psvId = row.psvId activateForm.psvId = row.psvId
activateForm.versionNo = row.versionNo || '' activateForm.versionNo = row.versionNo || ''
activateIsImportFresh.value = isImportFreshBuildMode(row.priceBuildMode)
activatePrevEffectiveTime.value = findPrevVersionEffectiveTime(row) activatePrevEffectiveTime.value = findPrevVersionEffectiveTime(row)
activateForm.effectiveTime = pickActivateDefaultTime(activatePrevEffectiveTime.value) activateBoundaryTimes.value = activateIsImportFresh.value
? collectActivateBoundaryTimes(row.psvId)
: []
const prev = activatePrevEffectiveTime.value
if (mode === 'reschedule') {
const cur = normalizeToHour(row.effectiveTime)
if (activateIsImportFresh.value) {
activateForm.effectiveTime = cur || pickActivateDefaultTime(prev, true)
} else if (cur && (!prev || toTimeMs(cur) >= toTimeMs(prev))) {
activateForm.effectiveTime = cur
} else {
activateForm.effectiveTime = pickActivateDefaultTime(prev, false)
}
} else {
activateForm.effectiveTime = pickActivateDefaultTime(prev, activateIsImportFresh.value)
}
activateVisible.value = true activateVisible.value = true
} }
function onActivateVersion(row) {
openActivateDialog(row, 'activate')
}
function onRescheduleVersion(row) { function onRescheduleVersion(row) {
if (!row || row.psvId == null) return openActivateDialog(row, 'reschedule')
activateMode.value = 'reschedule'
activateForm.psvId = row.psvId
activateForm.versionNo = row.versionNo || ''
activatePrevEffectiveTime.value = findPrevVersionEffectiveTime(row)
const cur = normalizeToHour(row.effectiveTime)
const prev = activatePrevEffectiveTime.value
if (cur && (!prev || toTimeMs(cur) >= toTimeMs(prev))) {
activateForm.effectiveTime = cur
} else {
activateForm.effectiveTime = pickActivateDefaultTime(prev)
}
activateVisible.value = true
} }
function onActivateClosed() { function onActivateClosed() {
@@ -353,6 +403,8 @@ function onActivateClosed() {
activateForm.versionNo = '' activateForm.versionNo = ''
activateForm.effectiveTime = undefined activateForm.effectiveTime = undefined
activatePrevEffectiveTime.value = undefined activatePrevEffectiveTime.value = undefined
activateIsImportFresh.value = false
activateBoundaryTimes.value = []
activateFormRef.value && activateFormRef.value.clearValidate() activateFormRef.value && activateFormRef.value.clearValidate()
} }
@@ -394,8 +446,8 @@ function onCreateVersion() {
function onRemoveVersion(row) { function onRemoveVersion(row) {
if (!row || row.psvId == null) return if (!row || row.psvId == null) return
const msg = isEffectVersion(row) const msg = isEffectVersion(row)
? "确认软删除已生效版本 " + (row.versionNo || '') + "?版本及产品、明细将标记为已删除,上一版将自动恢复生效并清空失效时间。" ? "确认软删除已生效版本(生效时间:" + (row.effectiveTime || '未设置') + ")?版本及产品、明细将标记为已删除,上一版将自动恢复生效并清空失效时间。"
: "确认删除" + (isPendingVersion(row) ? "待生效" : "草稿") + "版本 " + (row.versionNo || '') + "?其明细与附加费将一并删除。" : "确认删除" + (isPendingVersion(row) ? "待生效" : "草稿") + "版本(生效时间:" + (row.effectiveTime || '未设置') + ")?其明细与附加费将一并删除。"
proxy.$modal.confirm(msg).then(() => { proxy.$modal.confirm(msg).then(() => {
removingPsvId.value = row.psvId removingPsvId.value = row.psvId
proxy.$modal.loading("正在删除,请稍候...") proxy.$modal.loading("正在删除,请稍候...")
@@ -12,10 +12,16 @@ export const SET_TYPE = {
DISCOUNT: 'DISCOUNT' DISCOUNT: 'DISCOUNT'
} }
/** Version create-price mode */ /**
* Version create-price mode
* PULL: from product library
* IMPORT: Excel + preload latest prior version
* IMPORT_FRESH: Excel only, no prior version data
*/
export const PRICE_BUILD_MODE = { export const PRICE_BUILD_MODE = {
PULL: 'PULL', PULL: 'PULL',
IMPORT: 'IMPORT' IMPORT: 'IMPORT',
IMPORT_FRESH: 'IMPORT_FRESH'
} }
export function isPullBuildMode(mode) { export function isPullBuildMode(mode) {
@@ -26,6 +32,15 @@ export function isImportBuildMode(mode) {
return mode === PRICE_BUILD_MODE.IMPORT return mode === PRICE_BUILD_MODE.IMPORT
} }
export function isImportFreshBuildMode(mode) {
return mode === PRICE_BUILD_MODE.IMPORT_FRESH
}
/** Either Excel mode (with or without prior version) */
export function isExcelBuildMode(mode) {
return isImportBuildMode(mode) || isImportFreshBuildMode(mode)
}
/** 套餐状态枚举:D 待启用 / A 审核中 / E 生效 / R 驳回 / S 已停用 */ /** 套餐状态枚举:D 待启用 / A 审核中 / E 生效 / R 驳回 / S 已停用 */
export const SET_STATUS = { export const SET_STATUS = {
DRAFT: 'D', DRAFT: 'D',