cc to accounts (#557)

* cc to accounts
This commit is contained in:
RB 2022-12-16 20:23:44 +08:00 committed by GitHub
parent eb319ba113
commit 55ce1fbc39
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 206 additions and 46 deletions

View file

@ -232,12 +232,14 @@
</dependency>
-->
<!-- Use Live Reload: https://docs.spring.io/spring-boot/docs/current/reference/html/using.html#using.devtools.livereload -->
<!--
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>provided</scope>
<optional>true</optional>
</dependency>
-->
<!-- Use SPEC -->
<dependency>

View file

@ -190,9 +190,11 @@ public class ApprovalProcessor extends SetUser {
ccs4share = nextNodes.getCcUsers4Share(this.getUser(), this.record, selectNextUsers);
}
Set<String> ccAccounts = nextNodes.getCcAccounts(this.record);
FlowNode currentNode = getFlowParser().getNode((String) stepApprover[2]);
Application.getBean(ApprovalStepService.class)
.txApprove(approvedStep, currentNode.getSignMode(), ccs, nextApprovers, nextNode, addedData, checkUseGroup);
.txApprove(approvedStep, currentNode.getSignMode(), ccs, ccAccounts, nextApprovers, nextNode, addedData, checkUseGroup);
// 非主事物
if (ccs4share != null) share2CcIfNeed(this.record, ccs4share);
@ -435,7 +437,7 @@ public class ApprovalProcessor extends SetUser {
this.approval = status.getApprovalId();
Object[][] array = Application.createQueryNoFilter(
"select approver,state,remark,approvedTime,createdOn,createdBy,node,prevNode,nodeBatch,ccUsers from RobotApprovalStep" +
"select approver,state,remark,approvedTime,createdOn,createdBy,node,prevNode,nodeBatch,ccUsers,ccAccounts from RobotApprovalStep" +
" where recordId = ? and isWaiting = 'F' and isCanceled = 'F' order by createdOn")
.setParameter(1, this.record)
.array();
@ -537,6 +539,11 @@ public class ApprovalProcessor extends SetUser {
for (ID u : (ID[]) step[9]) names.add(UserHelper.getName(u));
s.put("ccUsers", names);
}
if (step.length > 10 && step[10] != null) {
List<String> mobileOrEmails = new ArrayList<>();
Collections.addAll(mobileOrEmails, step[10].toString().split(","));
s.put("ccAccounts", mobileOrEmails);
}
return s;
}

View file

@ -10,6 +10,7 @@ package com.rebuild.core.service.approval;
import cn.devezhao.bizz.privileges.impl.BizzPermission;
import cn.devezhao.commons.CalendarUtils;
import cn.devezhao.commons.ObjectUtils;
import cn.devezhao.commons.RegexUtils;
import cn.devezhao.persist4j.Entity;
import cn.devezhao.persist4j.PersistManagerFactory;
import cn.devezhao.persist4j.Record;
@ -35,6 +36,7 @@ import com.rebuild.core.service.trigger.RobotTriggerManual;
import com.rebuild.core.service.trigger.TriggerAction;
import com.rebuild.core.service.trigger.TriggerWhen;
import com.rebuild.core.support.i18n.Language;
import com.rebuild.core.support.integration.SMSender;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
@ -123,12 +125,13 @@ public class ApprovalStepService extends InternalPersistService {
* @param stepRecord
* @param signMode
* @param cc
* @param ccAccounts
* @param nextApprovers [驳回时无需]
* @param nextNode 下一节点或回退节点
* @param addedData [驳回时无需]
* @param checkUseGroup [驳回时无需]
*/
public void txApprove(Record stepRecord, String signMode, Set<ID> cc, Set<ID> nextApprovers, String nextNode, Record addedData, String checkUseGroup) {
public void txApprove(Record stepRecord, String signMode, Set<ID> cc, Set<String> ccAccounts, Set<ID> nextApprovers, String nextNode, Record addedData, String checkUseGroup) {
// 审批时更新主记录驳回时不会传这个值
if (addedData != null) {
GeneralEntityServiceContextHolder.setAllowForceUpdate(addedData.getPrimary());
@ -158,6 +161,9 @@ public class ApprovalStepService extends InternalPersistService {
if (cc != null && !cc.isEmpty()) {
stepRecord.setIDArray("ccUsers", cc.toArray(new ID[0]));
}
if (ccAccounts != null && !ccAccounts.isEmpty()) {
stepRecord.setString("ccAccounts", StringUtils.join(ccAccounts, ","));
}
super.update(stepRecord);
final ID stepRecordId = stepRecord.getPrimary();
@ -175,14 +181,30 @@ public class ApprovalStepService extends InternalPersistService {
final String entityLabel = EasyMetaFactory.getLabel(MetadataHelper.getEntity(recordId.getEntityCode()));
final String remark = stepRecord.getString("remark");
// 抄送人
// 抄送
final String ccMsg = Language.L("用户 @%s 提交的 %s 审批已由 @%s %s请知悉",
submitter, entityLabel, approver, Language.L(state));
if (cc != null && !cc.isEmpty()) {
String ccMsg = Language.L("用户 @%s 提交的 %s 审批已由 @%s %s请知悉",
submitter, entityLabel, approver, Language.L(state));
if (StringUtils.isNotBlank(remark)) ccMsg += "\n > " + remark;
String innerMsg = ccMsg;
if (StringUtils.isNotBlank(remark)) innerMsg += "\n > " + remark;
for (ID c : cc) {
Application.getNotifications().send(MessageBuilder.createApproval(c, ccMsg, recordId));
Application.getNotifications().send(MessageBuilder.createApproval(c, innerMsg, recordId));
}
}
// v3.2 外部人员
if (ccAccounts != null && !ccAccounts.isEmpty()) {
String mobileMsg = MessageBuilder.formatMessage(ccMsg, Boolean.FALSE);
String emailSubject = Language.L("审批通知");
String emailMsg = ccMsg;
if (StringUtils.isNotBlank(remark)) emailMsg += "\n > " + remark;
emailMsg = MessageBuilder.formatMessage(emailMsg, Boolean.TRUE);
for (String me : ccAccounts) {
if (SMSender.availableSMS() && RegexUtils.isCNMobile(me)) SMSender.sendSMSAsync(me, mobileMsg);
else if (SMSender.availableMail()) SMSender.sendMailAsync(me, emailSubject, emailMsg);
}
}

View file

@ -7,12 +7,15 @@ See LICENSE and COMMERCIAL in the project root for license information.
package com.rebuild.core.service.approval;
import cn.devezhao.commons.RegexUtils;
import cn.devezhao.persist4j.Entity;
import cn.devezhao.persist4j.Field;
import cn.devezhao.persist4j.engine.ID;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.rebuild.core.Application;
import com.rebuild.core.metadata.EntityHelper;
import com.rebuild.core.metadata.MetadataHelper;
import com.rebuild.core.privileges.UserHelper;
import com.rebuild.core.privileges.bizz.Department;
import com.rebuild.utils.JSONUtils;
@ -146,9 +149,7 @@ public class FlowNode {
*/
public Set<ID> getSpecUsers(ID operator, ID record) {
JSONArray userDefs = getDataMap().getJSONArray("users");
if (userDefs == null || userDefs.isEmpty()) {
return Collections.emptySet();
}
if (userDefs == null || userDefs.isEmpty()) return Collections.emptySet();
String userType = userDefs.getString(0);
if (USER_SELF.equalsIgnoreCase(userType)) {
@ -216,6 +217,38 @@ public class FlowNode {
return users;
}
/**
* 获取外部抄送人手机或邮箱
*
* @param record
* @return
*/
public Set<String> getCcAccounts(ID record) {
JSONArray accountFields = getDataMap().getJSONArray("accounts");
if (accountFields == null || accountFields.isEmpty()) return Collections.emptySet();
Entity useEntity = MetadataHelper.getEntity(record.getEntityCode());
List<String> useFields = new ArrayList<>();
for (Object o : accountFields) {
if (MetadataHelper.getLastJoinField(useEntity, (String) o) != null) {
useFields.add((String) o);
}
}
if (useFields.isEmpty()) return Collections.emptySet();
Object[] o = Application.getQueryFactory().uniqueNoFilter(record, useFields.toArray(new String[0]));
if (o == null) return Collections.emptySet();
Set<String> mobileOrEmail = new HashSet<>();
for (Object me : o) {
if (RegexUtils.isCNMobile((String) me) || RegexUtils.isEMail((String) me)) {
mobileOrEmail.add((String) me);
}
}
return mobileOrEmail;
}
@Override
public String toString() {
String string = String.format("Id:%s, Type:%s", nodeId, type);

View file

@ -105,6 +105,21 @@ public class FlowNodeGroup {
return users;
}
/**
* @param recordId
* @return
*/
public Set<String> getCcAccounts(ID recordId) {
Set<String> mobileOrEmails = new HashSet<>();
// 一般就一个但不排除多个 CC 节点
for (FlowNode node : nodes) {
if (FlowNode.TYPE_CC.equals(node.getType())) {
mobileOrEmails.addAll(node.getCcAccounts(recordId));
}
}
return mobileOrEmails;
}
/**
* @param operator
* @param recordId

View file

@ -60,7 +60,7 @@ public class SMSender {
try {
sendMail(to, subject, content);
} catch (Exception ex) {
log.error("Mail failed to send : {} < {}", to, subject, ex);
log.error("Email failed to send!", ex);
}
});
}
@ -131,7 +131,7 @@ public class SMSender {
return emailId;
} catch (EmailException ex) {
log.error("SMTP failed to send : {} > {}", to, subject, ex);
log.error("SMTP failed to send : {} | {} | {}", to, subject, content, ex);
return null;
}
}
@ -157,7 +157,7 @@ public class SMSender {
String r = OkHttpUtils.post("https://api-v4.mysubmail.com/mail/send.json", params);
rJson = JSON.parseObject(r);
} catch (Exception ex) {
log.error("Submail failed to send : {} > {}", to, subject, ex);
log.error("Submail failed to send : {} | {} | {}", to, subject, content, ex);
return null;
}
@ -166,12 +166,11 @@ public class SMSender {
String sendId = ((JSONObject) returns.get(0)).getString("send_id");
createLog(to, logContent, TYPE_EMAIL, sendId, null);
return sendId;
} else {
log.error("Mail failed to send : {} > {}\nError : {}", to, subject, rJson);
createLog(to, logContent, TYPE_EMAIL, null, rJson.getString("msg"));
return null;
}
log.error("Submail failed to send : {} | {} | {}\nError : {}", to, subject, content, rJson);
createLog(to, logContent, TYPE_EMAIL, null, rJson.getString("msg"));
return null;
}
/**
@ -229,7 +228,7 @@ public class SMSender {
try {
sendSMS(to, content);
} catch (Exception ex) {
log.error("SMS failed to send : {}", to, ex);
log.error("SMS failed to send!", ex);
}
});
}
@ -274,7 +273,7 @@ public class SMSender {
String r = OkHttpUtils.post("https://api-v4.mysubmail.com/sms/send.json", params);
rJson = JSON.parseObject(r);
} catch (Exception ex) {
log.error("Subsms failed to send : {} > {}", to, content, ex);
log.error("Subsms failed to send : {} | {}", to, content, ex);
return null;
} finally {
HeavyStopWatcher.clean();
@ -284,12 +283,11 @@ public class SMSender {
String sendId = rJson.getString("send_id");
createLog(to, content, TYPE_SMS, sendId, null);
return sendId;
} else {
log.error("SMS failed to send : {} > {}\nError : {}", to, content, rJson);
createLog(to, content, TYPE_SMS, null, rJson.getString("msg"));
return null;
}
log.error("Subsms failed to send : {} | {}\nError : {}", to, content, rJson);
createLog(to, content, TYPE_SMS, null, rJson.getString("msg"));
return null;
}
// @see com.rebuild.core.support.CommonsLog

View file

@ -126,6 +126,7 @@ public class ApprovalController extends BaseController {
JSONObject data = new JSONObject();
data.put("nextApprovers", formatUsers(approverList));
data.put("nextCcs", formatUsers(ccList));
data.put("nextCcAccounts", nextNodes.getCcAccounts(recordId));
data.put("approverSelfSelecting", nextNodes.allowSelfSelectingApprover());
data.put("ccSelfSelecting", nextNodes.allowSelfSelectingCc());
data.put("isLastStep", nextNodes.isLastStep());

View file

@ -298,6 +298,7 @@
<field name="isBacked" type="bool" default-value="F" description="是否退回"/>
<field name="nodeBatch" type="string" max-length="100" updatable="false" description="审批节点批次"/>
<field name="ccUsers" type="reference-list" ref-entity="User" updatable="false" description="抄送人"/>
<field name="ccAccounts" type="string" max-length="500" updatable="false" description="抄送外部人员"/>
<index field-list="recordId,approvalId,node,isCanceled,isWaiting,isBacked,nodeBatch"/>
</entity>

View file

@ -439,6 +439,7 @@ create table if not exists `robot_approval_step` (
`IS_BACKED` char(1) default 'F' comment '是否退回',
`NODE_BATCH` varchar(100) comment '审批节点批次',
`CC_USERS` varchar(420) comment '抄送人',
`CC_ACCOUNTS` varchar(500) comment '抄送外部人员',
`MODIFIED_ON` timestamp not null default current_timestamp comment '修改时间',
`MODIFIED_BY` char(20) not null comment '修改人',
`CREATED_BY` char(20) not null comment '创建人',
@ -853,4 +854,4 @@ insert into `project_plan_config` (`CONFIG_ID`, `PROJECT_ID`, `PLAN_NAME`, `SEQ`
-- DB Version (see `db-upgrade.sql`)
insert into `system_config` (`CONFIG_ID`, `ITEM`, `VALUE`)
values ('021-9000000000000001', 'DBVer', 47);
values ('021-9000000000000001', 'DBVer', 49);

View file

@ -1,6 +1,10 @@
-- Database upgrade scripts for rebuild 1.x and 2.x
-- Each upgraded starts with `-- #VERSION`
-- #49 (v3.2)
alter table `robot_approval_step`
add column `CC_ACCOUNTS` varchar(500) comment '抄送外部人员';
-- #47 (v3.1)
alter table `robot_approval_step`
add column `CC_USERS` varchar(420) comment '抄送人';

View file

@ -3030,6 +3030,21 @@ form {
margin-top: 3px;
}
.form.approval-form .cc-accounts {
color: #666;
}
.form.approval-form .cc-accounts a {
color: #404040;
}
.form.approval-form .cc-accounts a + a::before {
content: ',';
margin-left: 1px;
margin-right: 4px;
color: #8a8a8a;
}
.select2-sm .select2-container--default .select2-selection--single,
.select2-sm .select2-container--default .select2-selection--multiple {
min-height: 36px;
@ -4407,7 +4422,7 @@ html.external-auth .auth-body.must-center .login {
.user-popup .infos p.phone::after,
.user-popup .infos p.email::after {
font-family: 'Material-Design-Iconic-Font', serif;
font-family: 'Material Design Icons', serif;
}
.user-popup .infos p.phone::after,
@ -4418,12 +4433,11 @@ html.external-auth .auth-body.must-center .login {
}
.user-popup .infos p.phone::after {
content: '\f2d4';
content: '\F011C';
}
.user-popup .infos p.email::after {
content: '\f15a';
transform: translateY(1px);
content: '\F01F0';
}
.copied-check > .zmdi-copy::before {

View file

@ -203,6 +203,7 @@ class SimpleNode extends NodeSpec {
if (data.selfSelecting && data.users.length > 0) descs.push($L('允许自选'))
if (data.ccAutoShare) descs.push($L('自动共享'))
if ((data.accounts || []).length > 0) descs.push(`${$L('外部人员')}(${data.accounts.length})`)
if (this.nodeType === 'approver') descs.push(data.signMode === 'AND' ? $L('会签') : data.signMode === 'ALL' ? $L('依次审批') : $L('或签'))
return (
@ -764,10 +765,6 @@ class ApproverNodeConfig extends StartNodeConfig {
// 抄送人
class CCNodeConfig extends StartNodeConfig {
constructor(props) {
super(props)
}
render() {
return (
<div>
@ -790,18 +787,45 @@ class CCNodeConfig extends StartNodeConfig {
<span className="custom-control-label">{$L('抄送人无读取权限时自动共享')}</span>
</label>
</div>
<div className="form-group mt-3">
<label className="text-bold">
{$L('抄送给外部人员 (可选)')} <sup className="rbv" title={$L('增值功能')} />
</label>
<UserSelectorWithField ref={(c) => (this._UserSelector2 = c)} userType={2} hideUser hideDepartment hideRole hideTeam />
<p className="form-text">{$L('选择外部人员的电话手机或邮箱字段')}</p>
</div>
</div>
{this.renderButton()}
</div>
)
}
componentDidMount() {
super.componentDidMount()
if ((this.props.accounts || []).length > 0) {
$.post(`/commons/search/user-selector?entity=${this.props.entity || wpc.applyEntity}`, JSON.stringify(this.props.accounts), (res) => {
if (res.error_code === 0 && res.data.length > 0) {
this._UserSelector2.setState({ selected: res.data })
}
})
}
}
save = () => {
const d = {
nodeName: this.state.nodeName,
users: this._UserSelector.getSelected(),
selfSelecting: this.state.selfSelecting,
ccAutoShare: this.state.ccAutoShare,
accounts: this._UserSelector2.getSelected(),
}
if (d.accounts.length > 1 && rb.commercial < 1) {
RbHighbar.error(WrapHtml($L('免费版不支持抄送给外部人员功能 [(查看详情)](https://getrebuild.com/docs/rbv-features)')))
return
}
if (d.users.length === 0 && !d.selfSelecting) {
@ -1040,12 +1064,31 @@ class UserSelectorWithField extends UserSelector {
super.componentDidMount()
this._fields = []
$.get(`/admin/robot/approval/user-fields?entity=${this.props.entity || wpc.applyEntity}`, (res) => {
this._fields = res.data || []
})
// 外部人员
if (this.props.userType === 2) {
$.get(`/commons/metadata/fields?deep=2&entity=${this.props.entity || wpc.applyEntity}`, (res) => {
res.data &&
res.data.forEach((item) => {
if (item.type === 'PHONE' || item.type === 'EMAIL') {
this._fields.push({ id: item.name, text: item.label })
}
})
this.switchTab()
})
} else {
$.get(`/admin/robot/approval/user-fields?entity=${this.props.entity || wpc.applyEntity}`, (res) => {
this._fields = res.data || []
})
}
}
switchTab(type) {
if (this.props.userType === 2) {
this.setState({ tabType: 'FIELDS', items: this._fields || [] })
return
}
type = type || this.state.tabType
if (type === 'FIELDS') {
const q = this.state.query

View file

@ -323,6 +323,17 @@ class ApprovalUsersForm extends RbFormHandler {
<UserSelector ref={(c) => (this._ccSelector = c)} />
</div>
)}
{(this.state.nextCcAccounts || []).length > 0 && (
<div className="mt-2 cc-accounts">
<span>{$L('及以下外部人员')}</span>
<div className="mt-1">
{this.state.nextCcAccounts.map((me) => {
return <a key={me}>{me}</a>
})}
</div>
</div>
)}
</div>
)}
</React.Fragment>
@ -734,16 +745,23 @@ class ApprovalStepViewer extends React.Component {
<p className="text-wrap">{item.remark}</p>
</blockquote>
)}
{item.ccUsers && item.state >= 10 && (
{item.state >= 10 && (item.ccUsers || []).length + (item.ccAccounts || []).length > 0 && (
<blockquote className="blockquote timeline-blockquote mb-0 cc">
<p className="text-wrap">
<span className="mr-1">
<i className="zmdi zmdi-mail-send mr-1" />
{$L('已抄送')}
</span>
{item.ccUsers.map((item) => {
{(item.ccUsers || []).map((item) => {
return <a key={item}>{item}</a>
})}
{(item.ccAccounts || []).map((item) => {
return (
<a key={item} title={$L('外部人员')}>
{item}
</a>
)
})}
</p>
</blockquote>
)}

View file

@ -60,6 +60,7 @@ class ContentSendNotification extends ActionContentSpec {
</div>
<div className={this.state.userType === 2 ? '' : 'hide'}>
<AccountSelectorWithField ref={(c) => (this._sendTo2 = c)} hideUser hideDepartment hideRole hideTeam />
<p className="form-text">{$L('选择外部人员的电话手机或邮箱字段')}</p>
</div>
</div>
</div>
@ -180,12 +181,12 @@ class AccountSelectorWithField extends UserSelector {
this._fields = []
$.get(`/commons/metadata/fields?deep=2&entity=${this.props.entity || wpc.sourceEntity}`, (res) => {
$(res.data).each((idx, item) => {
if (item.type === 'PHONE' || item.type === 'EMAIL') {
this._fields.push({ id: item.name, text: item.label })
}
})
res.data &&
res.data.forEach((item) => {
if (item.type === 'PHONE' || item.type === 'EMAIL') {
this._fields.push({ id: item.name, text: item.label })
}
})
this.switchTab()
})
}