feat(shopro_wechat_trade): 新增微信发货一律自提功能 - 添加 shipping_force_self_pickup 配置项,默认开启,用于控制 upload_shipping_info 时固定使用 logistics_type=4(用户自提) - 修改 WxShippingPayload 类,新增 resolveLogisticsType 和 shouldUseExpressTracking 方法处理自提逻辑 - 更新配置页面添加自提开关组件及说明 - 修改数据库操作逻辑,支持配置项不存在时的插入操作 - 在 README.md 中补充相关命令行工具使用说明和配置说明 - 更新安装脚本添加默认配置值 ```
187 行
6.5 KiB
PHP
187 行
6.5 KiB
PHP
<?php
|
||
|
||
namespace addons\shopro_wechat_trade\library;
|
||
|
||
use addons\shopro\model\OrderItem;
|
||
|
||
class WxShippingPayload
|
||
{
|
||
protected static $logisticsMap = [
|
||
'express' => 1,
|
||
'store' => 2,
|
||
'autosend' => 3,
|
||
'selfetch' => 4,
|
||
];
|
||
|
||
public static function mapLogisticsType($dispatchType)
|
||
{
|
||
return self::$logisticsMap[$dispatchType] ?? 1;
|
||
}
|
||
|
||
/** 同步微信发货时使用的 logistics_type(1 快递 2 同城 3 虚拟 4 自提) */
|
||
public static function resolveLogisticsType($dispatchType)
|
||
{
|
||
if (WxTradeConfig::getInstance()->shippingForceSelfPickup()) {
|
||
return 4;
|
||
}
|
||
return self::mapLogisticsType($dispatchType);
|
||
}
|
||
|
||
public static function shouldUseExpressTracking(array $item)
|
||
{
|
||
if (WxTradeConfig::getInstance()->shippingForceSelfPickup()) {
|
||
return false;
|
||
}
|
||
$dispatchType = $item['dispatch_type'] ?? 'express';
|
||
return in_array($dispatchType, ['express', 'store'], true);
|
||
}
|
||
|
||
public static function buildUploadPayload($order, array $items, $triggerItem = null)
|
||
{
|
||
$transactionId = WxOrderContext::resolveTransactionId($order);
|
||
$openid = WxOrderContext::resolveOpenid($order);
|
||
if ($transactionId === '') {
|
||
throw new \RuntimeException('缺少微信 transaction_id,无法同步发货');
|
||
}
|
||
if ($openid === '') {
|
||
throw new \RuntimeException('缺少用户 openid,无法同步发货');
|
||
}
|
||
|
||
$shippableItems = self::filterShippableItems($items);
|
||
if (!$shippableItems) {
|
||
throw new \RuntimeException('没有可同步的已发货商品');
|
||
}
|
||
|
||
$allItems = self::filterAllActiveItems($items);
|
||
$deliveryMode = count($shippableItems) >= count($allItems) ? 1 : 2;
|
||
|
||
$dispatchHint = $triggerItem['dispatch_type'] ?? ($shippableItems[0]['dispatch_type'] ?? 'express');
|
||
$logisticsType = self::resolveLogisticsType($dispatchHint);
|
||
$shippingList = [];
|
||
foreach ($shippableItems as $item) {
|
||
$shippingList[] = self::buildShippingListItem($item, $order);
|
||
}
|
||
|
||
return [
|
||
'payload' => [
|
||
'order_key' => [
|
||
'order_number_type' => 2,
|
||
'transaction_id' => $transactionId,
|
||
],
|
||
'logistics_type' => $logisticsType,
|
||
'delivery_mode' => $deliveryMode,
|
||
'shipping_list' => $shippingList,
|
||
'upload_time' => self::formatUploadTime(time()),
|
||
'payer' => ['openid' => $openid],
|
||
],
|
||
'meta' => [
|
||
'transaction_id' => $transactionId,
|
||
'delivery_mode' => $deliveryMode,
|
||
'logistics_type' => $logisticsType,
|
||
],
|
||
];
|
||
}
|
||
|
||
public static function buildConfirmPayload($order)
|
||
{
|
||
$transactionId = WxOrderContext::resolveTransactionId($order);
|
||
if ($transactionId === '') {
|
||
throw new \RuntimeException('缺少微信 transaction_id,无法同步确认收货');
|
||
}
|
||
$confirmTime = time();
|
||
$ext = is_array($order['ext_arr'] ?? null) ? $order['ext_arr'] : [];
|
||
if (!empty($ext['confirm_time'])) {
|
||
$confirmTime = intval($ext['confirm_time']);
|
||
}
|
||
|
||
// 官方 POST /wxa/sec/order/notify_confirm_receive:received_time 为秒级时间戳(number),非 RFC3339
|
||
$payload = [
|
||
'transaction_id' => $transactionId,
|
||
'received_time' => intval($confirmTime),
|
||
];
|
||
$mchid = WxOrderContext::resolveMchid($order);
|
||
if ($mchid !== '') {
|
||
$payload['merchant_id'] = $mchid;
|
||
}
|
||
$orderSn = trim((string) ($order['order_sn'] ?? ''));
|
||
if ($orderSn !== '') {
|
||
$payload['merchant_trade_no'] = $orderSn;
|
||
}
|
||
return $payload;
|
||
}
|
||
|
||
protected static function buildShippingListItem(array $item, $order)
|
||
{
|
||
$desc = mb_substr($item['goods_title'] ?? '商城商品', 0, 120);
|
||
$entry = ['item_desc' => $desc];
|
||
|
||
if (self::shouldUseExpressTracking($item)) {
|
||
$trackingNo = trim((string) ($item['express_no'] ?? ''));
|
||
$expressCode = WxExpressMapper::toWechatCode($item['express_code'] ?? '');
|
||
if ($trackingNo === '') {
|
||
throw new \RuntimeException('快递发货缺少物流单号: item#' . ($item['id'] ?? ''));
|
||
}
|
||
if ($expressCode === '') {
|
||
throw new \RuntimeException('快递公司编码未映射: ' . ($item['express_code'] ?? ''));
|
||
}
|
||
$entry['tracking_no'] = $trackingNo;
|
||
$entry['express_company'] = $expressCode;
|
||
} else {
|
||
$entry['tracking_no'] = $order['order_sn'] ?? ('ITEM' . ($item['id'] ?? ''));
|
||
}
|
||
|
||
if (!empty($order['phone'])) {
|
||
$entry['contact'] = [
|
||
'receiver_contact' => self::maskPhone($order['phone']),
|
||
];
|
||
}
|
||
|
||
return $entry;
|
||
}
|
||
|
||
protected static function filterShippableItems(array $items)
|
||
{
|
||
$result = [];
|
||
foreach ($items as $item) {
|
||
$item = is_object($item) && method_exists($item, 'toArray') ? $item->toArray() : (array) $item;
|
||
if (intval($item['dispatch_status'] ?? 0) < OrderItem::DISPATCH_STATUS_SENDED) {
|
||
continue;
|
||
}
|
||
if (in_array(intval($item['refund_status'] ?? 0), [OrderItem::REFUND_STATUS_OK, OrderItem::REFUND_STATUS_FINISH], true)) {
|
||
continue;
|
||
}
|
||
$result[] = $item;
|
||
}
|
||
return $result;
|
||
}
|
||
|
||
protected static function filterAllActiveItems(array $items)
|
||
{
|
||
$result = [];
|
||
foreach ($items as $item) {
|
||
$item = is_object($item) && method_exists($item, 'toArray') ? $item->toArray() : (array) $item;
|
||
if (in_array(intval($item['refund_status'] ?? 0), [OrderItem::REFUND_STATUS_OK, OrderItem::REFUND_STATUS_FINISH], true)) {
|
||
continue;
|
||
}
|
||
$result[] = $item;
|
||
}
|
||
return $result;
|
||
}
|
||
|
||
protected static function formatUploadTime($timestamp)
|
||
{
|
||
$dt = new \DateTime('@' . intval($timestamp));
|
||
$dt->setTimezone(new \DateTimeZone('Asia/Shanghai'));
|
||
return $dt->format('Y-m-d\TH:i:s.vP');
|
||
}
|
||
|
||
protected static function maskPhone($phone)
|
||
{
|
||
$phone = trim((string) $phone);
|
||
if (strlen($phone) >= 11) {
|
||
return substr($phone, 0, 3) . '****' . substr($phone, -4);
|
||
}
|
||
return $phone;
|
||
}
|
||
}
|