Aura中调用服务器端方法的通用方法

// In helper
callServerAction : function(component, action) {
	return new Promise(function(resolve, reject) {
		action.setCallback(this, function(response) {
			let _state = response.getState();
                	if (_state === "SUCCESS") {
                    		resolve(response.getReturnValue());
                	} else {
                    		let errors = response.getError();
                    		let message = "Error";
                    		if (errors && Array.isArray(errors) && errors.length > 0) {
                        		message = JSON.stringify(errors);
                    		}
                		reject(new Error(message));
                	}
            	});
            	$A.enqueueAction(action);
	});
},

// In Controller
doMethod : function(component, event, helper) {
        let myAction = component.get("c.doSomething");
        myAction.setParams({
            "param1": param1
        });
        let myAcitonPromise = helper.callServerAction(component, myAction);
        myAcitonPromise.then(function(_returnValue) {
             // DO SOMETHINT OR CONTINUE PROMISE
	}).catch(function(_error) {
    		console.error(_error);
	}).finally(function() {
	});

}

Aura中Javascript版的String.replace()

作为Java的高度定制豪华版,Apex的String对象提供了三个与字符串替换相关的函数,replace(), replaceAll(), replaceFirst()。其中replace负责用字符串进行替换,replaceAll()负责用正则表达式进行替换,replaceFisrt负责用正则表达式替换第一个匹配到的结果。

在Apex范畴,这三个方法已经足够日常使用。但是在Javascript领域状况有所不同。
在Javascript中也有个函数叫做replace(),但却复杂很多。

首先引用来自官方文档的例子

const p = 'The quick brown fox jumps over the lazy dog. If the dog reacted, was it really lazy?';

const regex = /dog/gi;

console.log(p.replace(regex, 'ferret'));
// expected output: "The quick brown fox jumps over the lazy ferret. If the ferret reacted, was it really lazy?"

console.log(p.replace('dog', 'monkey'));
// expected output: "The quick brown fox jumps over the lazy monkey. If the dog reacted, was it really lazy?"

Javascipt的replace同时支持字符串替换与正则表达式替换,并且支持特殊替换符等高级功能。
但是,此replace却非彼replace。
Apex的replace使用字符串替模式会更加直白一些,直接使用 a.replace(b,c); 就能将a中的b替换成c,注意,是所有的b都被替换c。
而Javascript的字符串替换模式则仅替换第一个匹配项。与Apex的replaceFirst方法效果等同。

这样的话,在Lightning Component中如果打算实现与Apex中的replace一样的效果,就需要启用正则表达式模式。

在Helper添加如下方法:

    replace : function(content, reg, replacement, isCaseSensitive) {
        let mode = 'g';
        if(!isCaseSensitive) {
            mode = mode + 'i';
        }  
        let re = new RegExp(reg, mode);
        if(content !== null && content !== undefined && content !== "") {
            content = content.replace(re, replacement);
        }
        return content;
    }

虽然无法像原生方法一样直接写成a.replace(b,c); 但好在仍然是一行代码调用。

	init : function(component, event, helper) {
            const p = 'The quick brown fox jumps over the lazy dog. If the dog reacted, was it really lazy?';
            const reg = 'Dog';
            const replacement = 'ferret';
            let result = helper.replace(p, reg, replacement);
            console.log('result', result);
            // result The quick brown fox jumps over the lazy ferret. If the ferret reacted, was it really lazy?
        }

如果需要打开大小写敏感模式,则直接增加第四个参数 helper.replace(p, reg, replacement, true); 即可。

Winter’21升级导致Aura的createRecordEvent中Datetime赋值行为变化

众所周知,Winter‘21在发布此文时已经正式上线。
其中,由于对Aura中使用Apex的权限采取了更严格的标准,从而导致了大批功能崩溃的惨剧。

除此之外,还有另一个Aura的底层变化也值得注意。
在最新的文档中,force:createRecord这个event的文档下面增加了一行小字

Date and time field values must use the ISO 8601 format. For example:

Date: 2017-07-18
Datetime: 2017-07-18T03:00:00Z
While the create record panel presents datetime values in the user’s local time, you must convert datetime values to UTC to prepopulate the field.

这就导致了原来直接向Datatime字段赋值Date值的功能报错。

	let createRecordEvent = $A.get("e.force:createRecord");
        createRecordEvent .setParams({
            "entityApiName": "A__c",
            "defaultFieldValues": {
                'testDatetime__c' : '2020-10-21'
            }
        });
        createRecordEvent .fire();
