Преглед изворни кода

拉卡拉进行功能实现:
1. 新增进件控件器及service类、class类
2. 引入 lakalasdk/openapi 私有库包
3. 上传支付认证相关的二维码图

shizhongqi пре 1 месец
родитељ
комит
5ac38ec7c7

+ 127 - 0
app-ghs/controllers/LakalaAccountController.php

@@ -0,0 +1,127 @@
+<?php
+
+namespace ghs\controllers;
+
+use bizGhs\lakala\classes\LakalaApplicationClass;
+use bizGhs\lakala\services\LakalaAccountService;
+use common\components\util;
+use Yii;
+
+class LakalaAccountController extends BaseController
+{
+    public function actionList()
+    {
+        $list = LakalaAccountService::list($this->mainId, $this->shopId);
+        util::success($list);
+    }
+
+    public function actionDetail()
+    {
+        $detail = LakalaAccountService::detail((int)Yii::$app->request->get('id'), $this->mainId, $this->shopId);
+        util::success($detail);
+    }
+
+    public function actionCreate()
+    {
+        $post = Yii::$app->request->post();
+        $type = $post['type'] ?? LakalaApplicationClass::TYPE_ONBOARDING;
+        if (!in_array($type, [LakalaApplicationClass::TYPE_ONBOARDING, LakalaApplicationClass::TYPE_SETTLEMENT_CHANGE], true)) {
+            util::fail('申请类型错误');
+        }
+        util::success(LakalaAccountService::create($this->mainId, $this->shopId, $this->shop, $type, (int)($post['accountId'] ?? 0)));
+    }
+
+    public function actionSaveDraft()
+    {
+        $post = Yii::$app->request->post();
+        util::success(LakalaAccountService::saveDraft(
+            (int)($post['id'] ?? 0),
+            $this->mainId,
+            $this->shopId,
+            $this->form($post),
+            (int)($post['currentStep'] ?? 1)
+        ));
+    }
+
+    public function actionDeleteDraft()
+    {
+        LakalaAccountService::deleteDraft((int)Yii::$app->request->post('id'), $this->mainId, $this->shopId);
+        util::complete();
+    }
+
+    public function actionUploadFile()
+    {
+        $post = Yii::$app->request->post();
+        util::success(LakalaAccountService::upload(
+            (int)($post['id'] ?? 0),
+            $this->mainId,
+            $this->shopId,
+            $post['content'] ?? '',
+            $post['imgType'] ?? '',
+            (int)($post['isOcr'] ?? 0)
+        ));
+    }
+
+    public function actionOcrResult()
+    {
+        $get = Yii::$app->request->get();
+        util::success(LakalaAccountService::ocr(
+            (int)($get['id'] ?? 0),
+            $this->mainId,
+            $this->shopId,
+            $get['batchNo'] ?? '',
+            $get['imgType'] ?? ''
+        ));
+    }
+
+    public function actionOption()
+    {
+        $get = Yii::$app->request->get();
+        $option = LakalaAccountService::option($get['type'] ?? '', $get['parentCode'] ?? '', $get['keyword'] ?? '');
+        util::success($option);
+    }
+
+    public function actionApplyContract()
+    {
+        $contract = LakalaAccountService::applyContract((int)Yii::$app->request->post('id'), $this->mainId, $this->shopId);
+        util::success($contract);
+    }
+
+    public function actionContractStatus()
+    {
+        util::success(LakalaAccountService::contractStatus((int)Yii::$app->request->get('id'), $this->mainId, $this->shopId));
+    }
+
+    public function actionSubmit()
+    {
+        $post = Yii::$app->request->post();
+        $submit = LakalaAccountService::submit((int)($post['id'] ?? 0), $this->mainId, $this->shopId, $this->form($post));
+        util::success($submit);
+    }
+
+    public function actionRefresh()
+    {
+        $id = (int)Yii::$app->request->post('id');
+        $re = LakalaAccountService::refresh($id, $this->mainId, $this->shopId);
+        util::success($re);
+    }
+
+    public function actionSetCurrent()
+    {
+        $accountId = (int)Yii::$app->request->post('accountId');
+        LakalaAccountService::setCurrent($accountId, $this->mainId, $this->shopId);
+        util::complete('已设置为当前收款账户');
+    }
+
+    private function form(array $post)
+    {
+        $form = $post['formData'] ?? [];
+        if (is_string($form)) {
+            $form = json_decode($form, true);
+        }
+        if (!is_array($form)) {
+            util::fail('表单数据格式错误');
+        }
+        return $form;
+    }
+}

BIN
app-ghs/web/image/lakalaJingjian/拉卡拉微信授权.png


BIN
app-ghs/web/image/lakalaJingjian/拉卡拉支付宝授权.png


BIN
app-ghs/web/image/lakalaJingjian/系统微信授权.png


+ 10 - 0
biz-ghs/lakala/classes/LakalaAccountClass.php

@@ -0,0 +1,10 @@
+<?php
+
+namespace bizGhs\lakala\classes;
+
+use bizGhs\base\classes\BaseClass;
+
+class LakalaAccountClass extends BaseClass
+{
+    public static $baseFile = '\bizGhs\lakala\models\LakalaAccount';
+}

+ 19 - 0
biz-ghs/lakala/classes/LakalaApplicationClass.php

@@ -0,0 +1,19 @@
+<?php
+
+namespace bizGhs\lakala\classes;
+
+use bizGhs\base\classes\BaseClass;
+
+class LakalaApplicationClass extends BaseClass
+{
+    public static $baseFile = '\bizGhs\lakala\models\LakalaApplication';
+
+    const TYPE_ONBOARDING = 'onboarding';
+    const TYPE_SETTLEMENT_CHANGE = 'settlement_change';
+
+    const STATUS_DRAFT = 'DRAFT';
+    const STATUS_CONTRACT_PENDING = 'CONTRACT_PENDING';
+    const STATUS_WAIT_AUDIT = 'WAIT_AUDIT';
+    const STATUS_REJECT = 'REJECT';
+    const STATUS_APPROVED = 'APPROVED';
+}

+ 10 - 0
biz-ghs/lakala/classes/LakalaApplicationVersionClass.php

@@ -0,0 +1,10 @@
+<?php
+
+namespace bizGhs\lakala\classes;
+
+use bizGhs\base\classes\BaseClass;
+
+class LakalaApplicationVersionClass extends BaseClass
+{
+    public static $baseFile = '\bizGhs\lakala\models\LakalaApplicationVersion';
+}

+ 10 - 0
biz-ghs/lakala/classes/LakalaOperationClass.php

@@ -0,0 +1,10 @@
+<?php
+
+namespace bizGhs\lakala\classes;
+
+use bizGhs\base\classes\BaseClass;
+
+class LakalaOperationClass extends BaseClass
+{
+    public static $baseFile = '\bizGhs\lakala\models\LakalaOperation';
+}

+ 13 - 0
biz-ghs/lakala/models/LakalaAccount.php

@@ -0,0 +1,13 @@
+<?php
+
+namespace bizGhs\lakala\models;
+
+use bizGhs\base\models\Base;
+
+class LakalaAccount extends Base
+{
+    public static function tableName()
+    {
+        return 'xhLakalaAccount';
+    }
+}

+ 13 - 0
biz-ghs/lakala/models/LakalaApplication.php

@@ -0,0 +1,13 @@
+<?php
+
+namespace bizGhs\lakala\models;
+
+use bizGhs\base\models\Base;
+
+class LakalaApplication extends Base
+{
+    public static function tableName()
+    {
+        return 'xhLakalaApplication';
+    }
+}

+ 13 - 0
biz-ghs/lakala/models/LakalaApplicationVersion.php

