1. 项目缘起与整体设计思路
1.1 为什么会有这个对接需求
做过企业级应用的朋友大概率都遇到过这样的场景:公司内部用着一套ERP系统处理财务、采购、库存,而业务侧又跑着一套自研的Web平台或者小程序商城。两套系统各自为政,数据靠人工导出Excel再导入,一天两天还行,时间一长,财务对账对到怀疑人生,库存数据永远慢半拍。这时候,打通两套系统就成了绕不过去的坎。
我这次接到的任务,就是把一个基于PHP开发的业务中台跟金蝶的ERP系统做对接。核心诉求很明确:业务系统里产生的销售订单、采购入库单、付款单等单据,要能自动推送到金蝶,同时金蝶里的物料主数据、客户档案、库存余额也要能拉回来给业务系统用。说白了,就是让数据在两个系统之间自动流转,把人工搬运的活儿彻底干掉。
这个项目适合谁参考?如果你正在做PHP项目跟金蝶的集成,或者你用的是其他语言但想了解这类ERP对接的通用套路,这篇文章应该都能给你一些可以直接抄作业的东西。我会尽量把踩过的坑、试过的方案、最终跑通的代码逻辑都摊开来讲,不藏私。
1.2 技术选型背后的考量
金蝶这边提供的对接方式主要有几种:WebAPI、WebService、中间表、直接读数据库。这几种方式我都评估过,最后选了WebAPI为主、中间表为辅的方案。为什么这么选?我一个个说。
WebAPI是金蝶云星空版本之后主推的方式,基于HTTP协议,请求和响应都是JSON格式,对PHP来说非常友好。你不需要装任何额外的扩展,用curl就能搞定。而且金蝶的WebAPI有比较完整的文档,登录鉴权、单据保存、单据查询、基础资料查询这些接口都有。缺点是有些老版本的金蝶(比如K/3 WISE)不支持WebAPI,或者支持的接口不全。
WebService是基于SOAP协议的,PHP要用这个得装soap扩展,而且调试起来比较麻烦,XML报文又长又难读。我试过用PHP的SoapClient去调,能通,但开发效率明显不如JSON来得痛快。除非对方系统只提供WebService接口,否则我一般不优先选它。
中间表这种方式说白了就是两边约定好一个数据库表结构,一方写一方读。这种方式最直接,性能也最好,但耦合度太高,两边数据库得能互相访问,网络和安全策略上往往卡得很死。我把它作为兜底方案,在WebAPI搞不定的某些特殊场景下用。
直接读数据库这个方式我基本不考虑。风险太大,金蝶的表结构复杂,字段含义没有官方文档很难吃透,而且直接操作生产数据库容易出事故。除非是只读查询且对方DBA明确同意,否则不建议碰。
最终我的方案是:能用WebAPI的全部走WebAPI,个别WebAPI不支持的场景(比如某些自定义单据的批量写入)用中间表过渡。这样既保证了开发效率,又留了后路。
1.3 整体架构设计
整个对接层我设计成了一个独立的PHP模块,不跟业务代码混在一起。为什么?因为ERP对接这东西,接口地址、账号密码、字段映射规则这些配置经常变,如果散落在业务代码里,改起来就是灾难。我把它抽出来之后,业务系统只需要调用一个统一的Service类,传业务数据进去,拿返回结果出来,中间的鉴权、报文组装、错误重试、日志记录全部封装在内部。
架构上分四层:接口层负责跟金蝶通信,处理HTTP请求和响应;映射层负责把业务系统的字段转换成金蝶要求的字段格式;业务层负责具体的单据逻辑,比如销售订单推送、物料同步;调度层负责定时任务和失败重试。每一层职责单一,出了问题排查起来也快。
数据库方面,我建了两张辅助表:一张是接口日志表,记录每次请求的报文、响应、耗时、状态;另一张是同步状态表,记录每条业务数据同步到金蝶的状态,防止重复推送。这两张表在后面排查问题时帮了大忙,强烈建议你也加上。
2. 核心细节解析与实操要点
2.1 金蝶WebAPI的鉴权机制
金蝶WebAPI的鉴权方式跟一般的OAuth不太一样,它用的是登录接口换Session的方式。你先调一个Login接口,传用户名、密码、语言标识,它会返回一个SessionId或者叫ContextId。后续所有业务接口调用都要带上这个SessionId。这个SessionId是有有效期的,默认好像是20分钟左右,过期了就得重新登录。
这里有个坑我踩过:不要每次请求都去登录一次。有些开发者图省事,每次调业务接口之前都先调一次Login,这样虽然能跑通,但金蝶那边会记录大量登录日志,而且频繁登录可能触发安全策略导致账号被锁。正确的做法是把SessionId缓存起来,比如存到Redis或者文件里,每次用之前检查一下有没有过期,快过期了再重新登录。
class KingdeeAuth { private $sessionId; private $expireTime; public function getSessionId() { if ($this->sessionId && time() < $this->expireTime) { return $this->sessionId; } return $this->login(); } private function login() { $url = $this->baseUrl . '/Kingdee.BOS.WebApi.ServicesStub.AuthService.ValidateUser.common.kdsvc'; $params = [ 'acctID' => $this->acctId, 'username' => $this->username, 'password' => $this->password, 'lcid' => 2052 ]; $result = $this->httpPost($url, $params); $this->sessionId = $result['Context']['SessionId']; $this->expireTime = time() + 1200; // 20分钟有效期,留点余量 return $this->sessionId; } }注意:金蝶的登录接口地址在不同版本里可能不一样,云星空和K/3的地址格式有差异,一定要以你实际环境的文档为准。另外密码传的是明文还是加密后的,也要看具体版本的要求。
2.2 单据保存接口的报文结构
金蝶的单据保存接口是Kingdee.BOS.WebApi.ServicesStub.DynamicFormService.Save.common.kdsvc,请求体是一个JSON,里面包含表单ID和实际数据。表单ID就是金蝶里那个单据的标识,比如销售订单是SAL_SaleOrder,采购订单是PUR_PurchaseOrder。这个ID不能猜,得去金蝶的BOS设计器里查,或者问实施顾问要。
报文结构大概是这样的:
{ "formid": "SAL_SaleOrder", "data": { "Creator": "Administrator", "NeedUpDateFields": [], "NeedReturnFields": [], "IsDeleteEntry": "true", "SubSystemId": "", "IsVerifyBaseDataField": "false", "IsEntryBatchFill": "true", "ValidateFlag": "true", "NumberSearch": "true", "IsAutoAdjustField": "false", "InterationFlags": "", "IgnoreInterationFlag": "", "IsControlPrecision": "false", "ValidateRepeatJson": "false", "Model": { "FBillNo": "SO202401001", "FDate": "2024-01-15", "FCustId": {"FNumber": "C001"}, "FSaleOrgId": {"FNumber": "100"}, "FMaterialId": {"FNumber": "M001"}, "FQty": 100, "FPrice": 25.5, "FEntryNote": "测试订单" } } }这里有几个关键点。基础资料字段比如客户、物料、组织,不能直接传名称或者ID,要传FNumber(编码)或者FID(内码)。我一般用编码,因为编码在业务系统里通常有对应,好映射。日期字段格式是yyyy-MM-dd,别传时间戳。明细行的数据结构跟表头不一样,明细行是一个数组,每个元素代表一行,字段名通常带FEntry前缀或者用FEntity包裹,具体要看单据的元数据定义。
2.3 字段映射的坑与解法
字段映射是ERP对接里最磨人的环节。业务系统的字段名跟金蝶的字段名往往对不上,而且同一个含义的字段在不同单据里可能叫法还不一样。我的做法是建一个映射配置表,把业务字段和金蝶字段的对应关系、转换规则都配在数据库里,代码里只做通用处理。
比如业务系统里客户编码存在customer_code字段,金蝶里叫FCustId.FNumber,那配置就是:
| 业务字段 | 金蝶字段 | 转换规则 |
|---|---|---|
| customer_code | FCustId.FNumber | 直接映射 |
| order_date | FDate | 格式化为yyyy-MM-dd |
| total_amount | FAmount | 保留两位小数 |
| status | FDocumentStatus | 枚举映射:1→A,2→B |
枚举映射这块特别容易出错。业务系统的状态值跟金蝶的单据状态往往不是一一对应的,得仔细核对。我遇到过业务系统里"已审核"对应金蝶的"已提交",结果推过去之后单据卡在中间状态,财务那边看不到。后来把金蝶的单据状态流转图打印出来贴在显示器上,一个个对,才把映射关系理清楚。
实操心得:映射配置最好做成可视化的,至少也要能导出成Excel让业务人员确认。别自己闷头配,配错了推过去的数据要清理很麻烦。
2.4 并发与幂等处理
业务系统里同一张订单可能被多次触发推送,比如用户点了两次按钮,或者定时任务重跑。如果不做幂等,金蝶里就会出现重复单据。我的做法是在同步状态表里对每条业务数据加唯一索引,推送之前先查状态,已经成功的直接跳过,正在处理中的加锁等待,失败的根据重试次数决定是否再推。
public function pushOrder($orderId) { $this->db->beginTransaction(); $row = $this->db->query("SELECT status FROM sync_status WHERE biz_id = ? FOR UPDATE", [$orderId]); if ($row && $row['status'] == 'success') { $this->db->commit(); return true; } if ($row && $row['status'] == 'processing') { $this->db->commit(); throw new Exception('该订单正在处理中,请稍后'); } // 更新状态为处理中 $this->db->exec("INSERT INTO sync_status (biz_id, status) VALUES (?, 'processing') ON DUPLICATE KEY UPDATE status = 'processing'", [$orderId]); $this->db->commit(); try { $result = $this->doPush($orderId); $this->db->exec("UPDATE sync_status SET status = 'success' WHERE biz_id = ?", [$orderId]); return $result; } catch (Exception $e) { $this->db->exec("UPDATE sync_status SET status = 'failed', retry_count = retry_count + 1 WHERE biz_id = ?", [$orderId]); throw $e; } }这个逻辑看着简单,但少了它,后面数据一乱,排查起来能让你怀疑人生。我见过一个项目因为没做幂等,财务那边月底对账发现多了几十张重复单据,一张张去删,加班到凌晨。
3. 实操过程与核心环节实现
3.1 环境准备与基础配置
开始写代码之前,先把环境理清楚。你需要确认几件事:金蝶的版本是什么(云星空、K/3 WISE、K/3 Cloud还是EAS),WebAPI的地址是什么,有没有开通WebAPI服务,账号有没有对应的接口权限。这些信息找金蝶的实施顾问或者运维要,别自己瞎试。
PHP这边,我用的环境是PHP 7.4,框架是ThinkPHP 6,但下面的代码逻辑跟框架无关,你用原生PHP或者Laravel、Symfony都能改。需要确保curl扩展和json扩展是开启的,这两个是基础。
配置文件我单独放了一个kingdee.php:
return [ 'base_url' => 'http://your-kingdee-server/K3Cloud/', 'acct_id' => 'your_account_id', 'username' => 'your_username', 'password' => 'your_password', 'lcid' => 2052, 'timeout' => 30, 'retry_times' => 3, 'log_path' => runtime_path() . 'kingdee/', ];acct_id是金蝶的账套ID,这个在登录的时候要传,不同账套的数据是隔离的。lcid是语言标识,2052是简体中文。超时时间我设了30秒,因为有些单据保存涉及复杂的计算,响应会比较慢。重试次数设3次,配合指数退避策略。
3.2 封装HTTP请求类
金蝶的接口都是POST请求,Content-Type是application/json。我封装了一个通用的HTTP类,处理请求发送、响应解析、日志记录和异常抛出。
class KingdeeHttp { private $config; public function post($service, $params) { $url = $this->config['base_url'] . $service; $json = json_encode($params, JSON_UNESCAPED_UNICODE); $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => $url, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $json, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => $this->config['timeout'], CURLOPT_HTTPHEADER => [ 'Content-Type: application/json;charset=utf-8', 'Content-Length: ' . strlen($json) ], ]); $startTime = microtime(true); $response = curl_exec($ch); $cost = round((microtime(true) - $startTime) * 1000, 2); $error = curl_error($ch); curl_close($ch); $this->log($service, $json, $response, $cost, $error); if ($error) { throw new KingdeeException('请求失败:' . $error); } $result = json_decode($response, true); if (json_last_error() !== JSON_ERROR_NONE) { throw new KingdeeException('响应解析失败:' . $response); } return $result; } }日志这块我记了请求报文、响应报文、耗时和curl错误。别省这点磁盘空间,出问题的时候这些日志就是救命稻草。我一般按天切分日志文件,保留30天。
3.3 销售订单推送完整流程
销售订单推送是我做的第一个场景,也是最典型的一个。整个流程分几步:接收业务系统的订单数据,组装成金蝶要求的报文格式,调用保存接口,处理返回结果,更新同步状态。
先看业务数据长什么样:
$order = [ 'order_no' => 'SO202401001', 'order_date' => '2024-01-15', 'customer_code' => 'C001', 'sale_org_code' => '100', 'items' => [ ['material_code' => 'M001', 'qty' => 100, 'price' => 25.5, 'note' => ''], ['material_code' => 'M002', 'qty' => 50, 'price' => 30.0, 'note' => '加急'], ] ];组装报文的时候,表头字段和明细字段要分开处理。明细行在金蝶里通常是一个数组,每个元素包含物料编码、数量、价格这些。注意数量字段的类型,金蝶一般要求传数字,别传字符串。
public function buildSaleOrderData($order) { $model = [ 'FBillNo' => $order['order_no'], 'FDate' => $order['order_date'], 'FSaleOrgId' => ['FNumber' => $order['sale_org_code']], 'FCustId' => ['FNumber' => $order['customer_code']], ]; $entries = []; foreach ($order['items'] as $item) { $entries[] = [ 'FMaterialId' => ['FNumber' => $item['material_code']], 'FQty' => (float)$item['qty'], 'FPrice' => (float)$item['price'], 'FEntryNote' => $item['note'], ]; } $model['FSaleOrderEntry'] = $entries; return [ 'formid' => 'SAL_SaleOrder', 'data' => [ 'Creator' => 'Administrator', 'NeedUpDateFields' => [], 'NeedReturnFields' => ['FBillNo', 'FID'], 'IsDeleteEntry' => 'true', 'IsVerifyBaseDataField' => 'false', 'IsEntryBatchFill' => 'true', 'ValidateFlag' => 'true', 'NumberSearch' => 'true', 'IsAutoAdjustField' => 'false', 'Model' => $model, ] ]; }NeedReturnFields我设了返回单据编号和内部ID,这样推送成功后可以把金蝶的ID存回业务系统,方便后续查询和关联。IsVerifyBaseDataField设成false是跳过基础资料验证,能提高一点性能,但如果你的基础资料编码可能不存在,还是设成true让它校验比较好,免得推过去报错。
调用保存接口之后,返回结果里会有Result字段,里面包含ResponseStatus和Errors。ResponseStatus.IsSuccess为true表示成功,false的话Errors数组里会有具体错误信息。我一般把错误信息原样记到日志里,同时给业务系统返回一个友好的提示。
3.4 基础资料同步的实现
除了推单据,我还做了基础资料的拉取,主要是物料和客户。金蝶的查询接口是ExecuteBillQuery,传表单ID和查询字段,返回一个二维数组。
public function queryMaterial($pageSize = 100, $pageIndex = 0) { $params = [ 'data' => [ 'FormId' => 'BD_MATERIAL', 'FieldKeys' => 'FNumber,FName,FMaterialGroup.FName,FBaseUnitId.FName', 'FilterString' => "FDocumentStatus = 'C'", // 只查已审核的 'OrderString' => 'FNumber ASC', 'TopRowCount' => 0, 'StartRow' => $pageIndex * $pageSize, 'Limit' => $pageSize, ] ]; $result = $this->http->post('Kingdee.BOS.WebApi.ServicesStub.DynamicFormService.ExecuteBillQuery.common.kdsvc', $params); return $result; }FilterString是过滤条件,语法跟SQL的WHERE类似,但字段名要用金蝶的。FDocumentStatus = 'C'表示只查已审核的数据,这个状态值每个单据可能不一样,要查文档确认。分页用StartRow和Limit,我一般每页100条,太大了响应慢,太小了请求次数多。
拉回来的数据要跟业务系统做比对,新增的插入,修改的更新,金蝶里删了的业务系统里要不要删得看业务需求。我一般只做新增和更新,不做删除,避免误删数据。
3.5 定时任务与失败重试
同步任务我用了两个定时任务:一个是增量同步,每5分钟跑一次,拉取最近修改的基础资料;另一个是失败重试,每10分钟跑一次,把同步状态表里失败且重试次数小于3的记录重新推一遍。
重试策略我用的是指数退避,第一次失败等1分钟,第二次等5分钟,第三次等15分钟。超过3次就标记为最终失败,发邮件通知管理员人工处理。别无限重试,有些错误是数据本身的问题,重试一万次也没用,反而把日志撑爆。
public function retryFailed() { $rows = $this->db->query("SELECT * FROM sync_status WHERE status = 'failed' AND retry_count < 3 AND next_retry_time <= NOW()"); foreach ($rows as $row) { try { $this->pushOrder($row['biz_id']); } catch (Exception $e) { $nextRetry = date('Y-m-d H:i:s', time() + pow(5, $row['retry_count']) * 60); $this->db->exec("UPDATE sync_status SET next_retry_time = ? WHERE biz_id = ?", [$nextRetry, $row['biz_id']]); } } }注意:重试的时候要确保业务数据没有被修改过,如果业务数据变了,重试推的是旧数据,可能会覆盖掉金蝶里的新数据。我的做法是重试前先比对一下业务数据的更新时间,如果比同步状态表的记录时间新,就跳过重试,等下一次增量同步来处理。
4. 常见问题与排查技巧实录
4.1 登录失败与Session过期
问题现象:调用业务接口返回"会话已过期"或者"未登录"。
排查思路:先检查SessionId是不是没传或者传错了。金蝶的SessionId一般放在请求的Header里,字段名可能是kdservice-sessionid,具体看版本。如果SessionId是对的,那就是过期了,重新登录即可。如果重新登录也失败,检查账号密码有没有变,账号有没有被锁定,账套ID对不对。
避坑技巧:把SessionId的过期时间设得比实际短一点,比如实际20分钟,你设15分钟就刷新,避免边界情况下刚好过期。另外登录接口本身也可能失败,要做好异常捕获,别让登录失败把整个流程卡死。
4.2 单据保存报错"字段不存在"
问题现象:保存接口返回错误,提示某个字段不存在或者字段类型不匹配。
排查思路:这种错误九成是字段名写错了,或者字段类型不对。金蝶的字段名是区分大小写的,FBillNo和FBillno是两个不同的东西。另外有些字段是必填的,你没传就会报错。最靠谱的办法是去金蝶的BOS设计器里看单据的元数据,或者用查询接口先查一条已有数据,看看返回的字段名和格式是什么样的。
避坑技巧:我一般会先用查询接口拉一条金蝶里已有的单据数据下来,照着它的结构组装报文,这样字段名和格式基本不会错。另外金蝶的日期字段有时候要求传yyyy-MM-dd HH:mm:ss,有时候只要yyyy-MM-dd,以实际返回的为准。
4.3 基础资料编码不存在
问题现象:推送单据时提示"客户编码不存在"或"物料编码不存在"。
排查思路:先确认这个编码在金蝶里确实存在,而且状态是已审核、未禁用。如果金蝶里没有,要么先在金蝶里建好,要么在推送单据之前先调基础资料保存接口把资料推过去。如果金蝶里有但报不存在,检查一下编码有没有空格或者特殊字符,有时候从Excel复制过来的数据会带不可见字符。
避坑技巧:在业务系统里做一层校验,推送之前先查一下金蝶里有没有这个编码,没有的话提前报警,别等到推单据的时候才报错。我建了一个基础资料对照表,业务系统的编码和金蝶的编码做映射,定期同步,这样推送的时候直接查对照表就行。
4.4 并发导致的重复单据
问题现象:金蝶里出现了重复的销售订单,单号一样或者内容一样。
排查思路:检查业务系统有没有做幂等控制,是不是同一个订单被多次触发推送。另外检查定时任务有没有重叠执行,比如上一次还没跑完,下一次又启动了。
避坑技巧:除了前面说的同步状态表加锁,定时任务也要加锁,确保同一时间只有一个任务实例在跑。我用的是Redis的SETNX做分布式锁,任务开始前抢锁,抢不到就跳过本次执行。锁的过期时间设得比任务最长执行时间长一点,避免任务还没跑完锁就过期了。
4.5 响应超时与网络抖动
问题现象:接口调用偶尔超时,或者返回一半就断了。
排查思路:先看金蝶服务器那边的负载,是不是在跑大批量任务导致响应慢。如果是网络问题,检查两边服务器的网络延迟和丢包率。如果是金蝶那边处理慢,看看能不能优化报文,减少不必要的数据传输。
避坑技巧:超时时间别设太短,我设的30秒,大部分情况够用。如果确实有特别慢的接口,单独给它设更长的超时。另外重试的时候要注意,有些接口不是幂等的,重试可能导致重复数据,所以重试之前一定要确认接口的幂等性,或者用唯一的业务单号做去重。
4.6 常见问题速查表
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 会话已过期 | SessionId过期或未传 | 重新登录,检查Header |
| 字段不存在 | 字段名拼写错误或大小写不对 | 对照元数据检查字段名 |
| 基础资料不存在 | 编码错误或未审核 | 检查编码,确认状态 |
| 重复单据 | 未做幂等或并发触发 | 加同步状态锁,定时任务加分布式锁 |
| 响应超时 | 网络慢或金蝶负载高 | 增加超时时间,优化报文 |
| 日期格式错误 | 格式不符合要求 | 统一格式化为yyyy-MM-dd |
| 数量为负数 | 业务数据异常 | 推送前校验数据合法性 |
| 权限不足 | 账号没有接口权限 | 找管理员开通权限 |
5. 一些个人体会与后续扩展思路
这个项目做下来,我最大的感受是:ERP对接的难点不在技术,在沟通和细节。技术层面无非是HTTP请求、JSON解析、字段映射,这些都有成熟的方案。真正耗时间的是搞清楚金蝶那边每个字段的含义、每个状态值的流转规则、每个接口的边界条件。这些东西文档里往往写得不全,得跟实施顾问反复确认,有时候还得自己试。
另外一个体会是日志和监控一定要做好。对接这种跨系统的东西,出问题是必然的,关键是出问题之后能不能快速定位。我现在的做法是每次请求都记日志,关键节点打点,失败告警发到群里。这样即使半夜出问题,值班的人也能根据日志快速判断是网络问题、数据问题还是代码问题。
后续如果要扩展,我觉得有几个方向可以考虑。一是做成配置化的对接平台,把字段映射、接口配置、重试策略都做成可视化配置,这样换一个ERP或者换一个单据类型,不用改代码,改配置就行。二是加入数据校验层,推送之前先在本地做一轮校验,把明显有问题的数据拦下来,减少无效请求。三是做双向同步,现在主要是业务系统往金蝶推,后续可以把金蝶的库存变化、订单状态变更也拉回来,实现真正的双向实时同步。
最后分享一个小技巧:调试金蝶接口的时候,我习惯先用Postman把接口调通,确认报文格式和返回结果都对了,再把逻辑写到PHP代码里。这样能把接口本身的问题和代码的问题分开,排查起来快很多。另外金蝶的接口文档有时候跟实际版本有出入,以实际调用结果为准,别死磕文档。