// Error on Save : Value for field 'testDatetime__c' is not in ISO 8601 format, Value: 2020-10-21, Runtime class: java.lang.String

保存数据时会报如下错误

Error: Value for field ‘testDatetime__c’ is not in ISO 8601 format, Value: 2020-10-21, Runtime class: java.lang.String

如果有需要把date值写入datetime型字段的同学,可以在helper添加如下方法为date值填充时分秒,以此伪装成datatime值。

    // in helper
    paddingTime : function(date) {
        if(date != "" && date != null && date != undefined && date != {}) {
            date = date + "T00:00:00.000";
        }
        return date;
    }

    // in controller
    init : function(component, event, helper) {
        let createRecordEvent = $A.get("e.force:createRecord");
        createRecordEvent .setParams({
            "entityApiName": "A__c",
            "defaultFieldValues": {
                'testDatetime__c' : helper.paddingTime('2020-10-21')
            }
        });
        createRecordEvent .fire();
     }

另外需要值得注意的是,虽然aura的controller看起来是前端,但实际上createRecord事件的日期时间赋值行为等同于Apex————既作为UTC时间插入数据库,需要考虑显示时差问题。

那么相比之下,有同学会产生疑问,Apex受影响吗? 还能愉快的直接将date值写入datetime型字段吗?
答案是,放心,Apex一如既往。

// 后记
其实我并不确定官方文档里关于时期格式的描述一定是Winter’21后才加的,但是很确定Winter’21之前可以直接把date值赋给datetime型字段。毕竟aura event的文档无法查看历史版本,所以,就让我主观的视其为Winter’21吧。

使用Firefox访问Lex下的VF页面时csv下载失败的解决办法

众所周知,如果打算做一个纯前端的下载CSV的功能,可以使用如下代码。

let content = "test,test1\nabc,123";
let blob = new Blob([content], { type: 'text/csv' });
let csvUrl = URL.createObjectURL(blob);
let elementLink = document.createElement('a');
elementLink.href = csvUrl;
elementLink.target = '_blank';
elementLink.download = 'test.csv';
elementLink.click();

在DOM中新建了临时的a标签,并执行点击动作。之后就会把content的内容作为csv文件下载下来。
无论是在VF里还是Lightning Component中皆可使用。

在VF页面中嵌入这段代码之后,使用Chrome在Classic与Lex下使用都是正常的。
但是使用Firefox在Classic下正常,在Lex下却会报错失败。
比如在我的实验环境,错误信息为如下。

Content Security Policy: The page’s settings blocked the loading of a resource at blob:https://snrenv2-dev-ed–c.ap4.visual.force.com/016d4701-1e33-47a1-8099-eaec82c7ff1d (“frame-src”).

