种菜明细表:新增按月从聚水潭种菜报销工单同步的命令(带自动排重)
每个月都要手工整理「中心明细表」,改成一条命令:planting-details:sync-work-orders --month=YYYY-MM。 - 口径用 1~8 月已入库的 7 万多行反推验证过:只取「工单类型=种菜报销、状态=已完成」的工单,明细表「日期」= 工单完成日 - 1 天(历史命中 100%),所以某月数据 = 完成时间落在该月次日到下月次日的工单。 - 字段映射(聚水潭只返回 fieldId,不给字段名,这批 id 是拿历史数据逐列对撞出来的):财务售价/财务运费/财务佣金/京喜差价/扣款/财务空包费/财务打款金额 分别对应工单的 任务付款/任务快递费/任务佣金/京喜差价/任务扣款/任务空包费/最终报销金额;负责人、账户、类型、标签、创建人、平台商品ID 也各有对应字段。 - 自动排重:写入前先删掉「本月 + 本次订单号」的已有行,重复执行不会翻倍;--force-clear 才整月覆盖,--dry-run 只看不写。 - 所属利润中心没有工单来源,用表里已有的 店铺→利润中心 映射回填,命中不了的店铺在命令输出里逐个提示。 - 补测试:字段映射 + 日期口径 + 去重 + 利润中心回填 + force-clear。
This commit is contained in:
parent
636187a9d8
commit
ef62cac0d9
352
app/Business/PlantingWorkOrderSynchronizer.php
Normal file
352
app/Business/PlantingWorkOrderSynchronizer.php
Normal file
@ -0,0 +1,352 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use App\Models\Business\PlantingDetail;
|
||||
use Illuminate\Database\DatabaseManager;
|
||||
use InvalidArgumentException;
|
||||
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* 聚水潭「种菜报销」协同工单 → 种菜明细表(账单流水数据 › 种菜数据汇总 › 种菜明细表)月度同步。
|
||||
*
|
||||
* 口径(用 1~8 月已入库的 7 万多行反推、命中率 100%):
|
||||
* - 只取「工单类型 = 种菜报销、工单状态 = 已完成」的工单;
|
||||
* - 明细表「日期」= 工单完成日 − 1 天。所以某个月的数据 = 完成时间落在该月次日到下月次日的工单;
|
||||
* - 一张工单一行,金额列与工单表单字段一一对应(见 FIELD_* 常量)。
|
||||
*
|
||||
* 排重:写入前先删掉「本月 + 本次订单号」的已有行,所以同一个月重复执行不会翻倍;
|
||||
* 想整月覆盖(连没有对应工单的人工行一起清掉)用 --force-clear。
|
||||
*
|
||||
* 「所属利润中心」没有工单来源,用表里已有的 店铺→利润中心 历史映射回填,命中不了就留空。
|
||||
*/
|
||||
class PlantingWorkOrderSynchronizer
|
||||
{
|
||||
/** 聚水潭协同工单镜像(jiansun.dbo.jst_workOrderInfo → js_jst_work_order_info)。 */
|
||||
public const WORK_ORDER_DATASET = 'jiansun.dbo.jst_workOrderInfo';
|
||||
|
||||
/** 「种菜报销」这个工单类型的固定 id。 */
|
||||
public const WORK_TYPE_ID = '6F03128403F2442CABCF283B59FAD88D';
|
||||
|
||||
/** 已完成的工单状态。 */
|
||||
private const STATUS_CONFIRMED = 'CONFIRMED';
|
||||
|
||||
public const SOURCE = 'work-order-sync';
|
||||
|
||||
/** 工单表单字段(聚水潭只给 fieldId,不给字段名,这里的对应关系是拿历史数据逐列对撞出来的)。 */
|
||||
private const FIELD_SELLING_PRICE = 'NUMBERField1413856620250924131615'; // 财务售价 = 任务付款金额
|
||||
private const FIELD_FREIGHT = 'NUMBERField1413856620251113150537'; // 财务运费 = 任务快递费
|
||||
private const FIELD_COMMISSION = 'NUMBERField1413856620251230154457'; // 财务佣金 = 任务佣金
|
||||
private const FIELD_JINGXI_DIFF = 'NUMBERField1413856620250924131623'; // 京喜差价
|
||||
private const FIELD_DEDUCTION = 'NUMBERField1413856620251113150612'; // 扣款 = 任务扣款费用
|
||||
private const FIELD_EMPTY_PACKAGE = 'NUMBERField1413856620251113145620'; // 财务空包费 = 任务空包费
|
||||
private const FIELD_PAYMENT = 'NUMBERField1413856620251117101250'; // 财务打款金额 = 最终报销金额
|
||||
private const FIELD_OWNER = 'TEXTField1413856620251117180931'; // 负责人(收款人姓名)
|
||||
private const FIELD_ACCOUNT = 'TEXTField1413856620251117113451'; // 账户(打款账号)
|
||||
private const FIELD_TYPE = 'TEXTField1413856620251113150546'; // 类型(任务备注)
|
||||
private const FIELD_TAG = 'businessRemarkField'; // 标签(业务备注)
|
||||
private const FIELD_ORDER_NO = 'orderField'; // 订单编号(线上订单号)
|
||||
private const FIELD_STORE = 'shopField'; // 正确店铺(店铺名称)
|
||||
|
||||
/** 金额列,统一按 4 位小数落库。 */
|
||||
private const MONEY_COLUMNS = [
|
||||
'finance_selling_price',
|
||||
'finance_freight',
|
||||
'finance_commission',
|
||||
'jingxi_price_diff',
|
||||
'deduction',
|
||||
'finance_empty_package_fee',
|
||||
'finance_payment_amount',
|
||||
];
|
||||
|
||||
private const INSERT_CHUNK = 1000;
|
||||
|
||||
private const DELETE_CHUNK = 500;
|
||||
|
||||
public function __construct(
|
||||
private readonly DatabaseManager $databases,
|
||||
private readonly BusinessMirrorDatasetRegistry $datasets,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* from:string,to:string,orders:int,inserted:int,deleted:int,
|
||||
* selling_price:string,payment_amount:string,date_min:?string,date_max:?string,
|
||||
* unmatched_stores:array<string,int>,dry_run:bool
|
||||
* }
|
||||
*/
|
||||
public function sync(
|
||||
string $from,
|
||||
string $to,
|
||||
int $companyId = 1,
|
||||
bool $dryRun = false,
|
||||
bool $forceClear = false,
|
||||
): array {
|
||||
$from = $this->normalizeDate($from, '起始日期');
|
||||
$to = $this->normalizeDate($to, '结束日期');
|
||||
if ($from > $to) {
|
||||
throw new InvalidArgumentException('起始日期不能晚于结束日期。');
|
||||
}
|
||||
|
||||
// 「日期」= 工单完成日 − 1,所以完成时间窗口整体后移一天。
|
||||
$completeFrom = date('Y-m-d', strtotime($from . ' +1 day')) . ' 00:00:00';
|
||||
$completeTo = date('Y-m-d', strtotime($to . ' +1 day')) . ' 00:00:00';
|
||||
|
||||
$target = $this->databases->connection();
|
||||
$mirror = $this->databases->connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||||
$table = $this->datasets->get(self::WORK_ORDER_DATASET)->targetTable();
|
||||
if (! $mirror->getSchemaBuilder()->hasTable($table)) {
|
||||
throw new RuntimeException('聚水潭协同工单镜像尚未建立(' . $table . ')。');
|
||||
}
|
||||
|
||||
$storeToProfitCenter = $this->storeToProfitCenter($companyId);
|
||||
$now = now();
|
||||
$rows = [];
|
||||
$orders = 0;
|
||||
$unmatchedStores = [];
|
||||
$sellingPrice = 0.0;
|
||||
$paymentAmount = 0.0;
|
||||
$dateMin = null;
|
||||
$dateMax = null;
|
||||
|
||||
$records = $mirror->table($table)
|
||||
->where('woTypeId', self::WORK_TYPE_ID)
|
||||
->where('status', self::STATUS_CONFIRMED)
|
||||
->where('completeTime', '>=', $completeFrom)
|
||||
->where('completeTime', '<', $completeTo)
|
||||
->orderBy('completeTime')
|
||||
->orderBy('woNo')
|
||||
->get(['woNo', 'createby', 'completeTime', 'businessFieldList']);
|
||||
|
||||
foreach ($records as $record) {
|
||||
$fields = $this->fields((string) $record->businessFieldList);
|
||||
$orderNo = $this->text($fields[self::FIELD_ORDER_NO] ?? null);
|
||||
if ($orderNo === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$billDate = date('Y-m-d', strtotime((string) $record->completeTime . ' -1 day'));
|
||||
if ($billDate < $from || $billDate > $to) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$store = $this->text($fields[self::FIELD_STORE . '#label'] ?? null);
|
||||
$profitCenter = $store === null ? null : ($storeToProfitCenter[$store] ?? null);
|
||||
if ($store !== null && $profitCenter === null) {
|
||||
$unmatchedStores[$store] = ($unmatchedStores[$store] ?? 0) + 1;
|
||||
}
|
||||
|
||||
$row = [
|
||||
'company_id' => $companyId,
|
||||
'bill_date' => $billDate,
|
||||
'order_no' => mb_substr($orderNo, 0, 100),
|
||||
'pieces' => 1,
|
||||
'remark' => null,
|
||||
'store' => $store,
|
||||
'owner' => $this->text($fields[self::FIELD_OWNER] ?? null),
|
||||
'account' => $this->text($fields[self::FIELD_ACCOUNT] ?? null),
|
||||
'type' => $this->text($fields[self::FIELD_TYPE] ?? null),
|
||||
'creator' => $this->text($record->createby),
|
||||
'tag' => $this->text($fields[self::FIELD_TAG] ?? null),
|
||||
'profit_center' => $profitCenter,
|
||||
'responsible_platform' => null,
|
||||
'external_id' => $this->text($this->platformItemId($fields), 100),
|
||||
'created_from' => self::SOURCE,
|
||||
'created_by' => null,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
foreach (self::MONEY_COLUMNS as $column) {
|
||||
$row[$column] = number_format(0.0, 4, '.', '');
|
||||
}
|
||||
foreach ([
|
||||
'finance_selling_price' => self::FIELD_SELLING_PRICE,
|
||||
'finance_freight' => self::FIELD_FREIGHT,
|
||||
'finance_commission' => self::FIELD_COMMISSION,
|
||||
'jingxi_price_diff' => self::FIELD_JINGXI_DIFF,
|
||||
'deduction' => self::FIELD_DEDUCTION,
|
||||
'finance_empty_package_fee' => self::FIELD_EMPTY_PACKAGE,
|
||||
'finance_payment_amount' => self::FIELD_PAYMENT,
|
||||
] as $column => $field) {
|
||||
$row[$column] = number_format($this->number($fields[$field] ?? null), 4, '.', '');
|
||||
}
|
||||
|
||||
$orders++;
|
||||
$sellingPrice += (float) $row['finance_selling_price'];
|
||||
$paymentAmount += (float) $row['finance_payment_amount'];
|
||||
$dateMin = $dateMin === null || $billDate < $dateMin ? $billDate : $dateMin;
|
||||
$dateMax = $dateMax === null || $billDate > $dateMax ? $billDate : $dateMax;
|
||||
$rows[] = $row;
|
||||
}
|
||||
|
||||
$orderNumbers = array_values(array_unique(array_column($rows, 'order_no')));
|
||||
$deleted = 0;
|
||||
|
||||
if (! $dryRun) {
|
||||
$deleted = $this->clear($target, $companyId, $from, $to, $orderNumbers, $forceClear);
|
||||
foreach (array_chunk($rows, self::INSERT_CHUNK) as $chunk) {
|
||||
$target->table('planting_details')->insert($chunk);
|
||||
}
|
||||
}
|
||||
|
||||
arsort($unmatchedStores);
|
||||
|
||||
return [
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'orders' => $orders,
|
||||
'inserted' => $dryRun ? 0 : count($rows),
|
||||
'deleted' => $deleted,
|
||||
'selling_price' => number_format($sellingPrice, 4, '.', ''),
|
||||
'payment_amount' => number_format($paymentAmount, 4, '.', ''),
|
||||
'date_min' => $dateMin,
|
||||
'date_max' => $dateMax,
|
||||
'unmatched_stores' => $unmatchedStores,
|
||||
'dry_run' => $dryRun,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 排重:删掉本月内、这次要写入的那批订单号对应的已有行。
|
||||
*
|
||||
* @param list<string> $orderNumbers
|
||||
*/
|
||||
private function clear(
|
||||
\Illuminate\Database\ConnectionInterface $target,
|
||||
int $companyId,
|
||||
string $from,
|
||||
string $to,
|
||||
array $orderNumbers,
|
||||
bool $forceClear,
|
||||
): int {
|
||||
$base = static fn () => $target->table('planting_details')
|
||||
->where('company_id', $companyId)
|
||||
->where('bill_date', '>=', $from)
|
||||
->where('bill_date', '<=', $to);
|
||||
|
||||
if ($forceClear) {
|
||||
return (int) $base()->delete();
|
||||
}
|
||||
|
||||
$deleted = 0;
|
||||
foreach (array_chunk($orderNumbers, self::DELETE_CHUNK) as $chunk) {
|
||||
$deleted += (int) $base()->whereIn('order_no', $chunk)->delete();
|
||||
}
|
||||
|
||||
return $deleted;
|
||||
}
|
||||
|
||||
/** @return array<string,string> 店铺 → 利润中心(同店铺多个取值时取出现次数最多的) */
|
||||
private function storeToProfitCenter(int $companyId): array
|
||||
{
|
||||
$counts = [];
|
||||
$rows = PlantingDetail::query()
|
||||
->where('company_id', $companyId)
|
||||
->whereNotNull('store')
|
||||
->whereNotNull('profit_center')
|
||||
->selectRaw('store, profit_center, COUNT(*) AS total')
|
||||
->groupBy('store', 'profit_center')
|
||||
->get();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$store = (string) $row->store;
|
||||
$counts[$store][(string) $row->profit_center] = (int) $row->total;
|
||||
}
|
||||
|
||||
$map = [];
|
||||
foreach ($counts as $store => $values) {
|
||||
arsort($values);
|
||||
$map[$store] = (string) array_key_first($values);
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> fieldId → value(带 valueLabel 的另存为 fieldId#label) */
|
||||
private function fields(string $businessFieldList): array
|
||||
{
|
||||
$fields = [];
|
||||
$items = json_decode($businessFieldList, true);
|
||||
if (! is_array($items)) {
|
||||
return $fields;
|
||||
}
|
||||
|
||||
foreach ($items as $item) {
|
||||
if (! is_array($item) || ! isset($item['fieldId'])) {
|
||||
continue;
|
||||
}
|
||||
if ($item['fieldId'] === 'itemInfo') {
|
||||
// 商品明细是嵌套结构,原样留着给 platformItemId 取平台商品 ID。
|
||||
$fields['itemInfo'] = $item;
|
||||
|
||||
continue;
|
||||
}
|
||||
$value = $item['value'] ?? null;
|
||||
if (is_array($value)) {
|
||||
$value = implode(',', array_map('strval', $value));
|
||||
}
|
||||
$fields[(string) $item['fieldId']] = $value;
|
||||
if (($item['valueLabel'] ?? null) !== null) {
|
||||
$fields[(string) $item['fieldId'] . '#label'] = $item['valueLabel'];
|
||||
}
|
||||
}
|
||||
|
||||
return $fields;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $fields */
|
||||
private function platformItemId(array $fields): ?string
|
||||
{
|
||||
$itemInfo = $fields['itemInfo'] ?? null;
|
||||
if (! is_array($itemInfo)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ((array) ($itemInfo['childFieldList'] ?? []) as $childRow) {
|
||||
foreach ((array) $childRow as $child) {
|
||||
if (is_array($child) && ($child['fieldId'] ?? null) === 'platformItemId') {
|
||||
return $this->text($child['value'] ?? null, 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function text(mixed $value, int $limit = 191): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$text = trim((string) (is_scalar($value) ? $value : ''));
|
||||
|
||||
return $text === '' ? null : mb_substr($text, 0, $limit);
|
||||
}
|
||||
|
||||
private function number(mixed $value): float
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return is_numeric($value) ? (float) $value : 0.0;
|
||||
}
|
||||
|
||||
private function normalizeDate(string $value, string $label): string
|
||||
{
|
||||
$value = trim($value);
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) !== 1) {
|
||||
throw new InvalidArgumentException($label . '必须是 YYYY-MM-DD。');
|
||||
}
|
||||
|
||||
[$year, $month, $day] = array_map('intval', explode('-', $value));
|
||||
if (! checkdate($month, $day, $year)) {
|
||||
throw new InvalidArgumentException($label . '不是有效日期。');
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
78
app/Console/Commands/SyncPlantingDetailsFromWorkOrders.php
Normal file
78
app/Console/Commands/SyncPlantingDetailsFromWorkOrders.php
Normal file
@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Business\PlantingWorkOrderSynchronizer;
|
||||
use Illuminate\Console\Command;
|
||||
use InvalidArgumentException;
|
||||
use Throwable;
|
||||
|
||||
class SyncPlantingDetailsFromWorkOrders extends Command
|
||||
{
|
||||
protected $signature = 'planting-details:sync-work-orders
|
||||
{--month= : 月份 YYYY-MM(与 --from/--to 二选一)}
|
||||
{--from= : 起始日期 YYYY-MM-DD}
|
||||
{--to= : 结束日期 YYYY-MM-DD}
|
||||
{--company=1 : 公司 ID}
|
||||
{--dry-run : 只统计不写库}
|
||||
{--force-clear : 整月覆盖(连没有对应工单的人工行一起清掉)}';
|
||||
|
||||
protected $description = '把聚水潭「种菜报销」工单同步成种菜明细表(日期 = 工单完成日 − 1;按月重复执行不会重复写)';
|
||||
|
||||
public function handle(PlantingWorkOrderSynchronizer $synchronizer): int
|
||||
{
|
||||
try {
|
||||
[$from, $to] = $this->window();
|
||||
|
||||
$result = $synchronizer->sync(
|
||||
$from,
|
||||
$to,
|
||||
(int) $this->option('company'),
|
||||
(bool) $this->option('dry-run'),
|
||||
(bool) $this->option('force-clear'),
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$unmatched = $result['unmatched_stores'];
|
||||
$result['unmatched_stores'] = count($unmatched);
|
||||
$this->info(json_encode($result, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
foreach (array_slice($unmatched, 0, 20, true) as $store => $count) {
|
||||
$this->warn(sprintf('未匹配利润中心:%s(%d 行)', $store, $count));
|
||||
}
|
||||
if (count($unmatched) > 20) {
|
||||
$this->warn('……还有 ' . (count($unmatched) - 20) . ' 家店铺未匹配到利润中心。');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/** @return array{0:string,1:string} */
|
||||
private function window(): array
|
||||
{
|
||||
$from = trim((string) $this->option('from'));
|
||||
$to = trim((string) $this->option('to'));
|
||||
$month = trim((string) $this->option('month'));
|
||||
|
||||
if ($month !== '') {
|
||||
if ($from !== '' || $to !== '') {
|
||||
throw new InvalidArgumentException('--month 与 --from/--to 只能给一个。');
|
||||
}
|
||||
if (preg_match('/^\d{4}-\d{2}$/', $month) !== 1) {
|
||||
throw new InvalidArgumentException('--month 必须是 YYYY-MM。');
|
||||
}
|
||||
|
||||
return [$month . '-01', date('Y-m-t', strtotime($month . '-01'))];
|
||||
}
|
||||
|
||||
if ($from === '' || $to === '') {
|
||||
throw new InvalidArgumentException('请给 --month=YYYY-MM,或同时给 --from 与 --to。');
|
||||
}
|
||||
|
||||
return [$from, $to];
|
||||
}
|
||||
}
|
||||
@ -3,10 +3,13 @@
|
||||
namespace Tests\Feature\Business;
|
||||
|
||||
use App\Business\PlantingDetailImporter;
|
||||
use App\Business\PlantingWorkOrderSynchronizer;
|
||||
use App\Models\Auth\Permission;
|
||||
use App\Models\Business\PlantingDetail;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tests\Feature\FeatureTestCase;
|
||||
|
||||
/**
|
||||
@ -129,6 +132,185 @@ class PlantingDetailsPageTest extends FeatureTestCase
|
||||
$this->assertSame($longId, $record->external_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 种菜报销工单同步:字段映射、日期=完成日-1、利润中心回填、重复执行不翻倍。
|
||||
*/
|
||||
public function test_sync_from_work_orders_maps_fields_and_dedupes(): void
|
||||
{
|
||||
$this->allowPage();
|
||||
$this->fakeWorkOrderMirror();
|
||||
|
||||
// 历史行提供 店铺 → 利润中心 映射
|
||||
PlantingDetail::query()->create([
|
||||
'company_id' => $this->company->id,
|
||||
'bill_date' => '2026-06-01',
|
||||
'order_no' => 'HISTORY-1',
|
||||
'store' => '天猫-亿家达旗舰店',
|
||||
'profit_center' => '运营中心A',
|
||||
'finance_payment_amount' => '1.0000',
|
||||
]);
|
||||
// 本月里没有对应工单的人工行:默认不删
|
||||
$manual = PlantingDetail::query()->create([
|
||||
'company_id' => $this->company->id,
|
||||
'bill_date' => '2026-09-10',
|
||||
'order_no' => 'MANUAL-ONLY',
|
||||
'store' => '天猫-亿家达旗舰店',
|
||||
'finance_payment_amount' => '9.0000',
|
||||
]);
|
||||
|
||||
$result = app(PlantingWorkOrderSynchronizer::class)->sync('2026-09-01', '2026-09-30', $this->company->id);
|
||||
|
||||
// 只认「种菜报销 + 已完成 + 完成时间在 9/2~10/1」的两张工单
|
||||
$this->assertSame(2, $result['orders']);
|
||||
$this->assertSame(2, $result['inserted']);
|
||||
$this->assertSame(0, $result['deleted']);
|
||||
|
||||
$first = PlantingDetail::query()->where('company_id', $this->company->id)
|
||||
->where('order_no', '3316392626048007769')->firstOrFail();
|
||||
$this->assertSame('2026-09-01', $first->bill_date->toDateString()); // 完成日 9-02 → 日期 9-01
|
||||
$this->assertSame('37.7900', $first->finance_selling_price);
|
||||
$this->assertSame('2.0000', $first->finance_commission);
|
||||
$this->assertSame('37.7900', $first->finance_payment_amount);
|
||||
$this->assertSame('天猫-亿家达旗舰店', $first->store);
|
||||
$this->assertSame('黄文梳', $first->owner);
|
||||
$this->assertSame('6214830551373771', $first->account);
|
||||
$this->assertSame('CCDD', $first->type);
|
||||
$this->assertSame('CCDD', $first->tag);
|
||||
$this->assertSame('黄文梳', $first->creator);
|
||||
$this->assertSame('1074872715022', $first->external_id);
|
||||
$this->assertSame('运营中心A', $first->profit_center); // 从历史行回填
|
||||
$this->assertSame(1, $first->pieces);
|
||||
$this->assertSame(PlantingWorkOrderSynchronizer::SOURCE, $first->created_from);
|
||||
|
||||
$second = PlantingDetail::query()->where('company_id', $this->company->id)
|
||||
->where('order_no', '260901-000000000000001')->firstOrFail();
|
||||
$this->assertSame('2026-09-29', $second->bill_date->toDateString()); // 完成日 9-30
|
||||
|
||||
// 再跑一遍:同月同订单号先删后写,不会翻倍
|
||||
$again = app(PlantingWorkOrderSynchronizer::class)->sync('2026-09-01', '2026-09-30', $this->company->id);
|
||||
$this->assertSame(2, $again['deleted']);
|
||||
$this->assertSame(2, $again['inserted']);
|
||||
$this->assertSame(1, PlantingDetail::query()->where('company_id', $this->company->id)
|
||||
->where('order_no', '3316392626048007769')->count());
|
||||
$this->assertSame(1, PlantingDetail::query()->where('company_id', $this->company->id)
|
||||
->whereKey($manual->id)->count());
|
||||
|
||||
// dry-run 不写库
|
||||
$dry = app(PlantingWorkOrderSynchronizer::class)->sync('2026-09-01', '2026-09-30', $this->company->id, true);
|
||||
$this->assertTrue($dry['dry_run']);
|
||||
$this->assertSame(2, $dry['orders']);
|
||||
$this->assertSame(0, $dry['inserted']);
|
||||
}
|
||||
|
||||
public function test_sync_force_clear_replaces_whole_month(): void
|
||||
{
|
||||
$this->allowPage();
|
||||
$this->fakeWorkOrderMirror();
|
||||
|
||||
PlantingDetail::query()->create([
|
||||
'company_id' => $this->company->id,
|
||||
'bill_date' => '2026-09-10',
|
||||
'order_no' => 'MANUAL-ONLY',
|
||||
'finance_payment_amount' => '9.0000',
|
||||
]);
|
||||
|
||||
$result = app(PlantingWorkOrderSynchronizer::class)
|
||||
->sync('2026-09-01', '2026-09-30', $this->company->id, false, true);
|
||||
|
||||
$this->assertSame(1, $result['deleted']);
|
||||
$this->assertSame(2, $result['inserted']);
|
||||
$this->assertSame(0, PlantingDetail::query()->where('company_id', $this->company->id)
|
||||
->where('order_no', 'MANUAL-ONLY')->count());
|
||||
$this->assertSame(2, PlantingDetail::query()->where('company_id', $this->company->id)->count());
|
||||
}
|
||||
|
||||
/** 造一个只有聚水潭协同工单镜像的 sqlite 业务镜像连接。 */
|
||||
private function fakeWorkOrderMirror(): void
|
||||
{
|
||||
config(['upstream_mirrors.connection' => 'business_mirror']);
|
||||
config(['database.connections.business_mirror' => [
|
||||
'driver' => 'sqlite',
|
||||
'database' => ':memory:',
|
||||
'prefix' => '',
|
||||
]]);
|
||||
DB::purge('business_mirror');
|
||||
|
||||
Schema::connection('business_mirror')->create('js_jst_work_order_info', function ($table): void {
|
||||
$table->increments('Id');
|
||||
$table->string('woNo')->nullable();
|
||||
$table->string('woTypeId')->nullable();
|
||||
$table->string('status')->nullable();
|
||||
$table->string('createby')->nullable();
|
||||
$table->string('completeTime')->nullable();
|
||||
$table->text('businessFieldList')->nullable();
|
||||
});
|
||||
|
||||
foreach ($this->workOrderFixtures() as $row) {
|
||||
DB::connection('business_mirror')->table('js_jst_work_order_info')->insert($row);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
private function workOrderFixtures(): array
|
||||
{
|
||||
$planting = PlantingWorkOrderSynchronizer::WORK_TYPE_ID;
|
||||
|
||||
$fields = [
|
||||
['fieldId' => 'orderField', 'type' => 'TEXT', 'value' => '3316392626048007769'],
|
||||
['fieldId' => 'shopField', 'type' => 'SELECT', 'value' => '18501511', 'valueLabel' => '天猫-亿家达旗舰店'],
|
||||
['fieldId' => 'NUMBERField1413856620250924131615', 'type' => 'NUMBER', 'value' => '37.79'],
|
||||
['fieldId' => 'NUMBERField1413856620251230154457', 'type' => 'NUMBER', 'value' => '2'],
|
||||
['fieldId' => 'NUMBERField1413856620251117101250', 'type' => 'NUMBER', 'value' => '37.79'],
|
||||
['fieldId' => 'TEXTField1413856620251117180931', 'type' => 'TEXT', 'value' => '黄文梳'],
|
||||
['fieldId' => 'TEXTField1413856620251117113451', 'type' => 'TEXT', 'value' => '6214830551373771'],
|
||||
['fieldId' => 'TEXTField1413856620251113150546', 'type' => 'TEXT', 'value' => 'CCDD'],
|
||||
['fieldId' => 'businessRemarkField', 'type' => 'TEXTAREA', 'value' => 'CCDD'],
|
||||
['fieldId' => 'itemInfo', 'type' => 'FORM', 'childFieldList' => [[
|
||||
['fieldId' => 'erpItemId', 'type' => 'TEXT', 'value' => '11961631'],
|
||||
['fieldId' => 'platformItemId', 'type' => 'TEXT', 'value' => '1074872715022'],
|
||||
]]],
|
||||
];
|
||||
|
||||
$plain = [
|
||||
['fieldId' => 'orderField', 'type' => 'TEXT', 'value' => '260901-000000000000001'],
|
||||
['fieldId' => 'shopField', 'type' => 'SELECT', 'value' => '21355501', 'valueLabel' => '拼多多-归来舍下住宅家具旗舰店'],
|
||||
['fieldId' => 'NUMBERField1413856620250924131615', 'type' => 'NUMBER', 'value' => '0.06'],
|
||||
['fieldId' => 'NUMBERField1413856620251230154457', 'type' => 'NUMBER', 'value' => '0.5'],
|
||||
['fieldId' => 'NUMBERField1413856620251117101250', 'type' => 'NUMBER', 'value' => '0.56'],
|
||||
];
|
||||
|
||||
return [
|
||||
[
|
||||
'woNo' => 'GD565967381', 'woTypeId' => $planting, 'status' => 'CONFIRMED',
|
||||
'createby' => '黄文梳', 'completeTime' => '2026-09-02 11:47:53',
|
||||
'businessFieldList' => json_encode($fields, JSON_UNESCAPED_UNICODE),
|
||||
],
|
||||
[
|
||||
'woNo' => 'GD999999999', 'woTypeId' => $planting, 'status' => 'CONFIRMED',
|
||||
'createby' => '陆小凤', 'completeTime' => '2026-09-30 09:27:29',
|
||||
'businessFieldList' => json_encode($plain, JSON_UNESCAPED_UNICODE),
|
||||
],
|
||||
// 未完成 → 忽略
|
||||
[
|
||||
'woNo' => 'GD888888888', 'woTypeId' => $planting, 'status' => 'WAIT_CONFIRM',
|
||||
'createby' => '陆小凤', 'completeTime' => '2026-09-15 09:27:29',
|
||||
'businessFieldList' => json_encode($plain, JSON_UNESCAPED_UNICODE),
|
||||
],
|
||||
// 别的工单类型 → 忽略
|
||||
[
|
||||
'woNo' => 'GD777777777', 'woTypeId' => 'ORDER_AFSALES', 'status' => 'CONFIRMED',
|
||||
'createby' => '关雎', 'completeTime' => '2026-09-15 09:27:29',
|
||||
'businessFieldList' => json_encode($plain, JSON_UNESCAPED_UNICODE),
|
||||
],
|
||||
// 完成时间 10/2 → 日期 10/1,不在 9 月窗口 → 忽略
|
||||
[
|
||||
'woNo' => 'GD666666666', 'woTypeId' => $planting, 'status' => 'CONFIRMED',
|
||||
'createby' => '陆小凤', 'completeTime' => '2026-10-02 09:27:29',
|
||||
'businessFieldList' => json_encode($plain, JSON_UNESCAPED_UNICODE),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function csvFixture(): string
|
||||
{
|
||||
$path = $this->writeFile('planting.csv');
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user