@@ -0,0 +1,13 @@
+<?php
+
+namespace bizGhs\lakala\models;
+
+use bizGhs\base\models\Base;
+
+class LakalaApplicationVersion extends Base
+{
+    public static function tableName()
+    {
+        return 'xhLakalaApplicationVersion';
+    }
+}

+ 13 - 0
biz-ghs/lakala/models/LakalaOperation.php

@@ -0,0 +1,13 @@
+<?php
+
+namespace bizGhs\lakala\models;
+
+use bizGhs\base\models\Base;
+
+class LakalaOperation extends Base
+{
+    public static function tableName()
+    {
+        return 'xhLakalaOperation';
+    }
+}

+ 768 - 0
biz-ghs/lakala/services/LakalaAccountService.php

@@ -0,0 +1,768 @@
+<?php
+
+namespace bizGhs\lakala\services;
+
+use bizGhs\lakala\classes\LakalaAccountClass;
+use bizGhs\lakala\classes\LakalaApplicationClass;
+use bizGhs\lakala\classes\LakalaApplicationVersionClass;
+use bizGhs\lakala\classes\LakalaOperationClass;
+use bizGhs\shop\classes\ShopClass;
+use common\components\lakala\LakalaOnboardingClient;
+use common\components\oss;
+use common\components\stringUtil;
+use common\components\util;
+use Yii;
+
+class LakalaAccountService
+{
+    const RATE = '0.33';
+    const MCC = '5992';
+    const ACTIVITY_ID = '37';
+
+    public static function list($mainId, $shopId)
+    {
+        $applications = LakalaApplicationClass::getAllByCondition(
+            ['mainId' => $mainId, 'shopId' => $shopId],
+            ['id' => SORT_DESC],
+            '*'
+        );
+        $accounts = LakalaAccountClass::getAllByCondition(
+            ['mainId' => $mainId, 'shopId' => $shopId],
+            ['isCurrent' => SORT_DESC, 'id' => SORT_DESC],
+            '*'
+        );
+        foreach ($applications as &$application) {
+            $application['formData'] = self::decode($application['formData'] ?? '');
+        }
+        return ['applications' => $applications, 'accounts' => $accounts];
+    }
+
+    public static function detail($id, $mainId, $shopId)
+    {
+        $application = self::application($id, $mainId, $shopId);
+        $application['formData'] = self::decode($application['formData'] ?? '');
+        return $application;
+    }
+
+    public static function create($mainId, $shopId, $shop, $type = LakalaApplicationClass::TYPE_ONBOARDING, $accountId = 0)
+    {
+        $now = date('Y-m-d H:i:s');
+        $form = self::defaultForm($shop);
+        if ($type === LakalaApplicationClass::TYPE_SETTLEMENT_CHANGE && $accountId > 0) {
+            $account = LakalaAccountClass::getByCondition(['id' => $accountId, 'mainId' => $mainId, 'shopId' => $shopId]);
+            if (empty($account)) {
+                util::fail('没有找到收款账户');
+            }
+            $form['mode'] = 'settlement-change';
+            $form['settlement']['accountName'] = $account['accountName'] ?? '';
+            $form['settlement']['accountNo'] = $account['accountNo'] ?? '';
+            $form['settlement']['bankName'] = $account['bankName'] ?? '';
+        }
+        $application = LakalaApplicationClass::add([
+            'mainId' => $mainId,
+            'shopId' => $shopId,
+            'accountId' => $accountId,
+            'type' => $type,
+            'status' => LakalaApplicationClass::STATUS_DRAFT,
+            'currentStep' => $type === LakalaApplicationClass::TYPE_SETTLEMENT_CHANGE ? 3 : 1,
+            'formData' => self::encode($form),
+            'createTime' => $now,
+            'updateTime' => $now,
+        ], true);
+        return self::detail($application->id, $mainId, $shopId);
+    }
+
+    public static function saveDraft($id, $mainId, $shopId, array $form, $step)
+    {
+        $application = self::application($id, $mainId, $shopId, true);
+        if (!in_array($application->status, [LakalaApplicationClass::STATUS_DRAFT, LakalaApplicationClass::STATUS_REJECT], true)) {
+            util::fail('当前申请不能修改');
+        }
+        $application->formData = self::encode($form);
+        $application->currentStep = max(1, min(5, (int)$step));
+        $application->updateTime = date('Y-m-d H:i:s');
+        $application->save();
+        return self::detail($id, $mainId, $shopId);
+    }
+
+    public static function deleteDraft($id, $mainId, $shopId)
+    {
+        $application = self::application($id, $mainId, $shopId);
+        if ($application['status'] !== LakalaApplicationClass::STATUS_DRAFT) {
+            util::fail('仅草稿可以删除');
+        }
+        LakalaApplicationClass::deleteById($id);
+    }
+
+    public static function upload($id, $mainId, $shopId, $base64, $imgType, $ocr)
+    {
+        self::application($id, $mainId, $shopId);
+        if (!preg_match('/^data:image\/[a-zA-Z0-9.+-]+;base64,/', $base64)) {
+            util::fail('图片格式错误');
+        }
+        $content = base64_decode(substr($base64, strpos($base64, ',') + 1), true);
+        if ($content === false || strlen($content) > 10 * 1024 * 1024) {
+            util::fail('图片内容无效或超过10MB');
+        }
+        $object = 'lakala-onboarding/' . $mainId . '/' . date('Ymd') . '/' . $id . '/' . stringUtil::uniqueFileName() . '.jpg';
+        oss::uploadContent($object, $content);
+        $response = (new LakalaOnboardingClient())->upload($base64, $imgType, (bool)$ocr);
+        return [
+            'ossPath' => $object,
+            'imgType' => $imgType,
+            'batchNo' => $response['data']['batch_no'] ?? '',
+            'url' => $response['data']['url'] ?? '',
+            'status' => $response['data']['status'] ?? '',
+            'ocr' => $response['data']['result'] ?? null,
+        ];
+    }
+
+    public static function ocr($id, $mainId, $shopId, $batchNo, $imgType)
+    {
+        self::application($id, $mainId, $shopId);
+        $response = (new LakalaOnboardingClient())->ocr($batchNo, $imgType);
+        return $response['data'];
+    }
+
+    public static function option($type, $parentCode = '', $keyword = '')
+    {
+        $client = new LakalaOnboardingClient();
+        if ($type === 'region') {
+            return $client->query('/api/v3/tkbs/organization_parent_code', ['parent_code' => $parentCode ?: '1'])['data'];
+        }
+        if ($type === 'bank-region') {
+            return $client->query('/api/v3/tkbs/organization_bank_parent_code', ['parent_code' => $parentCode ?: '1'])['data'];
+        }
+        if ($type === 'bank') {
+            return $client->query('/api/v3/tkbs/bank', ['area_code' => $parentCode, 'bank_name' => $keyword])['data'];
+        }
+        util::fail('不支持的选项类型');
+    }
+
+    public static function applyContract($id, $mainId, $shopId)
+    {
+        $application = self::application($id, $mainId, $shopId, true);
+        $form = self::decode($application->formData);
+        self::validateContract($form);
+        $client = new LakalaOnboardingClient();
+        $orderNo = date('YmdHis') . substr(str_shuffle('0123456789'), 0, 8);
+        $request = self::contractRequest($form, $orderNo, $client->orgCode());
+        $response = $client->call('/api/v3/mms/open_api/ec/apply', $request);
+        $application->contractOrderNo = $orderNo;
+        $application->contractApplyId = $response['data']['ec_apply_id'] ?? '';
+        $application->contractStatus = 'UNDONE';
+        $application->status = LakalaApplicationClass::STATUS_CONTRACT_PENDING;
+        $application->updateTime = date('Y-m-d H:i:s');
+        $application->save();
+        self::operation($application, 0, 'contract', '', $request, $response, 'UNDONE');
+        return ['resultUrl' => $response['data']['result_url'] ?? '', 'status' => 'UNDONE'];
+    }
+
+    public static function contractStatus($id, $mainId, $shopId)
+    {
+        $application = self::application($id, $mainId, $shopId, true);
+        if (empty($application->contractApplyId) || empty($application->contractOrderNo)) {
+            util::fail('请先生成电子合同');
+        }
+        $client = new LakalaOnboardingClient();
+        $response = $client->query('/api/v3/mms/open_api/ec/q_status', [
+            'ec_apply_id' => $application->contractApplyId,
+            'order_no' => $application->contractOrderNo,
+        ]);
+        $application->contractStatus = $response['data']['ec_status'] ?? '';
+        $application->contractNo = $response['data']['ec_no'] ?? '';
+        $application->updateTime = date('Y-m-d H:i:s');
+        $application->save();
+        return ['status' => $application->contractStatus, 'contractNo' => $application->contractNo];
+    }
+
+    public static function submit($id, $mainId, $shopId, array $form)
+    {
+        $application = self::application($id, $mainId, $shopId, true);
+        if ($application->type === LakalaApplicationClass::TYPE_SETTLEMENT_CHANGE) {
+            return self::submitSettlementChange($application, $form);
+        }
+        self::validateOnboarding($form);
+        if ($application->contractStatus !== 'COMPLETED' || empty($application->contractNo)) {
+            util::fail('请先完成电子合同签约并刷新签约状态');
+        }
+        $client = new LakalaOnboardingClient();
+        $request = self::merchantRequest($form, $application->contractNo, $client);
+        $response = $client->call('/api/v3/tkbs/merchant_encry', $request, 'both');
+        $application->formData = self::encode($form);
+        $application->merchantNo = $response['data']['merchant_no'] ?? '';
+        $application->customerNo = $response['data']['merchant_no'] ?? '';
+        $application->status = LakalaApplicationClass::STATUS_WAIT_AUDIT;
+        $application->rejectReason = '';
+        $application->versionNo = ((int)$application->versionNo) + 1;
+        $application->updateTime = date('Y-m-d H:i:s');
+        $application->save();
+        self::version($application, $request, $response);
+        self::operation($application, 0, 'onboarding', '', $request, $response, $application->status);
+        return self::detail($application->id, $mainId, $shopId);
+    }
+
+    public static function refresh($id, $mainId, $shopId)
+    {
+        $application = self::application($id, $mainId, $shopId, true);
+        if ($application->type === LakalaApplicationClass::TYPE_SETTLEMENT_CHANGE) {
+            return self::refreshChange($application);
+        }
+        if (empty($application->customerNo) && empty($application->merchantNo)) {
+            util::fail('申请尚未提交');
+        }
+        $client = new LakalaOnboardingClient();
+        $response = $client->query('/api/v3/tkbs/open_merchant_info', [
+            'customer_no' => $application->customerNo,
+        ], 'both');
+        $customer = $response['data']['customer'];
+        $status = $customer['customer_status'];
+        if (in_array($status, ['REJECT', 'REVIEW_FAIL'], true)) {
+            $application->status = LakalaApplicationClass::STATUS_REJECT;
+            $application->rejectReason = $customer['audit_remark'] ?? '拉卡拉审核驳回';
+        } elseif (in_array($status, ['OPEN', 'ACTIVITY'], true)) {
+            $application->status = LakalaApplicationClass::STATUS_APPROVED;
+            $application->merchantNo = $customer['merchant_no'] ?? $application->merchantNo;
+            $application->customerNo = $customer['customer_no'] ?? $application->customerNo;
+            $shopInfo = $response['data']['shop_info_list'][0] ?? [];
+            $application->shopNo = $shopInfo['shop_id'] ?? $application->shopNo;
+            $account = self::upsertAccount($application, $response['data']);
+            self::ensureTermOperations($application, $account, $client);
+            self::syncTerms($application, $account, $client);
+        } else {
+            $application->status = LakalaApplicationClass::STATUS_WAIT_AUDIT;
+        }
+        $application->updateTime = date('Y-m-d H:i:s');
+        $application->save();
+        return self::detail($application->id, $mainId, $shopId);
+    }
+
+    public static function setCurrent($accountId, $mainId, $shopId)
+    {
+        $account = LakalaAccountClass::getByCondition(['id' => $accountId, 'mainId' => $mainId, 'shopId' => $shopId], true);
+        if (empty($account)) {
+            util::fail('没有找到收款账户');
+        }
+        if (empty($account->scanTermNo) || empty($account->b2bTermNo)) {
+            util::fail('账户业务尚未全部开通');
+        }
+        $transaction = Yii::$app->db->beginTransaction();
+        try {
+            LakalaAccountClass::updateByCondition(['shopId' => $shopId], ['isCurrent' => 0, 'updateTime' => date('Y-m-d H:i:s')]);
+            $account->isCurrent = 1;
+            $account->updateTime = date('Y-m-d H:i:s');
+            $account->save();
+            ShopClass::updateById($shopId, [
+                'lklSjNo' => $account->merchantNo,
+                'lklScanTermNo' => $account->scanTermNo,
+                'lklB2BTermNo' => $account->b2bTermNo,
+            ]);
+            $transaction->commit();
+        } catch (\Throwable $e) {
+            $transaction->rollBack();
+            throw $e;
+        }
+    }
+
+    private static function submitSettlementChange($application, array $form)
+    {
+        self::validateSettlement($form['settlement'] ?? []);
+        $account = LakalaAccountClass::getByCondition(['id' => $application->accountId, 'mainId' => $application->mainId], true);
+        if (empty($account)) {
+            util::fail('没有找到收款账户');
+        }
+        $client = new LakalaOnboardingClient();
+        $request = [
+            'org_code' => $client->orgCode(),
+            'customer_no' => $account->customerNo,
+            'customer_settle_update_dto' => self::settlementDto($form['settlement']),
+        ];
+        $response = $client->call('/api/v3/tkbs/open_merchant_update_settle_encry', $request, 'both');
+        $application->formData = self::encode($form);
+        $application->reviewRelatedId = $response['data']['review_related_id'] ?? '';
+        $application->status = LakalaApplicationClass::STATUS_WAIT_AUDIT;
+        $application->versionNo = ((int)$application->versionNo) + 1;
+        $application->updateTime = date('Y-m-d H:i:s');
+        $application->save();
+        self::version($application, $request, $response);
+        self::operation($application, $account->id, 'settlement_change', '', $request, $response, 'PASSING');
+        return self::detail($application->id, $application->mainId, $application->shopId);
+    }
+
+    private static function refreshChange($application)
+    {
+        if (empty($application->reviewRelatedId)) {
+            util::fail('变更申请尚未提交');
+        }
+        $client = new LakalaOnboardingClient();
+        $response = $client->query('/api/v3/tkbs/customer_update_review', ['review_related_id' => $application->reviewRelatedId]);
+        $review = $response['data']['review_pass'] ?? '';
+        if ($review === 'PASS') {
+            $application->status = LakalaApplicationClass::STATUS_APPROVED;
+            $account = LakalaAccountClass::getById($application->accountId, true);
+            if (!empty($account)) {
+                $settlement = self::decode($application->formData)['settlement'] ?? [];
+                $account->accountName = $settlement['accountName'] ?? $account->accountName;
+                $account->accountNo = $settlement['accountNo'] ?? $account->accountNo;
+                $account->bankName = $settlement['bankName'] ?? $account->bankName;
+                $account->accountType = $settlement['accountType'] ?? $account->accountType;
+                $account->updateTime = date('Y-m-d H:i:s');
+                $account->save();
+            }
+        } elseif ($review === 'UNPASS') {
+            $application->status = LakalaApplicationClass::STATUS_REJECT;
+            $application->rejectReason = $response['data']['review_result'] ?? '变更审核驳回';
+        } else {
+            $application->status = LakalaApplicationClass::STATUS_WAIT_AUDIT;
+        }
+        $application->updateTime = date('Y-m-d H:i:s');
+        $application->save();
+        return self::detail($application->id, $application->mainId, $application->shopId);
+    }
+
+    private static function ensureTermOperations($application, $account, $client)
+    {
+        foreach (['WECHAT_PAY', 'B2B_SYT'] as $bizType) { // 'B2B_SYT' -- 聚合收银台已经有了,不需要额外创建
+            $operation = LakalaOperationClass::getByCondition([
+                'applicationId' => $application->id,
+                'operationType' => 'add_term',
+                'bizType' => $bizType,
+                'status!=' => 'FAILED',
+            ], true);
+            if (!empty($operation)) {
+                continue;
+            }
+            $request = [
+                'merchant_no' => $account->merchantNo,
+                'bz_pos' => $bizType,
+                'term_num' => 1,
+                'shop_id' => $account->shopNo,
+                'org_code' => $client->orgCode(),
+                'fees' => self::termFees(),
+                'attachments' => [],
+            ];
+            try {
+                $response = $client->call('/api/v3/tkbs/open_merchant_addTerm', $request, 'both');
+                self::operation($application, $account->id, 'add_term', $bizType, $request, $response, 'PASSING');
+            } catch (\Throwable $e) {
+                self::operation($application, $account->id, 'add_term', $bizType, $request, [], 'FAILED', $e->getMessage());
+            }
+        }
+    }
+
+    private static function syncTerms($application, $account, $client)
+    {
+        $operations = LakalaOperationClass::getAllByCondition(
+            ['applicationId' => $application->id, 'operationType' => 'add_term'],
+            null,
+            '*',
+            null,
+            true
+        );
+        foreach ($operations as $operation) {
+            if (!empty($operation->reviewRelatedId) && !in_array($operation->status, ['PASS', 'FAILED'], true)) {
+                $review = $client->query('/api/v3/tkbs/customer_update_review', ['review_related_id' => $operation->reviewRelatedId]);
+                $operation->status = $review['data']['review_pass'] ?? $operation->status;
+                $operation->responseData = self::encode($review);
+                $operation->errorMessage = $review['data']['review_result'] ?? '';
+                $operation->updateTime = date('Y-m-d H:i:s');
+                $operation->save();
+            }
+        }
+        try {
+            $res = $client->query('/api/v3/tkbs/open_merchant_info', [
+                'customer_no' => $account->customerNo ?: $application->customerNo,
+                'merchant_no' => $account->merchantNo ?: $application->merchantNo,
+            ], 'both');
+            $merchantInfo = $res['data'];
+            $customer = $merchantInfo['customer'];
+            $terms = self::merchantInfoTermMap($merchantInfo);
+            $merchantNo = $customer['merchant_no'] ?? '';
+            if ($merchantNo !== '') {
+                $application->merchantNo = $merchantNo;
+                $account->merchantNo = $merchantNo;
+            }
+            $account->scanTermNo = $terms['scanTermNo'][0];
+            $account->b2bTermNo = $terms['b2BTermNo'][0];
+            $account->updateTime = date('Y-m-d H:i:s');
+            $account->save();
+            $application->save();
+        } catch (\Throwable $e) {
+            Yii::warning('Unable to sync Lakala merchant info terms: ' . $e->getMessage());
+        }
+    }
+
+    private static function upsertAccount($application, array $remote)
+    {
+        $account = LakalaAccountClass::getByCondition(['mainId' => $application->mainId, 'merchantNo' => $application->merchantNo], true);
+        $form = self::decode($application->formData);
+        $settlement = $form['settlement'] ?? [];
+        $license = $form['license'] ?? [];
+        $shopInfo = $remote['shop_info_list'][0] ?? [];
+        $data = [
+            'mainId' => $application->mainId,
+            'shopId' => $application->shopId,
+            'applicationId' => $application->id,
+            'merchantNo' => $application->merchantNo,
+            'customerNo' => $application->customerNo,
+            'shopNo' => $shopInfo['shop_id'] ?? $application->shopNo,
+            'accountName' => $settlement['accountName'] ?? '',
+            'accountNo' => $settlement['accountNo'] ?? '',
+            'bankName' => $settlement['bankName'] ?? '',
+            'accountType' => $settlement['accountType'] ?? '58',
+            'licenseName' => $license['licenseName'] ?? ($license['legalName'] ?? ''),
+            'identityExpire' => $license['legalIdEnd'] ?? '',
+            'status' => 'ACTIVE',
+            'updateTime' => date('Y-m-d H:i:s'),
+        ];
+        if (empty($account)) {
+            $data['createTime'] = date('Y-m-d H:i:s');
+            return LakalaAccountClass::add($data, true);
+        }
+        foreach ($data as $key => $value) {
+            $account->$key = $value;
+        }
+        $account->save();
+        return $account;
+    }
+
+    private static function defaultForm($shop)
+    {
+        $merchantName = $shop->merchantName ?? ($shop->shopName ?? '');
+        $mobile = $shop->mobile ?? ($shop->telephone ?? '');
+        return [
+            'mode' => 'create',
+            'license' => [
+                'merchantType' => 'TP_PERSONAL', 'licenseName' => '', 'licenseNo' => '',
+                'licenseStart' => '', 'licenseEnd' => '9999-12-31', 'registeredAddress' => '',
+                'businessContent' => '', 'legalName' => '', 'legalIdNo' => '',
+                'legalIdStart' => '', 'legalIdEnd' => '9999-12-31', 'attachments' => [],
+            ],
+            'merchant' => [
+                'businessName' => ($shop->city ?? '') . $merchantName,
+                'provinceCode' => '', 'cityCode' => '', 'countyCode' => '',
+                'provinceName' => $shop->province ?? '', 'cityName' => $shop->city ?? '',
+                'countyName' => $shop->dist ?? '', 'address' => $shop->address ?? ($shop->fullAddress ?? ''),
+                'contactName' => '石少华', 'contactMobile' => '15280215347', 'email' => '479439056@qq.com',
+            ],
+            'settlement' => [
+                'accountType' => '58', 'isLegalPerson' => true, 'accountName' => '',
+                'accountNo' => '', 'bankName' => '', 'bankNo' => '', 'clearingBankNo' => '',
+                'provinceCode' => '', 'provinceName' => '', 'cityCode' => '', 'cityName' => '',
+                'accountIdNo' => '', 'accountIdStart' => '', 'accountIdEnd' => '', 'attachments' => [],
+            ],
+            'shop' => [
+                'name' => $merchantName, 'provinceCode' => '', 'cityCode' => '', 'countyCode' => '',
+                'provinceName' => $shop->province ?? '', 'cityName' => $shop->city ?? '',
+                'countyName' => $shop->dist ?? '', 'address' => $shop->address ?? ($shop->fullAddress ?? ''),
+                'contactName' => '', 'contactMobile' => $mobile, 'longitude' => $shop->long ?? ($shop->shopLong ?? ''),
+                'latitude' => $shop->lat ?? ($shop->shopLat ?? ''), 'attachments' => [],
+            ],
+            'business' => ['signMobile' => $mobile ?: '15280215347', 'rate' => self::RATE, 'settleType' => 'D1', 'settlementType' => 'AUTOMATIC'],
+        ];
+    }
+
+    private static function merchantRequest(array $form, $contractNo, $client)
+    {
+        $l = $form['license'];
+        $m = $form['merchant'];
+        $s = $form['settlement'];
+        $shop = $form['shop'];
+
+        return [
+            'org_code' => $client->orgCode(),
+            'user_no' => $client->userNo(),
+            'email' => $m['email'],
+            'busi_code' => 'WECHAT_PAY',
+            'mer_reg_name' => $m['businessName'],
+            'mer_type' => $l['merchantType'],
+            'mer_name' => $m['businessName'],
+            'mer_addr' => $m['address'],
+            'province_code' => $m['provinceCode'],
+            'city_code' => $m['cityCode'],
+            'county_code' => $m['countyCode'],
+            'license_name' => $l['licenseName'],
+            'license_no' => $l['licenseNo'],
+            'license_dt_start' => $l['licenseStart'],
+            'license_dt_end' => $l['licenseEnd'],
+            'latitude' => $shop['longitude'],
+            'longtude' => $shop['latitude'],
+            'source' => 'APP',
+            'business_content' => $l['businessContent'],
+            'is_legal_person' => (bool)$s['isLegalPerson'],
+            'lar_name' => $l['legalName'], 'lar_id_type' => '01', 'lar_id_card' => $l['legalIdNo'],
+            'lar_id_card_start' => $l['legalIdStart'], 'lar_id_card_end' => $l['legalIdEnd'],
+            'contact_mobile' => $m['contactMobile'],
+            'contact_name' => $m['contactName'],
+            'openning_bank_code' => $s['bankNo'],
+            'openning_bank_name' => $s['bankName'],
+            'clearing_bank_code' => $s['clearingBankNo'], 'settle_province_code' => $s['provinceCode'],
+            'settle_province_name' => $s['provinceName'], 'settle_city_code' => $s['cityCode'],
+            'settle_city_name' => $s['cityName'], 'account_no' => $s['accountNo'], 'account_name' => $s['accountName'],
+            'account_type' => $s['accountType'], 'account_id_card' => $s['accountIdNo'] ?: $l['legalIdNo'],
+            'account_id_dt_start' => $s['accountIdStart'] ?: $l['legalIdStart'],
+            'account_id_dt_end' => $s['accountIdEnd'] ?: $l['legalIdEnd'],
+            'settle_type' => 'D1', 'settlement_type' => 'AUTOMATIC', 'contract_no' => $contractNo,
+            'biz_content' => ['term_num' => '1', 'mcc' => self::MCC, 'activity_id' => self::activityId(), 'fees' => self::merchantFees()],
+            'attchments' => self::attachments($form),
+        ];
+    }
+
+    private static function contractRequest(array $form, $orderNo, $orgCode)
+    {
+        $l = $form['license'];
+        $m = $form['merchant'];
+        $s = $form['settlement'];
+        $shop = $form['shop'];
+        $b = $form['business'];
+        $content = [];
+
+        foreach (['A' => 130, 'B' => 58, 'C' => 56, 'D' => 17, 'E' => 79] as $prefix => $max) {
+            for ($i = 1; $i <= $max; $i++) {
+                $content[$prefix . $i] = '/';
+            }
+        }
+        $content = array_merge($content, [
+            'A1' => $l['licenseName'] ?: $l['legalName'], 'A2' => '是', 'A6' => '是',
+            'A7' => '0.50', 'A8' => '20', 'A9' => '是', 'A10' => '0.60',
+            'A11' => '是', 'A12' => self::RATE, 'A13' => '是', 'A14' => self::RATE,
+            'A15' => '0.60', 'A34' => self::RATE, 'A35' => self::RATE,
+            'A63' => '是', 'A64' => self::RATE, 'A65' => '是', 'A66' => self::RATE,
+            'A116' => '自动结算',
+            'A117' => '是',
+            'A121' => ($shop['provinceName'] ?? '') . ($shop['cityName'] ?? ''),
+            'A124' => date('Y'), 'A125' => date('m'), 'A126' => date('d'), // 甲方签约 年/月/日
+            'A127' => date('Y'), 'A128' => date('m'), 'A129' => date('d'), // 乙方签约 年/月/日
+            'A130' => $l['legalName'] ?: $m['contactName'], // 甲方电子签名/签字/盖章
+            // ===== B 系列:商户注册登记 / 基本信息 / 结算账户 / 法人联系人 / 终端 / 拓展 =====
+            'B1' => date('Y'), 'B2' => date('m'), // 商户注册登记表 年/月
+            'B3' => '是',
+            'B8' => $l['licenseName'], // 基本信息:工商注册名称(中英文)
+            'B10' => $m['businessName'], // 基本信息:对外经营名称(中英文)
+            'B13' => ($m['provinceName'] ?? '') . ($m['cityName'] ?? ''), 'B14' => $l['licenseNo'] ?: '/',
+            'B18' => $l['legalName'],
+            'B19' => $s['bankName'],
+            'B20' => $s['accountNo'],
+            'B24' => $s['accountName'],
+            'B25' => $l['legalIdNo'], 'B26' => $b['signMobile'],
+            'B27' => $l['legalName'],
+            'B28' => $m['email'],
+            'B29' => $l['legalIdNo'],// 联系人证件号码
+            'B30' => $b['signMobile'],// 联系人手机号
+            'B31' => $l['licenseName'] ?: $l['legalName'],
+            'B32' => $l['legalName'],// 网点联系人姓名
+            'B33' => $m['address'],
+            'B34' => $m['contactMobile'],
+            // ===== D 系列:各类授权书 =====
+            'D1' => $s['bankName'],// 授权书(银行):结算账户所属银行
+            'D2' => $l['legalName'] ?: $m['contactName'],// 授权书(银行):授权承诺方签字
+            'D3' => date('Y/m/d'),// 授权书(银行):签章/签字日期
+            'D4' => $l['legalName'] ?: $m['contactName'],// 授权书(开通微信、支付宝收单):承诺授权方签名
+            'D5' => date('Y/m/d'),// 授权书(开通微信、支付宝收单):盖章/签字日期
+            // ===== E 系列:分银行费率明细(费率一致则不填,统一占位 "/")=====
+            'E78' => $l['legalName'] ?: $m['contactName'],// 电子签约服务授权委托书:承诺授权方签名
+            'E79' => $l['legalName'] ?: $m['contactName'],// 电子签约服务授权委托书:承诺签名或盖章
+        ]);
+        return [
+            'order_no' => $orderNo, 'org_id' => (int)$orgCode, 'ec_type_code' => 'EC015',
+            'cert_type' => 'RESIDENT_ID', 'cert_name' => $l['legalName'], 'cert_no' => $l['legalIdNo'],
+            'mobile' => $b['signMobile'], 'business_license_no' => $l['licenseNo'],
+            'business_license_name' => $l['licenseName'], 'openning_bank_code' => $s['bankNo'],
+            'openning_bank_name' => $s['bankName'], 'acct_type_code' => $s['accountType'],
+            'acct_no' => $s['accountNo'], 'acct_name' => $s['accountName'],
+            'ec_content_parameters' => self::encode($content), 'remark' => '销花宝商户入网',
+        ];
+    }
+
+    private static function settlementDto(array $s)
+    {
+        return [
+            'account_kind' => $s['accountType'], 'is_legal_person' => (bool)$s['isLegalPerson'],
+            'account_name' => $s['accountName'], 'identity_no' => $s['accountIdNo'],
+            'account_no' => $s['accountNo'], 'bank_no' => $s['bankNo'], 'bank_name' => $s['bankName'],
+            'clearing_bank_no' => $s['clearingBankNo'], 'settle_province_code' => $s['provinceCode'],
+            'settle_province_name' => $s['provinceName'], 'settle_city_code' => $s['cityCode'],
+            'settle_city_name' => $s['cityName'], 'attachments' => self::attachmentDtos($s['attachments'] ?? []),
+        ];
+    }
+
+    private static function merchantFees()
+    {
+        return [
+            ['fee_code' => 'WECHAT', 'fee_value' => self::RATE],
+            ['fee_code' => 'ALIPAY', 'fee_value' => self::RATE],
+            ['fee_code' => 'SCAN_PAY', 'fee_value' => self::RATE],
+            ['fee_code' => 'DEBIT_CARD', 'fee_value' => self::RATE, 'top_fee' => '20'],
+            ['fee_code' => 'CREDIT_CARD', 'fee_value' => '0.60'],
+            ['fee_code' => 'UNIONPAY_WALLET_DEBIT_FEE', 'fee_value' => self::RATE, 'top_fee' => '20'],
+            ['fee_code' => 'UNIONPAY_WALLET_CREDIT_FEE', 'fee_value' => self::RATE],
+        ];
+    }
+
+    private static function activityId()
+    {
+        return (string)(Yii::$app->params['lakalaOnboarding']['activity_id'] ?? getenv('LAKALA_ONBOARDING_ACTIVITY_ID') ?: self::ACTIVITY_ID);
+    }
+
+    private static function termFees()
+    {
+        return [
+            ['fee' => (float)self::RATE, 'top_fee' => 20, 'fee_type' => 'DEBIT_CARD'],
+            ['fee' => (float)self::RATE, 'fee_type' => 'CREDIT_CARD'],
+        ];
+    }
+
+    private static function attachments(array $form)
+    {
+        $all = [];
+        foreach (['license', 'settlement', 'shop'] as $section) {
+            $all = array_merge($all, self::attachmentDtos($form[$section]['attachments'] ?? [], true));
+        }
+        return $all;
+    }
+
+    private static function attachmentDtos(array $attachments, $merchantShape = false)
+    {
+        $result = [];
+        foreach ($attachments as $attachment) {
+            if (empty($attachment['url']) || empty($attachment['imgType'])) {
+                continue;
+            }
+            $result[] = $merchantShape
+                ? ['id' => $attachment['url'], 'type' => $attachment['imgType']]
+                : ['img_path' => $attachment['url'], 'img_type' => $attachment['imgType']];
+        }
+        return $result;
+    }
+
+    private static function validateContract(array $form)
+    {
+        self::validateOnboarding($form, false);
+        if (empty($form['business']['signMobile'])) {
+            util::fail('请填写签约手机号');
+        }
+    }
+
+    private static function validateOnboarding(array $form, $includeShop = true)
+    {
+        $l = $form['license'] ?? []; $m = $form['merchant'] ?? []; $s = $form['settlement'] ?? []; $shop = $form['shop'] ?? [];
+        $items = ['merchantType', 'legalName', 'legalIdNo', 'legalIdStart', 'legalIdEnd', 'businessContent'];
+        if ($l['merchantType'] === 'TP_PERSONAL') {
+            unset($items[5]);
+        }
+        foreach ($items as $key) {
+            if (empty($l[$key])) util::fail('执照信息未填写完整');
+        }
+        self::requireAttachments($l['attachments'] ?? [], ['ID_CARD_FRONT', 'ID_CARD_BEHIND'], '请上传法人身份证正反面');
+        if (($l['merchantType'] ?? '') === 'TP_MERCHANT' && (empty($l['licenseName']) || empty($l['licenseNo']))) {
+            util::fail('企业商户请填写营业执照信息');
+        }
+        if (($l['merchantType'] ?? '') === 'TP_MERCHANT') {
+            self::requireAttachments($l['attachments'] ?? [], ['BUSINESS_LICENCE'], '请上传营业执照');
+        }
+        foreach (['businessName', 'provinceCode', 'cityCode', 'countyCode', 'address'] as $key) {
+            if (empty($m[$key])) util::fail('商户信息未填写完整');
+        }
+        self::validateSettlement($s);
+        if ($includeShop) {
+            foreach (['name', 'address', 'contactName', 'contactMobile', 'longitude', 'latitude'] as $key) {
+                if (empty($shop[$key])) util::fail('门店信息未填写完整');
+            }
+            self::requireAttachments($shop['attachments'] ?? [], ['SHOP_OUTSIDE_IMG', 'SHOP_INSIDE_IMG', 'CHECKSTAND_IMG'], '请上传完整门店照片');
+        }
+    }
+
+    private static function validateSettlement(array $s)
+    {
+        foreach (['accountType', 'accountName', 'accountNo', 'bankName', 'bankNo', 'clearingBankNo', 'provinceCode', 'cityCode'] as $key) {
+            if (empty($s[$key])) util::fail('结算信息未填写完整');
+        }
+        if (($s['accountType'] ?? '58') === '57') {
+            self::requireAttachments($s['attachments'] ?? [], ['OPENING_PERMIT'], '请上传开户许可证');
+        } else {
+            self::requireAttachments($s['attachments'] ?? [], ['BANK_CARD'], '请上传银行卡照片');
+            if (empty($s['isLegalPerson'])) {
+                self::requireAttachments($s['attachments'] ?? [], ['ID_CARD_FRONT', 'ID_CARD_BEHIND', 'LETTER_OF_AUTHORIZATION'], '请上传结算人证件与法人授权函');
+            }
+        }
+    }
+
+    private static function requireAttachments(array $attachments, array $types, $message)
+    {
+        $present = array_column($attachments, 'imgType');
+        foreach ($types as $type) {
+            if (!in_array($type, $present, true)) {
+                util::fail($message);
+            }
+        }
+    }
+
+    private static function version($application, array $request, array $response)
+    {
+        LakalaApplicationVersionClass::add([
+            'applicationId' => $application->id, 'mainId' => $application->mainId,
+            'versionNo' => $application->versionNo, 'type' => $application->type,
+            'formData' => $application->formData, 'requestData' => self::encode($request),
+            'responseData' => self::encode($response), 'createTime' => date('Y-m-d H:i:s'),
+        ]);
+    }
+
+    private static function operation($application, $accountId, $type, $bizType, array $request, array $response, $status, $error = '')
+    {
+        return LakalaOperationClass::add([
+            'applicationId' => $application->id,
+            'accountId' => $accountId,
+            'mainId' => $application->mainId,
+            'operationType' => $type,
+            'bizType' => $bizType,
+            'status' => $status,
+            'reviewRelatedId' => $response['data']['review_related_id'] ?? '',
+            'requestData' => self::encode($request),
+            'responseData' => self::encode($response),
+            'errorMessage' => mb_substr($error, 0, 500),
+            'createTime' => date('Y-m-d H:i:s'),
+            'updateTime' => date('Y-m-d H:i:s'),
+        ], true);
+    }
+
+    private static function application($id, $mainId, $shopId, $object = false)
+    {
+        $application = LakalaApplicationClass::getByCondition(['id' => $id, 'mainId' => $mainId, 'shopId' => $shopId], $object);
+        if (empty($application)) {
+            util::fail('没有找到进件申请');
+        }
+        return $application;
+    }
+
+    private static function merchantInfoTermMap($value)
+    {
+        $result = [];
+        $terminalInfos = $value['terminal_info'];
+        foreach($terminalInfos as $term) {
+            if ($term['term_type_name'] == '专业化扫码') {
+                $result['scanTermNo'][] = $term['active_no_vo_list'][0]['term_no'];
+            }
+            if ($term['term_type_name'] == '聚合收银台') {
+                $list = $term['active_no_vo_list'];
+                foreach($list as $item) {
+                    if ($item['busi_type_code'] == 'QR_CODE_CARD') {
+                        $result['b2BTermNo'][] = $item['term_no'];
+                    }
+                }
+            }
+        }
+        return $result;
+    }
+
+    private static function encode($value)
+    {
+        return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+    }
+
+    private static function decode($value)
+    {
+        if (is_array($value)) return $value;
+        $decoded = json_decode((string)$value, true);
+        return is_array($decoded) ? $decoded : [];
+    }
+}