唔。。。Content Security Policy。(关于什么是CSP,可以参考阮一峰老师的文章或者MDN文档(什么是内容安全策略CSP用法)。
而且是来自于frame-src的CSP设定。

那么就一定存在iframe。
还记得在Classic时代,VF页面的主域名和标准页面的主域名是不同的。
VF:https://snrenv2-dev-ed–c.ap4.visual.force.com/
非VF:https://snrenv2-dev-ed.my.salesforce.com/

Lightning时代可能为了解决这个问题,在Lex的时候,居然将VF嵌套进了iframe里???

图例

这样的话,让我们来看看Salesforce的CSP是怎么设定的。F12->网络->respones header。

Content-Security-Policy:
default-src ‘self’; script-src ‘self’ ‘nonce-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx’ chrome-extension: ‘unsafe-inline’ ‘unsafe-eval’ *.canary.lwc.dev *.ap4.visual.force.com https://ssl.gstatic.com/accessibility/; object-src ‘self’ https://snrenv2-dev-ed–c.ap4.content.force.com; style-src ‘self’ blob: chrome-extension: ‘unsafe-inline’ *.ap4.visual.force.com https://snrenv2-dev-ed–c.ap4.content.force.com; img-src ‘self’ http: https: data: blob: *.ap4.visual.force.com; media-src ‘self’ *.ap4.visual.force.com https://snrenv2-dev-ed–c.ap4.content.force.com blob:; frame-ancestors ‘self’; frame-src https: mailto: *.ap4.visual.force.com; font-src ‘self’ https: data: *.ap4.visual.force.com; connect-src ‘self’ https://api.bluetail.salesforce.com https://staging.bluetail.salesforce.com https://preprod.bluetail.salesforce.com *.ap4.visual.force.com https://snrenv2-dev-ed–c.ap4.content.force.com https://ap4.salesforce.com

可以看到针对iframe的设定是允许https, mailto, vf域名。这明明vf域名是合法的啊。
按照最上面的写法,最后生成的url是blob:https://snrenv2-dev-ed–c.ap4.visual.force.com/016d4701-1e33-47a1-8099-eaec82c7ff1d,甚至将https://snrenv2-dev-ed–c.ap4.visual.force.com/016d4701-1e33-47a1-8099-eaec82c7ff1d直接拷贝到浏览器地址栏可以看到csv内容本身。那么为什么Firefox就根据CSP拦截了,而Chrome却没有拦截呢?

其实互联网的世界,并不是那么风平浪静。
以前在一篇文章里谈到过,计算机的世界其实并不是精确如一尘不染的实验室,而是建立在一系列没那么精确,不那么清晰,不是很靠谱的机制上。能跑就行。
虽然对于各种互联网协议的定义都有专门的组织去管理去发展。但是就如法律条文,协议与规范写的再精确也难免因为理解偏差而出现南辕北辙的情况。
而且从当年微软的IE带头不遵守规范伊始,各家浏览器的兼容性问题就始终困扰着广大开发者。就像你规定了IE,Firefox,Chrome都必须戴帽子出门,但是你会发现IE把帽子戴膝盖上了,Firefox斜着戴,Chrome反着戴——————谁让你没规定怎么戴。

这里关于CSP如何拦截我推测也是类似的情况(查阅浏览器代码实在力不从心),Chrome觉得blob: + 合法域名 = 合法,而Firefox觉得blob: 没有在名单里 = 不合法。

所以为了解决这个问题,我们只能干掉Firefox。我们只能绕开CSP,这是一种自己hack自己的行为,也许在某次Firefox更新或者salesforce更新之后就失效了。慎用。
如果说,VF所在的iFrame收到主页面的CSP策略约束,那么,我在iFrame中再造一个iFrame,外层iFrame没有设定CSP,在我造的iFrame里就可以为所欲为了。
代码如下

 let iframe = document.createElement('iframe');
 document.body.append(iframe);
 iframe.style.display = 'none';
 iframe.addEventListener('load', ()=> {
     let content = "test,test1\nabc,123";
     let blob = new Blob([content], { type: 'text/csv' });
     let csvUrl = URL.createObjectURL(blob);
     let elementLink = document.createElement('a');
     elementLink.href = csvUrl;
     elementLink.target = '_blank';
     elementLink.download = 'test.csv';
     elementLink.click();
 });

原理为,凭空捏造一个iFrame嵌到当前iFrame,然后监听load事件,将下载CSV的代码放入其中。这样当iFrame创建好了之后就会自动开始下载。

慎用。。。慎用。。。慎用

如何写出容易阅读的代码

一. 开篇废话

很多年前,我写过一篇文章叫《关于阅读代码 》。是在2014年写的,那时候刚开始写博客。根据后台统计数据显示,这篇文章这些年来也没什么人看。

其实文章分两种,一种是给自己看的,一种是给别人看的。就像写代码一样,有的代码你自己写的爽就行了,也没打算给别人看;还有一种,写出来的目的就是为了给别人看的。

所以回到《关于阅读代码》这篇文章,当时写的时候更多的是因为随着职业经历的积累自己有一些了感悟,觉得自己有一些绝妙的想法,想记录下来。这篇文章也一样。

作为程序员,工作职责中,可能写代码只占很小的一部分,不过因人而异。如果作为Salesfoce从业者,工作职责中80%以上都是写代码的话,我倒是觉得这其实是一件挺可悲的事情。应该考虑换一个平台或者试试别的角色。

但是,单就开发这部分来说,由于标准功能的存在,代码在Salesforce开发中的比重也不再如传统开发的100%。可以说我们的目标就是要干掉所有代码。

虽然说我们要干掉代码,但是代码却极其重要。毕竟所有标准功能无法实现的功能还是要代码去实现。这时候“对人友善”的代码在Salesforce开发活动中就格外的重要。毕竟在梳理业务流程的时候,谁也不想面对方便面一样的代码皱眉头。或者,被问到头上的时候,只能╮(╯▽╰)╭说:“这个功能好用但我不知道为什么。”

二. 不容易阅读的代码怎么来的

这些年我发现一个现象。习惯疯狂抽象分层的Java程序员,在转Salesforce开发之后往往更容易写出散文诗式流水账代码。然后再进行几次需求变动,代码就变成了方便面。

那么,为什么一个优秀的coder做salesforce之后会写出方便面代码?我觉得原因是接受到的信息的不同。

在Java时代,代码写手在动手之前接受到的信息为具体的功能模块,已经有架构师或同等角色的人将功能完全拆解或者绘制好蓝图。代码写手要做的是实现规划好的功能模块,将自己的模块成功嵌入到大框架内。而Salesforce代码写手接受的信息往往是业务描述——只是说了要做哪些事情,而不是严谨的功能模块划分。

举个例子,就像做意大利面,非Salesforce的厨师被提供了一个精确的食谱和严格的验收标准,要做的是用规定的食材严格按照食谱在指定地点把意大利面做好就行。而Salesforce的厨师只是被告知要做一盘什么味道的意大利面,可能连食材都还没买,到做好上桌之前都要自己搞定。

如果有自由发挥的空间,就像在饭店点蛋炒饭,每个厨师都会做出不同的味道。

继续阅读“如何写出容易阅读的代码”

Javascript版的String.format()

Salesforce的Apex语言作为Java的高度豪华定制版。不仅提供了很多Java原生就有的很好的方法与语法。同时也对Java进行了纷纷总总的简化。
比如Apex的String.format方法。
Apex的format方法提供了一个简单的方式可以对字符串模板进行关键字替换。
下面是官方文档的例子。

String template = '{0} was last updated {1}';
List<Object> parameters = new List<Object> {'Universal Containers', DateTime.newInstance(2018, 11, 15) };
String formatted = String.format(template, parameters);
System.debug ('Newly formatted string is:' + formatted);

如此一来,我们在custom label里做好带{x}字样的字符串模板,就可以随心所欲的动态生成内容了。

但是,随着进入Lightning时代,在Lightning Component前端承担的业务和内容越来越多,随之而来的就是出现了在前端直接获取字符串模板并动态生成内容的需求。
而Aura库和原生Javascript都没有提供类似与Apex的String.format方法。

俗话说,自己动手丰衣足食。那就自己写一个吧。
首先在Helper.js里面添加如下format方法。

    },    //.......... Other Helper Method
    format: function(label, args) {
        for (var k in args) {
            label = label.replace("{" + k + "}", args[k]);
        }
        return label;
    },
  // .......... Other Helper Method

然后在Controller.js中使用的时候,如同Apex版,第一个参数传入模板,第二个参数为按模板参数顺序传入的字符串数组。

let template = "{0} was last updated {1}";
let myLabel = helper.format(template , ["Universal Containers", "2018/11/15"]);

如何在使用e.force:createRecord创建数据之后阻止跳转到数据详细页面

有一天,小明同学和他的同伙们在实现一个需求。

三天前我路过他座位的时候简单的了解了下,感觉功能都实现的差不多了,怎么这会又开始一堆人聚一起叽叽喳喳的乱叫什么”CreateRecord事件”啦,什么“自定义Modal”啦,还让不让人静一静了。

“喂喂喂,怎么了怎么了?”。我实在忍不住上去扒拉开了最外面的几个。
被围在最里面的可怜人回头看到了我,立马趁机脱离开了包围圈。扯了扯衣角,开始介绍起情况来了。“是这样的,这个需求是我几天前给你介绍过的,就是一个Lightning Component上有一个按钮,然后点一下弹出一个新建数据的页面。”

“嗯嗯,然后呢”。我点了点头,心想着果然nothing new under sunshine,还是那个需求。
“然后我决定使用createRecord事件,就是e.force:createRecord。”,说着他扒拉开站在他身后的人,俯身用鼠标高亮了$A.get(“e.force:createRecord”)那行代码。“然后我们发现,居然在EditForm点保存之后,就直接跳到了新建数据的详细页面!”
在我开头之前,他立马切到了e.force:createRecord官方文档的页面,指在上面赶紧补充到,“我看了官方文档,上面也没有可以使用的参数或者选项。所以现在这几个人主张让我自己写一个modal出来,虽然这样一切都可控,但是工程量太大了。所以我们还在争论这件事。”