+ 114 - 0
common/components/lakala/LakalaOnboardingClient.php

@@ -0,0 +1,114 @@
+<?php
+
+namespace common\components\lakala;
+
+use Lakala\OpenAPISDK\V3\Api\LakalaApi;
+use Lakala\OpenAPISDK\V3\Configuration;
+use Lakala\OpenAPISDK\V3\Model\ModelRequest;
+use Yii;
+
+class LakalaOnboardingClient
+{
+    private $config;
+    private $orgCode;
+    private $userNo;
+
+    public function __construct()
+    {
+        $params = Yii::$app->params['lakalaOnboarding'] ?? [];
+        $production = getenv('YII_ENV') === 'production';
+        $defaults = [
+            'app_id' => 'OP00000003',
+            'serial_no' => '00dfba8194c41b84cf',
+            'sm4_key' => 'LHo55AjrT4aDhAIBZhb5KQ==',
+            'org_code' => '1951582',
+            'user_no' => '29153396',
+            'merchant_private_key_path' => Yii::getAlias('@vendor/lakalasdk/openapi/example/RSAKeys/DEV/OP00000003_private_key.pem'),
+            'lkl_certificate_path' => Yii::getAlias('@vendor/lakalasdk/openapi/example/RSAKeys/DEV/lkl-apigw-v2.cer'),
+        ];
+        if (!$production) {
+            $params = array_merge($defaults, $params);
+        }
+        $params = array_merge($params, array_filter([
+            'app_id' => getenv('LAKALA_ONBOARDING_APP_ID'),
+            'serial_no' => getenv('LAKALA_ONBOARDING_SERIAL_NO'),
+            'sm4_key' => getenv('LAKALA_ONBOARDING_SM4_KEY'),
+            'org_code' => getenv('LAKALA_ONBOARDING_ORG_CODE'),
+            'user_no' => getenv('LAKALA_ONBOARDING_USER_NO'),
+            'merchant_private_key_path' => getenv('LAKALA_ONBOARDING_PRIVATE_KEY_PATH'),
+            'lkl_certificate_path' => getenv('LAKALA_ONBOARDING_CERTIFICATE_PATH'),
+        ]));
+        foreach (['app_id', 'serial_no', 'sm4_key', 'org_code', 'user_no', 'merchant_private_key_path', 'lkl_certificate_path'] as $key) {
+            if (empty($params[$key])) {
+                throw new \RuntimeException('缺少拉卡拉进件生产配置:' . $key);
+            }
+        }
+        $this->orgCode = (string)$params['org_code'];
+        $this->userNo = (string)$params['user_no'];
+        $this->config = new Configuration([
+            'app_debug' => !$production,
+            'host_test' => 'https://test.wsmsd.cn/sit',
+            'host_pro' => 'https://s2.lakala.com',
+            'app_id' => $params['app_id'],
+            'serial_no' => $params['serial_no'],
+            'sm4_key' => $params['sm4_key'],
+            'merchant_private_key_path' => $params['merchant_private_key_path'],
+            'lkl_certificate_path' => $params['lkl_certificate_path'],
+        ]);
+    }
+
+    public function orgCode()
+    {
+        return $this->orgCode;
+    }
+
+    public function userNo()
+    {
+        return $this->userNo;
+    }
+
+    public function call($path, array $data, $mode = 'none')
+    {
+        $api = new LakalaApi($this->config, $mode);
+        $request = new ModelRequest();
+        $request->setReqData($data);
+        try {
+            $response = $api->tradeApi($path, $request);
+            $raw = json_decode($response->getOriginalText(), true);
+            if ($response->getCode() !== '000000' && $response->getCode() !== 'BBS00000') {
+                throw new \RuntimeException($response->getMsg() ?: '拉卡拉接口请求失败');
+            }
+            return [
+                'code' => $response->getCode(),
+                'msg' => $response->getMsg(),
+                'data' => json_decode(json_encode($response->getRespData()), true),
+                'raw' => $raw,
+            ];
+        } catch (\Throwable $e) {
+            Yii::error('Lakala onboarding ' . $path . ': ' . $e->getMessage());
+            throw new \RuntimeException('拉卡拉接口请求失败:' . $e->getMessage());
+        }
+    }
+
+    public function upload($base64, $imgType, $ocr = false)
+    {
+        return $this->call('/api/v3/tkbs/customer/file/upload', [
+            'file_base64' => $base64,
+            'img_type' => $imgType,
+            'prefix' => 'reg',
+            'sourcechnl' => 1,
+            'is_ocr' => $ocr ? 'true' : 'false',
+        ]);
+    }
+
+    public function ocr($batchNo, $imgType)
+    {
+        return $this->call('/api/v3/tkbs/ocr_result', ['batch_no' => $batchNo, 'img_type' => $imgType]);
+    }
+
+    public function query($path, array $data, $mode = 'none')
+    {
+        $data['org_code'] = $data['org_code'] ?? $this->orgCode;
+        return $this->call($path, $data, $mode);
+    }
+}