“主张做modal的先散了吧。”。接下来我做到了座位上,找出了相关的一个Idea 《Allow redirect after creating a new record using force:createRecord》。

小明难以置信的瞪大了眼睛,说:“这上面明明写的是没有这个功能啊。。。”
“上了这么多年的网,还不知道评论才是精华!?”

俗话说,文档上没写的不一定就没有。

——沃·兹几硕德

评论里提到了两个属性,一个是panelOnDestroyCallback,在Summer’19之后单独使用已经无效。另一个是navigationLocation有个固定写法”navigationLocation”:”LOOKUP”。

一起服用才有效果。

var createRecordEvent = $A.get("e.force:createRecord");
createRecordEvent.setParams({
	"entityApiName": "Opportunity",
	"navigationLocation": "LOOKUP",
	"panelOnDestroyCallback": function(event) {
		// Refesh current cmp
		$A.get('e.force:refreshView').fire();
		/* OR direct to anywhere
		let urlDirectEvent = $A.get("e.force:navigateToURL");
		urlDirectEvent.setParams({
			"url": "/lightning/n/XXXX"
		});
		urlDirectEvent.fire();
		*/
	}
});
createRecordEvent .fire();

但是,由于此属性从未出现在官方文档中,所以有可能就像Summer’19失效那次一样,在某一次升级之后又不好用了,使用还需慎重。

SFDX Create Project with Manifest模式下建议的.gitignore模板

俗话说,标题不长没人看。
不过这篇只是做一个存档,方便分享传阅,并没有故事。

如果使用VS Code选择了SFDX Create Project with Manifest,并且使用git进行代码版本管理,建议使用如下.gitignore

/*
!.gitignore
!anonymous.apex
!/force-app
!/manifest

此配置只追踪代码所在文件夹和package.xml所在文件夹。能有效的加快git处理速度。

// Update
解释:
Line 1 忽略此路径下所有文件夹,所属文件,及子文件夹及所属文件
Line 2 不忽略自己(.gitignore)
Line 3 VScode支持apex后缀的文件作为匿名块。所以你可以配和sfdx的命令愉快的储存匿名块重复使用了。公用的匿名块放在anonymous.apex同步到repo,私用的新建一个新的不同步到repo。
Line 4 不忽略代码所在文件夹及所属文件,及子文件夹与所属文件
Line 5 不忽略Package.xml所在文件夹及所属文件,及子文件夹与所属文件

Salesforce执行顺序小口诀

  1. 页面后台两不同,
  2. 布局规则最优先,
  3. 格式长度和必填。
  4. Before Trigger触发前,
  5. 后台只将外键检。
  6. 批量插入有例外,
  7. 验证规则提前验。
  8. Before之后做验证,
  9. 自定规则和必填,
  10. 系统规则不二遍。
  11. 验证之后跑去重,
  12. 存入DB不提交。
  13. After Trigger触发后,
  14. 分配/回复/工作流,
  15. 如果字段有更新,
  16. 验证/去重不再做,
  17. Trigger仅再跑一次。
  18. PB/Flow依次跑,
  19. 数据操作从头走,
  20. Case规则在随后。
  21. 父表汇总此时算,
  22. 工作流把父表更,
  23. 共享规则重计算。
  24. 数据DB提交后,
  25. 后续邮件才发送。

// 逐句解释更新中……
解释:
行1,行2,行3:与使用apex或者api新建或者更新数据相比, 通过Page Layout新建或者更新数据的行为会少许不同。通过Page Layout的时候会根据layout的字段属性配置提前进行一次字段格式(比如邮箱类型,电话号码格式等)/字段长度/字段是否必填等检查,才会进行后续的Trigger处理。
行4,行5:对于通过Apex或者API等方式后台创建/更新数据,虽然不会提前做一次系统级检查,但会对外键做一次检查,确保没有发生引用自己的情况。
行6,行7 :按理说接下来无论是用过Page Layout还是通过后台,下一步都应该进入Trigger处理了吧?还不一定。这里有一个例外形况,就是如果发生了例如Opportunity Line Item或者Quote Line Item的批量创建,则会提前执行一次自定义的Validation Rule。

官方文档地址: https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_triggers_order_of_execution.htm

关于CANNOT_CASCADE_PRODUCT_ACTIVE错误

某日,一个悠闲的午后,我泡的一杯毛尖正在阳光下散发着热气。嗯,接到一个小活儿。平平稳稳,不需要经历大风大浪,不需过五关斩六将的小活儿——-导数据。

这次需要更新的数据是Product。就是那个标准SObject,Product2,三位prefix是01t。人们常说用标准SObject用多了总会遇见坑,但人生又何尝不是如此。不是
完全控制在自己手里的东西,只能去了解,去接受,最后习以为常。

果然,这次就掉坑里了。工具执行结束之后,一看Log。在1000条数据里面,有50多条报了如下的错误。
StatusCode: CANNOT_CASCADE_PRODUCT_ACTIVE
Error Message: Cannot update associated pricebook entries without the Manage Price Books permission
然后看了下报错的数据,也没什么特别明显的特征。 貌似是无差别报错。

唔。。。万事不决查文档。
很快就在《SOAP API Developer Guide》定位到了这个Status Code。
https://developer.salesforce.com/docs/atlas.en-us.api.meta/api/sforce_api_calls_concepts_core_data_objects.htm


CANNOT_CASCADE_PRODUCT_ACTIVE
An update to a product caused by a cascade can’t be done because the associated product is active.

《SOAP API Developer Guide》

咦?怎么详描述的与系统返回的信息不一样?


系统反馈给我的信息是“由于没有Manage Price Books Permission, 所以无法更新关联的Pricebook”。只是说由于我没有Manage Price Books权限才报错,但是我更新Product为什么又会在意我有没有Manage Price Books的权限?

而文档给的信息是“由于product是active,所以无法完成由联级更新引起的被关联的product。 ”这写的是啥啥啥?什么叫caused by a cascade,谁是发出动作的人?是我的话我又cascade了谁?谁又Associated了我?

既然《SOAP API Developer Guide》的描述完全不知所云,那么就从系统直接反馈的错误信息入手吧。查了一下使用的账号,Profile和Permission上确实都没有PriceBook的CRUD权限。不过。。。。如果是因为没有PriceBook的权限的话,为什么绝大部分数据还更新成功了?如果真是这样,由于我没有权限,应该一条都不会成功才对。

结合两条截然不同的信息来看。线索指向了两个关键点。
1. PriceBook的权限。
2. Product的active状态。

于是做了一个试验,在没有PriceBook编辑权限的前提下,尝试去变更Product的active状态。果然报出的相同的错误信息。
就是说,如果想更新product的active状态,需要Pricebook的编辑权限。
唔。。。。


故事当然没有到此结束,不甘心的我突发奇想的在本地硬盘搜索了一下这个status code,结果在一本上古文档里发现了这样一段描述。

CANNOT_CASCADE_PRODUCT_ACTIVE

You cannot activate or deactivate this product without also being able to edit pricebooks.

某上古文档

原来错误信息还有第三个版本。这个版本明确的指出了问题的所在。

接下来是无责任脑洞推理没有任何实锤和Evidence。

在Salesforce中,Product和Price Book是多对多的关系。一个product可以有多个Price Book,而Price Book也可以包含多个Product。Price Book Entry就是二者的Junction Object。
如果用workbench查看Price Book Entry上关联Product与Price Book的字段的关系属性,会发现该关系具有联级删除属性(cascadeDelete: true)。
所以在此推定Product与Price Book Entry,Price Book与Price Book Entry之间的关系皆为Master-Detail。
在表权限层级。

在Profile中,如果想要拥有子表的编辑权限,那么至少要拥有父表的View权限。反过来,如果连父表的view权限都没有,则子表没有任何权限。

所以,由于我没有Price Book的任何权限,导致失去Price Book Entry的所有权限。
而根据观察,Product在active状态变化的时候,会同步active状态到所有旗下的Product Book Entry。
在此推测Salesforce在Product上加了一个Trigger/Workflow/Flow/Process Builder用来用来同步active状态,而且在这个处理中特意做了权限检查。如果发现没有Price Book Entry的权限,则去检查Price Book的权限,如果也没有权限,就报错。

这也正是系统直接返回的错误信息的由来:

Cannot update associated pricebook entries without the Manage Price Books permission

至于为何错误信息出现这么多版本,我推测是由于与Product有多对多关系的不仅仅只有Price Book,所以同一个StatusCode从单一情景变为也会复用在其他的情景。所以错误信息在文档里就改成了更加泛指的版本。而这次我遇见的错误是一个很具体的场景,则出现的信息又由泛指的版本变为更加具体的版本。

脑洞完毕。我去加权限了。。