+ 2 - 2
composer.lock

@@ -1115,7 +1115,7 @@
             "source": {
                 "type": "git",
                 "url": "git@git.huaml.com:shizhongqi/lakalasdk.git",
-                "reference": "2b6223de6c184be975cccac8cfda55eefeaa5c7d"
+                "reference": "00b04335ea20d6ed35f08dce1d674076fbafac43"
             },
             "require": {
                 "php": "^7.0 || ^8.0"
@@ -1156,7 +1156,7 @@
                 "rest",
                 "sdk"
             ],
-            "time": "2026-06-10T02:30:22+00:00"
+            "time": "2026-06-15T06:13:33+00:00"
         },
         {
             "name": "lcobucci/jwt",

+ 85 - 0
scripts/sql/add_lakala_onboarding.sql

@@ -0,0 +1,85 @@
+-- 拉卡拉进件、收款账户及远端操作记录
+
+CREATE TABLE IF NOT EXISTS `xhLakalaApplication` (
+    `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '申请主键ID',
+    `mainId` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '所属主体ID',
+    `shopId` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '所属门店ID',
+    `accountId` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '关联拉卡拉收款账户ID,首次进件草稿为0',
+    `type` varchar(32) NOT NULL DEFAULT 'onboarding' COMMENT '申请类型:onboarding首次进件,settlement_change结算账户变更',
+    `status` varchar(32) NOT NULL DEFAULT 'DRAFT' COMMENT '申请状态:草稿、合同待签、待审核、已驳回、已通过等',
+    `currentStep` tinyint(3) unsigned NOT NULL DEFAULT 1 COMMENT '五步进件表单当前步骤,范围1至5',
+    `formData` mediumtext COMMENT '当前申请表单及附件数据JSON',
+    `merchantNo` varchar(64) NOT NULL DEFAULT '' COMMENT '拉卡拉商户号',
+    `customerNo` varchar(64) NOT NULL DEFAULT '' COMMENT '拉卡拉客户号',
+    `shopNo` varchar(64) NOT NULL DEFAULT '' COMMENT '拉卡拉网点号',
+    `contractOrderNo` varchar(64) NOT NULL DEFAULT '' COMMENT '电子合同申请订单号',
+    `contractApplyId` varchar(64) NOT NULL DEFAULT '' COMMENT '电子合同申请ID',
+    `contractNo` varchar(64) NOT NULL DEFAULT '' COMMENT '签约完成后的电子合同编号',
+    `contractStatus` varchar(32) NOT NULL DEFAULT '' COMMENT '电子合同签约状态',
+    `reviewRelatedId` varchar(64) NOT NULL DEFAULT '' COMMENT '拉卡拉审核关联ID,用于查询申请审核状态',
+    `rejectReason` varchar(500) NOT NULL DEFAULT '' COMMENT '最近一次审核驳回原因',
+    `versionNo` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '当前已提交版本号',
+    `createTime` datetime NOT NULL COMMENT '创建时间',
+    `updateTime` datetime NOT NULL COMMENT '最后更新时间',
+    PRIMARY KEY (`id`),
+    KEY `idx_main_shop` (`mainId`, `shopId`),
+    KEY `idx_status` (`status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='拉卡拉进件申请';
+
+CREATE TABLE IF NOT EXISTS `xhLakalaApplicationVersion` (
+    `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '版本快照主键ID',
+    `applicationId` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '关联申请主键ID',
+    `mainId` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '所属主体ID',
+    `versionNo` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '申请版本号,同一申请内递增',
+    `type` varchar(32) NOT NULL DEFAULT '' COMMENT '版本类型:首次提交、驳回重提或结算账户变更',
+    `formData` mediumtext COMMENT '提交时不可变表单及附件快照JSON',
+    `requestData` mediumtext COMMENT '提交至拉卡拉的请求数据JSON',
+    `responseData` mediumtext COMMENT '拉卡拉提交响应数据JSON',
+    `createTime` datetime NOT NULL COMMENT '版本创建时间',
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_application_version` (`applicationId`, `versionNo`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='拉卡拉进件版本快照';
+
+CREATE TABLE IF NOT EXISTS `xhLakalaAccount` (
+    `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '收款账户主键ID',
+    `mainId` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '所属主体ID',
+    `shopId` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '所属门店ID',
+    `applicationId` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '认证通过的来源进件申请ID',
+    `merchantNo` varchar(64) NOT NULL DEFAULT '' COMMENT '拉卡拉商户号',
+    `customerNo` varchar(64) NOT NULL DEFAULT '' COMMENT '拉卡拉客户号',
+    `shopNo` varchar(64) NOT NULL DEFAULT '' COMMENT '拉卡拉网点号',
+    `accountName` varchar(100) NOT NULL DEFAULT '' COMMENT '银行结算账户开户名',
+    `accountNo` varchar(64) NOT NULL DEFAULT '' COMMENT '银行结算账号',
+    `bankName` varchar(150) NOT NULL DEFAULT '' COMMENT '开户银行名称',
+    `accountType` varchar(8) NOT NULL DEFAULT '' COMMENT '结算账户类型:对公或对私',
+    `licenseName` varchar(150) NOT NULL DEFAULT '' COMMENT '营业执照名称',
+    `identityExpire` varchar(32) NOT NULL DEFAULT '' COMMENT '法人或结算人身份证有效期',
+    `scanTermNo` varchar(64) NOT NULL DEFAULT '' COMMENT 'WECHAT_PAY扫码终端号',
+    `b2bTermNo` varchar(64) NOT NULL DEFAULT '' COMMENT 'B2B_SYT批发终端号',
+    `status` varchar(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '账户状态',
+    `isCurrent` tinyint(1) unsigned NOT NULL DEFAULT 0 COMMENT '是否为当前门店收款账户:0否,1是',
+    `createTime` datetime NOT NULL COMMENT '创建时间',
+    `updateTime` datetime NOT NULL COMMENT '最后更新时间',
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_main_merchant` (`mainId`, `merchantNo`),
+    KEY `idx_shop_current` (`shopId`, `isCurrent`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='拉卡拉收款账户';
+
+CREATE TABLE IF NOT EXISTS `xhLakalaOperation` (
+    `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '远端操作主键ID',
+    `applicationId` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '关联申请主键ID',
+    `accountId` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '关联拉卡拉收款账户ID',
+    `mainId` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '所属主体ID',
+    `operationType` varchar(40) NOT NULL DEFAULT '' COMMENT '远端操作类型:合同申请、首次进件、增终或结算变更等',
+    `bizType` varchar(32) NOT NULL DEFAULT '' COMMENT '业务子类型,如WECHAT_PAY或B2B_SYT',
+    `status` varchar(32) NOT NULL DEFAULT 'PREPARE' COMMENT '远端操作状态',
+    `reviewRelatedId` varchar(64) NOT NULL DEFAULT '' COMMENT '拉卡拉审核关联ID,用于幂等和审核查询',
+    `requestData` mediumtext COMMENT '远端操作请求数据JSON',
+    `responseData` mediumtext COMMENT '远端操作响应数据JSON',
+    `errorMessage` varchar(500) NOT NULL DEFAULT '' COMMENT '远端操作失败错误信息',
+    `createTime` datetime NOT NULL COMMENT '创建时间',
+    `updateTime` datetime NOT NULL COMMENT '最后更新时间',
+    PRIMARY KEY (`id`),
+    KEY `idx_application_operation` (`applicationId`, `operationType`, `bizType`),
+    KEY `idx_review_related` (`reviewRelatedId`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='拉卡拉远端操作';

+ 2 - 2
vendor/composer/installed.json

@@ -1396,7 +1396,7 @@
             "source": {
                 "type": "git",
                 "url": "git@git.huaml.com:shizhongqi/lakalasdk.git",
-                "reference": "2b6223de6c184be975cccac8cfda55eefeaa5c7d"
+                "reference": "00b04335ea20d6ed35f08dce1d674076fbafac43"
             },
             "require": {
                 "php": "^7.0 || ^8.0"
@@ -1405,7 +1405,7 @@
                 "friendsofphp/php-cs-fixer": "^3.5",
                 "phpunit/phpunit": "^9.6"
             },
-            "time": "2026-06-10T02:30:22+00:00",
+            "time": "2026-06-15T06:13:33+00:00",
             "default-branch": true,
             "type": "library",
             "installation-source": "source",

+ 3 - 3
vendor/composer/installed.php

@@ -5,7 +5,7 @@
         'type' => 'project',
         'install_path' => __DIR__ . '/../../',
         'aliases' => array(),
-        'reference' => 'ec2e404102afbb2ed97182ad2acceea34381e752',
+        'reference' => 'b776bfb8a18718ac9414cdb2aaa7345c1561d7fd',
         'name' => 'yiisoft/yii2-app-advanced',
         'dev' => true,
     ),
@@ -224,7 +224,7 @@
             'aliases' => array(
                 0 => '9999999-dev',
             ),
-            'reference' => '2b6223de6c184be975cccac8cfda55eefeaa5c7d',
+            'reference' => '00b04335ea20d6ed35f08dce1d674076fbafac43',
             'dev_requirement' => false,
         ),
         'lcobucci/jwt' => array(
@@ -753,7 +753,7 @@
             'type' => 'project',
             'install_path' => __DIR__ . '/../../',
             'aliases' => array(),
-            'reference' => 'ec2e404102afbb2ed97182ad2acceea34381e752',
+            'reference' => 'b776bfb8a18718ac9414cdb2aaa7345c1561d7fd',
             'dev_requirement' => false,
         ),
         'yiisoft/yii2-bootstrap' => array(

+ 1 - 1
vendor/lakalasdk/openapi

@@ -1 +1 @@
-Subproject commit 2b6223de6c184be975cccac8cfda55eefeaa5c7d
+Subproject commit 00b04335ea20d6ed35f08dce1d674076fbafac43